├── .eslintrc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE.txt ├── Readme.md ├── bin └── formio ├── commands ├── clone.js ├── commands.js ├── copy.js ├── deploy.js ├── migrate.js └── submissions.js ├── examples └── migrate │ ├── import.csv │ └── transform.js ├── index.js ├── package.json ├── src ├── Cloner.js ├── analytics.js ├── authenticate.js ├── clone.js ├── copy.js ├── deploy.js ├── eachComponentAsync.js ├── execute.js ├── exportTemplate.js ├── fetch.js ├── importTemplate.js ├── loadTemplate.js ├── migrate.js ├── series.js ├── submissions.js ├── transforms │ ├── csv.js │ └── form.js ├── utils.js └── welcome │ ├── logo.txt │ ├── welcome.js │ └── welcome.txt ├── test.env ├── test ├── clearData.js ├── clone.js ├── copy │ └── copy.js ├── createTemplate.js ├── deploy │ └── deploy.js ├── docker │ ├── .env │ └── docker-compose.yml ├── index.js ├── migrate │ ├── import.csv │ ├── migrate.js │ └── transform.js ├── submissions │ ├── middleware.js │ └── submission.js ├── templates │ ├── default.json │ └── test.json ├── util.js └── waitApisReady.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "eslint-config-formio", 4 | "env": { 5 | "node": true, 6 | "es6": true 7 | }, 8 | "parserOptions": { 9 | "ecmaVersion": 9 10 | }, 11 | "rules": { 12 | "curly": [2, "all"], 13 | "no-unused-vars": [1, {"args": "none"}], 14 | "strict": [2, "global"], 15 | "max-statements": [2, 35], 16 | "no-console": 0, 17 | "indent": ["error", 2], 18 | "new-cap": ["error", { "capIsNewExceptions": ["Formio"] }] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Repo 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' # This will make sure all push events on any branch triggers this workflow. 7 | 8 | env: 9 | NODE_VERSION: 20.x 10 | ADMIN_KEY: testAdminKey 11 | DOCKER_API_SRC_PORT: 4001 12 | DOCKER_API_DST_PORT: 4002 13 | DOCKER_MONGO_SRC: mongodb://mongo:27017/cli-test-src 14 | DOCKER_MONGO_DST: mongodb://mongo:27017/cli-test-dst 15 | LICENSE_REMOTE: false 16 | LICENSE_KEY: ${{ secrets.LICENSE_KEY }} 17 | API_SRC: http://localhost:4001 18 | API_DST: http://localhost:4002 19 | MONGO_SRC: mongodb://localhost:27018/cli-test-src 20 | MONGO_DST: mongodb://localhost:27018/cli-test-dst 21 | 22 | jobs: 23 | test: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - run: echo "Triggered by ${{ github.event_name }} event." 27 | - name: Check out repository code ${{ github.repository }} on ${{ github.ref }} 28 | uses: actions/checkout@v3 29 | 30 | - name: Set up Node.js ${{ env.NODE_VERSION }} 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: ${{ env.NODE_VERSION }} 34 | cache: 'npm' 35 | 36 | - name: Installing dependencies 37 | uses: borales/actions-yarn@v4 38 | with: 39 | cmd: install --frozen-lockfile 40 | 41 | - name: Test 42 | if: true 43 | uses: borales/actions-yarn@v4 44 | with: 45 | cmd: test 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | clonestate.json 2 | node_modules 3 | .dccache 4 | .vscode 5 | .idea 6 | .DS_Store 7 | .vscode/launch.json 8 | npm-debug.log 9 | test/docker/data -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017 Form.io 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in the 6 | Software without restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the 8 | Software, and to permit persons to whom the Software is furnished to do so, 9 | subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 16 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 17 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 18 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 19 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | The Form.io Command Line Interface 2 | ================================= 3 | This project is the command line interface for Form.io, which allows you to quickly bootstrap full working projects as 4 | well as interface with the Form.io API. 5 | 6 | Installation 7 | ------------------- 8 | Installation is easy... Simply type the following in your command line. 9 | 10 | ``` 11 | npm install -g @formio/cli 12 | ``` 13 | 14 | Commands 15 | ------------- 16 | 17 | ### Migrate 18 | 19 | formio migrate [] --src-key [SOURCE_API_KEY] --dst-key [DESTINATION_API_KEY] 20 | 21 | The migrate command allows you to migrate submission data from one source to another using a simple command. You can either migrate data from a CSV into a form, or from a form into another form. This works by taking the data from ``````, sending it through a middleware function called `````` (which you provide) that transforms the data into the correct format, and then saving that data as a submission into the `````` form. If you are migrating data from one form to the same form within two different projects, you will just provide ```form``` as your transform and your command would be as follows. 22 | 23 | formio migrate form --src-key [SOURCE_API_KEY] --dst-key [DESTINATION_API_KEY] 24 | 25 | As an example, if you wish to move submission data from one form in a project to your remotely deployed project. You can use the following command. 26 | 27 | formio migrate https://myproject.form.io/myform form https://formio.mydomain.com/myproject/myform --src-key abc1234 --dst-key cde2468 28 | 29 | Where you would replace the domains of your from and to, but also need to replace the ```src-key``` and ```dst-key``` with the API Keys from the from project and API key of your destination project respectively. 30 | 31 | #### Migrating an entire project 32 | You can also migrate an entire project by using the "project" transform as follows. 33 | 34 | formio migrate https://myproject.form.io project https://forms.mydomain.com/myproject --src-key=abc1234 --dst-key=cde2468 35 | 36 | #### Migrating from CSV 37 | In many cases, you may wish to migrate data from a local CSV file into a project submission table. This requires the transform middleware where you will map the columns of your CSV file into the Submission data going into Form.io. 38 | 39 | Example: Let's suppose you have the following CSV file of data. 40 | 41 | ***import.csv*** 42 | ``` 43 | First Name, Last Name, Email 44 | Joe, Smith, joe@example.com 45 | Jane, Thompson, jane@example.com 46 | Terry, Jones, terry@example.com 47 | ``` 48 | And now you wish to import all of that data into a form. You can create the transform file like the following. 49 | 50 | ***transform.js*** 51 | ``` 52 | var header = true; 53 | module.exports = function(record, next) { 54 | if (header) { 55 | // Ignore the header row. 56 | header = false; 57 | return next(); 58 | } 59 | next(null, { 60 | data: { 61 | firstName: record[0], 62 | lastName: record[1], 63 | email: record[2] 64 | } 65 | }); 66 | }; 67 | ``` 68 | 69 | This transform middleware file can be a complete Node.js middleware method and works asynchronously so if you need to perform asynchronous behavior, you can do that by only calling the ```next``` function when the record is ready. 70 | 71 | You can now migrate that data into your form with the following command. 72 | 73 | formio migrate import.csv transform.js https://myproject.form.io/myform --key [YOUR_API_KEY] 74 | 75 | #### Migrate and Delete 76 | In many cases, when you migrate, you may wish to delete previous submissions during the migration phase. You can do this by adding the following option to your command. 77 | 78 | ``` 79 | --delete-previous 80 | ``` 81 | 82 | For example, the following will perform a migration and delete any previous migration records during the migration. 83 | 84 | ``` 85 | formio migrate https://myproject.form.io project https://forms.mydomain.com/myproject --src-key=abc1234 --dst-key=cde2468 --delete-previous 86 | ``` 87 | 88 | #### Migrate and Delete Before and After 89 | You can also provide a window of records that should be deleted using the ```--delete-after``` and ```--delete-before``` flags. The values should be in the format ```2022-05-30T12:00:00.000Z```. For example, if you wish to migrate your data, but also remove any records before 2022-05-30T09:00:00.000Z and 2022-05-30T12:00:00.000Z, you would provide the following command. 90 | 91 | ``` 92 | formio migrate https://myproject.form.io project https://forms.mydomain.com/myproject --src-key=abc1234 --dst-key=cde2468 --delete-after=2022-05-30T09:00:00.000Z --delete-before=2022-05-30T12:00:00.000Z --delete-previous 93 | ``` 94 | 95 | ### Clone 96 | 97 | formio clone --src-project=[PROJECT_ID] 98 | 99 | Clones a project from one database into another, and includes all forms, submissions, and every other resources within the project. This command 100 | also retains any _id's from the source database. 101 | 102 | ### Clone Multiple Projects 103 | 104 | It is also possible to clone multiple projects at the same time by providing a comma separated list of the project ids, like this. 105 | 106 | formio clone --src-project=234234234234,345345345345345,45456456456456 107 | 108 | ### Clone only records created after a certain date 109 | 110 | You can also clone only the records that have been created after a certain ISO Timestamp. This is useful if you wish to perform multiple migrations and only wish to clone records since the last clone command was called. 111 | 112 | formio clone --src-project=[PROJECT_ID] --created-after=2342342342 113 | 114 | ### Clone Submissions 115 | 116 | formio clone -o --src-project=[PROJECT_ID] 117 | 118 | This command only clones the submissions from one environment to another. 119 | 120 | ### Deploy 121 | 122 | ``` 123 | formio deploy [src] [dst] 124 | ``` 125 | 126 | You can deploy a project on a paid plan on form.io to a hosted server with this command or deploy a project from an open source server to an enterprise project. Specify the source and destination servers and the project will be created or updated on the destination server. 127 | 128 | Examples: 129 | 130 | ``` 131 | // A project without a server is implied from https://form.io 132 | formio deploy myproject http://myproject.localhost:3000 133 | 134 | // Projects can be specified with a subdomain. 135 | formio deploy https://myproject.form.io http://myproject.localhost:3000 136 | 137 | // Projects can also be referred to with their project id which will need to be looked up. 138 | formio deploy https://form.io/project/{projectId} http://localhost:3000/project/{projectId} 139 | 140 | // Forms and Resources from an open source server can be deployed to an enterprise project by using the following command 141 | // This will copy all your forms and resources from the open source formio server to the enterprise project 142 | formio deploy / --src-key --dst-key 143 | formio deploy http://localhost:3001 http://localhost:3000/pfcelycsrkqjccq --src-key 123 --dst-key 456 144 | ``` 145 | 146 | Each server will require authentication so you will need to add an API Key to each of the projects. 147 | Documentation on how to do that can be found here: https://help.form.io/userguide/projects/project-settings#api-settings 148 | For adding an API Key to the open source server you will need to set the environment variable API_KEYS. You can view our formio README if you need help on how to do this https://github.com/formio/formio/blob/master/README.md 149 | 150 | 151 | ### Copy 152 | 153 | ``` 154 | formio copy form [src] [dest] 155 | ``` 156 | 157 | This command will copy the components of a form into another form. **This will overwrite all components within the destination form if that form exists**. 158 | You can also chain together multiple source forms which will aggregate the components of those forms into the destination form. 159 | 160 | Examples: 161 | 162 | ``` 163 | // Copy a form from one project to another. 164 | formio copy form https://myapp.form.io/myform https://myotherapp.form.io/myform 165 | 166 | // Aggregate multiple forms into the same form. 167 | formio copy form https://myapp.form.io/form1,https://myapp.form.io/form2 https://myapp.form.io/allforms 168 | ``` 169 | -------------------------------------------------------------------------------- /bin/formio: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../index.js'); -------------------------------------------------------------------------------- /commands/clone.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | 'use strict'; 3 | module.exports = function(program, next) { 4 | program 5 | .command('clone ') 6 | .description('Clone a database (project) from one place to another.') 7 | .option('--deleted-after [timestamp]', 'Only clone items deleted after the provided UNIX timestamp (in milliseconds).') 8 | .option('--created-after [timestamp]', 'Only clone items created after the provided UNIX timestamp (in milliseconds).') 9 | .option('--modified-after [timestamp]', 'Only clone items modified after the provided UNIX timestamp.') 10 | .option('-a, --all', 'Include All items (including deleted items', false) 11 | .option('-o, --submissions-only', 'Only clone the submissions within a project', false) 12 | .option('--include-form-revisions', 'Include all form revisions', false) 13 | .option('--include-submission-revisions', 'Include all submission revisions', false) 14 | .option('-f, --delete-submissions', 'Delete all submissions on the receiving form before cloning', false) 15 | .option('-s, --src-project ', 'The Source project ID, or comma separated projects for multiple') 16 | .option('-d, --dst-project ', 'The Destination project ID') 17 | .option('-p, --project ', 'The project ID that you wish to clone from one database to another.') 18 | .option('--forms ', 'A comma-separated value of all the Form ID\'s you wish to clone. If included, then only the provided forms will be cloned.', false) 19 | .option('--exclude ', 'A comma-separated value of all the Form ID\'s you wish to exclude in the clone process.', false) 20 | .option('-u, --update-existing', 'Update existing Projects and Forms instead of cloning (No OSS).', true) 21 | .option('--update-existing-submissions', 'Update existing Submissions when found in the destination (slows down the clone process if set).', false) 22 | .option('--src-ca ', 'The TLS certificate authority for the source mongo url') 23 | .option('--src-cert ', 'Allows you to provide the TLS certificate file for connections.') 24 | .option('--dst-ca ', 'The TLS certificate authority for the destination mongo url') 25 | .option('--dst-cert ', 'Allows you to provide the TLS certificate file for connections.') 26 | .option('--api-source', 'Provide this if you clone from API source') 27 | .option('-k, --key [key]', 'The API Key to provide to the source API.') 28 | .option('-l, --limit [limit]', 'Limits the amount of records fetched from the API at once (default - 1000)') 29 | 30 | .option( 31 | '--src-db-secret ', 32 | 'Source API DB_SECRET config (provide this if your project has encrypted settings of fields).' 33 | ) 34 | .option( 35 | '--dst-db-secret ', 36 | 'Destination API DB_SECRET config (provide this if your project has encrypted settings of fields).' 37 | ) 38 | .action((source, destination, options) => require('../src/clone')(source, destination, options)); 39 | }; 40 | -------------------------------------------------------------------------------- /commands/commands.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(program, next) { 4 | return { 5 | clone: require('./clone.js')(program, next), 6 | deploy: require('./deploy.js')(program, next), 7 | copy: require('./copy.js')(program, next), 8 | migrate: require('./migrate.js')(program, next), 9 | submissions: require('./submissions.js')(program, next) 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /commands/copy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var series = require('../src/series'); 4 | 5 | module.exports = function(program, next) { 6 | program 7 | .command('copy ') 8 | .description('Copy a form or project from one source to a destination.') 9 | .option('-p, --protocol [protocol]', 'Change the protocol.') 10 | .option('-h, --host [host]', 'Set the host for the copy.') 11 | .option('-k, --key [key]', 'The API Key to provide to the destination forms.') 12 | .option('--src-key [key]', 'The API Key to provide to the source form') 13 | .option('--dst-key [key]', 'The API Key to provide to the destination form') 14 | .option('--src-admin-key [key]', 'The Admin API Key to provide to the source form') 15 | .option('--dst-admin-key [key]', 'The Admin API Key to provide to the destination form') 16 | .option('--full', 'Will copy full form or resource structure') 17 | .option( 18 | '--migrate-pdf-files [migratePdfFiles]', 19 | 'Pass this option if you want to migrate PDF files from source PDF server to the destination for PDF forms', 20 | false 21 | ) 22 | .action(series([ 23 | require('../src/authenticate')({ 24 | src: 1, 25 | dst: 2 26 | }), 27 | require('../src/copy') 28 | ], next)); 29 | }; 30 | -------------------------------------------------------------------------------- /commands/deploy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var series = require('../src/series'); 4 | 5 | module.exports = function(program, next) { 6 | program 7 | .command('deploy ') 8 | .description('Deploy a project to another server. Source and destination may be the project name on form.io or the full url to any project on a server such as https://test.form.io or https://form.io/project/{projectId}. Source may also be a local json file.') 9 | .option('--key [key]', 'The API Key to provide to the destination forms.') 10 | .option('--src-key [key]', 'The API Key to provide to the source form') 11 | .option('--dst-key [key]', 'The API Key to provide to the destination form') 12 | .option('--src-admin-key [key]', 'The Admin API Key to provide to the source form') 13 | .option('--dst-admin-key [key]', 'The Admin API Key to provide to the destination form') 14 | .action(series([ 15 | require('../src/authenticate')({ 16 | src: 0, 17 | dst: 1 18 | }), 19 | require('../src/deploy') 20 | ], next)); 21 | }; 22 | -------------------------------------------------------------------------------- /commands/migrate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var series = require('../src/series'); 4 | 5 | module.exports = function(program, next) { 6 | program 7 | .command('migrate ') 8 | .description('Migrate data from a source (CSV or Form) to a destination form.') 9 | .option('-p, --protocol [protocol]', 'Change the protocol.') 10 | .option('-h, --host [host]', 'Set the host for the copy.') 11 | .option('--key [key]', 'The API Key to provide to the destination forms.') 12 | .option('--src-key [key]', 'The API Key to provide to the source form') 13 | .option('--dst-key [key]', 'The API Key to provide to the destination form') 14 | .option('--src-admin-key [key]', 'The Admin API Key to provide to the source form') 15 | .option('--dst-admin-key [key]', 'The Admin API Key to provide to the destination form') 16 | .option( 17 | '--migrate-pdf-files [migratePdfFiles]', 18 | 'Pass this option if you want to migrate PDF files from source PDF server to the destination for PDF forms', 19 | false 20 | ) 21 | .option('--start-with [startWith]', 'Start the migration from a specific form. Useful to replay migrations.') 22 | .option('--delete [delete]', 'Deletes all submissions in the destination form before the migration occurs.') 23 | .option('--delete-previous [deletePrevious]', 'Deletes previous submissions that have been migrated with the migrate script.') 24 | .option('--delete-after [deleteAfter]', 'Provides the ability to delete submissions created in the Source after the provided timestamp. The timestamp should be in the format of 2022-05-30T12:00:00.000Z. Use with delete-before to create a delete "window".') 25 | .option('--delete-before [deleteBefore]', 'Provides the ability to delete submissions created in the Before after the provided timestamp. The timestamp should be in the format of 2022-05-30T12:00:00.000Z. Use with delete-after to create a delete "window".') 26 | .action(series([ 27 | require('../src/authenticate')({ 28 | src: 0, 29 | dst: 2 30 | }), 31 | require('../src/migrate') 32 | ], next)); 33 | }; 34 | -------------------------------------------------------------------------------- /commands/submissions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var series = require('../src/series'); 4 | 5 | module.exports = function(program, next) { 6 | program 7 | .command('submissions [each]') 8 | .description('Reads submissions from a form and either outputs that to the terminal or hands each submission to an each middleware') 9 | .option('-p, --protocol [protocol]', 'Change the protocol.') 10 | .option('-h, --host [host]', 'Set the host for the copy.') 11 | .option('--key [key]', 'The API Key to provide to the destination forms.') 12 | .option('--admin-key [key]', 'The Admin API Key to provide to the destination forms') 13 | .action(series([ 14 | require('../src/authenticate')({ 15 | dst: 0 16 | }), 17 | require('../src/submissions') 18 | ], next)); 19 | }; 20 | -------------------------------------------------------------------------------- /examples/migrate/import.csv: -------------------------------------------------------------------------------- 1 | First Name, Last Name, Email 2 | Joe, Smith, joe@example.com 3 | Jane, Thompson, jane@example.com 4 | Terry, Jones, terry@example.com 5 | -------------------------------------------------------------------------------- /examples/migrate/transform.js: -------------------------------------------------------------------------------- 1 | var header = true; 2 | module.exports = function(record, next) { 3 | if (header) { 4 | // Ignore the header row. 5 | header = false; 6 | return next(); 7 | } 8 | next(null, { 9 | data: { 10 | firstName: record[0], 11 | lastName: record[1], 12 | email: record[2] 13 | } 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('colors'); 4 | const {program} = require('commander'); 5 | var pkg = require(__dirname + '/package.json'); 6 | 7 | // Register all the commands. 8 | require(__dirname + '/commands/commands')(program, function(err) { 9 | if (err) { 10 | console.log(err.toString().red); 11 | } 12 | }); 13 | 14 | // The version of the CLI. 15 | program.version(pkg.version); 16 | 17 | // Show welcome. 18 | require(__dirname + '/src/welcome/welcome')(function() { 19 | // Parse the command line tool. 20 | program.parse(process.argv); 21 | }); 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@formio/cli", 3 | "version": "2.3.2", 4 | "description": "The Form.io Command Line Interface application.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "cd ./test/docker && docker-compose up -d && cd ../.. && yarn mocha ./test/index -b -t 60000 --exit && cd ./test/docker && docker-compose down", 8 | "lint": "eslint *.js **/**.js src/**.js src/**/**.js" 9 | }, 10 | "author": "", 11 | "license": "MIT", 12 | "bin": { 13 | "formio": "./bin/formio" 14 | }, 15 | "dependencies": { 16 | "@formio/core": "^1.2.0", 17 | "@formio/node-fetch-http-proxy": "^1.1.0", 18 | "JSONStream": "^1.3.5", 19 | "adm-zip": "^0.5.5", 20 | "async": "^3.2.4", 21 | "axios": "^1.7.7", 22 | "colors": "^1.4.0", 23 | "commander": "^6.2.0", 24 | "core-js": "^3.24.1", 25 | "csv-parse": "^4.15.4", 26 | "dotenv": "^16.3.1", 27 | "express": "^4.18.1", 28 | "formio-service": "^1.5.0", 29 | "formiojs": "^4.14.8", 30 | "fs-extra": "^9.1.0", 31 | "keycred": "^1.0.0", 32 | "keygenerator": "^1.0.4", 33 | "lodash": "^4.17.21", 34 | "mongodb": "^5.5.0", 35 | "nunjucks": "^3.2.3", 36 | "progress": "^2.0.3", 37 | "prompt": "^1.3.0", 38 | "request": "^2.88.2", 39 | "stream-transform": "^2.1.0", 40 | "supertest": "^6.3.3", 41 | "universal-analytics": "^0.4.23" 42 | }, 43 | "devDependencies": { 44 | "@faker-js/faker": "^8.0.2", 45 | "chance": "^1.1.8", 46 | "eslint": "^7.25.0", 47 | "eslint-config-formio": "^1.1.4", 48 | "mocha": "^10.2.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Cloner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-disable max-depth */ 3 | const {MongoClient, ObjectId} = require('mongodb'); 4 | const fs = require('fs'); 5 | const _ = require('lodash'); 6 | const crypto = require('crypto'); 7 | const keygenerator = require('keygenerator'); 8 | const {eachComponentAsync} = require('./eachComponentAsync'); 9 | const {createHmac} = require('node:crypto'); 10 | const fetch = require('./fetch'); 11 | 12 | class Cloner { 13 | /** 14 | * @param {*} srcPath - The source connection string. 15 | * @param {*} mongoDest - The destination mongo connection string. 16 | * @param {*} options - The options for the cloner. 17 | * @param {*} options.srcCa - The source CA file. 18 | * @param {*} options.srcCert - The source certificate file. 19 | * @param {*} options.srcDbSecret - The source database secret. 20 | * @param {*} options.dstCa - The destination CA file. 21 | * @param {*} options.dstCert - The destination certificate file. 22 | * @param {*} options.dstDbSecret - The destination database secret. 23 | * @param {*} options.all - Clone all data including deleted data. 24 | * @param {*} options.project - The source project to clone. 25 | * @param {*} options.srcProject - Alias for options.project. 26 | * @param {*} options.dstProject - The destination project to clone to. 27 | * @param {*} options.deletedAfter - Clone only data deleted after a certain date. 28 | * @param {*} options.createdAfter - Clone only data created after a certain date. 29 | * @param {*} options.modifiedAfter - Clone only data modified after a certain date. 30 | * @param {*} options.deleteSubmissions - Delete the submissions of a form before cloning it. 31 | * @param {*} options.includeFormRevisions - Include form revisions when cloning forms. 32 | * @param {*} options.includeSubmissionRevisions - Include submission revisions when cloning submissions. 33 | * @param {*} options.updateExistingSubmissions - Update existing submissions when found in the destination (performs a complete re-clone). 34 | * @param {*} options.forms - A comma-separated value of all the Form ID's you wish to clone. If included, then only the provided forms will be cloned. 35 | * @param {*} options.exclude - A comma-separated value of all the Form ID's you wish to exclude from cloning. 36 | * @param {*} options.submissionsOnly - Only clone submissions. 37 | * @param {*} options.apiSource - Flag to define if we need to clone from an API. 38 | * @param {*} options.key - API Key to be used to authenticate in source. 39 | * @param {*} options.limit - Parameter passed to the URL query when fetching from the API, limits the amount of records returned at once. 40 | */ 41 | constructor(srcPath, mongoDest = '', options = {}) { 42 | this.srcPath = srcPath; 43 | this.mongoDest = mongoDest; 44 | this.beforeAll = null; 45 | this.afterAll = null; 46 | this.createNew = (srcPath === mongoDest); 47 | this.defaultSaltLength = 40; 48 | this.options = options; 49 | this.src = null; 50 | this.dest = null; 51 | this.cloneState = {}; 52 | this.exclude = this.options.exclude ? this.options.exclude.split(',') : []; 53 | 54 | if (this.options.apiSource) { 55 | this.requestHeaders = { 56 | 'x-raw-data-access': createHmac('sha256', this.options.key).digest('hex') 57 | }; 58 | 59 | this.fetch = fetch({ 60 | key: this.options.key, 61 | noThrowOnError: true 62 | }); 63 | 64 | this.limit = _.get(this.options, 'limit') ? Number(this.options.limit) : 1000; 65 | this.currentForm = null; 66 | this.currentSubmission = null; 67 | } 68 | 69 | try { 70 | this.cloneState = JSON.parse(fs.readFileSync('clonestate.json', 'utf8')); 71 | } 72 | catch (e) { 73 | this.cloneState = {}; 74 | console.log('No clonestate.json file found.'); 75 | } 76 | process.on('exit', async() => { 77 | await this.disconnect(); 78 | fs.writeFileSync('clonestate.json', JSON.stringify(this.cloneState, null, 2)); 79 | }); 80 | } 81 | 82 | /** 83 | * Encrypt data or settings using a secret. 84 | * @param {*} secret 85 | * @param {*} rawData 86 | * @param {*} nosalt 87 | * @returns 88 | */ 89 | encrypt(secret, rawData, nosalt) { 90 | if (!secret || !rawData) { 91 | return null; 92 | } 93 | const salt = nosalt ? '' : keygenerator._({ 94 | length: this.defaultSaltLength 95 | }); 96 | const cipher = crypto.createCipher('aes-256-cbc', secret); 97 | const decryptedJSON = JSON.stringify(rawData) + salt; 98 | return Buffer.concat([ 99 | cipher.update(decryptedJSON), 100 | cipher.final() 101 | ]); 102 | } 103 | 104 | /** 105 | * Decrypt data or settings using a secret. 106 | * @param {*} secret 107 | * @param {*} cipherbuffer 108 | * @param {*} nosalt 109 | * @returns 110 | */ 111 | decrypt(secret, cipherbuffer, nosalt) { 112 | if (!secret || !cipherbuffer) { 113 | return null; 114 | } 115 | let data = {}; 116 | try { 117 | const buffer = Buffer.isBuffer(cipherbuffer) ? cipherbuffer : cipherbuffer.buffer; 118 | const decipher = crypto.createDecipher('aes-256-cbc', secret); 119 | const decryptedJSON = Buffer.concat([ 120 | decipher.update(buffer), // Buffer contains encrypted utf8 121 | decipher.final() 122 | ]); 123 | data = JSON.parse(nosalt ? decryptedJSON : decryptedJSON.slice(0, -this.defaultSaltLength)); 124 | } 125 | catch (e) { 126 | console.log(e); 127 | data = null; 128 | } 129 | return data; 130 | } 131 | 132 | /** 133 | * Create the database configuration for the given database source type. 134 | * @param {*} type - The type of database source. 135 | * @returns MongoDB configuration. 136 | */ 137 | dbConfig(type) { 138 | const config = { 139 | useNewUrlParser: true, 140 | useUnifiedTopology: true 141 | }; 142 | if (this.options[`${type}Ca`]) { 143 | const caFile = this.options[`${type}Ca`]; 144 | config.tls = true; 145 | config.tlsCAFile = `${process.cwd()}/${caFile}`; 146 | if (this.options[`${type}Cert`]) { 147 | const certFile = this.options[`${type}Cert`]; 148 | config.tlsCertificateKeyFile = `${process.cwd()}/${certFile}`; 149 | } 150 | config.tlsAllowInvalidHostnames = true; 151 | } 152 | return config; 153 | } 154 | 155 | /** 156 | * Iterate through each document in a collection. 157 | * 158 | * @param {*} collection - The collection to iterate through. 159 | * @param {*} query - The query to use to find the documents. 160 | * @param {*} cb - The callback to call for each document. 161 | */ 162 | async each(collection, query, cb, sort = {created: 1}) { 163 | if (!query._id && this.cloneState[collection]) { 164 | query.created = {$gt: new Date(this.cloneState[collection])}; 165 | } 166 | const cursor = _.get(this, collection).find(query).sort(sort); 167 | while (await cursor.hasNext()) { 168 | const doc = await cursor.next(); 169 | // eslint-disable-next-line callback-return 170 | await cb(doc); 171 | process.stdout.write(collection === 'src.submissionrevisions' ? '_' : '.'); 172 | this.cloneState[collection] = (new Date(doc.created || null)).toISOString(); 173 | } 174 | delete this.cloneState[collection]; 175 | } 176 | 177 | /** 178 | * Before handler for each document. 179 | * 180 | * @param {*} collection - The collection to run the before handler on. 181 | * @param {*} beforeEach - The callback to call before each document. 182 | * @param {*} src - The source document. 183 | * @param {*} update - The updated document. 184 | * @param {*} dest - The destination document. 185 | */ 186 | async before(collection, beforeEach, src, update, dest) { 187 | if (beforeEach) { 188 | return await beforeEach(src, update, dest); 189 | } 190 | if (this.beforeAll) { 191 | await this.beforeAll(collection, src, update, dest); 192 | } 193 | } 194 | 195 | /** 196 | * After handler for each document. 197 | * @param {*} collection - The collection to run the after handler on. 198 | * @param {*} afterEach - The callback to call after each document. 199 | * @param {*} srcItem - The source item. 200 | * @param {*} update - The updated item. 201 | * @param {*} dest - The destination item. 202 | */ 203 | async after(collection, afterEach, srcItem, update, dest) { 204 | if (afterEach) { 205 | await afterEach(srcItem, update, dest); 206 | } 207 | if (this.afterAll) { 208 | await this.afterAll(collection, srcItem, update, dest); 209 | } 210 | } 211 | 212 | /** 213 | * Find the last item in the collection by provided query. 214 | * @param {string} collection - The collection where to run find query. 215 | * @param {object} query - The query to use to find the item in the database. 216 | * @param {object} sort - The sort option to use when searching for the item. 217 | */ 218 | async findLast(collection, query) { 219 | if (!collection || !query) { 220 | return; 221 | } 222 | const [found] = await _.get(this, collection) 223 | .find(query) 224 | .sort({created: -1}) 225 | .limit(1) 226 | .toArray(); 227 | 228 | return found; 229 | } 230 | 231 | /** 232 | * Determine if we should clone the given item. 233 | * @param {*} srcItem - The source item to check. 234 | */ 235 | shouldClone(collection, srcItem, updatedItem) { 236 | if (this.options.submissionsOnly && collection !== 'submissions') { 237 | return false; 238 | } 239 | const srcCreated = (srcItem.created instanceof Date) ? srcItem.created.getTime() : parseInt(srcItem.created, 10); 240 | if (this.options.createdAfter && srcCreated < parseInt(this.options.createdAfter, 10)) { 241 | return false; 242 | } 243 | // eslint-disable-next-line max-len 244 | const srcModified = (srcItem.modified instanceof Date) ? srcItem.modified.getTime() : parseInt(srcItem.modified, 10); 245 | if (this.options.modifiedAfter && srcModified < parseInt(this.options.modifiedAfter, 10)) { 246 | return false; 247 | } 248 | 249 | if (updatedItem._id) { 250 | // Make sure to not re-clone the same item it if has not been modified. 251 | // eslint-disable-next-line max-len 252 | const updatedCreated = (updatedItem.created instanceof Date) ? updatedItem.created.getTime() : parseInt(updatedItem.created, 10); 253 | // eslint-disable-next-line max-len 254 | const updatedModified = (updatedItem.modified instanceof Date) ? updatedItem.modified.getTime() : parseInt(updatedItem.modified, 10); 255 | if (srcCreated === updatedCreated && srcModified === updatedModified) { 256 | return false; 257 | } 258 | } 259 | return true; 260 | } 261 | 262 | /** 263 | * Find a query. 264 | * @param {*} current - The current record. 265 | */ 266 | findQuery(current, findQuery = null) { 267 | if (findQuery) { 268 | return findQuery(current); 269 | } 270 | if (findQuery === false) { 271 | return null; 272 | } 273 | return {_id: current._id}; 274 | } 275 | 276 | /** 277 | * Convert value to Date. 278 | * @param {*} value 279 | */ 280 | convertToDate(value) { 281 | if (value instanceof Date) { 282 | return value; 283 | } 284 | 285 | if (typeof value === 'string' || typeof value === 'number') { 286 | return new Date(value); 287 | } 288 | 289 | throw new Error(`Can't extract date from ${value}`); 290 | } 291 | 292 | /** 293 | * Convert value to ObjectId. 294 | * @param {*} value 295 | */ 296 | convertToObjectId(value) { 297 | if (value instanceof ObjectId) { 298 | return value; 299 | } 300 | 301 | if (ObjectId.isValid(value)) { 302 | return new ObjectId(value); 303 | } 304 | 305 | return null; 306 | } 307 | 308 | /** 309 | * Converts API returned item fields that have id's to ObjectId type. 310 | * @param {*} item - The item to convert. 311 | */ 312 | convertApiObjectToDbRecord(item) { 313 | const idFields = ['owner', '_rid', 'form', 'project']; 314 | const dateFields = ['trial', 'created', 'modified']; 315 | 316 | idFields.forEach(fieldName => { 317 | // eslint-disable-next-line no-prototype-builtins 318 | if (item.hasOwnProperty(fieldName)) { 319 | item[fieldName] = this.convertToObjectId(item[fieldName]); 320 | } 321 | }); 322 | 323 | dateFields.forEach(fieldName => { 324 | // eslint-disable-next-line no-prototype-builtins 325 | if (item.hasOwnProperty(fieldName)) { 326 | item[fieldName] = this.convertToDate(item[fieldName]); 327 | } 328 | }); 329 | } 330 | 331 | upsertCurrent(eachItem, beforeEach, afterEach, collection, find) { 332 | return async(current) => { 333 | const srcItem = _.cloneDeep(current); 334 | 335 | if (this.options.apiSource) { 336 | srcItem._id = this.convertToObjectId(srcItem._id); 337 | // eslint-disable-next-line no-prototype-builtins 338 | if (srcItem.hasOwnProperty('project')) { 339 | srcItem.project = this.convertToObjectId(srcItem.project); 340 | } 341 | } 342 | 343 | if (collection === 'forms' && this.exclude.includes(srcItem._id.toString())) { 344 | return; 345 | } 346 | 347 | if (eachItem) { 348 | eachItem(srcItem); 349 | } 350 | 351 | try { 352 | // Create the item we will be inserting/updating. 353 | const destItem = await this.findLast( 354 | `dest.${collection}`, 355 | this.findQuery( 356 | this.options.apiSource ? srcItem : current, 357 | find 358 | )); 359 | const updatedItem = {...destItem, ...(_.omit(srcItem, ['_id']))}; 360 | 361 | // Call before handler and then update if it says we should. 362 | if ( 363 | this.shouldClone(collection, srcItem, updatedItem) && 364 | await this.before(collection, beforeEach, srcItem, updatedItem, destItem) !== false 365 | ) { 366 | if (destItem) { 367 | if (this.options.apiSource) { 368 | this.convertApiObjectToDbRecord(updatedItem); 369 | } 370 | await this.dest[collection].updateOne(this.findQuery(destItem), 371 | {$set: _.omit(updatedItem, ['_id', 'machineName'])} 372 | ); 373 | } 374 | else { 375 | updatedItem._id = srcItem._id; 376 | 377 | if (this.options.apiSource) { 378 | if (collection === 'projects') { 379 | updatedItem.machineName = updatedItem.name; 380 | } 381 | this.convertApiObjectToDbRecord(updatedItem); 382 | } 383 | await this.dest[collection].insertOne(updatedItem); 384 | } 385 | } 386 | 387 | if (!updatedItem._id) { 388 | updatedItem._id = srcItem._id; 389 | } 390 | 391 | // Call the after handler. 392 | await this.after(collection, afterEach, srcItem, updatedItem, destItem); 393 | } 394 | catch (err) { 395 | console.error(`Error creating ${collection} ${current._id}`, err); 396 | } 397 | }; 398 | } 399 | 400 | /** 401 | * Upsert a record in the destination database. 402 | * @param {*} collection - The collection to upsert to. 403 | * @param {*} query - The query to use to find the document in the source database. 404 | * @param {*} beforeEach - The callback to call before upserting the document. 405 | * @param {*} afterEach - The callback to call after upserting the document. 406 | * @param {*} find - The query to use to find the "equivalent" document in the destination database. 407 | */ 408 | async upsertAll(collection, query, eachItem, beforeEach, afterEach, find = null) { 409 | if (this.options.apiSource) { 410 | await this.cloneFromApi( 411 | collection, 412 | query, 413 | this.upsertCurrent(eachItem, beforeEach, afterEach, collection, find) 414 | ); 415 | } 416 | else { 417 | await this.each( 418 | `src.${collection}`, 419 | query, 420 | this.upsertCurrent(eachItem, beforeEach, afterEach, collection, find) 421 | ); 422 | } 423 | } 424 | 425 | /** 426 | * Create a query for the given collection. 427 | * @param {*} query - The default query to decorate. 428 | * @returns - The decorated query. 429 | */ 430 | query(query, includeAll = false) { 431 | let newQuery = _.cloneDeep(query); 432 | if (!this.options.all && !includeAll) { 433 | newQuery.deleted = {$eq: null}; 434 | } 435 | return newQuery; 436 | } 437 | 438 | /** 439 | * Get the destination project id. 440 | */ 441 | get destProject() { 442 | return this.options.dstProject || this.options.project; 443 | } 444 | 445 | /** 446 | * Get the source project id. 447 | */ 448 | get sourceProject() { 449 | return this.options.srcProject || this.options.project; 450 | } 451 | 452 | /** 453 | * Create a decorated query for a single item. 454 | * @param {*} query - The default query to decorate. 455 | * @returns - The decorated query. 456 | */ 457 | itemQuery(query, includeAll = false) { 458 | let newQuery = _.cloneDeep(query); 459 | if (this.options.deletedAfter) { 460 | if (!includeAll) { 461 | newQuery['$or'] = [ 462 | {deleted: {$eq: null}}, 463 | {deleted: {$gt: new Date(parseInt(this.options.deletedAfter, 10))}} 464 | ]; 465 | } 466 | } 467 | else { 468 | newQuery = this.query(newQuery, includeAll); 469 | } 470 | // If it's OSS, then project would be undefined 471 | // eslint-disable-next-line no-prototype-builtins 472 | if (newQuery.hasOwnProperty('project') && !newQuery.project) { 473 | delete newQuery.project; 474 | } 475 | return newQuery; 476 | } 477 | 478 | /** 479 | * Adding a query for created and modified dates. 480 | * @param {*} query - The default query to decorate. 481 | */ 482 | afterQuery(query, createdAfter, modifiedAfter, includeAll = false) { 483 | const newQuery = this.itemQuery(query, includeAll); 484 | createdAfter = createdAfter || this.options.createdAfter; 485 | modifiedAfter = modifiedAfter || this.options.modifiedAfter; 486 | if (createdAfter) { 487 | newQuery.created = {$gt: new Date(parseInt(createdAfter, 10))}; 488 | } 489 | if (modifiedAfter) { 490 | newQuery.modified = {$gt: new Date(parseInt(modifiedAfter, 10))}; 491 | } 492 | return newQuery; 493 | } 494 | 495 | /** 496 | * Create a decorated query for project searching. 497 | * @param {*} srcProject - The source project to use. 498 | * @param {*} defaultQuery - The default query to decorate. 499 | * @returns - The decorated project query. 500 | */ 501 | projectQuery(srcProject = null, defaultQuery = {}) { 502 | const query = this.itemQuery(defaultQuery); 503 | if (srcProject && srcProject._id) { 504 | query.project = srcProject._id; 505 | } 506 | else { 507 | const sourceProject = this.sourceProject; 508 | if (sourceProject) { 509 | if (sourceProject.indexOf(',') === -1) { 510 | query._id = new ObjectId(sourceProject); 511 | } 512 | else { 513 | query._id = {$in: sourceProject.split(',').map((id) => new ObjectId(id))}; 514 | } 515 | } 516 | } 517 | return query; 518 | } 519 | 520 | /** 521 | * Create a decorated query for forms searching. 522 | */ 523 | formQuery(srcProject = null, defaultQuery = {}) { 524 | const query = this.projectQuery(srcProject, defaultQuery); 525 | if (this.options.forms) { 526 | query._id = {$in: this.options.forms.split(',').map((id) => new ObjectId(id))}; 527 | } 528 | return query; 529 | } 530 | 531 | /** 532 | * Sets the submission collection configuration for the destination imports. 533 | * @param {*} form - The form we are currently cloning. 534 | * @param {*} srcProj - The source project. 535 | * @param {*} destProj - The destination project. 536 | */ 537 | setCollection(form, srcProj, destProj) { 538 | const subsCollection = _.get(form, 'settings.collection'); 539 | 540 | if (!subsCollection) { 541 | if (!this.options.apiSource) { 542 | this.src.submissions = this.src.ogSubmissions; 543 | } 544 | this.dest.submissions = this.dest.ogSubmissions; 545 | return; 546 | } 547 | 548 | console.log(`Using collection - ${subsCollection}`); 549 | 550 | if (!this.options.apiSource) { 551 | this.src.submissions = srcProj && subsCollection 552 | ? this.src.db.collection(`${srcProj.name}_${subsCollection}`) 553 | : this.src.ogSubmissions; 554 | } 555 | 556 | this.dest.submissions = destProj && subsCollection 557 | ? this.dest.db.collection(`${destProj.name}_${subsCollection}`) 558 | : this.dest.ogSubmissions; 559 | } 560 | 561 | /** 562 | * Connnect to a mongodb database. 563 | * @param {*} uri - The uri to connect to. 564 | * @param {*} type - The type of database to connect to. 565 | * @returns - A promise that resolves to an object with all database collections. 566 | */ 567 | async connectDb(uri, type) { 568 | try { 569 | console.log(`Connecting to ${uri}\n`); 570 | const client = new MongoClient(uri, this.dbConfig(type)); 571 | await client.connect(); 572 | const db = await client.db(); 573 | console.log(`Succesfully connected to ${uri}`); 574 | return { 575 | client, 576 | db, 577 | projects: db.collection('projects'), 578 | forms: db.collection('forms'), 579 | ogSubmissions: db.collection('submissions'), 580 | submissions: db.collection('submissions'), 581 | submissionrevisions: db.collection('submissionrevisions'), 582 | roles: db.collection('roles'), 583 | actions: db.collection('actions'), 584 | actionItems: db.collection('actionitems'), 585 | formrevisions: db.collection('formrevisions'), 586 | tags: db.collection('tags') 587 | }; 588 | } 589 | catch (err) { 590 | throw new Error(`Could not connect to database ${uri}: ${err.message}`); 591 | } 592 | } 593 | 594 | /** 595 | * Based on DB collection name defines the API endpoint path. 596 | * @param {*} collection - DB collection name. 597 | */ 598 | getApiPath(collection) { 599 | switch (collection) { 600 | case 'projects': 601 | return `${this.src}`; 602 | 603 | case 'roles': 604 | return `${this.src}/role`; 605 | 606 | case 'formrevisions': 607 | return `${this.src}/form/${this.currentForm._id}/v`; 608 | 609 | case 'tags': 610 | return `${this.src}/tag`; 611 | 612 | case 'forms': 613 | return `${this.src}/form`; 614 | 615 | case 'submissions': 616 | return `${this.src}/form/${this.currentForm._id}/submission`; 617 | 618 | case 'actions': 619 | return `${this.src}/form/${this.currentForm._id}/action`; 620 | 621 | case 'submissionrevisions': 622 | return `${this.src}/form/${this.currentForm._id}/submission/${this.currentSubmission._id}/v`; 623 | 624 | default: 625 | throw new Error(`Unknown collection ${collection}`); 626 | } 627 | } 628 | 629 | /** 630 | * Based on DB collection name, DB query and skip parameter creates the URL for request. 631 | * @param {String} collection - DB collection name. 632 | * @param {Object} query - DB query object. 633 | * @param {Number} skip - skip URL parameter. 634 | */ 635 | getRequestUrl(collection, query, skip) { 636 | if (collection === 'projects') { 637 | return this.getApiPath(collection); 638 | } 639 | 640 | const url = `${this.getApiPath(collection)}?limit=${this.limit}&skip=${skip}`; 641 | 642 | if (collection === 'submissions' && query.created && query.created.$gt) { 643 | return `${url}&created__gt=${this.convertToDate(query.created.$gt).getTime()}`; 644 | } 645 | 646 | return url; 647 | } 648 | 649 | /** 650 | * Does a request to the API, returns response data in Array format. 651 | * @param {String} collection - DB collection name. 652 | * @param {Object} query - DB query object. 653 | * @param {Number} skip - skip URL parameter. 654 | * @returns {Array} 655 | */ 656 | async fetchRecordsFromApi(collection, query, skip) { 657 | const res = await this.fetch({ 658 | url: this.getRequestUrl(collection, query, skip), 659 | method: 'GET', 660 | headers: this.requestHeaders 661 | }); 662 | 663 | // If request was successful or if the API has already returned all the existing records 664 | if (res.ok || (!res.ok && res.status === 416)) { 665 | return Array.isArray(res.body) ? res.body : [res.body]; 666 | } 667 | else { 668 | console.error(`HTTP Error: ${res.status} ${res.statusText} ${res.url} \n ${JSON.stringify(res.body)}`); 669 | return; 670 | } 671 | } 672 | 673 | /** 674 | * Gets records from the API and passes them to callback function. 675 | * @param {String} collection - DB collection name. 676 | * @param {Object} query - DB query object. 677 | * @param {Function} cb - Callback to which each fetched record is passed. 678 | * @returns {Array} 679 | */ 680 | async cloneFromApi(collection, query, cb) { 681 | let skip = 0; 682 | // eslint-disable-next-line no-constant-condition 683 | while (true) { 684 | const records = await this.fetchRecordsFromApi(collection, query, skip); 685 | 686 | if (!records.length) { 687 | break; 688 | } 689 | 690 | for (const record of records) { 691 | // eslint-disable-next-line callback-return 692 | await cb(record); 693 | } 694 | 695 | // Fetch only one project 696 | if (collection === 'projects') { 697 | break; 698 | } 699 | 700 | skip += this.limit; 701 | } 702 | } 703 | 704 | async connect() { 705 | if (!this.srcPath) { 706 | throw new Error('Source should be provided.'); 707 | } 708 | 709 | if (this.srcPath === this.mongoDest) { 710 | throw new Error('Source and destination databases cannot be the same.'); 711 | } 712 | 713 | if (this.options.apiSource && !this.options.key) { 714 | throw new Error('An API Key should be provided via --key option to clone from the API.'); 715 | } 716 | 717 | this.src = this.options.apiSource 718 | ? this.srcPath 719 | : await this.connectDb(this.srcPath, 'src'); 720 | 721 | // If there are no source projects, then we can assume we are cloning from OSS. 722 | this.oss = this.options.apiSource ? false : !(await this.src.projects.findOne({})); 723 | 724 | // Connect to the destination databases. 725 | if (this.mongoDest) { 726 | this.dest = await this.connectDb(this.mongoDest, 'dst'); 727 | } 728 | } 729 | 730 | /** 731 | * Disconnect the database connections. 732 | */ 733 | async disconnect() { 734 | // Disconnect from the source and destination databases. 735 | if (!this.options.apiSource) { 736 | await this.src.client.close(); 737 | } 738 | await this.dest.client.close(); 739 | } 740 | 741 | /** 742 | * Migrate the data encrypted within a submission to a new secret. 743 | * @param {*} src - The source record 744 | * @param {*} update - The updated record 745 | * @param {string} decryptKey - The source key to decrypt data (either project.settings.secret or DB_SECRET). 746 | */ 747 | migrateDataEncryption(src, update, compsWithEncryptedData) { 748 | const srcSecret = this.srcSecret || this.options.srcDbSecret; 749 | const destSecret = this.destSecret || this.options.dstDbSecret; 750 | if ( 751 | !compsWithEncryptedData.length || 752 | !srcSecret || 753 | !destSecret || 754 | srcSecret === destSecret 755 | ) { 756 | return; 757 | } 758 | compsWithEncryptedData.forEach((compPath) => { 759 | if (_.get(src, `data.${compPath}`, false)) { 760 | _.set(update, `data.${compPath}`, 761 | this.encrypt(destSecret, this.decrypt(srcSecret, _.get(src, `data.${compPath}`))) 762 | ); 763 | } 764 | }); 765 | } 766 | 767 | /** 768 | * Clone submissions from one form to another. 769 | * @param {*} srcForm - The source form from the database. 770 | * @param {*} destForm - The destination from from the database. 771 | * @param {string[]} compsWithEncryptedData - Array with paths of components that are encrypted. 772 | */ 773 | async cloneSubmissions(srcForm, destForm, compsWithEncryptedData) { 774 | process.stdout.write('\n'); 775 | process.stdout.write(' - Submissions:'); 776 | 777 | // Determine the last one cloned, and ensure that we only fetch submissions after that date. 778 | const lastSubmission = this.options.updateExistingSubmissions ? null : await this.findLast('dest.submissions', { 779 | form: destForm._id, 780 | project: destForm.project 781 | }); 782 | 783 | // Submissions always "create new" so we need to ensure we only clone the ones that have not yet been cloned. 784 | const query = this.afterQuery({ 785 | form: srcForm._id, 786 | project: srcForm.project 787 | }, 788 | lastSubmission && lastSubmission.created 789 | ? this.convertToDate(lastSubmission.created).getTime() 790 | : null 791 | ); 792 | 793 | // Clone the submissions. 794 | await this.upsertAll('submissions', query, null, async(src, update) => { 795 | update.form = destForm._id; 796 | update.project = destForm.project; 797 | if (compsWithEncryptedData.length) { 798 | this.migrateDataEncryption(src, update, compsWithEncryptedData); 799 | } 800 | update.roles = this.migrateRoles(update.roles); 801 | this.migrateAccess(src.access, update.access); 802 | }, async(srcSubmission, destSubmission) => { 803 | this.currentSubmission = srcSubmission; 804 | await this.cloneSubmissionRevisions(srcSubmission, destSubmission, compsWithEncryptedData); 805 | }, this.options.updateExistingSubmissions ? null : false); 806 | } 807 | 808 | /** 809 | * Clone submission revisions from one database to another. 810 | * @param {*} srcSubmission - The source submission. 811 | * @param {*} destSubmission - The destination submission. 812 | */ 813 | async cloneSubmissionRevisions(srcSubmission, destSubmission, compsWithEncryptedData) { 814 | if (!this.options.includeSubmissionRevisions) { 815 | return; 816 | } 817 | await this.upsertAll('submissionrevisions', this.afterQuery({ 818 | _rid: srcSubmission._id 819 | }), null, async(src, update) => { 820 | update._rid = destSubmission._id; 821 | this.migrateDataEncryption(src, update, compsWithEncryptedData); 822 | }); 823 | } 824 | 825 | /** 826 | * Provided the source role id, and the destination project, this will return the equivalent destination role id. 827 | * @param {ObjectId|string} srcId - The source resource id. 828 | * @param {ObjectId|string} destProjectId - The destination project id. 829 | */ 830 | async getDestinationRoleId(srcId, destProjectId) { 831 | let role; 832 | 833 | if (this.options.apiSource) { 834 | const roleRes = await this.fetch({ 835 | url: `${this.src}/role/${srcId.toString()}`, 836 | headers: this.requestHeaders 837 | }); 838 | 839 | if (roleRes.ok) { 840 | role = roleRes.body; 841 | } 842 | else { 843 | console.error(`Failed to fetch role: ${roleRes.status} ${roleRes.statusText} ${roleRes.url}`); 844 | return; 845 | } 846 | } 847 | const srcRole = this.options.apiSource && role 848 | ? role 849 | : await this.src.roles.findOne({_id: this.convertToObjectId(srcId)}); 850 | 851 | if (srcRole) { 852 | // eslint-disable-next-line max-len 853 | const destRole = await this.dest.roles.findOne({title: srcRole.title, project: new ObjectId(destProjectId.toString())}); 854 | if (destRole) { 855 | return destRole._id.toString(); 856 | } 857 | } 858 | return srcId.toString(); 859 | } 860 | 861 | /** 862 | * Provided the destination form id, the destination project id and the date of submission creation, will return the next created submission. 863 | * @param {ObjectId} destFormId - The destination form id. 864 | * @param {ObjectId} destProjectId - The destination project id. 865 | * @param {Date|string} createdDate - Date or date string when the submission was created. 866 | */ 867 | async getDestinationSubmissionByDate(destFormId, destProjectId, createdDate) { 868 | return await _.get(this, 'dest.submissions').findOne({ 869 | form: new ObjectId(destFormId.toString()), 870 | project: new ObjectId(destProjectId.toString()), 871 | created: {$gt: _.isDate(createdDate) ? createdDate : new Date(createdDate)} 872 | }); 873 | } 874 | 875 | /** 876 | * Clone actions from one form to another. 877 | * @param {*} srcForm - The source form to clone actions from. 878 | * @param {*} destForm - The destination form to clone actions to. 879 | */ 880 | async cloneActions(srcForm, destForm) { 881 | process.stdout.write('\n'); 882 | process.stdout.write(' - Actions:'); 883 | await this.upsertAll('actions', this.itemQuery({ 884 | form: srcForm._id 885 | }), null, async(src, update) => { 886 | update.form = destForm._id; 887 | if (!update.settings) { 888 | return; 889 | } 890 | if (update.settings.role) { 891 | const srcId = update.settings.role; 892 | update.settings.role = await this.getDestinationRoleId(srcId, destForm.project); 893 | } 894 | }, null, (current) => { 895 | return {$or: [{_id: current._id}, {machineName: current.machineName}]}; 896 | }); 897 | } 898 | 899 | /** 900 | * Clone forms from one project to another. 901 | * @param {*} srcProject - The source project. 902 | * @param {*} destProject - The destination project. 903 | */ 904 | async cloneForms(srcProject, destProject) { 905 | process.stdout.write('\n'); 906 | process.stdout.write(' - Forms:'); 907 | let compsWithEncryptedData = []; 908 | await this.upsertAll('forms', this.formQuery(srcProject), (form) => { 909 | process.stdout.write('\n'); 910 | process.stdout.write(`- Form: ${form.title} (${form._id})`); 911 | }, async(src, update, dest) => { 912 | if (this.options.submissionsOnly) { 913 | return false; 914 | } 915 | 916 | await eachComponentAsync(update.components, async(component, path) => { 917 | if (component.encrypted) { 918 | compsWithEncryptedData.push(path); 919 | } 920 | }); 921 | 922 | this.setCollection(update, srcProject, destProject); 923 | update.project = destProject._id; 924 | this.migrateFormAccess(src, update); 925 | }, async(srcForm, destForm) => { 926 | if (this.options.deleteSubmissions) { 927 | console.log(`Deleting submissions from ${destForm.title}`); 928 | await this.dest.submissions.deleteMany(this.itemQuery({ 929 | project: destForm.project, 930 | form: destForm._id 931 | })); 932 | } 933 | this.currentForm = srcForm; 934 | await this.cloneSubmissions(srcForm, destForm, compsWithEncryptedData); 935 | await this.cloneActions(srcForm, destForm); 936 | await this.cloneFormRevisions(srcForm, destForm); 937 | compsWithEncryptedData = []; 938 | }); 939 | } 940 | 941 | async cloneRoles(srcProject, destProject) { 942 | process.stdout.write('\n'); 943 | process.stdout.write(' - Roles:'); 944 | await this.upsertAll('roles', this.projectQuery(srcProject), null, (src, update) => { 945 | update.project = destProject._id; 946 | }, null, (current) => { 947 | return { 948 | project: destProject._id, 949 | _id: current._id, 950 | title: current.title 951 | }; 952 | }); 953 | } 954 | 955 | async cloneTags(srcProject, destProject) { 956 | process.stdout.write('\n'); 957 | process.stdout.write(' - Tags:'); 958 | await this.upsertAll('tags', this.projectQuery(srcProject), null, (src, update) => { 959 | update.project = destProject._id; 960 | }); 961 | } 962 | 963 | async cloneFormRevisions(srcForm, destForm) { 964 | if (!this.options.includeFormRevisions) { 965 | return; 966 | } 967 | process.stdout.write('\n'); 968 | process.stdout.write(' - Revisions:'); 969 | await this.upsertAll('formrevisions', this.afterQuery({ 970 | _rid: srcForm._id, 971 | project: srcForm.project 972 | }), null, (src, update, dest) => { 973 | // If the revision already exists in the destination, don't upsert it 974 | if (dest) { 975 | return false; 976 | } 977 | update._rid = destForm._id; 978 | update.project = destForm.project; 979 | }); 980 | } 981 | 982 | destRole(roleId) { 983 | const srcRole = this.srcRoles.find((role) => role._id.toString() === roleId.toString()); 984 | if (!srcRole) { 985 | return new ObjectId(roleId); 986 | } 987 | const roleTitle = srcRole.title; 988 | const dstRole = this.destRoles.find((role) => role.title === roleTitle); 989 | if (!dstRole || !dstRole._id) { 990 | return new ObjectId(roleId); 991 | } 992 | return new ObjectId(dstRole._id.toString()); 993 | } 994 | 995 | /** 996 | * Migrate a roles array to use destination roles instead of source roles. 997 | * @param {*} roles - An array of role ids that need to be migrated. 998 | */ 999 | migrateRoles(roles) { 1000 | if (!roles || !roles.length) { 1001 | return []; 1002 | } 1003 | const newRoles = []; 1004 | if (roles && roles.length) { 1005 | roles.forEach((roleId) => newRoles.push(this.destRole(roleId))); 1006 | } 1007 | return newRoles; 1008 | } 1009 | 1010 | /** 1011 | * Migrate access array to use destination roles instead of source roles. 1012 | * @param {*} access - An access array. 1013 | */ 1014 | migrateAccess(srcAccess, dstAccess) { 1015 | if (srcAccess && srcAccess.length) { 1016 | srcAccess.forEach((roleAccess) => { 1017 | if (!roleAccess.type || roleAccess.type.indexOf('team_') === -1) { 1018 | const existing = dstAccess.find((access) => access.type === roleAccess.type); 1019 | if (existing) { 1020 | existing.roles = this.migrateRoles(roleAccess.roles); 1021 | } 1022 | else { 1023 | dstAccess.push(_.assign({}, roleAccess, {roles: this.migrateRoles(roleAccess.roles)})); 1024 | } 1025 | } 1026 | }); 1027 | } 1028 | } 1029 | 1030 | /** 1031 | * Migrate the form access to use destination roles instead of source roles. 1032 | * @param {*} item 1033 | */ 1034 | migrateFormAccess(src, dest) { 1035 | if (src && dest) { 1036 | this.migrateAccess(src.access, dest.access); 1037 | this.migrateAccess(src.submissionAccess, dest.submissionAccess); 1038 | } 1039 | } 1040 | 1041 | /** 1042 | * Migrate the project settings from one project encryption to another. 1043 | * @param {*} srcProject - The source project. 1044 | * @param {*} destProject - The destination project. 1045 | */ 1046 | async migrateSettings(src, update, dest) { 1047 | if ( 1048 | !src || 1049 | !src['settings_encrypted'] || 1050 | !this.options.srcDbSecret || 1051 | !this.options.dstDbSecret || 1052 | this.options.srcDbSecret === this.options.dstDbSecret 1053 | ) { 1054 | return; 1055 | } 1056 | const decryptedDstSettings = dest ? this.decrypt(this.options.dstDbSecret, dest['settings_encrypted'], true) : {}; 1057 | if (!decryptedDstSettings) { 1058 | return; 1059 | } 1060 | if (decryptedDstSettings.secret) { 1061 | this.dstSecret = decryptedDstSettings.secret; 1062 | } 1063 | 1064 | // Decrypt the source, and set the update to the re-encrypted settings object. 1065 | const decryptedSrcSettings = this.decrypt(this.options.srcDbSecret, src['settings_encrypted'], true); 1066 | if (decryptedSrcSettings.secret) { 1067 | this.srcSecret = decryptedSrcSettings.secret; 1068 | } 1069 | 1070 | // Do not overwrite existing destination project settings. 1071 | if (dest && dest['settings_encrypted']) { 1072 | update['settings_encrypted'] = dest['settings_encrypted']; 1073 | return; 1074 | } 1075 | 1076 | update['settings_encrypted'] = this.encrypt(this.options.dstDbSecret, decryptedSrcSettings, true); 1077 | } 1078 | 1079 | /** 1080 | * Close all items within a project. 1081 | * @param {*} srcProject - The source project 1082 | * @param {*} destProject - The destination project. 1083 | */ 1084 | async cloneProjectItems(srcProject, destProject) { 1085 | if (!destProject) { 1086 | throw new Error('Destination project not found for this cloning operation.'); 1087 | } 1088 | await this.cloneRoles(srcProject, destProject); 1089 | 1090 | let roles; 1091 | 1092 | if (this.options.apiSource) { 1093 | const rolesRes = await this.fetch({ 1094 | url: `${this.src}/role`, 1095 | headers: this.requestHeaders 1096 | }); 1097 | 1098 | if (rolesRes.ok) { 1099 | roles = rolesRes.body; 1100 | } 1101 | else { 1102 | console.error(`Failed to fetch project roles: ${rolesRes.status} ${rolesRes.statusText} ${rolesRes.url}`); 1103 | return; 1104 | } 1105 | } 1106 | 1107 | this.srcRoles = this.options.apiSource && roles 1108 | ? roles 1109 | : await this.src.roles.find(srcProject ? {project: srcProject._id} : {}).toArray(); 1110 | this.destRoles = await this.dest.roles.find({project: destProject._id}).toArray(); 1111 | if (srcProject && destProject) { 1112 | this.migrateFormAccess(srcProject, destProject); 1113 | await this.dest.projects.updateOne( 1114 | {_id: destProject._id}, 1115 | {$set: {access: destProject.access}} 1116 | ); 1117 | } 1118 | await this.cloneForms(srcProject, destProject); 1119 | } 1120 | 1121 | /** 1122 | * Clone the project from one database to another. 1123 | */ 1124 | async cloneProject() { 1125 | process.stdout.write('\n'); 1126 | process.stdout.write('Fetching formio project owner.'); 1127 | const formioProject = await this.dest.projects.findOne({name: 'formio'}); 1128 | const formioOwner = formioProject ? formioProject.owner : null; 1129 | const query = this.projectQuery(); 1130 | await this.upsertAll('projects', query, (project) => { 1131 | if (project.name === 'formio') { 1132 | return false; 1133 | } 1134 | process.stdout.write('\n'); 1135 | process.stdout.write(`- Project ${project.title}:`); 1136 | }, async(src, update, dest) => { 1137 | // Do not update if they provide a destination project or we are the "formio" project. 1138 | if (src.name === 'formio') { 1139 | return false; 1140 | } 1141 | 1142 | if (formioOwner) { 1143 | update.owner = formioOwner; 1144 | } 1145 | 1146 | // Migrate the settings. 1147 | this.srcSecret = null; 1148 | this.dstSecret = null; 1149 | await this.migrateSettings(src, update, dest); 1150 | 1151 | // Keep the access settings. 1152 | update.access = dest ? dest.access : []; 1153 | }, async(src, dest) => { 1154 | if (src.name === 'formio') { 1155 | return; 1156 | } 1157 | 1158 | await this.cloneProjectItems(src, dest); 1159 | await this.cloneTags(src, dest); 1160 | }); 1161 | } 1162 | 1163 | /** 1164 | * Clone a number of projects from one database to another. 1165 | */ 1166 | async clone(beforeAll = null, afterAll = null) { 1167 | this.beforeAll = beforeAll; 1168 | this.afterAll = afterAll; 1169 | 1170 | // Connect to the source and destination. 1171 | await this.connect(); 1172 | 1173 | // If they wish to only clone submissions, then do that. 1174 | if ((this.options.dstProject || this.options.submissionsOnly || this.oss) && !this.options.apiSource) { 1175 | process.stdout.write('\n'); 1176 | process.stdout.write(`Cloning forms and submissions of ${this.sourceProject} to ${this.destProject}.`); 1177 | process.stdout.write('\n'); 1178 | if (!this.destProject) { 1179 | throw new Error('You must provide a destination project id.'); 1180 | } 1181 | await this.cloneProjectItems( 1182 | await this.src.projects.findOne(this.itemQuery({_id: new ObjectId(this.sourceProject)})), 1183 | await this.dest.projects.findOne(this.itemQuery({_id: new ObjectId(this.destProject)})) 1184 | ); 1185 | } 1186 | else { 1187 | await this.cloneProject(); 1188 | } 1189 | try { 1190 | fs.rmSync('clonestate.json'); 1191 | } 1192 | catch (err) { 1193 | // Do nothing. 1194 | } 1195 | 1196 | // Say we are done. 1197 | process.stdout.write('\n'); 1198 | console.log('Done!'); 1199 | if (process.env.TEST_SUITE !== '1') { 1200 | await this.disconnect(); 1201 | } 1202 | } 1203 | } 1204 | 1205 | module.exports = Cloner; 1206 | -------------------------------------------------------------------------------- /src/analytics.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var ua = require('universal-analytics'); 4 | var visitor = ua('UA-58453303-3'); 5 | 6 | module.exports = function(command) { 7 | return function(options, next) { 8 | visitor.event(command, options.params[0]).send(); 9 | next(); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/authenticate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Provides a way to authenticate commands against Form.io 5 | */ 6 | var async = require('async'); 7 | var url = require('url'); 8 | var prompt = require('prompt'); 9 | prompt.start(); 10 | module.exports = function(config) { 11 | /** 12 | * Returns the server options. 13 | * 14 | * @param path 15 | * @param options 16 | * @returns {*} 17 | */ 18 | var getServerOptions = function(path, options) { 19 | if (path.indexOf('http') === 0) { 20 | var urlObject = url.parse(path); 21 | var pathnameParts = urlObject.pathname.split('/').filter(Boolean); 22 | // If the url contains 'project' (ex. http://localhost:3000/project/63ac4f3768baf92d9bb0106f) 23 | // then we extract project id from it, else - get the project name 24 | if (urlObject.href.match(/http[s]?:\/\/[^/]+\/project\//)) { 25 | options.projectId = pathnameParts[1]; 26 | } 27 | else { 28 | options.projectName = pathnameParts[0]; 29 | } 30 | 31 | options.server = urlObject.href.replace(urlObject.pathname, ''); 32 | options.host = urlObject.host; 33 | // Slice gets rid of the ":" at the end. 34 | options.protocol = urlObject.protocol.slice(0, -1); 35 | } 36 | else { 37 | options.projectName = options.project; 38 | options.project = 'https://' + options.project + '.form.io'; 39 | } 40 | 41 | return options; 42 | }; 43 | 44 | /** 45 | * Perform an authentication. 46 | * 47 | * @param options 48 | * @param next 49 | * @returns {*} 50 | */ 51 | var authenticate = function(text, options, next) { 52 | // Let them know what is going on. 53 | console.log(''); 54 | var serverName = text; 55 | if (serverName) { 56 | serverName += '::'; 57 | } 58 | serverName += options.server + '.'; 59 | console.log('This action requires a login to '.green + serverName.green); 60 | 61 | // If the API Key is provided. 62 | if (options.key || options.adminKey || (options.srcAdminKey && options.dstAdminKey)) { 63 | console.log('An API Key was provided for authentication'); 64 | console.log(''); 65 | return next(null); 66 | } 67 | 68 | if (options.server === 'https://form.io') { 69 | console.log('You can create a free account by going to https://portal.form.io/#/auth/register'.green); 70 | } 71 | 72 | return next('Either API key(s) or Admin key(s) should be provided to authenticate'); 73 | }; 74 | 75 | var getAuthOptions = function(prefix, options) { 76 | var authOptions = { 77 | key: options.key, 78 | adminKey: options.adminKey, 79 | srcAdminKey: options.srcAdminKey, 80 | dstAdminKey: options.dstAdminKey 81 | }; 82 | 83 | if (config && (typeof config[prefix] === 'number')) { 84 | var paramIndex = config[prefix]; 85 | var url = options.params[paramIndex]; 86 | if (url.substr(0, 4) !== 'http') { 87 | // This is local and does not need authentication. 88 | return null; 89 | } 90 | getServerOptions(options.params[paramIndex], authOptions); 91 | } 92 | 93 | if (options[prefix + 'Key']) { 94 | authOptions.key = options[prefix + 'Key']; 95 | } 96 | if (options[prefix + 'AdminKey']) { 97 | authOptions.adminKey = options[prefix + 'AdminKey']; 98 | } 99 | return authOptions; 100 | }; 101 | 102 | var srcOptions = {}; 103 | var dstOptions = {}; 104 | 105 | return function(options, done) { 106 | async.series([ 107 | // Source authentication. 108 | function(next) { 109 | if (!config || !config.hasOwnProperty('src')) { 110 | return next(); 111 | } 112 | 113 | // Authenticate to the source. 114 | srcOptions = getAuthOptions('src', options); 115 | if (!srcOptions) { 116 | return next(); 117 | } 118 | 119 | authenticate('SOURCE', srcOptions, function(err) { 120 | if (err) { 121 | return next(err); 122 | } 123 | options.srcOptions = srcOptions; 124 | next(); 125 | }); 126 | }, 127 | // Destination authentication. 128 | function(next) { 129 | dstOptions = getAuthOptions('dst', options); 130 | if (!dstOptions) { 131 | return next(); 132 | } 133 | 134 | // Use the dstOptions if the servers are the same and the destination does not have creds. 135 | if ( 136 | srcOptions && 137 | srcOptions.server && 138 | (srcOptions.server === dstOptions.server) && 139 | (!dstOptions.key) 140 | ) { 141 | options.dstOptions = srcOptions; 142 | return next(); 143 | } 144 | 145 | // Authenticate to the destination. 146 | var text = (srcOptions && srcOptions.server) ? 'DESTINATION' : ''; 147 | authenticate(text, dstOptions, function(err) { 148 | if (err) { 149 | return next(err); 150 | } 151 | options.dstOptions = dstOptions; 152 | next(); 153 | }); 154 | } 155 | ], done); 156 | }; 157 | }; 158 | -------------------------------------------------------------------------------- /src/clone.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Cloner = require('./Cloner'); 3 | module.exports = async function(source, destination, options) { 4 | const cloner = new Cloner(source, destination, options); 5 | await cloner.clone(); 6 | }; 7 | -------------------------------------------------------------------------------- /src/copy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'); 4 | var _ = require('lodash'); 5 | var fetch = require('./fetch'); 6 | const {migratePdfFileForForm} = require('./utils'); 7 | 8 | module.exports = function(options, done) { 9 | var type = options.params[0].trim(); 10 | var src = options.params[1].trim(); 11 | var dest = options.params[2].trim(); 12 | 13 | if (!type) { 14 | return done('You must provide a type.'); 15 | } 16 | 17 | if (!src) { 18 | return done('You must provide a source form to copy.'); 19 | } 20 | 21 | if (!dest) { 22 | return done('You must provide a destination.'); 23 | } 24 | 25 | var destForm = {components:[]}; 26 | var sourceForms = src.split(','); 27 | 28 | async.series([ 29 | // Load the form. 30 | function(next) { 31 | if (['form', 'resource'].indexOf(type) === -1) { 32 | return next('Invalid form type given: ' + type); 33 | } 34 | 35 | var copyComponents = async function(form, cb) { 36 | if (options.full) { 37 | const formPart = _.omit(form, ['_id', '_vid', 'created', 'modified', 'machineName']); 38 | destForm = _.assign(formPart, {components: [...formPart.components, ...destForm.components]}); 39 | } 40 | else { 41 | const formPart = _.pick(form, ['title', 'components', 'tags', 'properties', 'settings', 'display']); 42 | destForm = _.assign(formPart, {components: [...formPart.components, ...destForm.components]}); 43 | } 44 | 45 | if (options.migratePdfFiles) { 46 | await migratePdfFileForForm(destForm, options); 47 | } 48 | 49 | return cb(); 50 | }; 51 | 52 | // For each source form, copy the components after uniquifying them. 53 | async.eachSeries(sourceForms, function(src, cb) { 54 | fetch(options)({ 55 | url: src 56 | }).then(({body: form}) => { 57 | copyComponents(form, cb); 58 | }).catch(err => { 59 | console.log('Loading form ' + src + ' returned error: ' + err.message.red); 60 | cb(err); 61 | }); 62 | }, function(err) { 63 | if (err) { 64 | return next(err); 65 | } 66 | 67 | return next(); 68 | }); 69 | }, 70 | // Copy the form. 71 | function(next) { 72 | console.log('Saving components to destination ' + type + ' ' + dest); 73 | var parts = dest.match(/^(http[s]?:\/\/)([^\/]+)\/(.*)/); 74 | if (parts.length < 4) { 75 | return next('Invalid destination: Must contain a ' + type + ' path'); 76 | } 77 | 78 | // Load the destination form. 79 | const fetchWithHeaders = fetch(options.dstOptions); 80 | 81 | fetchWithHeaders({ 82 | url: dest 83 | }).then(({body: form}) => { 84 | if (form && form.components) { 85 | console.log('Updating existing ' + type); 86 | var updatedForm = _.assign(form, destForm); 87 | fetchWithHeaders({ 88 | url: dest, 89 | method: 'PUT', 90 | body: updatedForm 91 | }).then(({body: form}) => { 92 | console.log('RESULT:' + JSON.stringify(form).green); 93 | next(); 94 | }).catch(next); 95 | } 96 | else { 97 | var name = ''; 98 | var projectUrl = parts[1] + parts[2]; 99 | if (parts[2].match(/form\.io$/)) { 100 | name = parts[3]; 101 | } 102 | else { 103 | var formPath = parts[3].split('/'); 104 | var projectName = formPath.shift(); 105 | projectUrl += '/' + projectName; 106 | if (formPath.length) { 107 | name = formPath.join('/').trim(); 108 | } 109 | else { 110 | parts = src.match(/^(http[s]?:\/\/)([^\/]+)\/(.*)/); 111 | formPath = parts[3].split('/'); 112 | formPath.shift(); 113 | name = formPath.join('/').trim(); 114 | } 115 | } 116 | var isSameProject = src.split('/')[3] === dest.split('/')[3]; 117 | var newForm = _.assign({}, destForm, { 118 | title: isSameProject ? 'Copy of ' + destForm.title : destForm.title, 119 | name: _.camelCase(name.split('/').join(' ')), 120 | path: name, 121 | type: type 122 | }); 123 | console.log('Creating new ' + type); 124 | fetchWithHeaders({ 125 | url: projectUrl + '/form', 126 | method: 'POST', 127 | body: newForm 128 | }).then(({body: form}) => { 129 | console.log('RESULT:' + JSON.stringify(form).green); 130 | next(); 131 | }).catch(next); 132 | } 133 | }) 134 | .catch((err) => { 135 | console.log(err); 136 | }); 137 | } 138 | ], function(err) { 139 | if (err) { 140 | console.log(err); 141 | return done(err); 142 | } 143 | console.log('Done!'); 144 | done(); 145 | }); 146 | }; 147 | -------------------------------------------------------------------------------- /src/deploy.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const async = require('async'); 4 | const _ = require('lodash'); 5 | const loadTemplate = require(__dirname + '/loadTemplate'); 6 | const exportTemplate = require(__dirname + '/exportTemplate'); 7 | const importTemplate = require(__dirname + '/importTemplate'); 8 | 9 | module.exports = function(options, next) { 10 | const steps = []; 11 | 12 | // Setup the source options. 13 | const srcOptions = _.clone(options.srcOptions); 14 | srcOptions.project = options.params[0]; 15 | if (srcOptions.project.indexOf('.json') !== -1) { 16 | steps.push(_.partial(loadTemplate, srcOptions)); 17 | } 18 | else { 19 | steps.push(_.partial(exportTemplate, srcOptions)); 20 | } 21 | 22 | // Setup the destination options. 23 | const dstOptions = _.clone(options.dstOptions); 24 | dstOptions.project = options.params[1]; 25 | 26 | // Copy the template from source to destination. 27 | steps.push(_.partial(function(src, dst, next) { 28 | dst.template = src.template; 29 | next(); 30 | }, srcOptions, dstOptions)); 31 | 32 | // Import the template into the destination. 33 | steps.push(_.partial(importTemplate, dstOptions)); 34 | async.series(steps, next); 35 | }; 36 | -------------------------------------------------------------------------------- /src/eachComponentAsync.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Iterate through each component within a form. 3 | * 4 | * @param {Object} components 5 | * The components to iterate. 6 | * @param {Function} fn 7 | * The iteration function to invoke for each component. 8 | * @param {Boolean} includeAll 9 | * Whether or not to include layout components. 10 | * @param {String} path 11 | * The current data path of the element. Example: data.user.firstName 12 | * @param {Object} parent 13 | * The parent object. 14 | */ 15 | 16 | const eachComponentAsync = async (components, fn, includeAll, path = '', parent) => { 17 | if (!components || !components.length || !fn) { 18 | return; 19 | } 20 | 21 | for (const component of components) { 22 | if (!component) { 23 | return; 24 | } 25 | const hasColumns = component.columns && Array.isArray(component.columns); 26 | const hasRows = component.rows && Array.isArray(component.rows); 27 | const hasComps = component.components && Array.isArray(component.components); 28 | let noRecurse = false; 29 | const newPath = component.key ? (path ? (`${path}.${component.key}`) : component.key) : ''; 30 | 31 | // Keep track of parent references. 32 | if (parent) { 33 | // Ensure we don't create infinite JSON structures. 34 | component.parent = {...parent}; 35 | delete component.parent.components; 36 | delete component.parent.componentMap; 37 | delete component.parent.columns; 38 | delete component.parent.rows; 39 | } 40 | 41 | // there's no need to add other layout components here because we expect that those would either have columns, rows or components 42 | const layoutTypes = ['htmlelement', 'content']; 43 | const isLayoutComponent = hasColumns || hasRows || hasComps || layoutTypes.indexOf(component.type) > -1; 44 | if (includeAll || component.tree || !isLayoutComponent) { 45 | noRecurse = await fn(component, newPath, components); 46 | } 47 | 48 | const subPath = () => { 49 | if ( 50 | component.key && 51 | !['panel', 'table', 'well', 'columns', 'fieldset', 'tabs', 'form'].includes(component.type) && 52 | ( 53 | ['datagrid', 'container', 'editgrid', 'address', 'dynamicWizard'].includes(component.type) || 54 | component.tree 55 | ) 56 | ) { 57 | return newPath; 58 | } 59 | else if ( 60 | component.key && 61 | component.type === 'form' 62 | ) { 63 | return `${newPath}.data`; 64 | } 65 | return path; 66 | }; 67 | 68 | if (!noRecurse) { 69 | if (hasColumns) { 70 | for (const column of component.columns) { 71 | await eachComponentAsync(column.components, fn, includeAll, subPath(), parent ? component : null); 72 | } 73 | } 74 | else if (hasRows) { 75 | for (const row of component.rows) { 76 | if (Array.isArray(row)) { 77 | for (const column of row) { 78 | await eachComponentAsync(column.components, fn, includeAll, subPath(), parent ? component : null); 79 | } 80 | } 81 | } 82 | } 83 | else if (hasComps) { 84 | await eachComponentAsync(component.components, fn, includeAll, subPath(), parent ? component : null); 85 | } 86 | } 87 | } 88 | } 89 | 90 | module.exports = { eachComponentAsync }; 91 | -------------------------------------------------------------------------------- /src/execute.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var exec = require('child_process').exec; 4 | 5 | module.exports = function(command, next) { 6 | var child = exec(command); 7 | var error = null; 8 | console.log('Executing ' + command + '...'); 9 | child.stdout.on('data', function(data) { 10 | console.log(data); 11 | }); 12 | child.stderr.on('data', function(err) { 13 | error = err; 14 | }); 15 | child.on('close', function() { 16 | next(error); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/exportTemplate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fetch = require('./fetch'); 3 | 4 | module.exports = function(options, next) { 5 | console.log('Exporting Template'); 6 | fetch(options)({ 7 | url: `${options.project}` 8 | }).then(({body})=> { 9 | if (body.plan && (body.plan === 'basic' || body.plan === 'archived') 10 | ) { 11 | return next('Deploy is only available for projects on a paid plan.'); 12 | } 13 | return fetch(options)({ 14 | url: `${options.project}/export`}); 15 | }).then(({body}) => { 16 | options.template = body; 17 | options.template = body; 18 | 19 | next(); 20 | }).catch(next); 21 | }; 22 | -------------------------------------------------------------------------------- /src/fetch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fetch = require('@formio/node-fetch-http-proxy'); 3 | 4 | module.exports = function(options = {}) { 5 | const baseHeaders = { 6 | 'Accept': 'application/json, text/plain, */*', 7 | }; 8 | 9 | if (options.key) { 10 | baseHeaders['x-token'] = options.key; 11 | } 12 | else if (options.adminKey) { 13 | baseHeaders['x-admin-key'] = options.adminKey; 14 | } 15 | 16 | const noThrowOnError = !!options.noThrowOnError; 17 | 18 | return function({url, method = 'GET', body, headers}) { 19 | const options = { 20 | method: method, 21 | headers: {...baseHeaders, ...headers}, 22 | rejectUnauthorized: false, 23 | }; 24 | 25 | if (body) { 26 | options.headers['Content-Type'] = 'application/json'; 27 | options.body = JSON.stringify(body); 28 | } 29 | 30 | return fetch(url, options).then(response => { 31 | const res = { 32 | status: response.status, 33 | headers: response.headers, 34 | ok: response.ok, 35 | }; 36 | 37 | if (response.headers.get('Content-Type').includes('json')) { 38 | return response.json().then(data => { 39 | res.body = data; 40 | 41 | // response.status < 200 && response.status > 300 42 | if (!response.ok && !noThrowOnError) { 43 | throw new Error( 44 | `HTTP Error: ${response.status} ${response.statusText} ${ 45 | response.url 46 | } \n ${JSON.stringify(res.body)}`.red 47 | ); 48 | } 49 | 50 | return res; 51 | }); 52 | } 53 | else { 54 | return response.text().then(data => { 55 | res.body = data; 56 | // response.status < 200 && response.status > 300 57 | if (!response.ok && !noThrowOnError) { 58 | throw new Error( 59 | `HTTP Error: ${response.status} ${response.statusText} ${ 60 | response.url 61 | } \n ${JSON.stringify(res.body)}`.red 62 | ); 63 | } 64 | 65 | return res; 66 | }); 67 | } 68 | }); 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /src/importTemplate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const fetch = require('./fetch'); 5 | 6 | module.exports = function(options, next) { 7 | const fetchWithHeaders = fetch(options); 8 | const template = options.template; 9 | 10 | // If project exists, this is an update. 11 | console.log('Importing Template'); 12 | 13 | fetchWithHeaders({ 14 | url: options.project, 15 | }).then(res => { 16 | const project = res.body; 17 | 18 | _.assign(project, { 19 | template: _.omit(template, 'title', 'description', 'name', 'machineName'), 20 | settings: template.settings 21 | }); 22 | 23 | fetchWithHeaders({ 24 | url: `${options.server}/${project.name}/import`, 25 | method: 'POST', 26 | body: {template: project.template} 27 | }).then(() => { 28 | console.log('Project Updated'); 29 | next(); 30 | }).catch(next); 31 | }).catch((err) => { 32 | if (err.message.includes('500')) { 33 | return next(err); 34 | } 35 | 36 | template.settings = template.settings || {}; 37 | 38 | if (!template.settings.cors) { 39 | template.settings.cors = '*'; 40 | } 41 | 42 | // Create a project from a template. 43 | const project = { 44 | title: template.title, 45 | description: template.description, 46 | name: template.name, 47 | template: _.omit(template, 'title', 'description', 'name'), 48 | settings: template.settings 49 | }; 50 | 51 | fetchWithHeaders({ 52 | url: `${options.server}/project`, 53 | method: 'POST', 54 | body: project 55 | }).then(()=> { 56 | console.log('Project Created'); 57 | next(); 58 | }).catch(next); 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /src/loadTemplate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs'); 4 | 5 | module.exports = function(options, next) { 6 | try { 7 | options.template = JSON.parse(fs.readFileSync(options.project)); 8 | return next(); 9 | } 10 | catch (err) { 11 | return next(err); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/migrate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const async = require('async'); 4 | const parse = require('csv-parse'); 5 | const JSONStream = require('JSONStream'); 6 | const transform = require('stream-transform'); 7 | const request = require('request'); 8 | const formTransform = require('./transforms/form'); 9 | const fetch = require('./fetch'); 10 | const path = require('path'); 11 | const axios = require('axios'); 12 | const {migratePdfFileForForm} = require('./utils'); 13 | 14 | module.exports = function(options, next) { 15 | let isProject = false; 16 | let src = ''; 17 | let dest = ''; 18 | let transformer = ''; 19 | if (options.params[1] === 'project') { 20 | isProject = true; 21 | transformer = 'form'; 22 | src = options.params[0]; 23 | dest = options.params[2]; 24 | } 25 | else { 26 | src = options.params[0]; 27 | transformer = (options.params.length === 2) ? 'form' : options.params[1]; 28 | dest = (options.params.length === 2) ? options.params[1] : options.params[2]; 29 | } 30 | 31 | const headers = {}; 32 | function setHeaders(type, options) { 33 | if (options) { 34 | headers[type] = {}; 35 | if (options.key) { 36 | headers[type]['x-token'] = options.key; 37 | } 38 | else if (options.adminKey) { 39 | headers[type]['x-admin-key'] = options.adminKey; 40 | } 41 | } 42 | if (headers[type]) { 43 | headers[type]['content-type'] = 'application/json'; 44 | } 45 | } 46 | 47 | setHeaders('src', options.srcOptions); 48 | setHeaders('dst', options.dstOptions); 49 | 50 | /** 51 | * Migrate a single form. 52 | * 53 | * @param _src 54 | * @param _dest 55 | * @param _transformer 56 | * @param done 57 | */ 58 | const migrateForm = function(_src, _dest, _transformer, done) { 59 | if (!_src) { 60 | return done('You must provide a source form or CSV to copy.'); 61 | } 62 | 63 | if (!_transformer) { 64 | return done('You must provide a transformer middleware file to perform the migration.'); 65 | } 66 | 67 | if (!_dest) { 68 | return done('You must provide a destination form.'); 69 | } 70 | 71 | // If they provide a form as the transform, then just use the form 72 | // transform. 73 | if (_transformer === 'form') { 74 | _transformer = formTransform; 75 | } 76 | else { 77 | try { 78 | // Require the transformer. 79 | _transformer = require(path.join(process.cwd() + '/' + _transformer)); 80 | } 81 | catch (err) { 82 | console.log(err); 83 | return; 84 | } 85 | } 86 | 87 | const deletePrevious = function(record, cb) { 88 | if (!options.deletePrevious) { 89 | return cb(); 90 | } 91 | // Load a previous submission if exists. 92 | request({ 93 | json: true, 94 | method: 'GET', 95 | rejectUnauthorized: false, 96 | url: _dest + '/submission', 97 | qs: {limit: 1000000, 'metadata.from': record._id.toString()}, 98 | headers: headers.dst 99 | }, (err, response) => { 100 | if (err) { 101 | console.log(err); 102 | return cb(); 103 | } 104 | if (response.statusCode !== 200) { 105 | console.log(response.statusMessage); 106 | return cb(); 107 | } 108 | if (!response.body || !response.body.length || !response.body[0]._id) { 109 | return cb(); 110 | } 111 | request({ 112 | json: true, 113 | method: 'DELETE', 114 | rejectUnauthorized: false, 115 | url: _dest + '/submission/' + response.body[0]._id, 116 | headers: headers.dst 117 | }, (err) => { 118 | if (err) { 119 | return cb(); 120 | } 121 | process.stdout.write('x'); 122 | cb(); 123 | }); 124 | }); 125 | }; 126 | 127 | const migrateData = function() { 128 | console.log(''); 129 | process.stdout.write(`Migrating to ${_dest} `); 130 | // Determine the stream based on the source type. 131 | let stream = null; 132 | const isFile = src.trim().endsWith('.csv'); 133 | 134 | if (isFile) { 135 | stream = fs.createReadStream(path.join(process.cwd(), '/', _src)).pipe(parse({ 136 | ltrim: true, 137 | rtrim: true 138 | })); 139 | } 140 | else { 141 | try { 142 | stream = request({ 143 | method: 'GET', 144 | rejectUnauthorized: false, 145 | url: _src + '/submission', 146 | qs: {select: '_id', limit: '10000000'}, 147 | headers: headers.src 148 | }).pipe(JSONStream.parse('*')); 149 | } 150 | catch (err) { 151 | console.log(err); 152 | } 153 | } 154 | 155 | const transformAndSubmitRecord = (rec, next) => _transformer(rec, (err, transformed) => { 156 | if (err) { 157 | console.log(err); 158 | return next(); 159 | } 160 | 161 | if (!transformed) { 162 | return next(); 163 | } 164 | 165 | const dstFormSubmitUrl = `${_dest}/submission${transformed._id ? '/' + transformed._id : ''}`; 166 | 167 | // Submit to the destination form. 168 | fetch(options.dstOptions)({ 169 | url: dstFormSubmitUrl, 170 | method: transformed._id ? 'PUT' : 'POST', 171 | body: transformed 172 | }).then(() => { 173 | next(); 174 | }).catch((err) => { 175 | console.log(`Failed to submit form: ${err.message}`.red); 176 | return next(); 177 | }); 178 | }); 179 | 180 | const streamTransform = transform(function(record, nextItem) { 181 | if (isFile) { 182 | transformAndSubmitRecord(record, nextItem); 183 | } 184 | else { 185 | request({ 186 | json: true, 187 | method: 'GET', 188 | rejectUnauthorized: false, 189 | url: _src + '/submission/' + record._id, 190 | headers: headers.src 191 | }, (err, response) => { 192 | if (err) { 193 | console.log(err); 194 | return nextItem(); 195 | } 196 | deletePrevious(record, () => { 197 | transformAndSubmitRecord(response.body, nextItem); 198 | }); 199 | }); 200 | } 201 | }, { 202 | parallel: isProject ? 1 : 100 203 | }); 204 | 205 | streamTransform.on('error', (err) => { 206 | console.log(err.message); 207 | return done(err); 208 | }); 209 | streamTransform.on('finish', () => { 210 | return done(); 211 | }); 212 | stream.pipe(streamTransform); 213 | }; 214 | 215 | const deleteData = function() { 216 | if (!options.deleteBefore && !options.deleteAfter && !options.delete) { 217 | return Promise.resolve(); 218 | } 219 | return new Promise((resolve, reject) => { 220 | console.log(''); 221 | if (options.delete) { 222 | process.stdout.write(`Deleting all submissions from form ${_dest}`); 223 | } 224 | else if (options.deleteBefore && !options.deleteAfter) { 225 | process.stdout.write(`Deleting submissions from form ${_dest} before ${options.deleteBefore}`); 226 | } 227 | else if (!options.deleteBefore && options.deleteAfter) { 228 | process.stdout.write(`Deleting submissions from form ${_dest} after ${options.deleteAfter}`); 229 | } 230 | else { 231 | process.stdout.write(`Deleting submissions from form ${_dest} between ${options.deleteAfter} and ${options.deleteBefore}`); 232 | } 233 | const deleteQuery = {select: '_id', limit: '10000000'}; 234 | if (options.deleteBefore) { 235 | deleteQuery.created__lt = options.deleteBefore; 236 | } 237 | if (options.deleteAfter) { 238 | deleteQuery.created__gt = options.deleteAfter; 239 | } 240 | request({ 241 | json: true, 242 | method: 'GET', 243 | rejectUnauthorized: false, 244 | url: _dest + '/submission', 245 | qs: deleteQuery, 246 | headers: headers.dst 247 | }, (err, response) => { 248 | if (err) { 249 | console.log(err); 250 | return reject(err); 251 | } 252 | if (response.statusCode !== 200) { 253 | return reject(response.statusMessage); 254 | } 255 | if (!response) { 256 | return resolve(); 257 | } 258 | let records = response.body; 259 | let deleteIndex = 0; 260 | function deleteNext() { 261 | if (deleteIndex < records.length) { 262 | request({ 263 | json: true, 264 | method: 'DELETE', 265 | rejectUnauthorized: false, 266 | url: _dest + '/submission/' + records[deleteIndex]._id, 267 | headers: headers.dst 268 | }, () => { 269 | process.stdout.write('.'); 270 | deleteIndex++; 271 | deleteNext(); 272 | }); 273 | } 274 | else { 275 | return resolve(); 276 | } 277 | } 278 | deleteNext(); 279 | }); 280 | }); 281 | }; 282 | 283 | const deleteThenMigrate = function() { 284 | deleteData() 285 | .then(() => migrateData()) 286 | .catch((err) => done(err)) 287 | .finally(() => console.log('Done!')); 288 | }; 289 | 290 | // Load the destination form to determine if it exists... 291 | fetch(options.dstOptions)({ 292 | url: _dest 293 | }).then(() => { 294 | deleteThenMigrate(); 295 | }).catch(() => { 296 | console.log(''); 297 | console.log(`Creating form ${_dest}`.green); 298 | 299 | fetch(options.srcOptions)({ 300 | url: _src, 301 | }).then(async({body}) => { 302 | const dstProject = _dest.replace(`/${body.path}`, ''); 303 | try { 304 | const destForm = { 305 | title: body.title, 306 | display: body.display, 307 | path: body.path, 308 | name: body.name, 309 | type: body.type, 310 | components: body.components, 311 | settings: body.settings || {} 312 | }; 313 | 314 | if (options.migratePdfFiles) { 315 | await migratePdfFileForForm(destForm, options); 316 | } 317 | 318 | await axios.post(`${dstProject}/form`, destForm, {'headers': {...headers.dst}}); 319 | 320 | // Migrate the data to this form. 321 | deleteThenMigrate(); 322 | } 323 | catch (err) { 324 | console.log(err); 325 | return done(err); 326 | } 327 | }); 328 | }); 329 | }; 330 | 331 | if (!isProject) { 332 | return migrateForm(src, dest, transformer, next); 333 | } 334 | 335 | // Fetch all forms from the source. 336 | request({ 337 | json: true, 338 | method: 'GET', 339 | url: `${src}/form`, 340 | qs: { 341 | limit: '10000000', 342 | select: '_id,path,title' 343 | }, 344 | headers: headers.src 345 | }, (err, response) => { 346 | if (err) { 347 | return next(err.message || err); 348 | } 349 | 350 | if (!response.body || !response.body.length) { 351 | return next('No forms were found within the source project.'); 352 | } 353 | 354 | let formFound = !options.startWith; 355 | 356 | // Iterate through each of the forms. 357 | async.eachSeries(response.body, (form, nextForm) => { 358 | if (!form || !form.path) { 359 | return nextForm(); 360 | } 361 | if (!formFound && options.startWith) { 362 | formFound = (form.path === options.startWith); 363 | } 364 | if (!formFound) { 365 | return nextForm(); 366 | } 367 | migrateForm( 368 | `${src}/${form.path}`, 369 | `${dest}/${form.path}`, 370 | transformer, 371 | nextForm 372 | ); 373 | }, (err) => { 374 | if (err) { 375 | return next(err.message || err); 376 | } 377 | 378 | return next(); 379 | }); 380 | }); 381 | }; 382 | -------------------------------------------------------------------------------- /src/series.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var async = require('async'); 4 | var _ = require('lodash'); 5 | module.exports = function(series, next) { 6 | return function() { 7 | // The action arguments. 8 | var args = Array.prototype.slice.call(arguments); 9 | var options = args.pop(); 10 | options.params = args; 11 | var actionSeries = []; 12 | _.each(series, function(action) { 13 | actionSeries.push(_.partial(action, options)); 14 | }); 15 | 16 | // Perform a series execution. 17 | async.series(actionSeries, function(err, result) { 18 | next(err, result); 19 | }); 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/submissions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var JSONStream = require('JSONStream'); 3 | const path = require('path'); 4 | 5 | var transform = require('stream-transform'); 6 | var request = require('request'); 7 | 8 | module.exports = function(options, next) { 9 | var src = options.params[0]; 10 | var eachSubmission = options.params[1]; 11 | 12 | if (!src) { 13 | return next('You must provide a source form to load submissions.'); 14 | } 15 | 16 | // Determine the stream based on the source type. 17 | var requestHeaders = { 18 | 'content-type': 'application/json' 19 | }; 20 | 21 | if (options.dstOptions.key) { 22 | requestHeaders['x-token'] = options.key; 23 | } 24 | else if (options.dstOptions.adminKey) { 25 | requestHeaders['x-admin-key'] = options.adminKey; 26 | } 27 | 28 | // Create the submission request. 29 | var stream = request({ 30 | method: 'GET', 31 | rejectUnauthorized: false, 32 | url: src + '/submission', 33 | qs: {limit: '10000000'}, 34 | headers: requestHeaders 35 | }); 36 | 37 | // See if they provided each submission handler. 38 | if (eachSubmission) { 39 | try { 40 | // Require the transformer. 41 | eachSubmission = require(path.join(process.cwd(), eachSubmission)); 42 | } 43 | catch (err) { 44 | console.log(err); 45 | return; 46 | } 47 | 48 | // Pipe the record through the each handler. 49 | var index = 0; 50 | stream 51 | .pipe(JSONStream.parse('*')) 52 | .pipe(transform(function(record) { 53 | eachSubmission(record, index++, options, next); 54 | })); 55 | } 56 | else { 57 | // Pipe the stream through stdout. 58 | process.stdout.write('RESULT:'); 59 | stream.pipe(process.stdout); 60 | } 61 | 62 | // Move on when the stream closes. 63 | stream.on('close', function() { 64 | return next(); 65 | }); 66 | }; 67 | -------------------------------------------------------------------------------- /src/transforms/csv.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is an example of what a transform would look like. 3 | * @param record 4 | * @param next 5 | * @returns {*} 6 | */ 7 | module.exports = function(record, next) { 8 | return next(null, { 9 | data: { 10 | firstName: record[1], 11 | lastName: record[2], 12 | email: record[3] 13 | } 14 | }); 15 | }; -------------------------------------------------------------------------------- /src/transforms/form.js: -------------------------------------------------------------------------------- 1 | module.exports = function(record, next) { 2 | var metadata = record.metadata || {}; 3 | metadata.from = record._id; 4 | return next(null, { 5 | data: record.data, 6 | metadata: metadata, 7 | created: record.created, 8 | modified: record.modified 9 | }); 10 | }; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const axios = require('axios'); 3 | const crypto = require('crypto'); 4 | 5 | async function getPdfFile(url) { 6 | const response = await axios.get(url, {responseType: 'arraybuffer'}); 7 | const buffer = Buffer.from(response.data, 'binary'); // Get the file buffer 8 | const fileName = `${crypto.randomUUID()}.pdf`; 9 | 10 | return { 11 | name: fileName, 12 | buffer 13 | }; 14 | } 15 | 16 | async function migratePdfFileForForm(form, options) { 17 | if (form.display !== 'pdf' || !form.settings.pdf) { 18 | return; 19 | } 20 | 21 | const destUploadUrl = `${options.dstOptions.server}/${options.dstOptions.projectName}/upload`; 22 | const file = await getPdfFile(form.settings.pdf.src + '.pdf'); 23 | const formData = new FormData(); 24 | const fileBlob = new Blob([file.buffer], {type: 'application/pdf'}); 25 | 26 | formData.set('file', fileBlob, file.name); 27 | 28 | const result = await axios.post(destUploadUrl, formData, { 29 | headers: { 30 | 'x-token': options.dstOptions.key, 31 | 'Content-Type': 'multipart/form-data', 32 | } 33 | }); 34 | 35 | // Assign new pdf file info to the form 36 | form.settings.pdf = { 37 | src: `${options.dstOptions.server}/pdf-proxy${result.data.path}`, 38 | id: result.data.file 39 | }; 40 | } 41 | 42 | module.exports = { 43 | migratePdfFileForForm 44 | }; 45 | -------------------------------------------------------------------------------- /src/welcome/logo.txt: -------------------------------------------------------------------------------- 1 | 2 | ▄ 3 | ▄███ 4 | ,▓██▀└ ╓▓╗ ███████ ██▌ 5 | ╓███▀' ▄ ╚╠╠▒, ██ ▄▄▄▄▄ ╔▄▄▄▄▄▄ ▄▄╓▄▄▄ ▄▄▄▄ ╓╓ ▄▄▄▄▄ 6 | ▀███┌ *███^ ╫╠╠▌ ███████ ╔██▀▀▀██ ║██▀▀▀ ██▀▀║████║█▌ ██▌ ╔██▀▀▀██ 7 | └▀███, └ ,@╬╠╩` ██ ██▌ ██ ║█▌ ██ ▐██ ║█▌ ██▌ ██▌ ██ 8 | ▀███▄ ╙╣╩ ██ ╙██▄▄▄██ ║█▌ ██ ▐██ ║█▌ ██▌ ╙██▄▄▄██ 9 | ╙███⌐ ██ ▀▀▀▀▀ ║█▌ ██ ██ └█▌ ██▌ ██▌ ▀▀▀▀▀ 10 | ▀ 11 | -------------------------------------------------------------------------------- /src/welcome/welcome.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs-extra'); 4 | require('colors'); 5 | 6 | module.exports = function(next) { 7 | console.log(''); 8 | console.log(fs.readFileSync(__dirname + '/logo.txt').toString()); 9 | console.log(''); 10 | console.log(fs.readFileSync(__dirname + '/welcome.txt').toString().green); 11 | next(); 12 | }; 13 | -------------------------------------------------------------------------------- /src/welcome/welcome.txt: -------------------------------------------------------------------------------- 1 | Welcome to the Form.io Command Line! 2 | 3 | - Help: https://help.form.io 4 | - Code: https://github.com/formio 5 | - Love: https://form.io 6 | -------------------------------------------------------------------------------- /test.env: -------------------------------------------------------------------------------- 1 | ADMIN_KEY=testAdminKey 2 | API_SRC=http://localhost:4001 3 | API_DST=http://localhost:4002 4 | MONGO_SRC=mongodb://localhost:27018/cli-test-src 5 | MONGO_DST=mongodb://localhost:27018/cli-test-dst 6 | -------------------------------------------------------------------------------- /test/clearData.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, before */ 2 | 'use strict'; 3 | const async = require('async'); 4 | const {MongoClient} = require('mongodb'); 5 | 6 | let src, dst; 7 | 8 | const connectDb = async(uri) => { 9 | try { 10 | console.log(`Connecting to ${uri}\n`); 11 | const client = new MongoClient(uri, { 12 | useNewUrlParser: true, 13 | useUnifiedTopology: true 14 | }); 15 | await client.connect(); 16 | const db = await client.db(); 17 | console.log(`Succesfully connected to ${uri}`); 18 | return { 19 | client, 20 | db, 21 | projects: db.collection('projects'), 22 | forms: db.collection('forms'), 23 | submissions: db.collection('submissions'), 24 | roles: db.collection('roles'), 25 | actions: db.collection('actions') 26 | }; 27 | } 28 | catch (err) { 29 | throw new Error(`Could not connect to database ${uri}: ${err.message}`); 30 | } 31 | }; 32 | 33 | module.exports = (mongoSrc= null, mongoDest=null) => { 34 | describe('', function() { 35 | it('Clear server Data', function(done) { 36 | done(); 37 | }); 38 | 39 | before(async() => { 40 | src= null; 41 | dst= null; 42 | try { 43 | if (mongoSrc) { 44 | src = await connectDb(mongoSrc); 45 | } 46 | 47 | if (mongoDest) { 48 | dst = await connectDb(mongoDest); 49 | } 50 | 51 | if (!mongoSrc && !mongoDest) { 52 | throw new Error('You need to provide at least one database path to be cleaned'); 53 | } 54 | } 55 | 56 | catch (err) { 57 | console.log(err); 58 | } 59 | }); 60 | 61 | before((done) => { 62 | const clearData = () => { 63 | var dropDocuments = async function(model, next) { 64 | await model.deleteMany({}); 65 | next(); 66 | }; 67 | 68 | const srcCollections = src? [src.forms, src.actions, src.roles, src.projects, src.submissions]:[]; 69 | const dstCollections = dst? [dst.forms, dst.actions, dst.roles, dst.projects, dst.submissions]:[]; 70 | 71 | const collectionsToClean = [...srcCollections, ...dstCollections]; 72 | 73 | async.series( 74 | collectionsToClean.map(x=> { 75 | return async.apply(dropDocuments, x); 76 | }), (err)=> { 77 | if (err) { 78 | done(err); 79 | } 80 | done(); 81 | }); 82 | }; 83 | clearData(); 84 | }); 85 | }); 86 | }; 87 | 88 | -------------------------------------------------------------------------------- /test/clone.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, before, after */ 2 | 'use strict'; 3 | const assert = require('assert'); 4 | const utils = require('./util'); 5 | const Cloner = require('../src/Cloner'); 6 | const request = require('supertest'); 7 | const {ObjectId} = require('bson'); 8 | const {submissionFirst} = require('./templates/test'); 9 | 10 | module.exports = (template) => { 11 | describe('Clone Command', function() { 12 | let cloner = null; 13 | let dstDb = null; 14 | let previousAmountSubmissions = null; 15 | require('./clearData')(null, process.env.MONGO_DST); 16 | describe('Clone with API: checking general flow', function() { 17 | before('Create clone options', async() => { 18 | cloner = new Cloner(`${template.appSrc}/formioApi`, process.env.MONGO_DST, { 19 | apiSource: true, 20 | key: template.xToken, 21 | }); 22 | dstDb = await cloner.connectDb(process.env.MONGO_DST); 23 | }); 24 | it('Should clone with api', async() => { 25 | assert.equal((await dstDb.submissions.find({}).toArray()).length, 0); 26 | assert.equal((await dstDb.projects.find({}).toArray()).length, 0); 27 | assert.equal((await dstDb.forms.find({}).toArray()).length, 0); 28 | assert.equal((await dstDb.roles.find({}).toArray()).length, 0); 29 | 30 | await cloner.clone(); 31 | const projects = await request(template.appSrc) 32 | .get('/project/'+ template.api.projectApi._id) 33 | .set('x-token', template.xToken); 34 | 35 | assert.equal((await dstDb.projects.find({}).toArray()).length, [projects.body].length); 36 | assert.equal((await dstDb.projects.find({}).toArray())[0].name, projects.body.name); 37 | 38 | const forms = await request(template.appSrc) 39 | .get('/project/'+ template.api.projectApi._id +'/form?limit=9999999') 40 | .set('x-token', template.xToken); 41 | 42 | assert.equal((await dstDb.forms.find({}).toArray()).length, forms.body.length); 43 | 44 | const submissions = await request(template.appSrc) 45 | .get('/project/'+ template.api.projectApi._id +'/form/'+ template.api.forms.textForm1._id +'/submission') 46 | .set('x-token', template.xToken); 47 | 48 | previousAmountSubmissions = submissions.body.length; 49 | // eslint-disable-next-line max-len 50 | assert.equal((await dstDb.submissions.find({form: new ObjectId(template.api.forms.textForm1._id)}).toArray()).length, 51 | previousAmountSubmissions); 52 | 53 | const roles = await request(template.appSrc) 54 | .get('/project/'+ template.api.projectApi._id +'/role') 55 | .set('x-token', template.xToken); 56 | 57 | assert.equal((await dstDb.roles.find({}).toArray()).length, roles.body.length); 58 | }); 59 | 60 | it('Add one more submission to the textForm1 and clone again', async() => { 61 | await request(template.appSrc) 62 | // eslint-disable-next-line max-len 63 | .post( `/project/${template.api.projectApi._id}/form/${template.api.forms.textForm1._id}/submission`) 64 | .set('x-admin-key', process.env.ADMIN_KEY) 65 | .send(submissionFirst); 66 | 67 | await cloner.clone(); 68 | 69 | // eslint-disable-next-line max-len 70 | assert.equal((await dstDb.submissions.find({form: new ObjectId(template.api.forms.textForm1._id)}).toArray()).length, 71 | previousAmountSubmissions + 1); 72 | }); 73 | }); 74 | 75 | require('./clearData')(null, process.env.MONGO_DST); 76 | describe('Clone with API: checking "only submissions" option', function() { 77 | before('Create clone options', async() => { 78 | cloner = new Cloner(`${template.appSrc}/formioApi`, process.env.MONGO_DST, { 79 | apiSource: true, 80 | key: template.xToken, 81 | submissionsOnly: true 82 | }); 83 | }); 84 | it('Should clone only submissions', async() => { 85 | assert.equal((await dstDb.submissions.find({}).toArray()).length, 0); 86 | assert.equal((await dstDb.projects.find({}).toArray()).length, 0); 87 | assert.equal((await dstDb.forms.find({}).toArray()).length, 0); 88 | assert.equal((await dstDb.roles.find({}).toArray()).length, 0); 89 | 90 | await cloner.clone(); 91 | 92 | assert.notEqual((await dstDb.submissions.find({}).toArray()).length, 0); 93 | assert.equal((await dstDb.projects.find({}).toArray()).length, 0); 94 | assert.equal((await dstDb.forms.find({}).toArray()).length, 0); 95 | assert.equal((await dstDb.roles.find({}).toArray()).length, 0); 96 | }); 97 | }); 98 | describe('One database to another.', function() { 99 | require('./clearData')(process.env.MONGO_SRC, process.env.MONGO_DST); 100 | let deployment = null; 101 | describe('One database to another.', function() { 102 | before('Create two projects with resources and submissions.', async() => { 103 | deployment = await utils.newDeployment( 104 | process.env.MONGO_SRC, 105 | process.env.MONGO_DST 106 | ); 107 | }); 108 | it('Should clone all projects and submissions from one to another.', async() => { 109 | deployment.cloner.options = { 110 | srcProject: deployment.project._id.toString() 111 | }; 112 | await deployment.cloner.clone(null, (collection, beforeItem, afterItem) => { 113 | if (collection === 'projects') { 114 | assert(beforeItem.name.toString() === afterItem.name.toString(), 'The project names should be the same.'); 115 | } 116 | assert(beforeItem._id.toString() === afterItem._id.toString(), 'The _id should be the same.'); 117 | }); 118 | 119 | const dstSubs = await deployment.cloner.dest.submissions.find({}).toArray(); 120 | assert.equal(dstSubs.length, 100); 121 | assert( 122 | await deployment.cloner.dest.forms.findOne({_id: dstSubs[0].form}), 123 | 'There should be a form in the destination database.' 124 | ); 125 | assert( 126 | await deployment.cloner.dest.projects.findOne({_id: dstSubs[0].project}), 127 | 'There should be a project in the destination database.' 128 | ); 129 | }); 130 | require('./clearData')(process.env.MONGO_SRC, process.env.MONGO_DST); 131 | }); 132 | 133 | describe('Same database.', function() { 134 | it('Should throw error when tying to clone within the same database', async() => { 135 | try { 136 | await utils.newDeployment( 137 | process.env.MONGO_SRC, 138 | process.env.MONGO_SRC 139 | ); 140 | } 141 | catch (err) { 142 | assert.equal(err.message, 'Source and destination databases cannot be the same.'); 143 | } 144 | }); 145 | }); 146 | 147 | describe('Clone Command: OSS to enterprise.', function() { 148 | let deployment = null; 149 | before('Create two projects with resources and submissions.', async() => { 150 | deployment = await utils.newDeployment( 151 | process.env.MONGO_SRC, 152 | process.env.MONGO_DST, 153 | true 154 | ); 155 | }); 156 | it('Should clone from OSS to enterprise', async() => { 157 | deployment.cloner.options = { 158 | dstProject: deployment.project._id.toString() 159 | }; 160 | await deployment.cloner.clone(); 161 | assert.equal((await deployment.cloner.dest.roles.find({ 162 | project: deployment.project._id 163 | }).toArray()).length, 5); 164 | assert.equal((await deployment.cloner.dest.forms.find({ 165 | project: deployment.project._id 166 | }).toArray()).length, 5); 167 | assert.equal((await deployment.cloner.dest.actions.find({}).toArray()).length, 5); 168 | assert.equal((await deployment.cloner.dest.submissions.find({ 169 | project: deployment.project._id 170 | }).toArray()).length, 100); 171 | }); 172 | require('./clearData')(process.env.MONGO_SRC, process.env.MONGO_DST); 173 | }); 174 | describe('Clone Command: checking "all" option', function() { 175 | before('Create two projects with resources and submissions.', async() => { 176 | deployment = await utils.newDeployment( 177 | process.env.MONGO_SRC, 178 | process.env.MONGO_DST 179 | ); 180 | }); 181 | describe('', function() { 182 | it('Should clone all documents including deleted documents', async() => { 183 | deployment.cloner.options = { 184 | srcProject: deployment.project._id.toString(), 185 | all:true 186 | 187 | }; 188 | 189 | const srcSubmissions = await deployment.cloner.src.submissions.find({}).toArray(); 190 | const srcForms = await deployment.cloner.src.forms.find({}).toArray(); 191 | await deployment.cloner.src.submissions.updateOne({ 192 | _id: srcSubmissions[0]._id 193 | }, { 194 | $set: { 195 | deleted: 12332434534523 196 | } 197 | }); 198 | 199 | await deployment.cloner.src.forms.updateOne({ 200 | _id: srcForms[0]._id 201 | }, { 202 | $set: { 203 | deleted: 12332434534523 204 | } 205 | }); 206 | 207 | await deployment.cloner.clone(); 208 | 209 | assert.equal((await deployment.cloner.dest.submissions.find({}).toArray()).length, srcSubmissions.length); 210 | assert.equal((await deployment.cloner.dest.forms.find({}).toArray()).length, srcForms.length); 211 | }); 212 | require('./clearData')(null, process.env.MONGO_DST); 213 | }); 214 | 215 | describe('', function() { 216 | it('Should clone all documents except deleted documents', async() => { 217 | deployment.cloner.options = { 218 | srcProject: deployment.project._id.toString(), 219 | }; 220 | 221 | const srcSubmissions = await deployment.cloner.src.submissions.find({}).toArray(); 222 | const srcForms = await deployment.cloner.src.forms.find({}).toArray(); 223 | const submissionsWithoutFirstForm = srcSubmissions. 224 | filter(x=> x.form.toString() !== srcForms[0]._id.toString()); 225 | 226 | await deployment.cloner.src.submissions.updateOne({ 227 | _id: submissionsWithoutFirstForm[0]._id 228 | }, { 229 | $set: { 230 | deleted: 12332434534523 231 | } 232 | }); 233 | 234 | await deployment.cloner.clone(); 235 | 236 | assert.equal((await deployment.cloner.dest.submissions.find({}).toArray()).length, 237 | submissionsWithoutFirstForm.length-1); 238 | assert.equal((await deployment.cloner.dest.forms.find({}).toArray()).length, srcForms.length-1); 239 | }); 240 | }); 241 | }); 242 | }); 243 | }); 244 | }; 245 | -------------------------------------------------------------------------------- /test/copy/copy.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 'use strict'; 3 | 4 | const request = require('supertest'); 5 | const assert = require('assert'); 6 | const copy = require('../../src/copy'); 7 | 8 | module.exports = (template) => { 9 | const options = {}; 10 | 11 | options.srcOptions = { 12 | adminKey:process.env.ADMIN_KEY, 13 | dstAdminKey:process.env.ADMIN_KEY, 14 | projectName:'formio', 15 | server:process.env.API_SRC, 16 | srcAdminKey:process.env.ADMIN_KEY 17 | }; 18 | 19 | options.dstOptions = { 20 | adminKey:process.env.ADMIN_KEY, 21 | dstAdminKey:process.env.ADMIN_KEY, 22 | projectName:'formio', 23 | server:process.env.API_DST, 24 | srcAdminKey:process.env.ADMIN_KEY 25 | }; 26 | 27 | options.srcAdminKey = process.env.ADMIN_KEY; 28 | options.dstAdminKey = process.env.ADMIN_KEY; 29 | 30 | describe('Copy command', function() { 31 | it('Should copy forms from source to destination', (done) => { 32 | options.params =[ 33 | 'form', 34 | `${process.env.API_SRC}/formio/textForm1`, 35 | `${process.env.API_DST}/formio/textFormDst2` 36 | ]; 37 | 38 | request(template.appDst) 39 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.textFormDst2._id) 40 | .set('x-admin-key', process.env.ADMIN_KEY) 41 | .expect(200) 42 | .expect('Content-Type', /json/) 43 | .end(function(err, res) { 44 | if (err) { 45 | return done(err); 46 | } 47 | const expectedComponents = template.dst.forms.textFormDst2.components.map(x=> x.type); 48 | 49 | res.body.components.forEach(x=> { 50 | assert.equal(expectedComponents.includes(x.type), true); 51 | }); 52 | copy(options, (err) => { 53 | if (!err) { 54 | request(template.appDst) 55 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.textFormDst2._id) 56 | .set('x-admin-key', process.env.ADMIN_KEY) 57 | .expect(200) 58 | .expect('Content-Type', /json/) 59 | .end(function(err, res) { 60 | if (err) { 61 | return done(err); 62 | } 63 | 64 | const expectedComponents = template.src.forms.textForm1.components.map(x=> x.type); 65 | 66 | res.body.components.forEach(x=> { 67 | assert.equal(expectedComponents.includes(x.type), true); 68 | }); 69 | 70 | done(); 71 | }); 72 | } 73 | }); 74 | }); 75 | }); 76 | 77 | it('Should copy resources from source to destination', (done) => { 78 | options.params =[ 79 | 'resource', 80 | `${process.env.API_SRC}/formio/textFormSrcResource`, 81 | `${process.env.API_DST}/formio/textFormDstResource` 82 | ]; 83 | 84 | request(template.appDst) 85 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.textFormDstResource._id) 86 | .set('x-admin-key', process.env.ADMIN_KEY) 87 | .expect(200) 88 | .expect('Content-Type', /json/) 89 | .end(function(err, res) { 90 | if (err) { 91 | return done(err); 92 | } 93 | const expectedComponents = template.dst.forms.textFormDstResource.components.map(x=> x.type); 94 | 95 | res.body.components.forEach(x=> { 96 | assert.equal(expectedComponents.includes(x.type), true); 97 | }); 98 | copy(options, (err) => { 99 | if (!err) { 100 | request(template.appDst) 101 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.textFormDstResource._id) 102 | .set('x-admin-key', process.env.ADMIN_KEY) 103 | .expect(200) 104 | .expect('Content-Type', /json/) 105 | .end(function(err, res) { 106 | if (err) { 107 | return done(err); 108 | } 109 | 110 | const expectedComponents = template.src.forms.textFormSrcResource.components.map(x=> x.type); 111 | 112 | res.body.components.forEach(x=> { 113 | assert.equal(expectedComponents.includes(x.type), true); 114 | }); 115 | 116 | done(); 117 | }); 118 | } 119 | }); 120 | }); 121 | }); 122 | 123 | it('Should copy multiple source form components into one destination form', (done) => { 124 | options.params =[ 125 | 'form', 126 | `${process.env.API_SRC}/formio/formCopyChainSrc,${process.env.API_SRC}/formio/textForm1`, 127 | `${process.env.API_DST}/formio/formCopyChainDst` 128 | ]; 129 | 130 | request(template.appDst) 131 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.formCopyChainDst._id) 132 | .set('x-admin-key', process.env.ADMIN_KEY) 133 | .expect(200) 134 | .expect('Content-Type', /json/) 135 | .end(function(err, res) { 136 | if (err) { 137 | return done(err); 138 | } 139 | const expectedComponents = template.dst.forms.formCopyChainDst.components.map(x=> x.type); 140 | 141 | res.body.components.forEach(x=> { 142 | assert.equal(expectedComponents.includes(x.type), true); 143 | }); 144 | copy(options, (err) => { 145 | if (!err) { 146 | request(template.appDst) 147 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.formCopyChainDst._id) 148 | .set('x-admin-key', process.env.ADMIN_KEY) 149 | .expect(200) 150 | .expect('Content-Type', /json/) 151 | .end(function(err, res) { 152 | if (err) { 153 | return done(err); 154 | } 155 | 156 | const formCopyChainSrcKeys = template.src.forms.formCopyChainSrc.components.map(x=> x.type); 157 | const formTextForm1SrcKeys = template.src.forms.textForm1.components.map(x=> x.type); 158 | 159 | const allKeys = [...formCopyChainSrcKeys, ...formTextForm1SrcKeys]; 160 | 161 | res.body.components.forEach(x=> { 162 | assert.equal(allKeys.includes(x.type), true); 163 | }); 164 | 165 | assert.equal(allKeys.includes('select'), false); 166 | 167 | done(); 168 | }); 169 | } 170 | }); 171 | }); 172 | }); 173 | }); 174 | }; 175 | -------------------------------------------------------------------------------- /test/createTemplate.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 'use strict'; 3 | const request = require('supertest'); 4 | const formioProject = require('./templates/default.json'); 5 | const async = require('async'); 6 | const { 7 | textFormFirstSrc, 8 | textFormFirstDst, 9 | submissionFirst, 10 | submissionSecond, 11 | textFormSecondSrc, 12 | submissionThird, 13 | textFormThirdSrc, 14 | textFormFirstDst2, 15 | textFormSrcResource, 16 | textFormDstResource, 17 | formCopyChainSrc, 18 | formCopyChainDst, 19 | formDeployCheck 20 | } = require('./templates/test'); 21 | const {createHmac} = require('crypto'); 22 | 23 | module.exports = (template) => { 24 | describe('Create Template', function() { 25 | it('Installs the form.io project', async function() { 26 | const formioSettings = { 27 | title: 'Form.io', 28 | name: 'formio', 29 | plan: 'commercial', 30 | template: formioProject 31 | }; 32 | 33 | const formioApiSettings = { 34 | title: 'Form.io', 35 | name: 'formioApi', 36 | plan: 'commercial', 37 | template: formioProject, 38 | settings: { 39 | cors: '*', 40 | appOrigin: template.appSrc, 41 | keys: [ 42 | { 43 | key: template.xToken, 44 | name: 'key 1' 45 | } 46 | ] 47 | } 48 | }; 49 | try { 50 | const projectSrc = await request(template.appSrc) 51 | .post('/project') 52 | .set('x-admin-key', process.env.ADMIN_KEY) 53 | .send(formioSettings) 54 | .expect(201); 55 | 56 | template.src.project = projectSrc.body; 57 | 58 | const projectDst = await request(template.appDst) 59 | .post('/project') 60 | .set('x-admin-key', process.env.ADMIN_KEY) 61 | .send(formioSettings) 62 | .expect(201); 63 | 64 | template.dst.project = projectDst.body; 65 | 66 | const result = await request(template.appSrc) 67 | .post('/project') 68 | .set('x-admin-key', process.env.ADMIN_KEY) 69 | // .set('x-raw-data-access', createHmac('sha256', template.xToken).digest('hex')) 70 | .send(formioApiSettings); 71 | 72 | template.api.projectApi = result.body; 73 | } 74 | catch (err) { 75 | console.log(err); 76 | } 77 | }); 78 | it('Create template src', (done) => { 79 | const createStageProject = (source, title, name)=> function(cb) { 80 | const newStage = { 81 | stageTitle:'testedStageProjectTitle', 82 | title: title, 83 | description: 'test Project', 84 | project: template.dst.project._id, 85 | copyFromProject: 'empty', 86 | type: 'stage', 87 | framework: 'custom' 88 | }; 89 | request(source) 90 | .post('/project') 91 | .send(newStage) 92 | .set('x-admin-key', process.env.ADMIN_KEY) 93 | .expect('Content-Type', /json/) 94 | .expect(201) 95 | .end(function(err, res) { 96 | if (err) { 97 | return done(err); 98 | } 99 | 100 | template.dst[name] = res.body; 101 | 102 | cb(); 103 | }); 104 | }; 105 | 106 | const createForm = (source, direction, fixture, api=false)=> function(cb) { 107 | request(source) 108 | .post(`/project/${api? template.api.projectApi._id: template[direction].project._id}/form`) 109 | .set('x-admin-key', process.env.ADMIN_KEY) 110 | .send(fixture) 111 | .end(function(err, resForm) { 112 | if (err) { 113 | return cb(err); 114 | } 115 | if (api) { 116 | template.api.forms[resForm.body.name] = resForm.body; 117 | } 118 | else { 119 | template[direction].forms[resForm.body.name] = resForm.body; 120 | } 121 | 122 | cb(); 123 | }); 124 | }; 125 | 126 | const createFormSubmission = (source, textForm, fixture, api=false)=> function(cb) { 127 | request(source) 128 | // eslint-disable-next-line max-len 129 | .post( `/project/${api? template.api.projectApi._id: template.src.project._id}/form/${api? template.api.forms[textForm]._id:template.src.forms[textForm]._id}/submission`) 130 | .set('x-admin-key', process.env.ADMIN_KEY) 131 | .send(fixture) 132 | .end(function(err, resFormSubmissions) { 133 | if (err) { 134 | return cb(err); 135 | } 136 | 137 | if (api) { 138 | template.api.submission[textForm].push(resFormSubmissions.body); 139 | } 140 | else { 141 | template.src.submission[textForm].push(resFormSubmissions.body); 142 | } 143 | 144 | cb(); 145 | }); 146 | }; 147 | 148 | request(template.appSrc) 149 | .get('/project/'+ template.src.project._id +'/form?limit=9999999') 150 | .set('x-admin-key', process.env.ADMIN_KEY) 151 | .expect(200) 152 | .expect('Content-Type', /json/) 153 | .end(function(err, res) { 154 | if (err) { 155 | return done(err); 156 | } 157 | async.series([ 158 | createForm(template.appSrc, 'src', textFormFirstSrc), 159 | createFormSubmission(template.appSrc, 'textForm1', submissionFirst ), 160 | createFormSubmission(template.appSrc, 'textForm1', submissionSecond ), 161 | createForm(template.appSrc, 'src', textFormSecondSrc), 162 | createFormSubmission(template.appSrc, 'textForm2', submissionThird ), 163 | createForm(template.appSrc, 'src', textFormThirdSrc), 164 | createFormSubmission(template.appSrc, 'textForm3', submissionThird ), 165 | createForm(template.appSrc, 'src', textFormSrcResource), 166 | createForm(template.appSrc, 'src', formCopyChainSrc), 167 | createForm(template.appSrc, 'src', formDeployCheck), 168 | 169 | createForm(template.appSrc, 'src', textFormFirstSrc, true), 170 | createForm(template.appSrc, 'src', textFormSecondSrc, true), 171 | createFormSubmission(template.appSrc, 'textForm1', submissionFirst, true ), 172 | createFormSubmission(template.appSrc, 'textForm1', submissionSecond, true ), 173 | createFormSubmission(template.appSrc, 'textForm2', submissionThird, true ), 174 | 175 | ], function(err) { 176 | if (err) { 177 | return done(err); 178 | } 179 | 180 | async.series([ 181 | createStageProject(template.appDst, 'testedStagedProject1', 'stagedProject'), 182 | createStageProject(template.appDst, 'testedStagedProject2', 'stagedProject2'), 183 | createForm(template.appDst, 'dst', textFormFirstDst), 184 | createForm(template.appDst, 'dst', textFormFirstDst2), 185 | createForm(template.appDst, 'dst', textFormDstResource), 186 | createForm(template.appDst, 'dst', formCopyChainDst), 187 | 188 | ], function(err) { 189 | if (err) { 190 | return done(err); 191 | } 192 | done(); 193 | }); 194 | }); 195 | }); 196 | }); 197 | }); 198 | }; 199 | -------------------------------------------------------------------------------- /test/deploy/deploy.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it, before */ 2 | 'use strict'; 3 | 4 | var request = require('supertest'); 5 | const assert = require('assert'); 6 | const deploy = require('../../src/deploy'); 7 | 8 | module.exports = (template) => { 9 | const options = {}; 10 | 11 | options.srcOptions = { 12 | adminKey:process.env.ADMIN_KEY, 13 | dstAdminKey:process.env.ADMIN_KEY, 14 | projectName:'formio', 15 | server: process.env.API_SRC, 16 | srcAdminKey:process.env.ADMIN_KEY 17 | }; 18 | 19 | options.dstOptions = { 20 | adminKey:process.env.ADMIN_KEY, 21 | dstAdminKey:process.env.ADMIN_KEY, 22 | projectName:'formio', 23 | server:process.env.API_DST, 24 | srcAdminKey:process.env.ADMIN_KEY 25 | }; 26 | 27 | describe('Deploy command', () => { 28 | before(() => { 29 | options.params =[`${process.env.API_SRC}/formio`, `${process.env.API_DST}/formio`]; 30 | options.srcAdminKey =process.env.ADMIN_KEY; 31 | options.dstAdminKey =process.env.ADMIN_KEY; 32 | }); 33 | 34 | it('Should deploy project', (done) => { 35 | request(template.appSrc) 36 | .get('/project/'+ template.src.project._id +'/form?limit=9999999') 37 | .set('x-admin-key', process.env.ADMIN_KEY) 38 | .expect(200) 39 | .expect('Content-Type', /json/) 40 | .end(function(err, res) { 41 | if (err) { 42 | return done(err); 43 | } 44 | const formsSrc = res.body; 45 | 46 | request(template.appDst) 47 | .get('/project/'+ template.dst.project._id +'/form?limit=9999999') 48 | .set('x-admin-key', process.env.ADMIN_KEY) 49 | .expect(200) 50 | .expect('Content-Type', /json/) 51 | .end(function(err, res) { 52 | if (err) { 53 | return done(err); 54 | } 55 | 56 | const formsDst = res.body; 57 | 58 | deploy(options, (err) => { 59 | if (!err) { 60 | request(template.appDst) 61 | .get('/project/'+ template.dst.project._id +'/form?limit=9999999') 62 | .set('x-admin-key', process.env.ADMIN_KEY) 63 | .expect(200) 64 | .expect('Content-Type', /json/) 65 | .end(function(err, res) { 66 | if (err) { 67 | return done(err); 68 | } 69 | 70 | const allForms = [...formsSrc, ...formsDst]; 71 | 72 | const allFormsUnique = allForms.reduce((o, i) => { 73 | if (!o.find(v => v.name === i.name)) { 74 | o.push(i); 75 | } 76 | return o; 77 | }, []); 78 | 79 | const expectedComponents = res.body.map(x=> x.name); 80 | assert.equal(expectedComponents.includes(template.src.forms.formDeployCheck.name), true); 81 | assert.equal(res.body.length, allFormsUnique.length); 82 | done(); 83 | }); 84 | } 85 | }); 86 | }); 87 | }); 88 | }); 89 | 90 | it('Should not allow to deploy for unpaid plans', (done) => { 91 | request(template.appSrc) 92 | .put('/project/'+ template.src.project._id) 93 | .set('x-admin-key', process.env.ADMIN_KEY) 94 | .send({plan: 'basic'}) 95 | .end(function(err) { 96 | if (err) { 97 | return done(err); 98 | } 99 | 100 | deploy(options, (err) => { 101 | assert.equal(err, 'Deploy is only available for projects on a paid plan.'); 102 | done(); 103 | }); 104 | }); 105 | }); 106 | }); 107 | }; 108 | -------------------------------------------------------------------------------- /test/docker/.env: -------------------------------------------------------------------------------- 1 | ADMIN_KEY=testAdminKey 2 | DOCKER_API_SRC_PORT=4001 3 | DOCKER_API_DST_PORT=4002 4 | DOCKER_MONGO_SRC=mongodb://mongo:27017/cli-test-src 5 | DOCKER_MONGO_DST=mongodb://mongo:27017/cli-test-dst 6 | LICENSE_REMOTE=false 7 | LICENSE_KEY= -------------------------------------------------------------------------------- /test/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | mongo: 4 | image: mongo 5 | restart: always 6 | volumes: 7 | - "./data/db:/data/db" 8 | environment: 9 | MONGO_INITDB_ROOT_USERNAME: 10 | MONGO_INITDB_ROOT_PASSWORD: 11 | ports: 12 | - "27018:27017" 13 | api-server-1: 14 | image: formio/formio-enterprise:latest 15 | mem_limit: 1024m 16 | restart: always 17 | links: 18 | - mongo 19 | ports: 20 | - "${DOCKER_API_SRC_PORT}:${DOCKER_API_SRC_PORT}" 21 | environment: 22 | MONGO: ${DOCKER_MONGO_SRC} 23 | PORT: ${DOCKER_API_SRC_PORT} 24 | LICENSE_KEY: ${LICENSE_KEY} 25 | env_file: 26 | - .env 27 | api-server-2: 28 | image: formio/formio-enterprise:latest 29 | mem_limit: 1024m 30 | restart: always 31 | links: 32 | - mongo 33 | ports: 34 | - "${DOCKER_API_DST_PORT}:${DOCKER_API_DST_PORT}" 35 | environment: 36 | MONGO: ${DOCKER_MONGO_DST} 37 | PORT: ${DOCKER_API_DST_PORT} 38 | LICENSE_KEY: ${LICENSE_KEY} 39 | env_file: 40 | - .env -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* globals describe, before */ 2 | 'use strict'; 3 | require('dotenv').config({path: 'test.env'}); 4 | 5 | const template = { 6 | src: {forms: {}, submission: {textForm1: [], textForm2: [], textForm3: []}}, 7 | dst: {forms: {}}, 8 | api: {forms: {}, submission: {textForm1: [], textForm2: []}}, 9 | xToken: 'D5NEdViqjuvuE3tj9LctJ5MBTSE32q' 10 | }; 11 | 12 | template.appSrc = process.env.API_SRC; 13 | template.appDst = process.env.API_DST; 14 | 15 | describe('Start tests', () => { 16 | before(async() => { 17 | process.env.TEST_SUITE = '1'; 18 | await require('./waitApisReady')(); 19 | }); 20 | 21 | require('./clearData')(process.env.MONGO_SRC, process.env.MONGO_DST); 22 | require('./createTemplate')(template); 23 | 24 | describe('CLI commands', () => { 25 | require('./submissions/submission')(template); 26 | require('./migrate/migrate')(template); 27 | require('./copy/copy')(template); 28 | require('./deploy/deploy')(template); 29 | require('./clone')(template); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/migrate/import.csv: -------------------------------------------------------------------------------- 1 | First Name, Last Name, Email 2 | Joe, Smith, joe@example.com -------------------------------------------------------------------------------- /test/migrate/migrate.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 'use strict'; 3 | 4 | const request = require('supertest'); 5 | const migrate = require('../../src/migrate'); 6 | const assert = require('assert'); 7 | const async = require('async'); 8 | 9 | module.exports = (template) => { 10 | const options = {}; 11 | 12 | options.srcOptions = { 13 | adminKey: process.env.ADMIN_KEY, 14 | dstAdminKey: process.env.ADMIN_KEY, 15 | host: process.env.API_SRC, 16 | projectName:'formio', 17 | server: process.env.API_SRC, 18 | srcAdminKey: process.env.ADMIN_KEY 19 | }; 20 | 21 | options.dstOptions = { 22 | adminKey: process.env.ADMIN_KEY, 23 | dstAdminKey: process.env.ADMIN_KEY, 24 | projectName:'formio', 25 | server: process.env.API_DST, 26 | srcAdminKey: process.env.ADMIN_KEY 27 | }; 28 | 29 | describe('Migrate command', function() { 30 | it('Should migrate submissions from source form to destination form', (done) => { 31 | options.params =[ 32 | `${process.env.API_SRC}/formio/textForm1`, 33 | 'form', 34 | `${process.env.API_DST}/formio/textFormDst` 35 | ]; 36 | 37 | migrate(options, (err) => { 38 | if (!err) { 39 | request(template.appDst) 40 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.textFormDst._id +'/submission') 41 | .set('x-admin-key', process.env.ADMIN_KEY) 42 | .expect(200) 43 | .expect('Content-Type', /json/) 44 | .end(function(err, res) { 45 | if (err) { 46 | return done(err); 47 | } 48 | 49 | res.body.sort((a,b) => a.data.textField - b.data.textField).forEach((element, index) => { 50 | assert.deepEqual(element.data, template.src.submission.textForm1[index].data ); 51 | }); 52 | done(); 53 | }); 54 | } 55 | }); 56 | }); 57 | 58 | it('Should migrate all source project forms and submissions to destination project', (done) => { 59 | options.params =[ 60 | `${process.env.API_SRC}/formio`, 61 | 'project', 62 | `${process.env.API_DST}/${template.dst.stagedProject.name}` 63 | ]; 64 | options.dstOptions.projectName =template.dst.stagedProject.name, 65 | 66 | migrate(options, (err) => { 67 | if (!err) { 68 | request(template.appDst) 69 | .get('/project/'+ template.dst.stagedProject._id +'/form?limit=9999999') 70 | .set('x-admin-key', process.env.ADMIN_KEY) 71 | .end(function(err, res) { 72 | if (err) { 73 | return done(err); 74 | } 75 | 76 | const formNames = Object.keys(template.src.forms); 77 | 78 | formNames.forEach(x=> { 79 | assert.equal(res.body.map(x=> x.name).includes(x), true); 80 | }); 81 | 82 | const formLookUp = res.body.filter(x=> formNames.includes(x.name)).map(x=> { 83 | return {name: x.name, id: x._id}; 84 | }); 85 | 86 | async.each(formLookUp, function(form, cb) { 87 | request(template.appDst) 88 | .get('/project/'+ template.dst.stagedProject._id +'/form/'+form.id +'/submission') 89 | .set('x-admin-key', process.env.ADMIN_KEY) 90 | .end(function(err, res) { 91 | if (err) { 92 | return done(err); 93 | } 94 | res.body.sort((a,b) => a.data.textField - b.data.textField).forEach((element, index) => { 95 | assert.deepEqual(element.data, template.src.submission[form.name][index].data ); 96 | }); 97 | 98 | cb(); 99 | }); 100 | }, function(err) { 101 | if (err) { 102 | return done(err); 103 | } 104 | done(); 105 | }); 106 | }); 107 | } 108 | }); 109 | }); 110 | 111 | it('Should migrate submissions from CSV file and pass them through transformer function', (done) => { 112 | const options = {}; 113 | options.dstAdminKey = process.env.ADMIN_KEY; 114 | options.dstOptions = { 115 | adminKey: process.env.ADMIN_KEY, 116 | dstAdminKey: process.env.ADMIN_KEY, 117 | projectName:'formio', 118 | server: process.env.API_SRC, 119 | srcAdminKey: undefined 120 | }; 121 | options.params =[ 122 | 'test/migrate/import.csv', 123 | 'test/migrate/transform.js', 124 | `${process.env.API_SRC}/${template.src.project.name}/form/${template.src.forms.textForm2._id}` 125 | ]; 126 | 127 | migrate(options, (err) => { 128 | if (!err) { 129 | request(template.appSrc) 130 | .get('/project/'+ template.src.project._id +'/form/'+ template.src.forms.textForm2._id + '/submission') 131 | .set('x-admin-key', process.env.ADMIN_KEY) 132 | .expect(200) 133 | .expect('Content-Type', /json/) 134 | .end(function(err, res) { 135 | if (err) { 136 | return done(err); 137 | } 138 | const result = res.body.find(x=> x.data.textField === 'Joe'); 139 | assert.equal(!!result, true); 140 | assert.equal(result.data.secondField, 'Smith'); 141 | assert.equal(result.data.thirdField, 'joe@example.com'); 142 | template.src.submission['textForm2']= res.body; 143 | done(); 144 | }); 145 | } 146 | }); 147 | }); 148 | 149 | it('Should delete destination form submissions when migrating with --delete option', (done) => { 150 | options.params =[ 151 | `${process.env.API_SRC}/formio/textForm2`, 152 | 'form', 153 | `${process.env.API_DST}/formio/textFormDst` 154 | ]; 155 | options.delete = true; 156 | 157 | request(template.appDst) 158 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.textFormDst._id +'/submission') 159 | .set('x-admin-key', process.env.ADMIN_KEY) 160 | .expect(200) 161 | .expect('Content-Type', /json/) 162 | .end(function(err, result) { 163 | if (err) { 164 | return done(err); 165 | } 166 | result.body.forEach(x=> assert.equal(['1','2'].includes(x.data.textField), true)); 167 | 168 | migrate(options, (err) => { 169 | if (err) { 170 | return done(err); 171 | } 172 | 173 | request(template.appDst) 174 | .get('/project/'+ template.dst.project._id +'/form/'+template.dst.forms.textFormDst._id +'/submission') 175 | .set('x-admin-key', process.env.ADMIN_KEY) 176 | .expect(200) 177 | .expect('Content-Type', /json/) 178 | .end(function(err, result) { 179 | if (err) { 180 | return done(err); 181 | } 182 | result.body.forEach(x=> assert.equal(['1','2'].includes(x.data.textField), false)); 183 | done(); 184 | }); 185 | }); 186 | }); 187 | }); 188 | 189 | it('Should migrate project forms starting with the specified form path', (done) => { 190 | options.params =[ 191 | `${process.env.API_SRC}/formio`, 192 | 'project', 193 | `${process.env.API_DST}/${template.dst.stagedProject2.name}` 194 | ]; 195 | options.startWith = 'textform2'; 196 | options.delete = false; 197 | options.dstOptions.projectName = template.dst.stagedProject2.name, 198 | 199 | migrate(options, (err) => { 200 | if (err) { 201 | return done(err); 202 | } 203 | 204 | request(template.appDst) 205 | .get('/project/'+ template.dst.stagedProject2._id +'/form') 206 | .set('x-admin-key', process.env.ADMIN_KEY) 207 | .expect(200) 208 | .expect('Content-Type', /json/) 209 | .end(function(err, result) { 210 | if (err) { 211 | return done(err); 212 | } 213 | 214 | const formSrcKeys = Object.keys(template.src.forms); 215 | const firstForm = formSrcKeys.shift(); 216 | const bodyForms = result.body.map(x=> x.name); 217 | 218 | const formLookUp = result.body.filter(x=> formSrcKeys.includes(x.name)).map(x=> { 219 | return {name: x.name, id: x._id}; 220 | }); 221 | 222 | formSrcKeys.forEach(x=> assert.equal(bodyForms.includes(x), true)); 223 | assert.equal(bodyForms.includes(firstForm), false); 224 | 225 | async.each(formLookUp, function(form, cb) { 226 | request(template.appDst) 227 | .get('/project/'+ template.dst.stagedProject2._id +'/form/'+form.id +'/submission') 228 | .set('x-admin-key', process.env.ADMIN_KEY) 229 | .end(function(err, res) { 230 | if (err) { 231 | return done(err); 232 | } 233 | res.body.forEach((element, index) => { 234 | assert.deepEqual(element.data, template.src.submission[form.name][index].data ); 235 | }); 236 | 237 | cb(); 238 | }); 239 | }, function(err) { 240 | if (err) { 241 | return done(err); 242 | } 243 | done(); 244 | }); 245 | }); 246 | }); 247 | }); 248 | }); 249 | }; 250 | -------------------------------------------------------------------------------- /test/migrate/transform.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | let header = true; 4 | module.exports = function(record, next) { 5 | if (header) { 6 | // Ignore the header row. 7 | header = false; 8 | return next(); 9 | } 10 | next(null, { 11 | data: { 12 | textField: record[0], 13 | secondField: record[1], 14 | thirdField: record[2] 15 | } 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /test/submissions/middleware.js: -------------------------------------------------------------------------------- 1 | // middleware.js 2 | 'use strict'; 3 | 4 | const middlewareFunc = (record, index, options, nextFunction) => { 5 | if (nextFunction) { 6 | nextFunction(null, record); 7 | } 8 | }; 9 | 10 | module.exports = middlewareFunc; 11 | -------------------------------------------------------------------------------- /test/submissions/submission.js: -------------------------------------------------------------------------------- 1 | /* globals describe, it */ 2 | 'use strict'; 3 | const assert = require('assert'); 4 | const submission = require('../../src/submissions'); 5 | 6 | module.exports = (template) => { 7 | describe('Submission Command', function() { 8 | const options={dstOptions:{}}; 9 | options.dstOptions.adminKey = process.env.ADMIN_KEY; 10 | options.adminKey = process.env.ADMIN_KEY; 11 | 12 | it('Should show submissions in console.', (done) => { 13 | options.params = [`${process.env.API_SRC}/formio/textForm1`, 'test/submissions/middleware.js']; 14 | 15 | submission(options, (err, submission) => { 16 | if (err) { 17 | console.log(err.toString().red); 18 | return done(err); 19 | } 20 | 21 | if (!submission) { 22 | return done(); 23 | } 24 | 25 | assert.ok( 26 | template.src.submission.textForm1.some(sub => sub._id === submission._id), 27 | 'Should output submission from template' 28 | ); 29 | }); 30 | }); 31 | 32 | it('Should throw an error if form URL not provided.', (done) => { 33 | options.params = []; 34 | 35 | submission(options, (err) => { 36 | assert.equal(err, 'You must provide a source form to load submissions.'); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /test/templates/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Default", 3 | "name": "default", 4 | "version": "2.0.0", 5 | "description": "Provides a simple User authentication system.", 6 | "roles": { 7 | "administrator": { 8 | "title": "Administrator", 9 | "description": "A role for Administrative Users.", 10 | "admin": true, 11 | "default": false 12 | }, 13 | "authenticated": { 14 | "title": "Authenticated", 15 | "description": "A role for Authenticated Users.", 16 | "admin": false, 17 | "default": false 18 | }, 19 | "anonymous": { 20 | "title": "Anonymous", 21 | "description": "A role for Anonymous Users.", 22 | "admin": false, 23 | "default": true 24 | } 25 | }, 26 | "resources": { 27 | "user": { 28 | "title": "User", 29 | "type": "resource", 30 | "name": "user", 31 | "path": "user", 32 | "tags": [], 33 | "submissionAccess": [ 34 | { 35 | "type": "create_all", 36 | "roles": ["administrator"] 37 | }, 38 | { 39 | "type": "read_all", 40 | "roles": ["administrator"] 41 | }, 42 | { 43 | "type": "update_all", 44 | "roles": ["administrator"] 45 | }, 46 | { 47 | "type": "delete_all", 48 | "roles": ["administrator"] 49 | }, 50 | { 51 | "type": "create_own", 52 | "roles": [] 53 | }, 54 | { 55 | "type": "read_own", 56 | "roles": [] 57 | }, 58 | { 59 | "type": "update_own", 60 | "roles": [] 61 | }, 62 | { 63 | "type": "delete_own", 64 | "roles": [] 65 | } 66 | ], 67 | "access": [ 68 | { 69 | "type": "read_all", 70 | "roles": [ 71 | "anonymous", 72 | "authenticated", 73 | "administrator" 74 | ] 75 | } 76 | ], 77 | "components": [ 78 | { 79 | "type": "email", 80 | "persistent": true, 81 | "unique": true, 82 | "required": true, 83 | "protected": false, 84 | "defaultValue": "", 85 | "suffix": "", 86 | "prefix": "", 87 | "placeholder": "Enter your email address", 88 | "key": "email", 89 | "label": "Email", 90 | "inputType": "email", 91 | "tableView": true, 92 | "input": true 93 | }, 94 | { 95 | "type": "password", 96 | "persistent": true, 97 | "protected": true, 98 | "suffix": "", 99 | "prefix": "", 100 | "placeholder": "Enter your password.", 101 | "key": "password", 102 | "label": "Password", 103 | "inputType": "password", 104 | "tableView": false, 105 | "input": true 106 | }, 107 | { 108 | "type": "button", 109 | "theme": "primary", 110 | "disableOnInvalid": true, 111 | "action": "submit", 112 | "block": false, 113 | "rightIcon": "", 114 | "leftIcon": "", 115 | "size": "md", 116 | "key": "submit", 117 | "tableView": false, 118 | "label": "Submit", 119 | "input": true 120 | } 121 | ] 122 | }, 123 | "admin": { 124 | "title": "Admin", 125 | "type": "resource", 126 | "name": "admin", 127 | "path": "admin", 128 | "tags": [], 129 | "submissionAccess": [ 130 | { 131 | "type": "create_all", 132 | "roles": ["administrator"] 133 | }, 134 | { 135 | "type": "read_all", 136 | "roles": ["administrator"] 137 | }, 138 | { 139 | "type": "update_all", 140 | "roles": ["administrator"] 141 | }, 142 | { 143 | "type": "delete_all", 144 | "roles": ["administrator"] 145 | }, 146 | { 147 | "type": "create_own", 148 | "roles": [] 149 | }, 150 | { 151 | "type": "read_own", 152 | "roles": [] 153 | }, 154 | { 155 | "type": "update_own", 156 | "roles": [] 157 | }, 158 | { 159 | "type": "delete_own", 160 | "roles": [] 161 | } 162 | ], 163 | "access": [ 164 | { 165 | "type": "read_all", 166 | "roles": [ 167 | "anonymous", 168 | "authenticated", 169 | "administrator" 170 | ] 171 | } 172 | ], 173 | "components": [ 174 | { 175 | "type": "email", 176 | "persistent": true, 177 | "unique": true, 178 | "required": true, 179 | "protected": false, 180 | "defaultValue": "", 181 | "suffix": "", 182 | "prefix": "", 183 | "placeholder": "Enter your email address", 184 | "key": "email", 185 | "label": "Email", 186 | "inputType": "email", 187 | "tableView": true, 188 | "input": true 189 | }, 190 | { 191 | "type": "password", 192 | "persistent": true, 193 | "protected": true, 194 | "suffix": "", 195 | "prefix": "", 196 | "placeholder": "Enter your password.", 197 | "key": "password", 198 | "label": "Password", 199 | "inputType": "password", 200 | "tableView": false, 201 | "input": true 202 | }, 203 | { 204 | "type": "button", 205 | "theme": "primary", 206 | "disableOnInvalid": true, 207 | "action": "submit", 208 | "block": false, 209 | "rightIcon": "", 210 | "leftIcon": "", 211 | "size": "md", 212 | "key": "submit", 213 | "tableView": false, 214 | "label": "Submit", 215 | "input": true 216 | } 217 | ] 218 | } 219 | }, 220 | "forms": { 221 | "userLogin": { 222 | "title": "User Login", 223 | "type": "form", 224 | "name": "userLogin", 225 | "path": "user/login", 226 | "tags": [], 227 | "access": [ 228 | { 229 | "type": "read_all", 230 | "roles": ["anonymous"] 231 | } 232 | ], 233 | "submissionAccess": [ 234 | { 235 | "type" : "create_own", 236 | "roles" : ["anonymous"] 237 | } 238 | ], 239 | "components": [ 240 | { 241 | "type": "email", 242 | "persistent": true, 243 | "unique": false, 244 | "protected": false, 245 | "defaultValue": "", 246 | "suffix": "", 247 | "prefix": "", 248 | "placeholder": "Enter your email address", 249 | "key": "email", 250 | "lockKey": true, 251 | "label": "Email", 252 | "inputType": "email", 253 | "tableView": true, 254 | "input": true 255 | }, 256 | { 257 | "type": "password", 258 | "persistent": true, 259 | "protected": true, 260 | "suffix": "", 261 | "prefix": "", 262 | "placeholder": "Enter your password.", 263 | "key": "password", 264 | "lockKey": true, 265 | "label": "Password", 266 | "inputType": "password", 267 | "tableView": false, 268 | "input": true 269 | }, 270 | { 271 | "type": "button", 272 | "theme": "primary", 273 | "disableOnInvalid": true, 274 | "action": "submit", 275 | "block": false, 276 | "rightIcon": "", 277 | "leftIcon": "", 278 | "size": "md", 279 | "key": "submit", 280 | "tableView": false, 281 | "label": "Submit", 282 | "input": true 283 | } 284 | ] 285 | }, 286 | "userRegister": { 287 | "title": "User Register", 288 | "name": "userRegister", 289 | "path": "user/register", 290 | "type": "form", 291 | "tags": [], 292 | "access": [ 293 | { 294 | "type": "read_all", 295 | "roles": ["anonymous"] 296 | } 297 | ], 298 | "submissionAccess": [ 299 | { 300 | "type": "create_own", 301 | "roles": ["anonymous"] 302 | } 303 | ], 304 | "components": [ 305 | { 306 | "type": "email", 307 | "persistent": true, 308 | "unique": false, 309 | "protected": false, 310 | "defaultValue": "", 311 | "suffix": "", 312 | "prefix": "", 313 | "placeholder": "Enter your email address", 314 | "key": "email", 315 | "lockKey": true, 316 | "label": "Email", 317 | "inputType": "email", 318 | "tableView": true, 319 | "input": true 320 | }, 321 | { 322 | "type": "password", 323 | "persistent": true, 324 | "protected": true, 325 | "suffix": "", 326 | "prefix": "", 327 | "placeholder": "Enter your password.", 328 | "key": "password", 329 | "lockKey": true, 330 | "label": "Password", 331 | "inputType": "password", 332 | "tableView": false, 333 | "input": true 334 | }, 335 | { 336 | "theme": "primary", 337 | "disableOnInvalid": true, 338 | "action": "submit", 339 | "block": false, 340 | "rightIcon": "", 341 | "leftIcon": "", 342 | "size": "md", 343 | "key": "submit", 344 | "label": "Submit", 345 | "input": true, 346 | "type": "button" 347 | } 348 | ] 349 | }, 350 | "adminLogin": { 351 | "title": "Admin Login", 352 | "type": "form", 353 | "name": "adminLogin", 354 | "path": "admin/login", 355 | "tags": [], 356 | "access": [ 357 | { 358 | "type": "read_all", 359 | "roles": ["anonymous"] 360 | } 361 | ], 362 | "submissionAccess": [ 363 | { 364 | "type" : "create_own", 365 | "roles" : ["anonymous"] 366 | } 367 | ], 368 | "components": [ 369 | { 370 | "type": "email", 371 | "persistent": true, 372 | "unique": false, 373 | "protected": false, 374 | "defaultValue": "", 375 | "suffix": "", 376 | "prefix": "", 377 | "placeholder": "Enter your email address", 378 | "key": "email", 379 | "lockKey": true, 380 | "label": "Email", 381 | "inputType": "email", 382 | "tableView": true, 383 | "input": true 384 | }, 385 | { 386 | "type": "password", 387 | "persistent": true, 388 | "protected": true, 389 | "suffix": "", 390 | "prefix": "", 391 | "placeholder": "Enter your password.", 392 | "key": "password", 393 | "lockKey": true, 394 | "label": "Password", 395 | "inputType": "password", 396 | "tableView": false, 397 | "input": true 398 | }, 399 | { 400 | "type": "button", 401 | "theme": "primary", 402 | "disableOnInvalid": true, 403 | "action": "submit", 404 | "block": false, 405 | "rightIcon": "", 406 | "leftIcon": "", 407 | "size": "md", 408 | "key": "submit", 409 | "tableView": false, 410 | "label": "Submit", 411 | "input": true 412 | } 413 | ] 414 | } 415 | }, 416 | "actions": { 417 | "user:role": { 418 | "title": "Role Assignment", 419 | "name": "role", 420 | "priority": 1, 421 | "handler": ["after"], 422 | "method": ["create"], 423 | "form": "user", 424 | "settings": { 425 | "association": "new", 426 | "type": "add", 427 | "role": "authenticated" 428 | } 429 | }, 430 | "user:save": { 431 | "title": "Save Submission", 432 | "name": "save", 433 | "form": "user", 434 | "handler": ["before"], 435 | "method": ["create", "update"], 436 | "priority": 10 437 | }, 438 | "userLogin:login": { 439 | "name": "login", 440 | "title": "Login", 441 | "form": "userLogin", 442 | "priority": 2, 443 | "method": ["create"], 444 | "handler": ["before"], 445 | "settings": { 446 | "resources": ["user"], 447 | "username": "email", 448 | "password": "password", 449 | "allowedAttempts": 5, 450 | "attemptWindow": 30, 451 | "lockWait": 1800 452 | } 453 | }, 454 | "userRegister:save": { 455 | "title": "Save Submission", 456 | "name": "save", 457 | "form": "userRegister", 458 | "handler": ["before"], 459 | "method": ["create", "update"], 460 | "priority": 11, 461 | "settings": { 462 | "resource": "user", 463 | "fields": { 464 | "email": "email", 465 | "password": "password" 466 | } 467 | } 468 | }, 469 | "userRegister:login": { 470 | "name": "login", 471 | "title": "Login", 472 | "form": "userRegister", 473 | "priority": 2, 474 | "method": ["create"], 475 | "handler": ["before"], 476 | "settings": { 477 | "resources": ["user"], 478 | "username": "email", 479 | "password": "password" 480 | } 481 | }, 482 | "admin:role": { 483 | "title": "Role Assignment", 484 | "name": "role", 485 | "priority": 1, 486 | "handler": ["after"], 487 | "method": ["create"], 488 | "form": "admin", 489 | "settings": { 490 | "association": "new", 491 | "type": "add", 492 | "role": "administrator" 493 | } 494 | }, 495 | "admin:save": { 496 | "title": "Save Submission", 497 | "name": "save", 498 | "form": "admin", 499 | "handler": ["before"], 500 | "method": ["create", "update"], 501 | "priority": 10 502 | }, 503 | "adminLogin:login": { 504 | "name": "login", 505 | "title": "Login", 506 | "form": "adminLogin", 507 | "priority": 2, 508 | "method": ["create"], 509 | "handler": ["before"], 510 | "settings": { 511 | "resources": ["admin"], 512 | "username": "email", 513 | "password": "password", 514 | "allowedAttempts": 5, 515 | "attemptWindow": 30, 516 | "lockWait": 1800 517 | } 518 | } 519 | }, 520 | "access": [ 521 | { 522 | "type": "create_all", 523 | "roles": [ 524 | "administrator" 525 | ] 526 | }, 527 | { 528 | "type": "read_all", 529 | "roles": [ 530 | "administrator" 531 | ] 532 | }, 533 | { 534 | "type": "update_all", 535 | "roles": [ 536 | "administrator" 537 | ] 538 | }, 539 | { 540 | "type": "delete_all", 541 | "roles": [ 542 | "administrator" 543 | ] 544 | } 545 | ] 546 | } 547 | -------------------------------------------------------------------------------- /test/templates/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "textFormFirstSrc": { 3 | "title": "textForm", 4 | "display": "form", 5 | "type": "form", 6 | "components": [ 7 | { 8 | "label": "Text Field", 9 | "applyMaskOn": "change", 10 | "tableView": true, 11 | "key": "textField", 12 | "type": "textfield", 13 | "input": true 14 | }, 15 | { 16 | "type": "button", 17 | "label": "Submit", 18 | "key": "submit", 19 | "disableOnInvalid": true, 20 | "input": true, 21 | "tableView": false 22 | } 23 | ], 24 | "access": [], 25 | "submissionAccess": [], 26 | "controller": "", 27 | "properties": {}, 28 | "settings": {}, 29 | "builder": false, 30 | "name": "textForm1", 31 | "path": "textform1" 32 | }, 33 | "textFormSecondSrc": { 34 | "title": "textForm2", 35 | "display": "form", 36 | "type": "form", 37 | "components": [ 38 | { 39 | "label": "Text Field", 40 | "applyMaskOn": "change", 41 | "tableView": true, 42 | "key": "textField", 43 | "type": "textfield", 44 | "input": true 45 | }, 46 | { 47 | "label": "Text Field", 48 | "applyMaskOn": "change", 49 | "tableView": true, 50 | "key": "secondField", 51 | "type": "textfield", 52 | "input": true 53 | }, 54 | { 55 | "label": "Text Field", 56 | "applyMaskOn": "change", 57 | "tableView": true, 58 | "key": "thirdField", 59 | "type": "textfield", 60 | "input": true 61 | }, 62 | { 63 | "type": "button", 64 | "label": "Submit", 65 | "key": "submit", 66 | "disableOnInvalid": true, 67 | "input": true, 68 | "tableView": false 69 | } 70 | ], 71 | "access": [], 72 | "submissionAccess": [], 73 | "controller": "", 74 | "properties": {}, 75 | "settings": {}, 76 | "builder": false, 77 | "name": "textForm2", 78 | "path": "textform2" 79 | }, 80 | "textFormThirdSrc": { 81 | "title": "textForm3", 82 | "display": "form", 83 | "type": "form", 84 | "components": [ 85 | { 86 | "label": "Text Field", 87 | "applyMaskOn": "change", 88 | "tableView": true, 89 | "key": "textField", 90 | "type": "textfield", 91 | "input": true 92 | }, 93 | { 94 | "label": "Text Field", 95 | "applyMaskOn": "change", 96 | "tableView": true, 97 | "key": "secondField", 98 | "type": "textfield", 99 | "input": true 100 | }, 101 | { 102 | "label": "Text Field", 103 | "applyMaskOn": "change", 104 | "tableView": true, 105 | "key": "thirdField", 106 | "type": "textfield", 107 | "input": true 108 | }, 109 | { 110 | "type": "button", 111 | "label": "Submit", 112 | "key": "submit", 113 | "disableOnInvalid": true, 114 | "input": true, 115 | "tableView": false 116 | } 117 | ], 118 | "access": [], 119 | "submissionAccess": [], 120 | "controller": "", 121 | "properties": {}, 122 | "settings": {}, 123 | "builder": false, 124 | "name": "textForm3", 125 | "path": "textform3" 126 | }, 127 | "textFormFirstDst": { 128 | "title": "textForm", 129 | "display": "form", 130 | "type": "form", 131 | "components": [ 132 | { 133 | "label": "Text Field", 134 | "applyMaskOn": "change", 135 | "tableView": true, 136 | "key": "textField", 137 | "type": "textfield", 138 | "input": true 139 | }, 140 | { 141 | "type": "button", 142 | "label": "Submit", 143 | "key": "submit", 144 | "disableOnInvalid": true, 145 | "input": true, 146 | "tableView": false 147 | } 148 | ], 149 | "access": [], 150 | "submissionAccess": [], 151 | "controller": "", 152 | "properties": {}, 153 | "settings": {}, 154 | "builder": false, 155 | "name": "textFormDst", 156 | "path": "textformDst" 157 | }, 158 | "textFormSrcResource": { 159 | "title": "textForm", 160 | "display": "form", 161 | "type": "resource", 162 | "components": [ 163 | { 164 | "label": "Text Field", 165 | "applyMaskOn": "change", 166 | "tableView": true, 167 | "key": "textField", 168 | "type": "textfield", 169 | "input": true 170 | }, 171 | { 172 | "type": "button", 173 | "label": "Submit", 174 | "key": "submit", 175 | "disableOnInvalid": true, 176 | "input": true, 177 | "tableView": false 178 | } 179 | ], 180 | "access": [], 181 | "submissionAccess": [], 182 | "controller": "", 183 | "properties": {}, 184 | "settings": {}, 185 | "builder": false, 186 | "name": "textFormSrcResource", 187 | "path": "textformsrcresource" 188 | }, 189 | "textFormDstResource": { 190 | "title": "textForm", 191 | "display": "form", 192 | "type": "resource", 193 | "components": [ 194 | { 195 | "label": "Select", 196 | "widget": "choicesjs", 197 | "tableView": true, 198 | "key": "select", 199 | "type": "select", 200 | "input": true 201 | }, 202 | { 203 | "type": "button", 204 | "label": "Submit", 205 | "key": "submit", 206 | "disableOnInvalid": true, 207 | "input": true, 208 | "tableView": false 209 | } 210 | ], 211 | "access": [], 212 | "submissionAccess": [], 213 | "controller": "", 214 | "properties": {}, 215 | "settings": {}, 216 | "builder": false, 217 | "name": "textFormDstResource", 218 | "path": "textformdstresource" 219 | }, 220 | "textFormFirstDst2": { 221 | "title": "textForm", 222 | "display": "form", 223 | "type": "form", 224 | "components": [ 225 | { 226 | "label": "Select", 227 | "widget": "choicesjs", 228 | "tableView": true, 229 | "key": "select", 230 | "type": "select", 231 | "input": true 232 | }, 233 | { 234 | "type": "button", 235 | "label": "Submit", 236 | "key": "submit", 237 | "disableOnInvalid": true, 238 | "input": true, 239 | "tableView": false 240 | } 241 | ], 242 | "access": [], 243 | "submissionAccess": [], 244 | "controller": "", 245 | "properties": {}, 246 | "settings": {}, 247 | "builder": false, 248 | "name": "textFormDst2", 249 | "path": "textformDst2" 250 | }, 251 | "submissionFirst": { 252 | "data": { "textField": "1", "submit": true } 253 | }, 254 | "submissionSecond": { 255 | "data": { "textField": "2", "submit": true } 256 | }, 257 | "submissionThird": { 258 | "data": { "textField": "3", "submit": true } 259 | }, 260 | "formCopyChainSrc": { 261 | "title": "textForm", 262 | "display": "form", 263 | "type": "form", 264 | "components": [ 265 | { 266 | "input": true, 267 | "tableView": false, 268 | "key": "validationStatus", 269 | "label": "Validation Status", 270 | "protected": true, 271 | "unique": false, 272 | "persistent": true, 273 | "type": "hidden" 274 | }, 275 | { 276 | "validate": { "required": true }, 277 | "type": "email", 278 | "persistent": true, 279 | "unique": false, 280 | "protected": false, 281 | "defaultValue": "", 282 | "suffix": "", 283 | "prefix": "", 284 | "placeholder": "Enter an email for a contact for this project", 285 | "key": "contactEmail", 286 | "label": "Contact Email", 287 | "inputType": "email", 288 | "tableView": true, 289 | "input": true 290 | } 291 | ], 292 | "access": [], 293 | "submissionAccess": [], 294 | "controller": "", 295 | "properties": {}, 296 | "settings": {}, 297 | "builder": false, 298 | "name": "formCopyChainSrc", 299 | "path": "formcopychainsrc" 300 | }, 301 | "formDeployCheck": { 302 | "title": "formDeployCheck", 303 | "display": "form", 304 | "type": "form", 305 | "components": [ 306 | { 307 | "input": true, 308 | "tableView": false, 309 | "key": "validationStatus", 310 | "label": "Validation Status", 311 | "protected": true, 312 | "unique": false, 313 | "persistent": true, 314 | "type": "hidden" 315 | } 316 | ], 317 | "access": [], 318 | "submissionAccess": [], 319 | "controller": "", 320 | "properties": {}, 321 | "settings": {}, 322 | "builder": false, 323 | "name": "formDeployCheck", 324 | "path": "formdeploycheck" 325 | }, 326 | "formCopyChainDst": { 327 | "title": "formCopyChainDst", 328 | "display": "form", 329 | "type": "form", 330 | "components": [ 331 | { 332 | "label": "Select", 333 | "widget": "choicesjs", 334 | "tableView": true, 335 | "key": "select", 336 | "type": "select", 337 | "input": true 338 | } 339 | ], 340 | "access": [], 341 | "submissionAccess": [], 342 | "controller": "", 343 | "properties": {}, 344 | "settings": {}, 345 | "builder": false, 346 | "name": "formCopyChainDst", 347 | "path": "formcopychaindst" 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Cloner = require('../src/Cloner'); 3 | const {faker} = require('@faker-js/faker'); 4 | module.exports = { 5 | async newDeployment(srcDb, dstDb, oss) { 6 | const cloner = new Cloner(srcDb, dstDb); 7 | const roles = []; 8 | const forms = []; 9 | const submissions = []; 10 | const actions = []; 11 | const actionItems = []; 12 | 13 | await cloner.connect(); 14 | 15 | const project = { 16 | title: `Test Project ${faker.string.alphanumeric(10)}`, 17 | name: `test${faker.string.alpha(10)}`, 18 | type: 'project', 19 | tag: '0.0.0', 20 | owner: null, 21 | project: null, 22 | deleted: null, 23 | created: new Date(), 24 | modified: new Date(), 25 | machineName: faker.string.alpha(10) 26 | }; 27 | if (oss) { 28 | project._id = (await cloner.dest.projects.insertOne(project)).insertedId; 29 | } 30 | else { 31 | project._id = (await cloner.src.projects.insertOne(project)).insertedId; 32 | } 33 | for (let i = 0; i < 5; i++) { 34 | const role = { 35 | title: faker.person.jobType, 36 | description: '', 37 | deleted: null, 38 | default: false, 39 | admin: false, 40 | created: new Date(), 41 | modified: new Date(), 42 | machineName: faker.string.alpha(10) 43 | }; 44 | if (!oss) { 45 | role.project = project._id; 46 | } 47 | role._id = (await cloner.src.roles.insertOne(role)).insertedId; 48 | roles.push(role); 49 | } 50 | for (let i = 0; i < 5; i++) { 51 | const form = { 52 | title: `Form ${faker.string.alpha(10)}`, 53 | path: faker.string.alpha(10), 54 | name: faker.string.alpha(10), 55 | type: 'form', 56 | deleted: null, 57 | components: [ 58 | {type: 'textfield', key: 'a', label: 'A'}, 59 | {type: 'textfield', key: 'b', label: 'B'}, 60 | {type: 'textfield', key: 'c', label: 'C'} 61 | ], 62 | machineName: faker.string.alpha(10) 63 | }; 64 | if (!oss) { 65 | form.project = project._id; 66 | } 67 | form._id = (await cloner.src.forms.insertOne(form)).insertedId; 68 | forms.push(form); 69 | const action = { 70 | title: 'Save Submission', 71 | name: 'save', 72 | handler: ['before'], 73 | method: ['create', 'update'], 74 | priority: 10, 75 | form: form._id, 76 | deleted: null, 77 | settings: {}, 78 | machineName: faker.string.alpha(10) 79 | }; 80 | action._id = (await cloner.src.actions.insertOne(action)).insertedId; 81 | actions.push(action); 82 | for (let j = 0; j < 20; j++) { 83 | const submission = { 84 | form: form._id, 85 | owner: null, 86 | deleted: null, 87 | roles: [], 88 | access: [], 89 | metadata: {}, 90 | data: { 91 | a: faker.string.alpha(10), 92 | b: faker.string.alpha(10), 93 | c: faker.string.alpha(10) 94 | }, 95 | machineName: faker.string.alpha(10) 96 | }; 97 | if (!oss) { 98 | submission.project = project._id; 99 | } 100 | submission._id = (await cloner.src.submissions.insertOne(submission)).insertedId; 101 | submissions.push(submission); 102 | } 103 | } 104 | return { 105 | cloner, 106 | project, 107 | roles, 108 | forms, 109 | submissions, 110 | actions, 111 | actionItems 112 | }; 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /test/waitApisReady.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fetch = require('../src/fetch'); 3 | 4 | module.exports = async() => { 5 | const maxRetries = 10; 6 | const retryInterval = 5000; 7 | 8 | console.log('Waiting for the API servers to start ...'); 9 | 10 | for (let i = 0; i < maxRetries; i++) { 11 | try { 12 | await fetch()({url: 'http://localhost:4001/status'}); 13 | await fetch()({url: 'http://localhost:4002/status'}); 14 | 15 | console.log('API servers ready.'); 16 | return; 17 | } 18 | catch (err) { 19 | await new Promise(resolve => setTimeout(resolve, retryInterval)); 20 | } 21 | } 22 | 23 | console.error('Timeout: API servers not started.'); 24 | process.exit(1); 25 | }; 26 | --------------------------------------------------------------------------------