1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-10 19:34:34 +00:00

Compare commits

...

201 Commits

Author SHA1 Message Date
semantic-release-bot
3a45e8f525 chore(release): 0.11.1 [skip ci]
## [0.11.1](https://github.com/sasjs/server/compare/v0.11.0...v0.11.1) (2022-07-18)

### Bug Fixes

* bank operator ([aa02741](aa027414ed))
* ensuring nosplash option only applies for sas.exe ([65e6de9](65e6de9663)), closes [#229](https://github.com/sasjs/server/issues/229)
2022-07-18 12:14:31 +00:00
Allan Bowe
c0e2f55a7b Merge pull request #231 from sasjs/issue229
fix: bank operator
2022-07-18 13:10:30 +01:00
Allan Bowe
aa027414ed fix: bank operator 2022-07-18 12:09:54 +00:00
Allan Bowe
8c4c52b1a9 Merge pull request #230 from sasjs/issue229
fix: ensuring nosplash option only applies for sas.exe
2022-07-18 12:58:15 +01:00
Allan Bowe
ff420434ae chore: removing line added automatically 2022-07-18 11:57:19 +00:00
Allan Bowe
65e6de9663 fix: ensuring nosplash option only applies for sas.exe
Closes #229
2022-07-18 11:55:35 +00:00
semantic-release-bot
2e53d43e11 chore(release): 0.11.0 [skip ci]
# [0.11.0](https://github.com/sasjs/server/compare/v0.10.0...v0.11.0) (2022-07-16)

### Bug Fixes

* **logs:** logs location is configurable ([e024a92](e024a92f16))

### Features

* **logs:** logs to file with rotating + code split into files ([92fda18](92fda183f3))
2022-07-16 21:58:08 +00:00
Allan Bowe
3795f748a7 Merge pull request #228 from sasjs/issue217
Issue217
2022-07-16 22:54:13 +01:00
Saad Jutt
e024a92f16 fix(logs): logs location is configurable 2022-07-16 05:07:00 +05:00
Saad Jutt
92fda183f3 feat(logs): logs to file with rotating + code split into files 2022-07-16 04:42:54 +05:00
Saad Jutt
6f2e6efd03 chore: fixed few vulnerabilites 2022-07-16 03:30:29 +05:00
Allan Bowe
3b4e9d20d4 Create FUNDING.yml 2022-07-08 20:51:10 +01:00
semantic-release-bot
4a67d0c63a chore(release): 0.10.0 [skip ci]
# [0.10.0](https://github.com/sasjs/server/compare/v0.9.0...v0.10.0) (2022-07-06)

### Bug Fixes

* add authorize middleware for appStreams ([e54a09d](e54a09db19))
* add isAdmin attribute to return response of get session and login requests ([bdf63df](bdf63df1d9))
* add permission authorization middleware to only specific routes ([f3dfc70](f3dfc7083f))
* bumping core and running lint ([a2d1396](a2d1396057))
* controller fixed for deleting permission ([b5f595a](b5f595a25c))
* do not show admin users in add permission modal ([a75edba](a75edbaa32))
* export GroupResponse interface ([38a7db8](38a7db8514))
* move permission filter modal to separate file and icons for different actions ([d000f75](d000f7508f))
* principalId type changed to  number from any ([4fcc191](4fcc191ce9))
* remove clientId from principal types ([0781ddd](0781ddd64e))
* remove duplicates principals from permission filter modal ([5b319f9](5b319f9ad1))
* show loading spinner in studio while executing code ([496247d](496247d0b9))
* show permission component only in server mode ([f863b81](f863b81a7d))
* update permission model ([39fc908](39fc908de1))
* update permission response ([e516b77](e516b7716d))
* **web:** only admin should be able to add, update or delete permission ([be8635c](be8635ccc5))

### Features

* add api endpoint for deleting permission ([0171344](01713440a4))
* add api endpoint for updating permission setting ([540f54f](540f54fb77))
* add authorize middleware for validating permissions ([7d916ec](7d916ec3e9))
* add basic UI for settings and permissions ([5652325](5652325452))
* add documentation link under usename dropdown menu ([eeb63b3](eeb63b330c))
* add permission model ([6bea1f7](6bea1f7666))
* add UI for updating permission ([e8c21a4](e8c21a43b2))
* add validation for registering permission ([e5200c1](e5200c1000))
* add, remove and update permissions from web component ([97ecfdc](97ecfdc955))
* added get authorizedRoutes api endpoint ([b10e932](b10e932605))
* created modal for adding permission ([1413b18](1413b18508))
* defined register permission and get all permissions api endpoints ([1103ffe](1103ffe07b))
* update swagger docs ([797c2bc](797c2bcc39))
2022-07-06 12:31:43 +00:00
Allan Bowe
dea204e3c5 Merge pull request #221 from sasjs/improve-web-UI
fix: Improve web UI
2022-07-06 13:26:19 +01:00
Allan Bowe
5f9e83759c Merge pull request #215 from sasjs/descs
chore: update descriptions
2022-07-06 13:26:00 +01:00
Allan Bowe
fefe63deb1 Merge pull request #151 from sasjs/issue-139
feat: enable permissions
2022-07-06 13:25:27 +01:00
ddd179bbee chore: added specs for verifying permissions 2022-07-05 16:18:14 +05:00
a10b87930c chore: quick fix 2022-07-05 15:29:44 +05:00
496247d0b9 fix: show loading spinner in studio while executing code 2022-07-05 08:23:51 +05:00
eeb63b330c feat: add documentation link under usename dropdown menu 2022-07-05 08:23:36 +05:00
Saad Jutt
1108d3dd7b chore: quick fix 2022-07-05 05:30:13 +05:00
Saad Jutt
7edb47a4cb chore: build fix 2022-07-05 03:40:54 +05:00
Saad Jutt
451cb4f6dd chore: fixed specs 2022-07-05 03:26:37 +05:00
Saad Jutt
0b759a5594 chore: code fixes API + web 2022-07-05 02:34:33 +05:00
Saad Jutt
5338ffb211 chore: Merge branch 'main' into issue-139 2022-07-04 23:59:51 +05:00
e42fdd3575 chore: conditionally call authorize middleware from authenticateToken 2022-07-04 20:13:46 +05:00
b10e932605 feat: added get authorizedRoutes api endpoint 2022-07-04 19:14:06 +05:00
e54a09db19 fix: add authorize middleware for appStreams 2022-07-04 17:14:17 +05:00
4c35e04802 chore: add snackbar for showing success alert 2022-07-04 16:00:23 +05:00
b5f595a25c fix: controller fixed for deleting permission 2022-07-04 04:27:58 +05:00
semantic-release-bot
a131adbae7 chore(release): 0.9.0 [skip ci]
# [0.9.0](https://github.com/sasjs/server/compare/v0.8.3...v0.9.0) (2022-07-03)

### Features

* removed secrets from env variables ([9c3da56](9c3da56901))
2022-07-03 10:40:36 +00:00
Allan Bowe
a20c3b9719 Merge pull request #220 from sasjs/issue213
feat: removed secrets from env variables
2022-07-03 11:36:24 +01:00
Saad Jutt
eee3a7b084 chore: code refactor 2022-07-03 07:03:15 +05:00
Saad Jutt
9c3da56901 feat: removed secrets from env variables 2022-07-03 06:56:18 +05:00
Allan Bowe
7e6524d7e4 chore: removing badge 2022-07-02 15:12:27 +01:00
Allan Bowe
0ea2690616 adding matrix chat link 2022-07-02 13:10:20 +01:00
semantic-release-bot
b369759f0f chore(release): 0.8.3 [skip ci]
## [0.8.3](https://github.com/sasjs/server/compare/v0.8.2...v0.8.3) (2022-07-02)

### Bug Fixes

* **deploy:** extract first json from zip file ([e290751](e290751c87))
2022-07-02 10:01:26 +00:00
Allan Bowe
ac9a835c5a Merge pull request #219 from sasjs/issue211
fix(deploy): extract first json from zip file
2022-07-02 10:57:16 +01:00
Saad Jutt
e290751c87 fix(deploy): extract first json from zip file 2022-07-02 14:39:33 +05:00
e516b7716d fix: update permission response 2022-07-02 01:03:53 +05:00
f3dfc7083f fix: add permission authorization middleware to only specific routes 2022-07-01 16:50:24 +05:00
7d916ec3e9 feat: add authorize middleware for validating permissions 2022-06-29 23:06:58 +05:00
70f279a49c chore: update swagger.yaml 2022-06-28 09:23:53 +05:00
66a3537271 chore: add specs 2022-06-28 06:50:35 +05:00
ca64c13909 chore: add principal type and permission setting enums 2022-06-28 00:00:04 +05:00
0a73a35547 chore: improve error handling 2022-06-27 23:21:48 +05:00
a75edbaa32 fix: do not show admin users in add permission modal 2022-06-26 01:49:07 +05:00
4ddfec0403 chore: add isAdmin field in user response 2022-06-26 01:48:31 +05:00
35439d7d51 chore: throw error when adding permission for admin user 2022-06-24 23:19:19 +05:00
907aa485fd chore: throw error when creating duplicate permission 2022-06-24 23:15:41 +05:00
888627e1c8 chore: close filterModal after applying/reseting 2022-06-24 22:32:18 +05:00
9cb9e2dd33 chore: add filter based on principal type 2022-06-24 16:28:41 +05:00
54d4bf835d chore: show principal type in permissions list 2022-06-24 15:50:09 +05:00
67fe298fd5 chore: lint fixes 2022-06-24 14:55:05 +05:00
97ecfdc955 feat: add, remove and update permissions from web component 2022-06-24 14:48:57 +05:00
5b319f9ad1 fix: remove duplicates principals from permission filter modal 2022-06-23 23:58:40 +05:00
be8635ccc5 fix(web): only admin should be able to add, update or delete permission 2022-06-23 23:35:06 +05:00
f863b81a7d fix: show permission component only in server mode 2022-06-23 23:14:54 +05:00
bdf63df1d9 fix: add isAdmin attribute to return response of get session and login requests 2022-06-23 22:50:00 +05:00
4c6b9c5e93 Merge branch 'main' into issue-139 2022-06-23 17:21:52 +05:00
Allan Bowe
a2d1396057 fix: bumping core and running lint 2022-06-23 09:00:22 +00:00
Allan Bowe
b2f21eb3ac chore: update descriptions 2022-06-23 08:44:12 +00:00
semantic-release-bot
71bcbb9134 chore(release): 0.8.2 [skip ci]
## [0.8.2](https://github.com/sasjs/server/compare/v0.8.1...v0.8.2) (2022-06-22)

### Bug Fixes

* getRuntimeAndFilePath function to handle the scenarion when path is provided with an extension other than runtimes ([5cc85b5](5cc85b57f8))
2022-06-22 10:18:59 +00:00
Allan Bowe
c86f0feff8 Merge pull request #214 from sasjs/fix-runtime-filePath
fix: getRuntimeAndFilePath function
2022-06-22 12:14:12 +02:00
Allan Bowe
d3d2ab9a36 Update getRunTimeAndFilePath.ts 2022-06-22 11:12:48 +01:00
5cc85b57f8 fix: getRuntimeAndFilePath function to handle the scenarion when path is provided with an extension other than runtimes 2022-06-22 14:24:06 +05:00
semantic-release-bot
ae0fc0c48c chore(release): 0.8.1 [skip ci]
## [0.8.1](https://github.com/sasjs/server/compare/v0.8.0...v0.8.1) (2022-06-21)

### Bug Fixes

* make CA_ROOT optional in getCertificates method ([1b5859e](1b5859ee37))
* update /logout route to /SASLogon/logout ([65380be](65380be2f3))
2022-06-21 20:10:31 +00:00
Saad Jutt
555c5d54e2 Merge pull request #212 from sasjs/update-logout-route
Update logout route
2022-06-21 13:06:30 -07:00
1b5859ee37 fix: make CA_ROOT optional in getCertificates method 2022-06-22 00:25:41 +05:00
65380be2f3 fix: update /logout route to /SASLogon/logout 2022-06-22 00:24:41 +05:00
Yury Shkoda
1933be15c2 Merge pull request #210 from sasjs/pr-template
chore(template): added pull request template
2022-06-21 19:11:19 +03:00
Yury Shkoda
56b20beb8c chore(template): added pull request template 2022-06-21 19:07:14 +03:00
semantic-release-bot
bfc5ac6a4f chore(release): 0.8.0 [skip ci]
# [0.8.0](https://github.com/sasjs/server/compare/v0.7.3...v0.8.0) (2022-06-21)

### Features

* **certs:** ENV variables updated and set CA Root for HTTPS server ([2119e9d](2119e9de9a))
2022-06-21 07:24:26 +00:00
Allan Bowe
6376173de0 Merge pull request #209 from sasjs/certificate-ca-root
feat(certs): ENV variables updated and set CA Root for HTTPS server
2022-06-21 09:18:15 +02:00
Saad Jutt
3130fbeff0 chore: code refactor for getting session controller 2022-06-21 03:41:44 +05:00
Saad Jutt
01e9a1d9e9 chore: README.md and .env.example updated 2022-06-21 03:26:12 +05:00
Saad Jutt
2119e9de9a feat(certs): ENV variables updated and set CA Root for HTTPS server 2022-06-21 03:17:14 +05:00
semantic-release-bot
87dbab98f6 chore(release): 0.7.3 [skip ci]
## [0.7.3](https://github.com/sasjs/server/compare/v0.7.2...v0.7.3) (2022-06-20)

### Bug Fixes

* path descriptions and defaults ([5d5d6ce](5d5d6ce326))
2022-06-20 15:31:20 +00:00
Allan Bowe
1bf122a0a2 Merge pull request #207 from sasjs/allanbowe/sasjs-server-fails-to-205
fix: path descriptions and defaults
2022-06-20 17:26:56 +02:00
Allan Bowe
5d5d6ce326 fix: path descriptions and defaults 2022-06-20 15:07:17 +00:00
semantic-release-bot
620eddb713 chore(release): 0.7.2 [skip ci]
## [0.7.2](https://github.com/sasjs/server/compare/v0.7.1...v0.7.2) (2022-06-20)

### Bug Fixes

* removing UTF-8 options from commandline.  There appears to be no reliable way to enforce ([f6dc74f](f6dc74f16b))
2022-06-20 15:03:27 +00:00
Allan Bowe
3c92034da3 Merge pull request #206 from sasjs/allanbowe/sasjs-server-fails-to-205
fix: removing UTF-8 options from commandline.
2022-06-20 16:57:58 +02:00
Allan Bowe
f6dc74f16b fix: removing UTF-8 options from commandline. There appears to be no reliable way to enforce
UTF-8 without additional modifications to the PATH variable to ensure a DBCS instance of SAS.
2022-06-20 14:38:19 +00:00
semantic-release-bot
8c48d00d21 chore(release): 0.7.1 [skip ci]
## [0.7.1](https://github.com/sasjs/server/compare/v0.7.0...v0.7.1) (2022-06-20)

### Bug Fixes

* default runtime should be sas ([91d29cb](91d29cb127))
* **Studio:** default selection of runtime fixed ([eb569c7](eb569c7b82))
* webout path fixed in code.js when running on windows ([99a1107](99a1107364))
2022-06-20 12:44:59 +00:00
Allan Bowe
48ff8d73d4 Merge pull request #204 from sasjs/fix-runtime-feature
fix: webout path in code.js fixed when running on windows
2022-06-20 14:40:55 +02:00
eb397b15c2 chore: lint fixes 2022-06-20 17:32:28 +05:00
eb569c7b82 fix(Studio): default selection of runtime fixed 2022-06-20 17:29:19 +05:00
99a1107364 fix: webout path fixed in code.js when running on windows 2022-06-20 17:28:25 +05:00
91d29cb127 fix: default runtime should be sas 2022-06-20 17:12:32 +05:00
semantic-release-bot
1e2c08a8d3 chore(release): 0.7.0 [skip ci]
# [0.7.0](https://github.com/sasjs/server/compare/v0.6.1...v0.7.0) (2022-06-19)

### Bug Fixes

* add runtimes to global process object ([194eaec](194eaec7d4))
* code fixes for executing program from program path including file extension ([53854d0](53854d0012))
* code/execute controller logic to handle different runtimes ([23b6692](23b6692f02))
* convert single executeProgram method to two methods i.e. executeSASProgram and executeJSProgram ([c58666e](c58666eb81))
* no need to stringify _webout in preProgramVarStatements, developer should have _webout as string in actual code ([9d5a5e0](9d5a5e051f))
* pass _program to execute file without extension ([5df619b](5df619b3f6))
* refactor code for session selection in preUploadMiddleware function ([b444381](b4443819d4))
* refactor code in executeFile method of session controller ([dffe6d7](dffe6d7121))
* refactor code in preUploadMiddleware function ([6d6bda5](6d6bda5626))
* refactor sas/js session controller classes to inherit from base session controller class ([2c704a5](2c704a544f))
* **Studio:** style fix for runtime dropdown ([9023cf3](9023cf33b5))

### Features

* configure child process with writeStream to write logs to log file ([058b3b0](058b3b0081))
* conver single session controller to two controller i.e. SASSessionController and JSSessionController ([07295aa](07295aa151))
* create and inject code for uploaded files to code.js ([1685616](16856165fb))
* validate sasjs_runtimes env var ([596ada7](596ada7ca8))
2022-06-19 13:44:02 +00:00
Saad Jutt
473fbd62c0 Merge pull request #186 from sasjs/issue-184
feat: Support JS runtime for services
2022-06-19 06:39:11 -07:00
Saad Jutt
b1a0fe7060 chore: code fixes 2022-06-19 18:35:10 +05:00
dde293c852 chore: write specs for get stp/execute 2022-06-19 07:08:36 +05:00
f738a6d7a3 chore: splitted functions into different files 2022-06-19 07:07:39 +05:00
Saad Jutt
3e0a2de2ad chore: nvmrc version updated to LTS 2022-06-18 01:49:47 +05:00
Saad Jutt
91cb7bd946 chore(workflow): CI/CD env variables updated 2022-06-18 01:13:56 +05:00
Saad Jutt
a501a300dc chore: lint fixes 2022-06-18 00:53:05 +05:00
Saad Jutt
b446baa822 chore: Merge branch 'main' into issue-184 2022-06-18 00:51:48 +05:00
9023cf33b5 fix(Studio): style fix for runtime dropdown 2022-06-17 23:17:23 +05:00
23b6692f02 fix: code/execute controller logic to handle different runtimes 2022-06-17 20:01:50 +05:00
Allan Bowe
6de91618ff Merge pull request #201 from sasjs/all-contributors/add-kknapen
docs: add kknapen as a contributor for userTesting
2022-06-17 16:33:41 +02:00
allcontributors[bot]
e06d66f312 docs: update .all-contributorsrc [skip ci] 2022-06-17 14:33:12 +00:00
allcontributors[bot]
1ffaf2e0ef docs: update README.md [skip ci] 2022-06-17 14:33:11 +00:00
Allan Bowe
393d3327db docs: add @VladislavParhomchik as a contributor 2022-06-17 14:32:52 +00:00
Allan Bowe
14cfb9a663 docs: add @allanbowe as a contributor 2022-06-17 14:29:14 +00:00
Allan Bowe
dd1f2b3ed7 docs: add @medjedovicm as a contributor 2022-06-17 14:29:06 +00:00
Allan Bowe
9f5dbbc8da docs: add @yuryshkoda as a contributor 2022-06-17 14:28:48 +00:00
Allan Bowe
9423bb2b23 docs: add @sabhas as a contributor 2022-06-17 14:28:39 +00:00
Allan Bowe
5bfcdc4dbb docs: add @saadjutt01 as a contributor 2022-06-17 14:27:53 +00:00
semantic-release-bot
ecd8ed9032 chore(release): 0.6.1 [skip ci]
## [0.6.1](https://github.com/sasjs/server/compare/v0.6.0...v0.6.1) (2022-06-17)

### Bug Fixes

* home page wording.  Using fix to force previous change through.. ([8702a4e](8702a4e8fd))
2022-06-17 13:19:51 +00:00
Allan Bowe
a8d89ff1d6 Merge pull request #200 from sasjs/nit
fix: home page wording.  Using fix to force previous change through..
2022-06-17 15:14:59 +02:00
Allan Bowe
8702a4e8fd fix: home page wording. Using fix to force previous change through.. 2022-06-17 13:14:02 +00:00
ab222cbaab chore: verify executable paths 2022-06-17 18:12:03 +05:00
Allan Bowe
5f06132ece Merge pull request #199 from sasjs/allanbowe-patch-1
Update Session.ts
2022-06-17 14:55:22 +02:00
Allan Bowe
56c80b0979 Update Session.ts 2022-06-17 13:46:08 +01:00
158acf1f97 chore: set sas,js as default run times 2022-06-17 04:02:40 +05:00
semantic-release-bot
c19a20c1d4 chore(release): 0.6.0 [skip ci]
# [0.6.0](https://github.com/sasjs/server/compare/v0.5.0...v0.6.0) (2022-06-16)

### Features

* get group by group name ([6b0b94a](6b0b94ad38))
2022-06-16 18:43:40 +00:00
Allan Bowe
f8eaadae7b Merge pull request #197 from sasjs/fetch-group-by-name
feat: get group by group name
2022-06-16 20:39:23 +02:00
90e0973a7f chore: add test for group name validation 2022-06-16 23:36:13 +05:00
869a13fc69 chore: error code fixes 2022-06-16 23:27:56 +05:00
1790e10fc1 chore: code fixes 2022-06-16 22:14:47 +05:00
semantic-release-bot
6d12b900ad chore(release): 0.5.0 [skip ci]
# [0.5.0](https://github.com/sasjs/server/compare/v0.4.2...v0.5.0) (2022-06-16)

### Bug Fixes

* npm audit fix to avoid warnings on npm i ([28a6a36](28a6a36bb7))

### Features

* **api:** deployment through zipped/compressed file ([b81d742](b81d742c6c))
2022-06-16 13:31:09 +00:00
Saad Jutt
ae5aa02733 Merge pull request #196 from sasjs/issue173
feat(api): deployment through zipped/compressed file
2022-06-16 06:26:32 -07:00
Allan Bowe
28a6a36bb7 fix: npm audit fix to avoid warnings on npm i 2022-06-16 13:07:59 +00:00
Saad Jutt
4e7579dc10 chore(specs): specs added for deploy upload file and zipped file 2022-06-16 17:58:56 +05:00
6b0b94ad38 feat: get group by group name 2022-06-16 13:06:33 +05:00
Saad Jutt
b81d742c6c feat(api): deployment through zipped/compressed file 2022-06-16 00:56:51 +05:00
semantic-release-bot
a61adbcac2 chore(release): 0.4.2 [skip ci]
## [0.4.2](https://github.com/sasjs/server/compare/v0.4.1...v0.4.2) (2022-06-15)

### Bug Fixes

* appStream redesign ([73792fb](73792fb574))
2022-06-15 15:04:11 +00:00
Allan Bowe
12000f4fc7 Merge pull request #195 from sasjs/appStream-design
fix: appStream redesign
2022-06-15 16:59:58 +02:00
73792fb574 fix: appStream redesign 2022-06-15 15:51:42 +02:00
53854d0012 fix: code fixes for executing program from program path including file extension 2022-06-15 16:18:07 +05:00
Saad Jutt
81501d17ab chore: code fixes 2022-06-15 16:03:04 +05:00
Saad Jutt
11a7f920f1 chore: Merge branch 'main' into issue-184 2022-06-15 15:56:17 +05:00
semantic-release-bot
c08cfcbc38 chore(release): 0.4.1 [skip ci]
## [0.4.1](https://github.com/sasjs/server/compare/v0.4.0...v0.4.1) (2022-06-15)

### Bug Fixes

* add/remove group to User when adding/removing user from group and return group membership on getting user ([e08bbcc](e08bbcc543))
2022-06-15 10:38:22 +00:00
Saad Jutt
8d38d5ac64 Merge pull request #193 from sasjs/issue-192
fix: add/remove group to User when adding/removing user from group
2022-06-15 03:32:32 -07:00
e08bbcc543 fix: add/remove group to User when adding/removing user from group and return group membership on getting user 2022-06-15 15:18:42 +05:00
semantic-release-bot
eef3cb270d chore(release): 0.4.0 [skip ci]
# [0.4.0](https://github.com/sasjs/server/compare/v0.3.10...v0.4.0) (2022-06-14)

### Features

* new APIs added for GET|PATCH|DELETE of user by username ([aef411a](aef411a0ea))
2022-06-14 17:28:50 +00:00
Saad Jutt
9cfbca23f8 Merge pull request #194 from sasjs/issue188
feat: new APIs added for GET|PATCH|DELETE of user by username
2022-06-14 10:24:42 -07:00
Saad Jutt
aef411a0ea feat: new APIs added for GET|PATCH|DELETE of user by username 2022-06-14 22:08:56 +05:00
Saad Jutt
e359265c4b chore: quick fix 2022-06-14 17:05:40 +05:00
Saad Jutt
8e7c9e671c chore: quick fix 2022-06-14 17:05:13 +05:00
Saad Jutt
c830f44e29 chore: code fixes 2022-06-14 16:48:58 +05:00
semantic-release-bot
806ea4cb5c chore(release): 0.3.10 [skip ci]
## [0.3.10](https://github.com/sasjs/server/compare/v0.3.9...v0.3.10) (2022-06-14)

### Bug Fixes

* correct syntax for encoding option ([32d372b](32d372b42f))
2022-06-14 09:53:53 +00:00
Allan Bowe
7205072358 Merge pull request #191 from sasjs/encodingfix
fix: correct syntax for encoding option
2022-06-14 11:49:38 +02:00
Allan Bowe
32d372b42f fix: correct syntax for encoding option 2022-06-14 09:49:05 +00:00
semantic-release-bot
e059bee7dc chore(release): 0.3.9 [skip ci]
## [0.3.9](https://github.com/sasjs/server/compare/v0.3.8...v0.3.9) (2022-06-14)

### Bug Fixes

* forcing utf 8 encoding. Closes [#76](https://github.com/sasjs/server/issues/76) ([8734489](8734489cf0))
2022-06-14 09:20:37 +00:00
Allan Bowe
6f56aafab1 Merge pull request #190 from sasjs/allanbowe/enforce-utf-76
fix: forcing utf 8 encoding. Closes #76
2022-06-14 11:14:35 +02:00
Allan Bowe
8734489cf0 fix: forcing utf 8 encoding. Closes #76 2022-06-14 09:12:41 +00:00
de9ed15286 chore: update error message when stored program not found 2022-06-13 20:51:44 +05:00
325285f447 Merge branch 'main' into issue-184 2022-06-13 20:42:28 +05:00
semantic-release-bot
7e6635f40f chore(release): 0.3.8 [skip ci]
## [0.3.8](https://github.com/sasjs/server/compare/v0.3.7...v0.3.8) (2022-06-13)

### Bug Fixes

* execution controller better error handling ([8a617a7](8a617a73ae))
* execution controller error details ([3fa2a7e](3fa2a7e2e3))
2022-06-13 12:32:32 +00:00
Allan Bowe
c0022a22f4 Merge pull request #189 from sasjs/issue-187
Execution controller more details in error message
2022-06-13 14:27:12 +02:00
Mihajlo Medjedovic
3fa2a7e2e3 fix: execution controller error details 2022-06-13 12:25:06 +00:00
8a617a73ae fix: execution controller better error handling 2022-06-13 14:01:12 +02:00
16856165fb feat: create and inject code for uploaded files to code.js 2022-06-09 14:54:11 +05:00
semantic-release-bot
e7babb9f55 chore(release): 0.3.7 [skip ci]
## [0.3.7](https://github.com/sasjs/server/compare/v0.3.6...v0.3.7) (2022-06-08)

### Bug Fixes

* **appstream:** redirect to relative + nested resource should be accessed ([5ab35b0](5ab35b02c4))
2022-06-08 20:21:22 +00:00
Saad Jutt
5ab35b02c4 fix(appstream): redirect to relative + nested resource should be accessed 2022-06-09 01:16:25 +05:00
058b3b0081 feat: configure child process with writeStream to write logs to log file 2022-06-08 02:01:31 +05:00
9d5a5e051f fix: no need to stringify _webout in preProgramVarStatements, developer should have _webout as string in actual code 2022-06-07 13:27:18 +05:00
2c704a544f fix: refactor sas/js session controller classes to inherit from base session controller class 2022-06-06 17:24:19 +05:00
6d6bda5626 fix: refactor code in preUploadMiddleware function 2022-06-06 17:23:09 +05:00
dffe6d7121 fix: refactor code in executeFile method of session controller 2022-06-06 15:23:42 +05:00
b4443819d4 fix: refactor code for session selection in preUploadMiddleware function 2022-06-06 15:19:39 +05:00
e5a7674fa1 chore: refactor ExecutionController class to remove code duplications 2022-06-06 09:09:21 +05:00
596ada7ca8 feat: validate sasjs_runtimes env var 2022-06-04 03:16:07 +05:00
f561ba4bf0 chore: documented sasjs_runtimes env variable in readme file 2022-06-04 03:12:51 +05:00
c58666eb81 fix: convert single executeProgram method to two methods i.e. executeSASProgram and executeJSProgram 2022-06-03 17:26:21 +05:00
5df619b3f6 fix: pass _program to execute file without extension 2022-06-03 17:24:29 +05:00
07295aa151 feat: conver single session controller to two controller i.e. SASSessionController and JSSessionController 2022-06-03 17:23:28 +05:00
194eaec7d4 fix: add runtimes to global process object 2022-06-03 17:19:12 +05:00
semantic-release-bot
ad82ee7106 chore(release): 0.3.6 [skip ci]
## [0.3.6](https://github.com/sasjs/server/compare/v0.3.5...v0.3.6) (2022-06-02)

### Bug Fixes

* **appstream:** should serve only new files for same app stream name with new deployment ([e6d1989](e6d1989847))
2022-06-02 08:30:39 +00:00
Allan Bowe
d2e9456d81 Merge pull request #185 from sasjs/issue183
fix(appstream): should serve only new files for same app stream name …
2022-06-02 11:25:27 +03:00
Saad Jutt
e6d1989847 fix(appstream): should serve only new files for same app stream name with new deployment 2022-06-02 04:17:12 +05:00
semantic-release-bot
7a932383b4 chore(release): 0.3.5 [skip ci]
## [0.3.5](https://github.com/sasjs/server/compare/v0.3.4...v0.3.5) (2022-05-30)

### Bug Fixes

* bumping sasjs/core library ([61815f8](61815f8ae1))
2022-05-30 18:07:50 +00:00
Allan Bowe
576e18347e Merge pull request #182 from sasjs/bumpcore
fix: bumping sasjs/core library
2022-05-30 21:02:59 +03:00
Allan Bowe
61815f8ae1 fix: bumping sasjs/core library 2022-05-30 18:02:30 +00:00
fa63dc071b chore: update specs and swagger.yaml 2022-05-18 00:29:42 +05:00
e8c21a43b2 feat: add UI for updating permission 2022-05-18 00:20:49 +05:00
1413b18508 feat: created modal for adding permission 2022-05-18 00:05:28 +05:00
dfbd155711 chore: move common interfaces to utils folder 2022-05-18 00:04:37 +05:00
4fcc191ce9 fix: principalId type changed to number from any 2022-05-18 00:03:11 +05:00
d000f7508f fix: move permission filter modal to separate file and icons for different actions 2022-05-17 15:42:29 +05:00
5652325452 feat: add basic UI for settings and permissions 2022-05-16 23:53:30 +05:00
0781ddd64e fix: remove clientId from principal types 2022-05-16 19:56:56 +05:00
7be77cc38a chore: remvoe code redundancy and add specs for get permissions api endpoint 2022-05-10 07:05:59 +05:00
98b8a75148 chore: add specs for delete permission api endpoint 2022-05-10 06:40:34 +05:00
72a3197a06 chore: add spec for update permission when permission with provided id not exists 2022-05-10 06:25:52 +05:00
fce05d6959 chore: add spec for invalid principal type 2022-05-10 06:18:19 +05:00
1aec3abd28 chore: add specs for update permission api endpoint 2022-05-10 06:11:24 +05:00
9136c95013 chore: write specs for create permission api endpoint 2022-05-09 13:08:15 +05:00
Saad Jutt
89b32e70ff refactor: code in permission controller 2022-04-30 03:49:26 +05:00
01713440a4 feat: add api endpoint for deleting permission 2022-04-30 01:16:52 +05:00
540f54fb77 feat: add api endpoint for updating permission setting 2022-04-30 01:02:47 +05:00
bf906aa544 Merge branch 'main' into issue-139 2022-04-29 15:41:35 +05:00
797c2bcc39 feat: update swagger docs 2022-04-29 15:31:24 +05:00
1103ffe07b feat: defined register permission and get all permissions api endpoints 2022-04-29 15:30:41 +05:00
e5200c1000 feat: add validation for registering permission 2022-04-29 15:28:29 +05:00
38a7db8514 fix: export GroupResponse interface 2022-04-29 15:27:34 +05:00
39fc908de1 fix: update permission model 2022-04-29 15:26:26 +05:00
be009d5b02 Merge branch 'main' into issue-139 2022-04-29 00:32:36 +05:00
6bea1f7666 feat: add permission model 2022-04-28 21:18:23 +05:00
110 changed files with 6716 additions and 1924 deletions

84
.all-contributorsrc Normal file
View File

@@ -0,0 +1,84 @@
{
"projectName": "server",
"projectOwner": "sasjs",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": true,
"commitConvention": "angular",
"contributors": [
{
"login": "saadjutt01",
"name": "Saad Jutt",
"avatar_url": "https://avatars.githubusercontent.com/u/8914650?v=4",
"profile": "https://github.com/saadjutt01",
"contributions": [
"code",
"test"
]
},
{
"login": "sabhas",
"name": "Sabir Hassan",
"avatar_url": "https://avatars.githubusercontent.com/u/82647447?v=4",
"profile": "https://github.com/sabhas",
"contributions": [
"code",
"test"
]
},
{
"login": "YuryShkoda",
"name": "Yury Shkoda",
"avatar_url": "https://avatars.githubusercontent.com/u/25773492?v=4",
"profile": "https://www.erudicat.com/",
"contributions": [
"code",
"test"
]
},
{
"login": "medjedovicm",
"name": "Mihajlo Medjedovic",
"avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4",
"profile": "https://github.com/medjedovicm",
"contributions": [
"code",
"test"
]
},
{
"login": "allanbowe",
"name": "Allan Bowe",
"avatar_url": "https://avatars.githubusercontent.com/u/4420615?v=4",
"profile": "https://4gl.io/",
"contributions": [
"code",
"doc"
]
},
{
"login": "VladislavParhomchik",
"name": "Vladislav Parhomchik",
"avatar_url": "https://avatars.githubusercontent.com/u/83717836?v=4",
"profile": "https://github.com/VladislavParhomchik",
"contributions": [
"test"
]
},
{
"login": "kknapen",
"name": "Koen Knapen",
"avatar_url": "https://avatars.githubusercontent.com/u/78609432?v=4",
"profile": "https://github.com/kknapen",
"contributions": [
"userTesting"
]
}
],
"contributorsPerLine": 7,
"skipCi": true
}

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [sasjs]

View File

@@ -55,6 +55,9 @@ jobs:
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}} REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}} AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}
SESSION_SECRET: ${{secrets.SESSION_SECRET}} SESSION_SECRET: ${{secrets.SESSION_SECRET}}
RUN_TIMES: 'sas,js'
SAS_PATH: '/some/path/to/sas'
NODE_PATH: '/some/path/to/node'
- name: Build Package - name: Build Package
working-directory: ./api working-directory: ./api

2
.nvmrc
View File

@@ -1 +1 @@
v16.14.0 v16.15.1

View File

@@ -1,3 +1,236 @@
## [0.11.1](https://github.com/sasjs/server/compare/v0.11.0...v0.11.1) (2022-07-18)
### Bug Fixes
* bank operator ([aa02741](https://github.com/sasjs/server/commit/aa027414ed3ce51f1014ef36c4191e064b2e963d))
* ensuring nosplash option only applies for sas.exe ([65e6de9](https://github.com/sasjs/server/commit/65e6de966383fe49a919b1f901d77c7f1e402c9b)), closes [#229](https://github.com/sasjs/server/issues/229)
# [0.11.0](https://github.com/sasjs/server/compare/v0.10.0...v0.11.0) (2022-07-16)
### Bug Fixes
* **logs:** logs location is configurable ([e024a92](https://github.com/sasjs/server/commit/e024a92f165990e08db8aa26ee326dbcb30e2e46))
### Features
* **logs:** logs to file with rotating + code split into files ([92fda18](https://github.com/sasjs/server/commit/92fda183f3f0f3956b7c791669eb8dd52c389d1b))
# [0.10.0](https://github.com/sasjs/server/compare/v0.9.0...v0.10.0) (2022-07-06)
### Bug Fixes
* add authorize middleware for appStreams ([e54a09d](https://github.com/sasjs/server/commit/e54a09db19ec8690e54a40760531a4e06d250974))
* add isAdmin attribute to return response of get session and login requests ([bdf63df](https://github.com/sasjs/server/commit/bdf63df1d915892486005ec904807749786b1c0c))
* add permission authorization middleware to only specific routes ([f3dfc70](https://github.com/sasjs/server/commit/f3dfc7083fbfb4b447521341b1a86730fb90b4c0))
* bumping core and running lint ([a2d1396](https://github.com/sasjs/server/commit/a2d13960578014312d2cb5e03145bfd1829d99ec))
* controller fixed for deleting permission ([b5f595a](https://github.com/sasjs/server/commit/b5f595a25c50550d62482409353c7629c5a5c3e0))
* do not show admin users in add permission modal ([a75edba](https://github.com/sasjs/server/commit/a75edbaa327ec2af49523c13996ac283061da7d8))
* export GroupResponse interface ([38a7db8](https://github.com/sasjs/server/commit/38a7db8514de0acd94d74ba96bc1efb732add30c))
* move permission filter modal to separate file and icons for different actions ([d000f75](https://github.com/sasjs/server/commit/d000f7508f6d7384afffafee4179151fca802ca8))
* principalId type changed to number from any ([4fcc191](https://github.com/sasjs/server/commit/4fcc191ce9edc7e4dcd8821fb8019f4eea5db4ea))
* remove clientId from principal types ([0781ddd](https://github.com/sasjs/server/commit/0781ddd64e3b5e5ca39647bb4e4e1a9332a0f4f8))
* remove duplicates principals from permission filter modal ([5b319f9](https://github.com/sasjs/server/commit/5b319f9ad1f941b306db6b9473a2128b2e42bf76))
* show loading spinner in studio while executing code ([496247d](https://github.com/sasjs/server/commit/496247d0b9975097a008cf4d3a999d77648fd930))
* show permission component only in server mode ([f863b81](https://github.com/sasjs/server/commit/f863b81a7d40a1296a061ec93946f204382af2c3))
* update permission model ([39fc908](https://github.com/sasjs/server/commit/39fc908de1945f2aaea18d14e6bce703f6bf0c06))
* update permission response ([e516b77](https://github.com/sasjs/server/commit/e516b7716da5ff7e23350a5f77cfa073b1171175))
* **web:** only admin should be able to add, update or delete permission ([be8635c](https://github.com/sasjs/server/commit/be8635ccc5eb34c3f0a5951c8a0421292ef69c97))
### Features
* add api endpoint for deleting permission ([0171344](https://github.com/sasjs/server/commit/01713440a4fa661b76368785c0ca731f096ac70a))
* add api endpoint for updating permission setting ([540f54f](https://github.com/sasjs/server/commit/540f54fb77b364822da7889dbe75c02242f48a59))
* add authorize middleware for validating permissions ([7d916ec](https://github.com/sasjs/server/commit/7d916ec3e9ef579dde1b73015715cd01098c2018))
* add basic UI for settings and permissions ([5652325](https://github.com/sasjs/server/commit/56523254525a66e756196e90b39a2b8cdadc1518))
* add documentation link under usename dropdown menu ([eeb63b3](https://github.com/sasjs/server/commit/eeb63b330c292afcdd5c8f006882b224c4235068))
* add permission model ([6bea1f7](https://github.com/sasjs/server/commit/6bea1f76668ddb070ad95b3e02c31238af67c346))
* add UI for updating permission ([e8c21a4](https://github.com/sasjs/server/commit/e8c21a43b215f5fced0463b70747cda1191a4e01))
* add validation for registering permission ([e5200c1](https://github.com/sasjs/server/commit/e5200c1000903185dfad9ee49c99583e473c4388))
* add, remove and update permissions from web component ([97ecfdc](https://github.com/sasjs/server/commit/97ecfdc95563c72dbdecaebcb504e5194250a763))
* added get authorizedRoutes api endpoint ([b10e932](https://github.com/sasjs/server/commit/b10e9326058193dd65a57fab2d2f05b7b06096e7))
* created modal for adding permission ([1413b18](https://github.com/sasjs/server/commit/1413b1850838ecc988ab289da4541bde36a9a346))
* defined register permission and get all permissions api endpoints ([1103ffe](https://github.com/sasjs/server/commit/1103ffe07b88496967cb03683b08f058ca3bbb9f))
* update swagger docs ([797c2bc](https://github.com/sasjs/server/commit/797c2bcc39005a05a995be15a150d584fecae259))
# [0.9.0](https://github.com/sasjs/server/compare/v0.8.3...v0.9.0) (2022-07-03)
### Features
* removed secrets from env variables ([9c3da56](https://github.com/sasjs/server/commit/9c3da56901672a818f54267f9defc9f4701ab7fb))
## [0.8.3](https://github.com/sasjs/server/compare/v0.8.2...v0.8.3) (2022-07-02)
### Bug Fixes
* **deploy:** extract first json from zip file ([e290751](https://github.com/sasjs/server/commit/e290751c872d24009482871a8c398e834357dcde))
## [0.8.2](https://github.com/sasjs/server/compare/v0.8.1...v0.8.2) (2022-06-22)
### Bug Fixes
* getRuntimeAndFilePath function to handle the scenarion when path is provided with an extension other than runtimes ([5cc85b5](https://github.com/sasjs/server/commit/5cc85b57f80b13296156811fe966d7b37d45f213))
## [0.8.1](https://github.com/sasjs/server/compare/v0.8.0...v0.8.1) (2022-06-21)
### Bug Fixes
* make CA_ROOT optional in getCertificates method ([1b5859e](https://github.com/sasjs/server/commit/1b5859ee37ae73c419115b9debfd5141a79733de))
* update /logout route to /SASLogon/logout ([65380be](https://github.com/sasjs/server/commit/65380be2f3945bae559f1749064845b514447a53))
# [0.8.0](https://github.com/sasjs/server/compare/v0.7.3...v0.8.0) (2022-06-21)
### Features
* **certs:** ENV variables updated and set CA Root for HTTPS server ([2119e9d](https://github.com/sasjs/server/commit/2119e9de9ab1e5ce1222658f554ac74f4f35cf4d))
## [0.7.3](https://github.com/sasjs/server/compare/v0.7.2...v0.7.3) (2022-06-20)
### Bug Fixes
* path descriptions and defaults ([5d5d6ce](https://github.com/sasjs/server/commit/5d5d6ce3265a43af2e22bcd38cda54fafaf7b3ef))
## [0.7.2](https://github.com/sasjs/server/compare/v0.7.1...v0.7.2) (2022-06-20)
### Bug Fixes
* removing UTF-8 options from commandline. There appears to be no reliable way to enforce ([f6dc74f](https://github.com/sasjs/server/commit/f6dc74f16bddafa1de9c83c2f27671a241abdad4))
## [0.7.1](https://github.com/sasjs/server/compare/v0.7.0...v0.7.1) (2022-06-20)
### Bug Fixes
* default runtime should be sas ([91d29cb](https://github.com/sasjs/server/commit/91d29cb1272c28afbceaf39d1e0a87e17fbfdcd6))
* **Studio:** default selection of runtime fixed ([eb569c7](https://github.com/sasjs/server/commit/eb569c7b827c872ed2c4bc114559b97d87fd2aa0))
* webout path fixed in code.js when running on windows ([99a1107](https://github.com/sasjs/server/commit/99a110736448f66f99a512396b268fc31a3feef0))
# [0.7.0](https://github.com/sasjs/server/compare/v0.6.1...v0.7.0) (2022-06-19)
### Bug Fixes
* add runtimes to global process object ([194eaec](https://github.com/sasjs/server/commit/194eaec7d4a561468f83bf6efce484909ee532eb))
* code fixes for executing program from program path including file extension ([53854d0](https://github.com/sasjs/server/commit/53854d001279462104b24c0e59a8c94ab4938a94))
* code/execute controller logic to handle different runtimes ([23b6692](https://github.com/sasjs/server/commit/23b6692f02e4afa33c9dc95d242eb8645c19d546))
* convert single executeProgram method to two methods i.e. executeSASProgram and executeJSProgram ([c58666e](https://github.com/sasjs/server/commit/c58666eb81514de500519e7b96c1981778ec149b))
* no need to stringify _webout in preProgramVarStatements, developer should have _webout as string in actual code ([9d5a5e0](https://github.com/sasjs/server/commit/9d5a5e051fd821295664ddb3a1fd64629894a44c))
* pass _program to execute file without extension ([5df619b](https://github.com/sasjs/server/commit/5df619b3f63571e8e326261d8114869d33881d91))
* refactor code for session selection in preUploadMiddleware function ([b444381](https://github.com/sasjs/server/commit/b4443819d42afecebc0f382c58afb9010d4775ef))
* refactor code in executeFile method of session controller ([dffe6d7](https://github.com/sasjs/server/commit/dffe6d7121d569e5c7d13023c6ca68d8c901c88e))
* refactor code in preUploadMiddleware function ([6d6bda5](https://github.com/sasjs/server/commit/6d6bda56267babde7b98cf69e32973d56d719f75))
* refactor sas/js session controller classes to inherit from base session controller class ([2c704a5](https://github.com/sasjs/server/commit/2c704a544f4e31a8e8e833a9a62ba016bcfa6c7c))
* **Studio:** style fix for runtime dropdown ([9023cf3](https://github.com/sasjs/server/commit/9023cf33b5fa4b13c2d5e9b80ae307df69c7fc02))
### Features
* configure child process with writeStream to write logs to log file ([058b3b0](https://github.com/sasjs/server/commit/058b3b00816e582e143953c2f0b8330bde2181b8))
* conver single session controller to two controller i.e. SASSessionController and JSSessionController ([07295aa](https://github.com/sasjs/server/commit/07295aa151175db8c93eeef806fc3b7fde40ac72))
* create and inject code for uploaded files to code.js ([1685616](https://github.com/sasjs/server/commit/16856165fb292dc9ffa897189ba105bd9f362267))
* validate sasjs_runtimes env var ([596ada7](https://github.com/sasjs/server/commit/596ada7ca88798d6d71f6845633a006fd22438ea))
## [0.6.1](https://github.com/sasjs/server/compare/v0.6.0...v0.6.1) (2022-06-17)
### Bug Fixes
* home page wording. Using fix to force previous change through.. ([8702a4e](https://github.com/sasjs/server/commit/8702a4e8fd1bbfaf4f426b75e8b85a87ede0e0b0))
# [0.6.0](https://github.com/sasjs/server/compare/v0.5.0...v0.6.0) (2022-06-16)
### Features
* get group by group name ([6b0b94a](https://github.com/sasjs/server/commit/6b0b94ad38215ae58e62279a4f73ac3ed2d9d0e8))
# [0.5.0](https://github.com/sasjs/server/compare/v0.4.2...v0.5.0) (2022-06-16)
### Bug Fixes
* npm audit fix to avoid warnings on npm i ([28a6a36](https://github.com/sasjs/server/commit/28a6a36bb708b93fb5c2b74d587e9b2e055582be))
### Features
* **api:** deployment through zipped/compressed file ([b81d742](https://github.com/sasjs/server/commit/b81d742c6c70d4cf1cab365b0e3efc087441db00))
## [0.4.2](https://github.com/sasjs/server/compare/v0.4.1...v0.4.2) (2022-06-15)
### Bug Fixes
* appStream redesign ([73792fb](https://github.com/sasjs/server/commit/73792fb574c90bd280c4324e0b41c6fee7d572b6))
## [0.4.1](https://github.com/sasjs/server/compare/v0.4.0...v0.4.1) (2022-06-15)
### Bug Fixes
* add/remove group to User when adding/removing user from group and return group membership on getting user ([e08bbcc](https://github.com/sasjs/server/commit/e08bbcc5435cbabaee40a41a7fb667d4a1f078e6))
# [0.4.0](https://github.com/sasjs/server/compare/v0.3.10...v0.4.0) (2022-06-14)
### Features
* new APIs added for GET|PATCH|DELETE of user by username ([aef411a](https://github.com/sasjs/server/commit/aef411a0eac625c33274dfe3e88b6f75115c44d8))
## [0.3.10](https://github.com/sasjs/server/compare/v0.3.9...v0.3.10) (2022-06-14)
### Bug Fixes
* correct syntax for encoding option ([32d372b](https://github.com/sasjs/server/commit/32d372b42fbf56b6c0779e8f704164eaae1c7548))
## [0.3.9](https://github.com/sasjs/server/compare/v0.3.8...v0.3.9) (2022-06-14)
### Bug Fixes
* forcing utf 8 encoding. Closes [#76](https://github.com/sasjs/server/issues/76) ([8734489](https://github.com/sasjs/server/commit/8734489cf014aedaca3f325e689493e4fe0b71ca))
## [0.3.8](https://github.com/sasjs/server/compare/v0.3.7...v0.3.8) (2022-06-13)
### Bug Fixes
* execution controller better error handling ([8a617a7](https://github.com/sasjs/server/commit/8a617a73ae63233332f5788c90f173d6cd5e1283))
* execution controller error details ([3fa2a7e](https://github.com/sasjs/server/commit/3fa2a7e2e32f90050f6b09e30ce3ef725eb0b15f))
## [0.3.7](https://github.com/sasjs/server/compare/v0.3.6...v0.3.7) (2022-06-08)
### Bug Fixes
* **appstream:** redirect to relative + nested resource should be accessed ([5ab35b0](https://github.com/sasjs/server/commit/5ab35b02c4417132dddb5a800982f31d0d50ef66))
## [0.3.6](https://github.com/sasjs/server/compare/v0.3.5...v0.3.6) (2022-06-02)
### Bug Fixes
* **appstream:** should serve only new files for same app stream name with new deployment ([e6d1989](https://github.com/sasjs/server/commit/e6d1989847761fbe562d7861ffa0ee542839b125))
## [0.3.5](https://github.com/sasjs/server/compare/v0.3.4...v0.3.5) (2022-05-30)
### Bug Fixes
* bumping sasjs/core library ([61815f8](https://github.com/sasjs/server/commit/61815f8ae18be132e17c199cd8e3afbcc2fa0b60))
## [0.3.4](https://github.com/sasjs/server/compare/v0.3.3...v0.3.4) (2022-05-30) ## [0.3.4](https://github.com/sasjs/server/compare/v0.3.3...v0.3.4) (2022-05-30)

19
PULL_REQUEST_TEMPLATE.md Normal file
View File

@@ -0,0 +1,19 @@
## Issue
Link any related issue(s) in this section.
## Intent
What this PR intends to achieve.
## Implementation
What code changes have been made to achieve the intent.
## Checks
- [ ] Code is formatted correctly (`npm run lint:fix`).
- [ ] Any new functionality has been unit tested.
- [ ] All unit tests are passing (`npm test`).
- [ ] All CI checks are green.
- [ ] Reviewer is assigned.

View File

@@ -1,5 +1,11 @@
# SASjs Server # SASjs Server
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or locally on your desktop. It provides: SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or locally on your desktop. It provides:
- Virtual filesystem for storing SAS programs and other content - Virtual filesystem for storing SAS programs and other content
@@ -61,10 +67,14 @@ MODE=
# Path to SAS executable (sas.exe / sas.sh) # Path to SAS executable (sas.exe / sas.sh)
SAS_PATH=/path/to/sas/executable.exe SAS_PATH=/path/to/sas/executable.exe
# Path to Node.js executable
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
# Path to working directory # Path to working directory
# This location is for SAS WORK, staged files, DRIVE, configuration etc # This location is for SAS WORK, staged files, DRIVE, configuration etc
SASJS_ROOT=./sasjs_root SASJS_ROOT=./sasjs_root
# options: [http|https] default: http # options: [http|https] default: http
PROTOCOL= PROTOCOL=
@@ -89,15 +99,12 @@ SASV9_OPTIONS= -NOXCMD
## Additional Web Server Options ## Additional Web Server Options
# #
# ENV variables required for PROTOCOL: `https` # ENV variables for PROTOCOL: `https`
PRIVATE_KEY=privkey.pem PRIVATE_KEY=privkey.pem (required)
FULL_CHAIN=fullchain.pem CERT_CHAIN=certificate.pem (required)
CA_ROOT=fullchain.pem (optional)
# ENV variables required for MODE: `server` # ENV variables required for MODE: `server`
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop` # options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
@@ -129,6 +136,16 @@ HELMET_CSP_CONFIG_PATH=./csp.config.json
# Docs: https://www.npmjs.com/package/morgan#predefined-formats # Docs: https://www.npmjs.com/package/morgan#predefined-formats
LOG_FORMAT_MORGAN= LOG_FORMAT_MORGAN=
# This location is for server logs with classical UNIX logrotate behavior
LOG_LOCATION=./sasjs_root/logs
# A comma separated string that defines the available runTimes.
# Priority is given to the runtime that comes first in the string.
# Possible options at the moment are sas and js
# options: [sas,js|js,sas|sas|js] default:sas
RUN_TIMES=
``` ```
## Persisting the Session ## Persisting the Session
@@ -185,3 +202,29 @@ The following credentials can be used for the initial connection to SASjs/server
- CLIENTID: `clientID1` - CLIENTID: `clientID1`
- USERNAME: `secretuser` - USERNAME: `secretuser`
- PASSWORD: `secretpassword` - PASSWORD: `secretpassword`
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Saad Jutt</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=saadjutt01" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=sabhas" title="Tests">⚠️</a></td>
<td align="center"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=YuryShkoda" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=YuryShkoda" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=medjedovicm" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=medjedovicm" title="Tests">⚠️</a></td>
<td align="center"><a href="https://4gl.io/"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=allanbowe" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Vladislav Parhomchik</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=VladislavParhomchik" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/kknapen"><img src="https://avatars.githubusercontent.com/u/78609432?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Koen Knapen</b></sub></a><br /><a href="#userTesting-kknapen" title="User Testing">📓</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!

View File

@@ -4,20 +4,21 @@ WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
PROTOCOL=[http|https] default considered as http PROTOCOL=[http|https] default considered as http
PRIVATE_KEY=privkey.pem PRIVATE_KEY=privkey.pem
FULL_CHAIN=fullchain.pem CERT_CHAIN=certificate.pem
CA_ROOT=fullchain.pem
PORT=[5000] default value is 5000 PORT=[5000] default value is 5000
HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
HELMET_COEP=[true|false] if omitted HELMET default will be used HELMET_COEP=[true|false] if omitted HELMET default will be used
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
RUN_TIMES=[sas|js|sas,js|js,sas] default considered as sas
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
SASJS_ROOT=./sasjs_root SASJS_ROOT=./sasjs_root
LOG_FORMAT_MORGAN=common LOG_FORMAT_MORGAN=common
LOG_LOCATION=./sasjs_root/logs

View File

@@ -1 +1 @@
v16.14.0 v16.15.1

1670
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore", "initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore",
"prestart": "npm run initial", "prestart": "npm run initial",
"prebuild": "npm run initial", "prebuild": "npm run initial",
"start": "nodemon ./src/server.ts", "start": "NODE_ENV=development nodemon ./src/server.ts",
"start:prod": "node ./build/src/server.js", "start:prod": "node ./build/src/server.js",
"build": "rimraf build && tsc", "build": "rimraf build && tsc",
"postbuild": "npm run copy:files", "postbuild": "npm run copy:files",
@@ -47,7 +47,7 @@
}, },
"author": "4GL Ltd", "author": "4GL Ltd",
"dependencies": { "dependencies": {
"@sasjs/core": "^4.23.1", "@sasjs/core": "^4.31.3",
"@sasjs/utils": "2.42.1", "@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0", "connect-mongo": "^4.6.0",
@@ -63,9 +63,13 @@
"mongoose-sequence": "^5.3.1", "mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.3", "multer": "^1.4.3",
"swagger-ui-express": "4.3.0" "rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11",
"url": "^0.10.3"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.0",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/cookie-parser": "^1.4.2", "@types/cookie-parser": "^1.4.2",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
@@ -80,6 +84,8 @@
"@types/node": "^15.12.2", "@types/node": "^15.12.2",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@types/swagger-ui-express": "^4.1.3", "@types/swagger-ui-express": "^4.1.3",
"@types/unzipper": "^0.10.5",
"adm-zip": "^0.5.9",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"http-headers-validation": "^0.0.1", "http-headers-validation": "^0.0.1",
"jest": "^27.0.6", "jest": "^27.0.6",

View File

@@ -47,41 +47,6 @@ components:
- userId - userId
type: object type: object
additionalProperties: false additionalProperties: false
LoginPayload:
properties:
username:
type: string
description: 'Username for user'
example: secretuser
password:
type: string
description: 'Password for user'
example: secretpassword
required:
- username
- password
type: object
additionalProperties: false
AuthorizeResponse:
properties:
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- code
type: object
additionalProperties: false
AuthorizePayload:
properties:
clientId:
type: string
description: 'Client ID'
example: clientID1
required:
- clientId
type: object
additionalProperties: false
ClientPayload: ClientPayload:
properties: properties:
clientId: clientId:
@@ -139,14 +104,24 @@ components:
- httpHeaders - httpHeaders
type: object type: object
additionalProperties: false additionalProperties: false
ExecuteSASCodePayload: RunTimeType:
enum:
- sas
- js
type: string
ExecuteCodePayload:
properties: properties:
code: code:
type: string type: string
description: 'Code of SAS program' description: 'Code of program'
example: '* SAS Code HERE;' example: '* Code HERE;'
runTime:
$ref: '#/components/schemas/RunTimeType'
description: 'runtime for program'
example: js
required: required:
- code - code
- runTime
type: object type: object
additionalProperties: false additionalProperties: false
MemberType.folder: MemberType.folder:
@@ -304,10 +279,28 @@ components:
type: string type: string
displayName: displayName:
type: string type: string
isAdmin:
type: boolean
required: required:
- id - id
- username - username
- displayName - displayName
- isAdmin
type: object
additionalProperties: false
GroupResponse:
properties:
groupId:
type: number
format: double
name:
type: string
description:
type: string
required:
- groupId
- name
- description
type: object type: object
additionalProperties: false additionalProperties: false
UserDetailsResponse: UserDetailsResponse:
@@ -325,6 +318,10 @@ components:
type: boolean type: boolean
autoExec: autoExec:
type: string type: string
groups:
items:
$ref: '#/components/schemas/GroupResponse'
type: array
required: required:
- id - id
- displayName - displayName
@@ -364,21 +361,6 @@ components:
- password - password
type: object type: object
additionalProperties: false additionalProperties: false
GroupResponse:
properties:
groupId:
type: number
format: double
name:
type: string
description:
type: string
required:
- groupId
- name
- description
type: object
additionalProperties: false
GroupDetailsResponse: GroupDetailsResponse:
properties: properties:
groupId: groupId:
@@ -421,6 +403,27 @@ components:
- description - description
type: object type: object
additionalProperties: false additionalProperties: false
_LeanDocument__LeanDocument_T__:
properties: {}
type: object
Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__:
properties:
_id:
$ref: '#/components/schemas/_LeanDocument__LeanDocument_T__'
description: 'This documents _id.'
__v:
description: 'This documents __v.'
id:
description: 'The string version of this documents _id.'
type: object
description: 'From T, pick a set of properties whose keys are in the union K'
Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_:
$ref: '#/components/schemas/Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__'
description: 'Construct a type with the properties of T except for those in type K.'
LeanDocument_this_:
$ref: '#/components/schemas/Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_'
IGroup:
$ref: '#/components/schemas/LeanDocument_this_'
InfoResponse: InfoResponse:
properties: properties:
mode: mode:
@@ -433,11 +436,26 @@ components:
type: array type: array
protocol: protocol:
type: string type: string
runTimes:
items:
type: string
type: array
required: required:
- mode - mode
- cors - cors
- whiteList - whiteList
- protocol - protocol
- runTimes
type: object
additionalProperties: false
AuthorizedRoutesResponse:
properties:
URIs:
items:
type: string
type: array
required:
- URIs
type: object type: object
additionalProperties: false additionalProperties: false
ExecuteReturnJsonPayload: ExecuteReturnJsonPayload:
@@ -448,6 +466,106 @@ components:
example: /Public/somefolder/some.file example: /Public/somefolder/some.file
type: object type: object
additionalProperties: false additionalProperties: false
LoginPayload:
properties:
username:
type: string
description: 'Username for user'
example: secretuser
password:
type: string
description: 'Password for user'
example: secretpassword
required:
- username
- password
type: object
additionalProperties: false
AuthorizeResponse:
properties:
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- code
type: object
additionalProperties: false
AuthorizePayload:
properties:
clientId:
type: string
description: 'Client ID'
example: clientID1
required:
- clientId
type: object
additionalProperties: false
PermissionDetailsResponse:
properties:
permissionId:
type: number
format: double
uri:
type: string
setting:
type: string
user:
$ref: '#/components/schemas/UserResponse'
group:
$ref: '#/components/schemas/GroupDetailsResponse'
required:
- permissionId
- uri
- setting
type: object
additionalProperties: false
PermissionSetting:
enum:
- Grant
- Deny
type: string
PrincipalType:
enum:
- user
- group
type: string
RegisterPermissionPayload:
properties:
uri:
type: string
description: 'Name of affected resource'
example: /SASjsApi/code/execute
setting:
$ref: '#/components/schemas/PermissionSetting'
description: 'The indication of whether (and to what extent) access is provided'
example: Grant
principalType:
$ref: '#/components/schemas/PrincipalType'
description: 'Indicates the type of principal'
example: user
principalId:
type: number
format: double
description: 'The id of user or group to which a rule is assigned.'
example: 123
required:
- uri
- setting
- principalType
- principalId
type: object
additionalProperties: false
UpdatePermissionPayload:
properties:
setting:
$ref: '#/components/schemas/PermissionSetting'
description: 'The indication of whether (and to what extent) access is provided'
example: Grant
required:
- setting
type: object
additionalProperties: false
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
@@ -518,86 +636,6 @@ paths:
- -
bearerAuth: [] bearerAuth: []
parameters: [] parameters: []
/:
get:
operationId: Home
responses:
'200':
description: Ok
content:
application/json:
schema:
type: string
summary: 'Render index.html'
tags:
- Web
security: []
parameters: []
/SASLogon/login:
post:
operationId: Login
responses:
'200':
description: Ok
content:
application/json:
schema:
properties:
user: {properties: {displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [displayName, username, id], type: object}
loggedIn: {type: boolean}
required:
- user
- loggedIn
type: object
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginPayload'
/SASLogon/authorize:
post:
operationId: Authorize
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizeResponse'
examples:
'Example 1':
value: {code: someRandomCryptoString}
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizePayload'
/logout:
get:
operationId: Logout
responses:
'200':
description: Ok
content:
application/json:
schema: {}
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
/SASjsApi/client: /SASjsApi/client:
post: post:
operationId: CreateClient operationId: CreateClient
@@ -626,7 +664,7 @@ paths:
$ref: '#/components/schemas/ClientPayload' $ref: '#/components/schemas/ClientPayload'
/SASjsApi/code/execute: /SASjsApi/code/execute:
post: post:
operationId: ExecuteSASCode operationId: ExecuteCode
responses: responses:
'200': '200':
description: Ok description: Ok
@@ -647,7 +685,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ExecuteSASCodePayload' $ref: '#/components/schemas/ExecuteCodePayload'
/SASjsApi/drive/deploy: /SASjsApi/drive/deploy:
post: post:
operationId: Deploy operationId: Deploy
@@ -723,7 +761,8 @@ paths:
examples: examples:
'Example 1': 'Example 1':
value: {status: failure, message: 'Deployment failed!'} value: {status: failure, message: 'Deployment failed!'}
summary: 'Creates/updates files within SASjs Drive using uploaded JSON file.' description: "Accepts JSON file and zipped compressed JSON file as well.\nCompressed file should only contain one JSON file and should have same name\nas of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip\nAny other file or JSON file in zipped will be ignored!"
summary: 'Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.'
tags: tags:
- Drive - Drive
security: security:
@@ -952,7 +991,7 @@ paths:
type: array type: array
examples: examples:
'Example 1': 'Example 1':
value: [{id: 123, username: johnusername, displayName: John}, {id: 456, username: starkusername, displayName: Stark}] value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}]
summary: 'Get list of all users (username, displayname). All users can request this.' summary: 'Get list of all users (username, displayname). All users can request this.'
tags: tags:
- User - User
@@ -985,6 +1024,94 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserPayload' $ref: '#/components/schemas/UserPayload'
'/SASjsApi/user/by/username/{username}':
get:
operationId: GetUserByUsername
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/UserDetailsResponse'
description: 'Only Admin or user itself will get user autoExec code.'
summary: 'Get user properties - such as group memberships, userName, displayName.'
tags:
- User
security:
-
bearerAuth: []
parameters:
-
description: 'The User''s username'
in: path
name: username
required: true
schema:
type: string
example: johnSnow01
patch:
operationId: UpdateUserByUsername
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/UserDetailsResponse'
examples:
'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
tags:
- User
security:
-
bearerAuth: []
parameters:
-
description: 'The User''s username'
in: path
name: username
required: true
schema:
type: string
example: johnSnow01
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserPayload'
delete:
operationId: DeleteUserByUsername
responses:
'204':
description: 'No content'
summary: 'Delete a user. Can be performed either by admins, or the user in question.'
tags:
- User
security:
-
bearerAuth: []
parameters:
-
description: 'The User''s username'
in: path
name: username
required: true
schema:
type: string
example: johnSnow01
requestBody:
required: true
content:
application/json:
schema:
properties:
password:
type: string
type: object
'/SASjsApi/user/{userId}': '/SASjsApi/user/{userId}':
get: get:
operationId: GetUser operationId: GetUser
@@ -1123,6 +1250,30 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/GroupPayload' $ref: '#/components/schemas/GroupPayload'
'/SASjsApi/group/by/groupname/{name}':
get:
operationId: GetGroupByGroupName
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/GroupDetailsResponse'
summary: 'Get list of members of a group (userName). All users can request this.'
tags:
- Group
security:
-
bearerAuth: []
parameters:
-
description: 'The group''s name'
in: path
name: name
required: true
schema:
type: string
'/SASjsApi/group/{groupId}': '/SASjsApi/group/{groupId}':
get: get:
operationId: GetGroup operationId: GetGroup
@@ -1152,8 +1303,14 @@ paths:
delete: delete:
operationId: DeleteGroup operationId: DeleteGroup
responses: responses:
'204': '200':
description: 'No content' description: Ok
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/IGroup'}
- {properties: {_id: {}}, required: [_id], type: object}
summary: 'Delete a group. Admin task only.' summary: 'Delete a group. Admin task only.'
tags: tags:
- Group - Group
@@ -1257,12 +1414,30 @@ paths:
$ref: '#/components/schemas/InfoResponse' $ref: '#/components/schemas/InfoResponse'
examples: examples:
'Example 1': 'Example 1':
value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http} value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http, runTimes: [sas, js]}
summary: 'Get server info (mode, cors, whiteList, protocol).' summary: 'Get server info (mode, cors, whiteList, protocol).'
tags: tags:
- Info - Info
security: [] security: []
parameters: [] parameters: []
/SASjsApi/info/authorizedRoutes:
get:
operationId: AuthorizedRoutes
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizedRoutesResponse'
examples:
'Example 1':
value: {URIs: [/AppStream, /SASjsApi/stp/execute]}
summary: 'Get authorized routes.'
tags:
- Info
security: []
parameters: []
/SASjsApi/session: /SASjsApi/session:
get: get:
operationId: Session operationId: Session
@@ -1275,7 +1450,7 @@ paths:
$ref: '#/components/schemas/UserResponse' $ref: '#/components/schemas/UserResponse'
examples: examples:
'Example 1': 'Example 1':
value: {id: 123, username: johnusername, displayName: John} value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
summary: 'Get session info (username).' summary: 'Get session info (username).'
tags: tags:
- Session - Session
@@ -1295,8 +1470,8 @@ paths:
anyOf: anyOf:
- {type: string} - {type: string}
- {type: string, format: byte} - {type: string, format: byte}
description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. Setting _debug=131 will\ncause the log to be streamed in the output.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response headers can be adjusted using the mfs_httpheader() macro. Any\nfile type can be returned, including binary files such as zip or xls.\n\nIf _debug is >= 131, response headers will contain Content-Type: 'text/plain'\n\nThis behaviour differs for POST requests, in which case the response is\nalways JSON." description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
summary: 'Execute Stored Program, return raw _webout content.' summary: 'Execute a Stored Program, returns raw _webout content.'
tags: tags:
- STP - STP
security: security:
@@ -1304,13 +1479,13 @@ paths:
bearerAuth: [] bearerAuth: []
parameters: parameters:
- -
description: 'Location of SAS program' description: 'Location of SAS or JS code'
in: query in: query
name: _program name: _program
required: true required: true
schema: schema:
type: string type: string
example: /Public/somefolder/some.file example: /Projects/myApp/some/program
post: post:
operationId: ExecuteReturnJson operationId: ExecuteReturnJson
responses: responses:
@@ -1323,8 +1498,8 @@ paths:
examples: examples:
'Example 1': 'Example 1':
value: {status: success, _webout: 'webout content', log: [], httpHeaders: {Content-type: application/zip, Cache-Control: 'public, max-age=1000'}} value: {status: success, _webout: 'webout content', log: [], httpHeaders: {Content-type: application/zip, Cache-Control: 'public, max-age=1000'}}
description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. In any case, the log is\nalways returned in the log object.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response will be a JSON object with the following root attributes: log,\nwebout, headers.\n\nThe webout will be a nested JSON object ONLY if the response-header\ncontains a content-type of application/json AND it is valid JSON.\nOtherwise it will be a stringified version of the webout content.\n\nResponse headers from the mfs_httpheader macro are simply listed in the\nheaders object, for POST requests they have no effect on the actual\nresponse header." description: "Trigger a SAS or JS program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms\n\nThe response will be a JSON object with the following root attributes:\nlog, webout, headers.\n\nThe webout attribute will be nested JSON ONLY if the response-header\ncontains a content-type of application/json AND it is valid JSON.\nOtherwise it will be a stringified version of the webout content."
summary: 'Execute Stored Program, return JSON' summary: 'Execute a Stored Program, return a JSON object'
tags: tags:
- STP - STP
security: security:
@@ -1332,32 +1507,218 @@ paths:
bearerAuth: [] bearerAuth: []
parameters: parameters:
- -
description: 'Location of SAS program' description: 'Location of SAS or JS code'
in: query in: query
name: _program name: _program
required: false required: false
schema: schema:
type: string type: string
example: /Public/somefolder/some.file example: /Projects/myApp/some/program
requestBody: requestBody:
required: false required: false
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ExecuteReturnJsonPayload' $ref: '#/components/schemas/ExecuteReturnJsonPayload'
/:
get:
operationId: Home
responses:
'200':
description: Ok
content:
application/json:
schema:
type: string
summary: 'Render index.html'
tags:
- Web
security: []
parameters: []
/SASLogon/login:
post:
operationId: Login
responses:
'200':
description: Ok
content:
application/json:
schema:
properties:
user: {properties: {isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [isAdmin, displayName, username, id], type: object}
loggedIn: {type: boolean}
required:
- user
- loggedIn
type: object
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginPayload'
/SASLogon/authorize:
post:
operationId: Authorize
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizeResponse'
examples:
'Example 1':
value: {code: someRandomCryptoString}
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizePayload'
/SASLogon/logout:
get:
operationId: Logout
responses:
'200':
description: Ok
content:
application/json:
schema: {}
summary: 'Destroy the session stored in cookies'
tags:
- Web
security: []
parameters: []
/SASjsApi/permission:
get:
operationId: GetAllPermissions
responses:
'200':
description: Ok
content:
application/json:
schema:
items:
$ref: '#/components/schemas/PermissionDetailsResponse'
type: array
examples:
'Example 1':
value: [{permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {permissionId: 124, uri: /SASjsApi/code/execute, setting: Grant, group: {groupId: 1, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
summary: 'Get list of all permissions (uri, setting and userDetail).'
tags:
- Permission
security:
-
bearerAuth: []
parameters: []
post:
operationId: CreatePermission
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/PermissionDetailsResponse'
examples:
'Example 1':
value: {permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
summary: 'Create a new permission. Admin only.'
tags:
- Permission
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterPermissionPayload'
'/SASjsApi/permission/{permissionId}':
patch:
operationId: UpdatePermission
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/PermissionDetailsResponse'
examples:
'Example 1':
value: {permissionId: 123, uri: /SASjsApi/code/execute, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
summary: 'Update permission setting. Admin only'
tags:
- Permission
security:
-
bearerAuth: []
parameters:
-
description: 'The permission''s identifier'
in: path
name: permissionId
required: true
schema:
format: double
type: number
example: 1234
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdatePermissionPayload'
delete:
operationId: DeletePermission
responses:
'204':
description: 'No content'
summary: 'Delete a permission. Admin only.'
tags:
- Permission
security:
-
bearerAuth: []
parameters:
-
description: 'The user''s identifier'
in: path
name: permissionId
required: true
schema:
format: double
type: number
example: 1234
servers: servers:
- -
url: / url: /
tags: tags:
- -
name: Info name: Info
description: 'Get Server Info' description: 'Get Server Information'
- -
name: Session name: Session
description: 'Get Session information' description: 'Get Session information'
- -
name: User name: User
description: 'Operations about users' description: 'Operations with users'
-
name: Permission
description: 'Operations about permissions'
- -
name: Client name: Client
description: 'Operations about clients' description: 'Operations about clients'
@@ -1366,16 +1727,16 @@ tags:
description: 'Operations about auth' description: 'Operations about auth'
- -
name: Drive name: Drive
description: 'Operations about drive' description: 'Operations on SASjs Drive'
- -
name: Group name: Group
description: 'Operations about group' description: 'Operations on groups and group memberships'
- -
name: STP name: STP
description: 'Operations about STP' description: 'Execution of Stored Programs'
- -
name: CODE name: CODE
description: 'Operations on SAS code' description: 'Execution of code (various runtimes are supported)'
- -
name: Web name: Web
description: 'Operations on Web' description: 'Operations on Web'

View File

@@ -6,7 +6,7 @@ import {
readFile, readFile,
SASJsFileType SASJsFileType
} from '@sasjs/utils' } from '@sasjs/utils'
import { apiRoot, sysInitCompiledPath } from '../src/utils' import { apiRoot, sysInitCompiledPath } from '../src/utils/file'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core') const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')

View File

@@ -8,7 +8,11 @@ import {
listFilesInFolder listFilesInFolder
} from '@sasjs/utils' } from '@sasjs/utils'
import { apiRoot, sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils' import {
apiRoot,
sasJSCoreMacros,
sasJSCoreMacrosInfo
} from '../src/utils/file'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core') const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')

View File

@@ -0,0 +1,21 @@
import { Express } from 'express'
import cors from 'cors'
import { CorsType } from '../utils'
export const configureCors = (app: Express) => {
const { CORS, WHITELIST } = process.env
if (CORS === CorsType.ENABLED) {
const whiteList: string[] = []
WHITELIST?.split(' ')
?.filter((url) => !!url)
.forEach((url) => {
if (url.startsWith('http'))
// removing trailing slash of URLs listing for CORS
whiteList.push(url.replace(/\/$/, ''))
})
console.log('All CORS Requests are enabled for:', whiteList)
app.use(cors({ credentials: true, origin: whiteList }))
}
}

View File

@@ -0,0 +1,32 @@
import { Express } from 'express'
import mongoose from 'mongoose'
import session from 'express-session'
import MongoStore from 'connect-mongo'
import { ModeType } from '../utils'
import { cookieOptions } from '../app'
export const configureExpressSession = (app: Express) => {
const { MODE } = process.env
if (MODE === ModeType.Server) {
let store: MongoStore | undefined
if (process.env.NODE_ENV !== 'test') {
store = MongoStore.create({
client: mongoose.connection!.getClient() as any,
collectionName: 'sessions'
})
}
app.use(
session({
secret: process.secrets.SESSION_SECRET,
saveUninitialized: false, // don't create session until something stored
resave: false, //don't save session if unmodified
store,
cookie: cookieOptions
})
)
}
}

View File

@@ -0,0 +1,33 @@
import path from 'path'
import { Express } from 'express'
import morgan from 'morgan'
import { createStream } from 'rotating-file-stream'
import { generateTimestamp } from '@sasjs/utils'
import { getLogFolder } from '../utils'
export const configureLogger = (app: Express) => {
const { LOG_FORMAT_MORGAN } = process.env
let options
if (
process.env.NODE_ENV !== 'development' &&
process.env.NODE_ENV !== 'test'
) {
const timestamp = generateTimestamp()
const filename = `${timestamp}.log`
const logsFolder = getLogFolder()
// create a rotating write stream
var accessLogStream = createStream(filename, {
interval: '1d', // rotate daily
path: logsFolder
})
console.log('Writing Logs to :', path.join(logsFolder, filename))
options = { stream: accessLogStream }
}
// setup the logger
app.use(morgan(LOG_FORMAT_MORGAN as string, options))
}

View File

@@ -0,0 +1,26 @@
import { Express } from 'express'
import { getEnvCSPDirectives } from '../utils/parseHelmetConfig'
import { HelmetCoepType, ProtocolType } from '../utils'
import helmet from 'helmet'
export const configureSecurity = (app: Express) => {
const { PROTOCOL, HELMET_CSP_CONFIG_PATH, HELMET_COEP } = process.env
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
HELMET_CSP_CONFIG_PATH
)
if (PROTOCOL === ProtocolType.HTTP)
cspConfigJson['upgrade-insecure-requests'] = null
app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
...cspConfigJson
}
},
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
})
)
}

View File

@@ -0,0 +1,4 @@
export * from './configureCors'
export * from './configureExpressSession'
export * from './configureLogger'
export * from './configureSecurity'

View File

@@ -1,30 +1,26 @@
import path from 'path' import path from 'path'
import express, { ErrorRequestHandler } from 'express' import express, { ErrorRequestHandler } from 'express'
import csrf from 'csurf' import csrf from 'csurf'
import session from 'express-session'
import MongoStore from 'connect-mongo'
import morgan from 'morgan'
import cookieParser from 'cookie-parser' import cookieParser from 'cookie-parser'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import cors from 'cors'
import helmet from 'helmet'
import { import {
connectDB,
copySASjsCore, copySASjsCore,
CorsType,
getWebBuildFolder, getWebBuildFolder,
HelmetCoepType,
instantiateLogger, instantiateLogger,
loadAppStreamConfig, loadAppStreamConfig,
ModeType,
ProtocolType, ProtocolType,
ReturnCode, ReturnCode,
setProcessVariables, setProcessVariables,
setupFolders, setupFolders,
verifyEnvVariables verifyEnvVariables
} from './utils' } from './utils'
import { getEnvCSPDirectives } from './utils/parseHelmetConfig' import {
configureCors,
configureExpressSession,
configureLogger,
configureSecurity
} from './app-modules'
dotenv.config() dotenv.config()
@@ -34,19 +30,7 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
const app = express() const app = express()
app.use(cookieParser()) const { PROTOCOL } = process.env
const {
MODE,
CORS,
WHITELIST,
PROTOCOL,
HELMET_CSP_CONFIG_PATH,
HELMET_COEP,
LOG_FORMAT_MORGAN
} = process.env
app.use(morgan(LOG_FORMAT_MORGAN as string))
export const cookieOptions = { export const cookieOptions = {
secure: PROTOCOL === ProtocolType.HTTPS, secure: PROTOCOL === ProtocolType.HTTPS,
@@ -54,79 +38,11 @@ export const cookieOptions = {
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000 // 24 hours
} }
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
HELMET_CSP_CONFIG_PATH
)
if (PROTOCOL === ProtocolType.HTTP)
cspConfigJson['upgrade-insecure-requests'] = null
/*********************************** /***********************************
* CSRF Protection * * CSRF Protection *
***********************************/ ***********************************/
export const csrfProtection = csrf({ cookie: cookieOptions }) export const csrfProtection = csrf({ cookie: cookieOptions })
/***********************************
* Handle security and origin *
***********************************/
app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
...cspConfigJson
}
},
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
})
)
/***********************************
* Enabling CORS *
***********************************/
if (CORS === CorsType.ENABLED) {
const whiteList: string[] = []
WHITELIST?.split(' ')
?.filter((url) => !!url)
.forEach((url) => {
if (url.startsWith('http'))
// removing trailing slash of URLs listing for CORS
whiteList.push(url.replace(/\/$/, ''))
})
console.log('All CORS Requests are enabled for:', whiteList)
app.use(cors({ credentials: true, origin: whiteList }))
}
/***********************************
* DB Connection & *
* Express Sessions *
* With Mongo Store *
***********************************/
if (MODE === ModeType.Server) {
let store: MongoStore | undefined
// NOTE: when exporting app.js as agent for supertest
// we should exclude connecting to the real database
if (process.env.NODE_ENV !== 'test') {
const clientPromise = connectDB().then((conn) => conn!.getClient() as any)
store = MongoStore.create({ clientPromise, collectionName: 'sessions' })
}
app.use(
session({
secret: process.env.SESSION_SECRET as string,
saveUninitialized: false, // don't create session until something stored
resave: false, //don't save session if unmodified
store,
cookie: cookieOptions
})
)
}
app.use(express.json({ limit: '100mb' }))
app.use(express.static(path.join(__dirname, '../public')))
const onError: ErrorRequestHandler = (err, req, res, next) => { const onError: ErrorRequestHandler = (err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') if (err.code === 'EBADCSRFTOKEN')
return res.status(400).send('Invalid CSRF token!') return res.status(400).send('Invalid CSRF token!')
@@ -136,6 +52,30 @@ const onError: ErrorRequestHandler = (err, req, res, next) => {
} }
export default setProcessVariables().then(async () => { export default setProcessVariables().then(async () => {
app.use(cookieParser())
configureLogger(app)
/***********************************
* Handle security and origin *
***********************************/
configureSecurity(app)
/***********************************
* Enabling CORS *
***********************************/
configureCors(app)
/***********************************
* DB Connection & *
* Express Sessions *
* With Mongo Store *
***********************************/
configureExpressSession(app)
app.use(express.json({ limit: '100mb' }))
app.use(express.static(path.join(__dirname, '../public')))
await setupFolders() await setupFolders()
await copySASjsCore() await copySASjsCore()

View File

@@ -129,8 +129,8 @@ const verifyAuthCode = async (
clientId: string, clientId: string,
code: string code: string
): Promise<InfoJWT | undefined> => { ): Promise<InfoJWT | undefined> => {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => { jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
if (err) return resolve(undefined) if (err) return resolve(undefined)
const clientInfo: InfoJWT = { const clientInfo: InfoJWT = {

View File

@@ -6,15 +6,21 @@ import {
getPreProgramVariables, getPreProgramVariables,
getUserAutoExec, getUserAutoExec,
ModeType, ModeType,
parseLogToArray parseLogToArray,
RunTimeType
} from '../utils' } from '../utils'
interface ExecuteSASCodePayload { interface ExecuteCodePayload {
/** /**
* Code of SAS program * Code of program
* @example "* SAS Code HERE;" * @example "* Code HERE;"
*/ */
code: string code: string
/**
* runtime for program
* @example "js"
*/
runTime: RunTimeType
} }
@Security('bearerAuth') @Security('bearerAuth')
@@ -26,17 +32,17 @@ export class CodeController {
* @summary Run SAS Code and returns log * @summary Run SAS Code and returns log
*/ */
@Post('/execute') @Post('/execute')
public async executeSASCode( public async executeCode(
@Request() request: express.Request, @Request() request: express.Request,
@Body() body: ExecuteSASCodePayload @Body() body: ExecuteCodePayload
): Promise<ExecuteReturnJsonResponse> { ): Promise<ExecuteReturnJsonResponse> {
return executeSASCode(request, body) return executeCode(request, body)
} }
} }
const executeSASCode = async ( const executeCode = async (
req: express.Request, req: express.Request,
{ code }: ExecuteSASCodePayload { code, runTime }: ExecuteCodePayload
) => { ) => {
const { user } = req const { user } = req
const userAutoExec = const userAutoExec =
@@ -46,13 +52,14 @@ const executeSASCode = async (
try { try {
const { webout, log, httpHeaders } = const { webout, log, httpHeaders } =
(await new ExecutionController().executeProgram( (await new ExecutionController().executeProgram({
code, program: code,
getPreProgramVariables(req), preProgramVariables: getPreProgramVariables(req),
{ ...req.query, _debug: 131 }, vars: { ...req.query, _debug: 131 },
{ userAutoExec }, otherArgs: { userAutoExec },
true returnJson: true,
)) as ExecuteReturnJson runTime: runTime
})) as ExecuteReturnJson
return { return {
status: 'success', status: 'success',

View File

@@ -96,7 +96,12 @@ export class DriveController {
} }
/** /**
* @summary Creates/updates files within SASjs Drive using uploaded JSON file. * Accepts JSON file and zipped compressed JSON file as well.
* Compressed file should only contain one JSON file and should have same name
* as of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip
* Any other file or JSON file in zipped will be ignored!
*
* @summary Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.
* *
*/ */
@Example<DeployResponse>(successDeployResponse) @Example<DeployResponse>(successDeployResponse)

View File

@@ -14,13 +14,13 @@ import Group, { GroupPayload } from '../model/Group'
import User from '../model/User' import User from '../model/User'
import { UserResponse } from './user' import { UserResponse } from './user'
interface GroupResponse { export interface GroupResponse {
groupId: number groupId: number
name: string name: string
description: string description: string
} }
interface GroupDetailsResponse { export interface GroupDetailsResponse {
groupId: number groupId: number
name: string name: string
description: string description: string
@@ -28,6 +28,11 @@ interface GroupDetailsResponse {
users: UserResponse[] users: UserResponse[]
} }
interface GetGroupBy {
groupId?: number
name?: string
}
@Security('bearerAuth') @Security('bearerAuth')
@Route('SASjsApi/group') @Route('SASjsApi/group')
@Tags('Group') @Tags('Group')
@@ -66,6 +71,18 @@ export class GroupController {
return createGroup(body) return createGroup(body)
} }
/**
* @summary Get list of members of a group (userName). All users can request this.
* @param name The group's name
* @example dcgroup
*/
@Get('by/groupname/{name}')
public async getGroupByGroupName(
@Path() name: string
): Promise<GroupDetailsResponse> {
return getGroup({ name })
}
/** /**
* @summary Get list of members of a group (userName). All users can request this. * @summary Get list of members of a group (userName). All users can request this.
* @param groupId The group's identifier * @param groupId The group's identifier
@@ -75,7 +92,7 @@ export class GroupController {
public async getGroup( public async getGroup(
@Path() groupId: number @Path() groupId: number
): Promise<GroupDetailsResponse> { ): Promise<GroupDetailsResponse> {
return getGroup(groupId) return getGroup({ groupId })
} }
/** /**
@@ -129,9 +146,13 @@ export class GroupController {
*/ */
@Delete('{groupId}') @Delete('{groupId}')
public async deleteGroup(@Path() groupId: number) { public async deleteGroup(@Path() groupId: number) {
const { deletedCount } = await Group.deleteOne({ groupId }) const group = await Group.findOne({ groupId })
if (deletedCount) return if (group) return await group.remove()
throw new Error('No Group deleted!') throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
} }
} }
@@ -145,6 +166,15 @@ const createGroup = async ({
description, description,
isActive isActive
}: GroupPayload): Promise<GroupDetailsResponse> => { }: GroupPayload): Promise<GroupDetailsResponse> => {
// Checking if user is already in the database
const groupnameExist = await Group.findOne({ name })
if (groupnameExist)
throw {
code: 409,
status: 'Conflict',
message: 'Group name already exists.'
}
const group = new Group({ const group = new Group({
name, name,
description, description,
@@ -162,15 +192,20 @@ const createGroup = async ({
} }
} }
const getGroup = async (groupId: number): Promise<GroupDetailsResponse> => { const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
const group = (await Group.findOne( const group = (await Group.findOne(
{ groupId }, findBy,
'groupId name description isActive users -_id' 'groupId name description isActive users -_id'
).populate( ).populate(
'users', 'users',
'id username displayName -_id' 'id username displayName isAdmin -_id'
)) as unknown as GroupDetailsResponse )) as unknown as GroupDetailsResponse
if (!group) throw new Error('Group not found.') if (!group)
throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
return { return {
groupId: group.groupId, groupId: group.groupId,
@@ -199,16 +234,32 @@ const updateUsersListInGroup = async (
action: 'addUser' | 'removeUser' action: 'addUser' | 'removeUser'
): Promise<GroupDetailsResponse> => { ): Promise<GroupDetailsResponse> => {
const group = await Group.findOne({ groupId }) const group = await Group.findOne({ groupId })
if (!group) throw new Error('Group not found.') if (!group)
throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
const user = await User.findOne({ id: userId }) const user = await User.findOne({ id: userId })
if (!user) throw new Error('User not found.') if (!user)
throw {
code: 404,
status: 'Not Found',
message: 'User not found.'
}
const updatedGroup = (action === 'addUser' const updatedGroup =
? await group.addUser(user._id) action === 'addUser'
: await group.removeUser(user._id)) as unknown as GroupDetailsResponse ? await group.addUser(user)
: await group.removeUser(user)
if (!updatedGroup) throw new Error('Unable to update group') if (!updatedGroup)
throw {
code: 400,
status: 'Bad Request',
message: 'Unable to update group.'
}
return { return {
groupId: updatedGroup.groupId, groupId: updatedGroup.groupId,

View File

@@ -4,6 +4,7 @@ export * from './code'
export * from './drive' export * from './drive'
export * from './group' export * from './group'
export * from './info' export * from './info'
export * from './permission'
export * from './session' export * from './session'
export * from './stp' export * from './stp'
export * from './user' export * from './user'

View File

@@ -1,10 +1,15 @@
import { Route, Tags, Example, Get } from 'tsoa' import { Route, Tags, Example, Get } from 'tsoa'
import { getAuthorizedRoutes } from '../utils'
export interface AuthorizedRoutesResponse {
URIs: string[]
}
export interface InfoResponse { export interface InfoResponse {
mode: string mode: string
cors: string cors: string
whiteList: string[] whiteList: string[]
protocol: string protocol: string
runTimes: string[]
} }
@Route('SASjsApi/info') @Route('SASjsApi/info')
@@ -18,7 +23,8 @@ export class InfoController {
mode: 'desktop', mode: 'desktop',
cors: 'enable', cors: 'enable',
whiteList: ['http://example.com', 'http://example2.com'], whiteList: ['http://example.com', 'http://example2.com'],
protocol: 'http' protocol: 'http',
runTimes: ['sas', 'js']
}) })
@Get('/') @Get('/')
public info(): InfoResponse { public info(): InfoResponse {
@@ -29,7 +35,23 @@ export class InfoController {
(process.env.MODE === 'server' ? 'disable' : 'enable'), (process.env.MODE === 'server' ? 'disable' : 'enable'),
whiteList: whiteList:
process.env.WHITELIST?.split(' ')?.filter((url) => !!url) ?? [], process.env.WHITELIST?.split(' ')?.filter((url) => !!url) ?? [],
protocol: process.env.PROTOCOL ?? 'http' protocol: process.env.PROTOCOL ?? 'http',
runTimes: process.runTimes
}
return response
}
/**
* @summary Get authorized routes.
*
*/
@Example<AuthorizedRoutesResponse>({
URIs: ['/AppStream', '/SASjsApi/stp/execute']
})
@Get('/authorizedRoutes')
public authorizedRoutes(): AuthorizedRoutesResponse {
const response = {
URIs: getAuthorizedRoutes()
} }
return response return response
} }

View File

@@ -1,21 +1,14 @@
import path from 'path' import path from 'path'
import fs from 'fs' import fs from 'fs'
import { getSessionController } from './' import { getSessionController, processProgram } from './'
import { import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
readFile,
fileExists,
createFile,
moveFile,
readFileBinary
} from '@sasjs/utils'
import { PreProgramVars, Session, TreeNode } from '../../types' import { PreProgramVars, Session, TreeNode } from '../../types'
import { import {
extractHeaders, extractHeaders,
generateFileUploadSasCode,
getFilesFolder, getFilesFolder,
getMacrosFolder,
HTTPHeaders, HTTPHeaders,
isDebugOn isDebugOn,
RunTimeType
} from '../../utils' } from '../../utils'
export interface ExecutionVars { export interface ExecutionVars {
@@ -33,39 +26,53 @@ export interface ExecuteReturnJson {
log?: string log?: string
} }
export class ExecutionController { interface ExecuteFileParams {
async executeFile( programPath: string
programPath: string, preProgramVariables: PreProgramVars
preProgramVariables: PreProgramVars, vars: ExecutionVars
vars: ExecutionVars, otherArgs?: any
otherArgs?: any, returnJson?: boolean
returnJson?: boolean,
session?: Session session?: Session
) { runTime: RunTimeType
if (!(await fileExists(programPath))) }
throw 'ExecutionController: SAS file does not exist.'
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
program: string
}
export class ExecutionController {
async executeFile({
programPath,
preProgramVariables,
vars,
otherArgs,
returnJson,
session,
runTime
}: ExecuteFileParams) {
const program = await readFile(programPath) const program = await readFile(programPath)
return this.executeProgram( return this.executeProgram({
program, program,
preProgramVariables, preProgramVariables,
vars, vars,
otherArgs, otherArgs,
returnJson, returnJson,
session session,
) runTime
})
} }
async executeProgram( async executeProgram({
program: string, program,
preProgramVariables: PreProgramVars, preProgramVariables,
vars: ExecutionVars, vars,
otherArgs?: any, otherArgs,
returnJson?: boolean, returnJson,
sessionByFileUpload?: Session session: sessionByFileUpload,
): Promise<ExecuteReturnRaw | ExecuteReturnJson> { runTime
const sessionController = getSessionController() }: ExecuteProgramParams): Promise<ExecuteReturnRaw | ExecuteReturnJson> {
const sessionController = getSessionController(runTime)
const session = const session =
sessionByFileUpload ?? (await sessionController.getSession()) sessionByFileUpload ?? (await sessionController.getSession())
@@ -83,78 +90,18 @@ export class ExecutionController {
preProgramVariables?.httpHeaders.join('\n') ?? '' preProgramVariables?.httpHeaders.join('\n') ?? ''
) )
const varStatments = Object.keys(vars).reduce( await processProgram(
(computed: string, key: string) => program,
`${computed}%let ${key}=${vars[key]};\n`, preProgramVariables,
'' vars,
session,
weboutPath,
tokenFile,
runTime,
logPath,
otherArgs
) )
const preProgramVarStatments = `
%let _sasjs_tokenfile=${tokenFile};
%let _sasjs_username=${preProgramVariables?.username};
%let _sasjs_userid=${preProgramVariables?.userId};
%let _sasjs_displayname=${preProgramVariables?.displayName};
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
%let _sasjs_apipath=/SASjsApi/stp/execute;
%let _metaperson=&_sasjs_displayname;
%let _metauser=&_sasjs_username;
%let sasjsprocessmode=Stored Program;
%let sasjs_stpsrv_header_loc=%sysfunc(pathname(work))/../stpsrv_header.txt;
%global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG;
%macro _sasjs_server_init();
%if "&SYSPROCESSMODE"="" %then %let SYSPROCESSMODE=&sasjsprocessmode;
%if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl;
%mend;
%_sasjs_server_init()
`
program = `
options insert=(SASAUTOS="${getMacrosFolder()}");
/* runtime vars */
${varStatments}
filename _webout "${weboutPath}" mod;
/* dynamic user-provided vars */
${preProgramVarStatments}
/* user autoexec starts */
${otherArgs?.userAutoExec ?? ''}
/* user autoexec ends */
/* actual job code */
${program}`
// if no files are uploaded filesNamesMap will be undefined
if (otherArgs?.filesNamesMap) {
const uploadSasCode = await generateFileUploadSasCode(
otherArgs.filesNamesMap,
session.path
)
//If sas code for the file is generated it will be appended to the top of sasCode
if (uploadSasCode.length > 0) {
program = `${uploadSasCode}` + program
}
}
const codePath = path.join(session.path, 'code.sas')
// Creating this file in a RUNNING session will break out
// the autoexec loop and actually execute the program
// but - given it will take several milliseconds to create
// (which can mean SAS trying to run a partial program, or
// failing due to file lock) we first create the file THEN
// we rename it.
await createFile(codePath + '.bkp', program)
await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session status
while (!session.completed) {
await delay(50)
}
const log = (await fileExists(logPath)) ? await readFile(logPath) : '' const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
const headersContent = (await fileExists(headersPath)) const headersContent = (await fileExists(headersPath))
? await readFile(headersPath) ? await readFile(headersPath)
@@ -228,5 +175,3 @@ ${program}`
return root return root
} }
} }
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -2,12 +2,17 @@ import { Request, RequestHandler } from 'express'
import multer from 'multer' import multer from 'multer'
import { uuidv4 } from '@sasjs/utils' import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.' import { getSessionController } from '.'
import {
executeProgramRawValidation,
getRunTimeAndFilePath,
RunTimeType
} from '../../utils'
export class FileUploadController { export class FileUploadController {
private storage = multer.diskStorage({ private storage = multer.diskStorage({
destination: function (req: Request, file: any, cb: any) { destination: function (req: Request, file: any, cb: any) {
//Sending the intercepted files to the sessions subfolder //Sending the intercepted files to the sessions subfolder
cb(null, req.sasSession?.path) cb(null, req.sasjsSession?.path)
}, },
filename: function (req: Request, file: any, cb: any) { filename: function (req: Request, file: any, cb: any) {
//req_file prefix + unique hash added to sas request files //req_file prefix + unique hash added to sas request files
@@ -20,15 +25,42 @@ export class FileUploadController {
//It will intercept request and generate unique uuid to be used as a subfolder name //It will intercept request and generate unique uuid to be used as a subfolder name
//that will store the files uploaded //that will store the files uploaded
public preUploadMiddleware: RequestHandler = async (req, res, next) => { public preUploadMiddleware: RequestHandler = async (req, res, next) => {
let session const { error: errQ, value: query } = executeProgramRawValidation(req.query)
const { error: errB, value: body } = executeProgramRawValidation(req.body)
const sessionController = getSessionController() if (errQ && errB) return res.status(400).send(errB.details[0].message)
session = await sessionController.getSession()
const programPath = (query?._program ?? body?._program) as string
let runTime
try {
;({ runTime } = await getRunTimeAndFilePath(programPath))
} catch (err: any) {
return res.status(400).send({
status: 'failure',
message: 'Job execution failed',
error: typeof err === 'object' ? err.toString() : err
})
}
let sessionController
try {
sessionController = getSessionController(runTime)
} catch (err: any) {
return res.status(400).send({
status: 'failure',
message: err.message,
error: typeof err === 'object' ? err.toString() : err
})
}
const session = await sessionController.getSession()
// marking consumed true, so that it's not available // marking consumed true, so that it's not available
// as readySession for any other request // as readySession for any other request
session.consumed = true session.consumed = true
req.sasSession = session req.sasjsSession = session
next() next()
} }

View File

@@ -5,24 +5,28 @@ import { execFile } from 'child_process'
import { import {
getSessionsFolder, getSessionsFolder,
generateUniqueFileName, generateUniqueFileName,
sysInitCompiledPath sysInitCompiledPath,
RunTimeType
} from '../../utils' } from '../../utils'
import { import {
deleteFolder, deleteFolder,
createFile, createFile,
fileExists, fileExists,
generateTimestamp, generateTimestamp,
readFile readFile,
isWindows
} from '@sasjs/utils' } from '@sasjs/utils'
const execFilePromise = promisify(execFile) const execFilePromise = promisify(execFile)
export class SessionController { abstract class SessionController {
private sessions: Session[] = [] protected sessions: Session[] = []
private getReadySessions = (): Session[] => protected getReadySessions = (): Session[] =>
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed) this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
protected abstract createSession(): Promise<Session>
public async getSession() { public async getSession() {
const readySessions = this.getReadySessions() const readySessions = this.getReadySessions()
@@ -34,8 +38,10 @@ export class SessionController {
return session return session
} }
}
private async createSession(): Promise<Session> { export class SASSessionController extends SessionController {
protected async createSession(): Promise<Session> {
const sessionId = generateUniqueFileName(generateTimestamp()) const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(getSessionsFolder(), sessionId) const sessionFolder = path.join(getSessionsFolder(), sessionId)
@@ -82,7 +88,9 @@ ${autoExecContent}`
// however we also need a promise so that we can update the // however we also need a promise so that we can update the
// session array to say that it has (eventually) finished. // session array to say that it has (eventually) finished.
execFilePromise(process.sasLoc, [ // Additional windows specific options to avoid the desktop popups.
execFilePromise(process.sasLoc!, [
'-SYSIN', '-SYSIN',
codePath, codePath,
'-LOG', '-LOG',
@@ -93,7 +101,9 @@ ${autoExecContent}`
session.path, session.path,
'-AUTOEXEC', '-AUTOEXEC',
autoExecPath, autoExecPath,
process.platform === 'win32' ? '-nosplash' : '' process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
isWindows() ? '-icon' : '',
isWindows() ? '-nologo' : ''
]) ])
.then(() => { .then(() => {
session.completed = true session.completed = true
@@ -152,12 +162,66 @@ ${autoExecContent}`
} }
} }
export const getSessionController = (): SessionController => { export class JSSessionController extends SessionController {
if (process.sessionController) return process.sessionController protected async createSession(): Promise<Session> {
const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(getSessionsFolder(), sessionId)
process.sessionController = new SessionController() const creationTimeStamp = sessionId.split('-').pop() as string
// death time of session is 15 mins from creation
const deathTimeStamp = (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
1000
).toString()
return process.sessionController const session: Session = {
id: sessionId,
ready: true,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp,
deathTimeStamp,
path: sessionFolder
}
const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'Content-type: application/json')
this.sessions.push(session)
return session
}
}
export const getSessionController = (
runTime: RunTimeType
): SASSessionController | JSSessionController => {
if (runTime === RunTimeType.SAS) {
return getSASSessionController()
}
if (runTime === RunTimeType.JS) {
return getJSSessionController()
}
throw new Error('No Runtime is configured')
}
const getSASSessionController = (): SASSessionController => {
if (process.sasSessionController) return process.sasSessionController
process.sasSessionController = new SASSessionController()
return process.sasSessionController
}
const getJSSessionController = (): JSSessionController => {
if (process.jsSessionController) return process.jsSessionController
process.jsSessionController = new JSSessionController()
return process.jsSessionController
} }
const autoExecContent = ` const autoExecContent = `

View File

@@ -0,0 +1,67 @@
import { isWindows } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types'
import { generateFileUploadJSCode } from '../../utils'
import { ExecutionVars } from './'
export const createJSProgram = async (
program: string,
preProgramVariables: PreProgramVars,
vars: ExecutionVars,
session: Session,
weboutPath: string,
tokenFile: string,
otherArgs?: any
) => {
const varStatments = Object.keys(vars).reduce(
(computed: string, key: string) =>
`${computed}const ${key} = '${vars[key]}';\n`,
''
)
const preProgramVarStatments = `
let _webout = '';
const weboutPath = '${
isWindows() ? weboutPath.replace(/\\/g, '\\\\') : weboutPath
}';
const _sasjs_tokenfile = '${tokenFile}';
const _sasjs_username = '${preProgramVariables?.username}';
const _sasjs_userid = '${preProgramVariables?.userId}';
const _sasjs_displayname = '${preProgramVariables?.displayName}';
const _metaperson = _sasjs_displayname;
const _metauser = _sasjs_username;
const sasjsprocessmode = 'Stored Program';
`
const requiredModules = `const fs = require('fs')`
program = `
/* runtime vars */
${varStatments}
/* dynamic user-provided vars */
${preProgramVarStatments}
/* actual job code */
${program}
/* write webout file only if webout exists*/
if (_webout) {
fs.writeFile(weboutPath, _webout, function (err) {
if (err) throw err;
})
}
`
// if no files are uploaded filesNamesMap will be undefined
if (otherArgs?.filesNamesMap) {
const uploadJSCode = await generateFileUploadJSCode(
otherArgs.filesNamesMap,
session.path
)
//If js code for the file is generated it will be appended to the top of jsCode
if (uploadJSCode.length > 0) {
program = `${uploadJSCode}\n` + program
}
}
return requiredModules + program
}

View File

@@ -0,0 +1,69 @@
import { PreProgramVars, Session } from '../../types'
import { generateFileUploadSasCode, getMacrosFolder } from '../../utils'
import { ExecutionVars } from './'
export const createSASProgram = async (
program: string,
preProgramVariables: PreProgramVars,
vars: ExecutionVars,
session: Session,
weboutPath: string,
tokenFile: string,
otherArgs?: any
) => {
const varStatments = Object.keys(vars).reduce(
(computed: string, key: string) => `${computed}%let ${key}=${vars[key]};\n`,
''
)
const preProgramVarStatments = `
%let _sasjs_tokenfile=${tokenFile};
%let _sasjs_username=${preProgramVariables?.username};
%let _sasjs_userid=${preProgramVariables?.userId};
%let _sasjs_displayname=${preProgramVariables?.displayName};
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
%let _sasjs_apipath=/SASjsApi/stp/execute;
%let _metaperson=&_sasjs_displayname;
%let _metauser=&_sasjs_username;
%let sasjsprocessmode=Stored Program;
%let sasjs_stpsrv_header_loc=%sysfunc(pathname(work))/../stpsrv_header.txt;
%global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG;
%macro _sasjs_server_init();
%if "&SYSPROCESSMODE"="" %then %let SYSPROCESSMODE=&sasjsprocessmode;
%if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl;
%mend;
%_sasjs_server_init()
`
program = `
options insert=(SASAUTOS="${getMacrosFolder()}");
/* runtime vars */
${varStatments}
filename _webout "${weboutPath}" mod;
/* dynamic user-provided vars */
${preProgramVarStatments}
/* user autoexec starts */
${otherArgs?.userAutoExec ?? ''}
/* user autoexec ends */
/* actual job code */
${program}`
// if no files are uploaded filesNamesMap will be undefined
if (otherArgs?.filesNamesMap) {
const uploadSasCode = await generateFileUploadSasCode(
otherArgs.filesNamesMap,
session.path
)
//If sas code for the file is generated it will be appended to the top of sasCode
if (uploadSasCode.length > 0) {
program = `${uploadSasCode}` + program
}
}
return program
}

View File

@@ -2,3 +2,6 @@ export * from './deploy'
export * from './Session' export * from './Session'
export * from './Execution' export * from './Execution'
export * from './FileUploadController' export * from './FileUploadController'
export * from './createSASProgram'
export * from './createJSProgram'
export * from './processProgram'

View File

@@ -0,0 +1,86 @@
import path from 'path'
import fs from 'fs'
import { execFileSync } from 'child_process'
import { once } from 'stream'
import { createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types'
import { RunTimeType } from '../../utils'
import { ExecutionVars, createSASProgram, createJSProgram } from './'
export const processProgram = async (
program: string,
preProgramVariables: PreProgramVars,
vars: ExecutionVars,
session: Session,
weboutPath: string,
tokenFile: string,
runTime: RunTimeType,
logPath: string,
otherArgs?: any
) => {
if (runTime === RunTimeType.JS) {
program = await createJSProgram(
program,
preProgramVariables,
vars,
session,
weboutPath,
tokenFile,
otherArgs
)
const codePath = path.join(session.path, 'code.js')
try {
await createFile(codePath, program)
// create a stream that will write to console outputs to log file
const writeStream = fs.createWriteStream(logPath)
// waiting for the open event so that we can have underlying file descriptor
await once(writeStream, 'open')
execFileSync(process.nodeLoc!, [codePath], {
stdio: ['ignore', writeStream, writeStream]
})
// copy the code.js program to log and end write stream
writeStream.end(program)
session.completed = true
console.log('session completed', session)
} catch (err: any) {
session.completed = true
session.crashed = err.toString()
console.log('session crashed', session.id, session.crashed)
}
} else {
program = await createSASProgram(
program,
preProgramVariables,
vars,
session,
weboutPath,
tokenFile,
otherArgs
)
const codePath = path.join(session.path, 'code.sas')
// Creating this file in a RUNNING session will break out
// the autoexec loop and actually execute the program
// but - given it will take several milliseconds to create
// (which can mean SAS trying to run a partial program, or
// failing due to file lock) we first create the file THEN
// we rename it.
await createFile(codePath + '.bkp', program)
await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session status
while (!session.completed) {
await delay(50)
}
}
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -0,0 +1,331 @@
import {
Security,
Route,
Tags,
Path,
Example,
Get,
Post,
Patch,
Delete,
Body
} from 'tsoa'
import Permission from '../model/Permission'
import User from '../model/User'
import Group from '../model/Group'
import { UserResponse } from './user'
import { GroupDetailsResponse } from './group'
export enum PrincipalType {
user = 'user',
group = 'group'
}
export enum PermissionSetting {
grant = 'Grant',
deny = 'Deny'
}
interface RegisterPermissionPayload {
/**
* Name of affected resource
* @example "/SASjsApi/code/execute"
*/
uri: string
/**
* The indication of whether (and to what extent) access is provided
* @example "Grant"
*/
setting: PermissionSetting
/**
* Indicates the type of principal
* @example "user"
*/
principalType: PrincipalType
/**
* The id of user or group to which a rule is assigned.
* @example 123
*/
principalId: number
}
interface UpdatePermissionPayload {
/**
* The indication of whether (and to what extent) access is provided
* @example "Grant"
*/
setting: PermissionSetting
}
export interface PermissionDetailsResponse {
permissionId: number
uri: string
setting: string
user?: UserResponse
group?: GroupDetailsResponse
}
@Security('bearerAuth')
@Route('SASjsApi/permission')
@Tags('Permission')
export class PermissionController {
/**
* @summary Get list of all permissions (uri, setting and userDetail).
*
*/
@Example<PermissionDetailsResponse[]>([
{
permissionId: 123,
uri: '/SASjsApi/code/execute',
setting: 'Grant',
user: {
id: 1,
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
},
{
permissionId: 124,
uri: '/SASjsApi/code/execute',
setting: 'Grant',
group: {
groupId: 1,
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
users: []
}
}
])
@Get('/')
public async getAllPermissions(): Promise<PermissionDetailsResponse[]> {
return getAllPermissions()
}
/**
* @summary Create a new permission. Admin only.
*
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
uri: '/SASjsApi/code/execute',
setting: 'Grant',
user: {
id: 1,
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
})
@Post('/')
public async createPermission(
@Body() body: RegisterPermissionPayload
): Promise<PermissionDetailsResponse> {
return createPermission(body)
}
/**
* @summary Update permission setting. Admin only
* @param permissionId The permission's identifier
* @example permissionId 1234
*/
@Example<PermissionDetailsResponse>({
permissionId: 123,
uri: '/SASjsApi/code/execute',
setting: 'Grant',
user: {
id: 1,
username: 'johnSnow01',
displayName: 'John Snow',
isAdmin: false
}
})
@Patch('{permissionId}')
public async updatePermission(
@Path() permissionId: number,
@Body() body: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> {
return updatePermission(permissionId, body)
}
/**
* @summary Delete a permission. Admin only.
* @param permissionId The user's identifier
* @example permissionId 1234
*/
@Delete('{permissionId}')
public async deletePermission(@Path() permissionId: number) {
return deletePermission(permissionId)
}
}
const getAllPermissions = async (): Promise<PermissionDetailsResponse[]> =>
(await Permission.find({})
.select({
_id: 0,
permissionId: 1,
uri: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.populate({
path: 'group',
select: 'groupId name description -_id',
populate: {
path: 'users',
select: 'id username displayName isAdmin -_id',
options: { limit: 15 }
}
})) as unknown as PermissionDetailsResponse[]
const createPermission = async ({
uri,
setting,
principalType,
principalId
}: RegisterPermissionPayload): Promise<PermissionDetailsResponse> => {
const permission = new Permission({
uri,
setting
})
let user: UserResponse | undefined
let group: GroupDetailsResponse | undefined
switch (principalType) {
case PrincipalType.user: {
const userInDB = await User.findOne({ id: principalId })
if (!userInDB)
throw {
code: 404,
status: 'Not Found',
message: 'User not found.'
}
if (userInDB.isAdmin)
throw {
code: 400,
status: 'Bad Request',
message: 'Can not add permission for admin user.'
}
const alreadyExists = await Permission.findOne({
uri,
user: userInDB._id
})
if (alreadyExists)
throw {
code: 409,
status: 'Conflict',
message: 'Permission already exists with provided URI and User.'
}
permission.user = userInDB._id
user = {
id: userInDB.id,
username: userInDB.username,
displayName: userInDB.displayName,
isAdmin: userInDB.isAdmin
}
break
}
case PrincipalType.group: {
const groupInDB = await Group.findOne({ groupId: principalId })
if (!groupInDB)
throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
const alreadyExists = await Permission.findOne({
uri,
group: groupInDB._id
})
if (alreadyExists)
throw {
code: 409,
status: 'Conflict',
message: 'Permission already exists with provided URI and Group.'
}
permission.group = groupInDB._id
group = {
groupId: groupInDB.groupId,
name: groupInDB.name,
description: groupInDB.description,
isActive: groupInDB.isActive,
users: groupInDB.populate({
path: 'users',
select: 'id username displayName isAdmin -_id',
options: { limit: 15 }
}) as unknown as UserResponse[]
}
break
}
default:
throw {
code: 400,
status: 'Bad Request',
message: 'Invalid principal type. Valid types are user or group.'
}
}
const savedPermission = await permission.save()
return {
permissionId: savedPermission.permissionId,
uri: savedPermission.uri,
setting: savedPermission.setting,
user,
group
}
}
const updatePermission = async (
id: number,
data: UpdatePermissionPayload
): Promise<PermissionDetailsResponse> => {
const { setting } = data
const updatedPermission = (await Permission.findOneAndUpdate(
{ permissionId: id },
{ setting },
{ new: true }
)
.select({
_id: 0,
permissionId: 1,
uri: 1,
setting: 1
})
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
.populate({
path: 'group',
select: 'groupId name description -_id'
})) as unknown as PermissionDetailsResponse
if (!updatedPermission)
throw {
code: 404,
status: 'Not Found',
message: 'Permission not found.'
}
return updatedPermission
}
const deletePermission = async (id: number) => {
const permission = await Permission.findOne({ permissionId: id })
if (!permission)
throw {
code: 404,
status: 'Not Found',
message: 'Permission not found.'
}
await Permission.deleteOne({ permissionId: id })
}

View File

@@ -13,7 +13,8 @@ export class SessionController {
@Example<UserResponse>({ @Example<UserResponse>({
id: 123, id: 123,
username: 'johnusername', username: 'johnusername',
displayName: 'John' displayName: 'John',
isAdmin: false
}) })
@Get('/') @Get('/')
public async session( public async session(
@@ -26,5 +27,6 @@ export class SessionController {
const session = (req: express.Request) => ({ const session = (req: express.Request) => ({
id: req.user!.userId, id: req.user!.userId,
username: req.user!.username, username: req.user!.username,
displayName: req.user!.displayName displayName: req.user!.displayName,
isAdmin: req.user!.isAdmin
}) })

View File

@@ -1,5 +1,4 @@
import express from 'express' import express from 'express'
import path from 'path'
import { import {
Request, Request,
Security, Security,
@@ -19,12 +18,12 @@ import {
} from './internal' } from './internal'
import { import {
getPreProgramVariables, getPreProgramVariables,
getFilesFolder,
HTTPHeaders, HTTPHeaders,
isDebugOn, isDebugOn,
LogLine, LogLine,
makeFilesNamesMap, makeFilesNamesMap,
parseLogToArray parseLogToArray,
getRunTimeAndFilePath
} from '../utils' } from '../utils'
import { MulterFile } from '../types/Upload' import { MulterFile } from '../types/Upload'
@@ -52,26 +51,15 @@ export interface ExecuteReturnJsonResponse {
@Tags('STP') @Tags('STP')
export class STPController { export class STPController {
/** /**
* Trigger a SAS program using it's location in the _program URL parameter. * Trigger a SAS or JS program using the _program URL parameter.
* Enable debugging using the _debug URL parameter. Setting _debug=131 will
* cause the log to be streamed in the output.
* *
* Additional URL parameters are turned into SAS macro variables. * Accepts URL parameters and file uploads. For more details, see docs:
* *
* Any files provided in the request body are placed into the SAS session with * https://server.sasjs.io/storedprograms
* corresponding _WEBIN_XXX variables created.
* *
* The response headers can be adjusted using the mfs_httpheader() macro. Any * @summary Execute a Stored Program, returns raw _webout content.
* file type can be returned, including binary files such as zip or xls. * @param _program Location of SAS or JS code
* * @example _program "/Projects/myApp/some/program"
* If _debug is >= 131, response headers will contain Content-Type: 'text/plain'
*
* This behaviour differs for POST requests, in which case the response is
* always JSON.
*
* @summary Execute Stored Program, return raw _webout content.
* @param _program Location of SAS program
* @example _program "/Public/somefolder/some.file"
*/ */
@Get('/execute') @Get('/execute')
public async executeReturnRaw( public async executeReturnRaw(
@@ -82,29 +70,22 @@ export class STPController {
} }
/** /**
* Trigger a SAS program using it's location in the _program URL parameter. * Trigger a SAS or JS program using the _program URL parameter.
* Enable debugging using the _debug URL parameter. In any case, the log is
* always returned in the log object.
* *
* Additional URL parameters are turned into SAS macro variables. * Accepts URL parameters and file uploads. For more details, see docs:
* *
* Any files provided in the request body are placed into the SAS session with * https://server.sasjs.io/storedprograms
* corresponding _WEBIN_XXX variables created.
* *
* The response will be a JSON object with the following root attributes: log, * The response will be a JSON object with the following root attributes:
* webout, headers. * log, webout, headers.
* *
* The webout will be a nested JSON object ONLY if the response-header * The webout attribute will be nested JSON ONLY if the response-header
* contains a content-type of application/json AND it is valid JSON. * contains a content-type of application/json AND it is valid JSON.
* Otherwise it will be a stringified version of the webout content. * Otherwise it will be a stringified version of the webout content.
* *
* Response headers from the mfs_httpheader macro are simply listed in the * @summary Execute a Stored Program, return a JSON object
* headers object, for POST requests they have no effect on the actual * @param _program Location of SAS or JS code
* response header. * @example _program "/Projects/myApp/some/program"
*
* @summary Execute Stored Program, return JSON
* @param _program Location of SAS program
* @example _program "/Public/somefolder/some.file"
*/ */
@Example<ExecuteReturnJsonResponse>({ @Example<ExecuteReturnJsonResponse>({
status: 'success', status: 'success',
@@ -131,18 +112,17 @@ const executeReturnRaw = async (
_program: string _program: string
): Promise<string | Buffer> => { ): Promise<string | Buffer> => {
const query = req.query as ExecutionVars const query = req.query as ExecutionVars
const sasCodePath =
path
.join(getFilesFolder(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
try { try {
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
const { result, httpHeaders } = const { result, httpHeaders } =
(await new ExecutionController().executeFile( (await new ExecutionController().executeFile({
sasCodePath, programPath: codePath,
getPreProgramVariables(req), preProgramVariables: getPreProgramVariables(req),
query vars: query,
)) as ExecuteReturnRaw runTime
})) as ExecuteReturnRaw
// Should over-ride response header for debug // Should over-ride response header for debug
// on GET request to see entire log rendering on browser. // on GET request to see entire log rendering on browser.
@@ -171,25 +151,23 @@ const executeReturnJson = async (
req: express.Request, req: express.Request,
_program: string _program: string
): Promise<ExecuteReturnJsonResponse> => { ): Promise<ExecuteReturnJsonResponse> => {
const sasCodePath =
path
.join(getFilesFolder(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
const filesNamesMap = req.files?.length const filesNamesMap = req.files?.length
? makeFilesNamesMap(req.files as MulterFile[]) ? makeFilesNamesMap(req.files as MulterFile[])
: null : null
try { try {
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
const { webout, log, httpHeaders } = const { webout, log, httpHeaders } =
(await new ExecutionController().executeFile( (await new ExecutionController().executeFile({
sasCodePath, programPath: codePath,
getPreProgramVariables(req), preProgramVariables: getPreProgramVariables(req),
{ ...req.query, ...req.body }, vars: { ...req.query, ...req.body },
{ filesNamesMap: filesNamesMap }, otherArgs: { filesNamesMap: filesNamesMap },
true, returnJson: true,
req.sasSession session: req.sasjsSession,
)) as ExecuteReturnJson runTime
})) as ExecuteReturnJson
let weboutRes: string | IRecordOfAny = webout let weboutRes: string | IRecordOfAny = webout
if (httpHeaders['content-type']?.toLowerCase() === 'application/json') { if (httpHeaders['content-type']?.toLowerCase() === 'application/json') {

View File

@@ -18,20 +18,23 @@ import { desktopUser } from '../middlewares'
import User, { UserPayload } from '../model/User' import User, { UserPayload } from '../model/User'
import { getUserAutoExec, updateUserAutoExec, ModeType } from '../utils' import { getUserAutoExec, updateUserAutoExec, ModeType } from '../utils'
import { GroupResponse } from './group'
export interface UserResponse { export interface UserResponse {
id: number id: number
username: string username: string
displayName: string displayName: string
isAdmin: boolean
} }
interface UserDetailsResponse { export interface UserDetailsResponse {
id: number id: number
displayName: string displayName: string
username: string username: string
isActive: boolean isActive: boolean
isAdmin: boolean isAdmin: boolean
autoExec?: string autoExec?: string
groups?: GroupResponse[]
} }
@Security('bearerAuth') @Security('bearerAuth')
@@ -46,12 +49,14 @@ export class UserController {
{ {
id: 123, id: 123,
username: 'johnusername', username: 'johnusername',
displayName: 'John' displayName: 'John',
isAdmin: false
}, },
{ {
id: 456, id: 456,
username: 'starkusername', username: 'starkusername',
displayName: 'Stark' displayName: 'Stark',
isAdmin: true
} }
]) ])
@Get('/') @Get('/')
@@ -77,6 +82,26 @@ export class UserController {
return createUser(body) return createUser(body)
} }
/**
* Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName.
* @param username The User's username
* @example username "johnSnow01"
*/
@Get('by/username/{username}')
public async getUserByUsername(
@Request() req: express.Request,
@Path() username: string
): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
const { user } = req
const getAutoExec = user!.isAdmin || user!.username == username
return getUser({ username }, getAutoExec)
}
/** /**
* Only Admin or user itself will get user autoExec code. * Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName. * @summary Get user properties - such as group memberships, userName, displayName.
@@ -94,7 +119,32 @@ export class UserController {
const { user } = req const { user } = req
const getAutoExec = user!.isAdmin || user!.userId == userId const getAutoExec = user!.isAdmin || user!.userId == userId
return getUser(userId, getAutoExec) return getUser({ id: userId }, getAutoExec)
}
/**
* @summary Update user properties - such as displayName. Can be performed either by admins, or the user in question.
* @param username The User's username
* @example username "johnSnow01"
*/
@Example<UserDetailsResponse>({
id: 1234,
displayName: 'John Snow',
username: 'johnSnow01',
isAdmin: false,
isActive: true
})
@Patch('by/username/{username}')
public async updateUserByUsername(
@Path() username: string,
@Body() body: UserPayload
): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop)
return updateDesktopAutoExec(body.autoExec ?? '')
return updateUser({ username }, body)
} }
/** /**
@@ -119,7 +169,21 @@ export class UserController {
if (MODE === ModeType.Desktop) if (MODE === ModeType.Desktop)
return updateDesktopAutoExec(body.autoExec ?? '') return updateDesktopAutoExec(body.autoExec ?? '')
return updateUser(userId, body) return updateUser({ id: userId }, body)
}
/**
* @summary Delete a user. Can be performed either by admins, or the user in question.
* @param username The User's username
* @example username "johnSnow01"
*/
@Delete('by/username/{username}')
public async deleteUserByUsername(
@Path() username: string,
@Body() body: { password?: string },
@Query() @Hidden() isAdmin: boolean = false
) {
return deleteUser({ username }, isAdmin, body)
} }
/** /**
@@ -133,13 +197,13 @@ export class UserController {
@Body() body: { password?: string }, @Body() body: { password?: string },
@Query() @Hidden() isAdmin: boolean = false @Query() @Hidden() isAdmin: boolean = false
) { ) {
return deleteUser(userId, isAdmin, body) return deleteUser({ id: userId }, isAdmin, body)
} }
} }
const getAllUsers = async (): Promise<UserResponse[]> => const getAllUsers = async (): Promise<UserResponse[]> =>
await User.find({}) await User.find({})
.select({ _id: 0, id: 1, username: 1, displayName: 1 }) .select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
.exec() .exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => { const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
@@ -174,11 +238,22 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
} }
} }
interface GetUserBy {
id?: number
username?: string
}
const getUser = async ( const getUser = async (
id: number, findBy: GetUserBy,
getAutoExec: boolean getAutoExec: boolean
): Promise<UserDetailsResponse> => { ): Promise<UserDetailsResponse> => {
const user = await User.findOne({ id }) const user = (await User.findOne(
findBy,
`id displayName username isActive isAdmin autoExec -_id`
).populate(
'groups',
'groupId name description -_id'
)) as unknown as UserDetailsResponse
if (!user) throw new Error('User is not found.') if (!user) throw new Error('User is not found.')
@@ -188,7 +263,8 @@ const getUser = async (
username: user.username, username: user.username,
isActive: user.isActive, isActive: user.isActive,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
autoExec: getAutoExec ? user.autoExec ?? '' : undefined autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
groups: user.groups
} }
} }
@@ -201,7 +277,7 @@ const getDesktopAutoExec = async () => {
} }
const updateUser = async ( const updateUser = async (
id: number, findBy: GetUserBy,
data: Partial<UserPayload> data: Partial<UserPayload>
): Promise<UserDetailsResponse> => { ): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive, autoExec } = data const { displayName, username, password, isAdmin, isActive, autoExec } = data
@@ -211,8 +287,13 @@ const updateUser = async (
if (username) { if (username) {
// Checking if user is already in the database // Checking if user is already in the database
const usernameExist = await User.findOne({ username }) const usernameExist = await User.findOne({ username })
if (usernameExist && usernameExist.id != id) if (usernameExist) {
if (
(findBy.id && usernameExist.id != findBy.id) ||
(findBy.username && usernameExist.username != findBy.username)
)
throw new Error('Username already exists.') throw new Error('Username already exists.')
}
params.username = username params.username = username
} }
@@ -221,9 +302,10 @@ const updateUser = async (
params.password = User.hashPassword(password) params.password = User.hashPassword(password)
} }
const updatedUser = await User.findOneAndUpdate({ id }, params, { new: true }) const updatedUser = await User.findOneAndUpdate(findBy, params, { new: true })
if (!updatedUser) throw new Error(`Unable to find user with id: ${id}`) if (!updatedUser)
throw new Error(`Unable to find user with ${findBy.id || findBy.username}`)
return { return {
id: updatedUser.id, id: updatedUser.id,
@@ -245,11 +327,11 @@ const updateDesktopAutoExec = async (autoExec: string) => {
} }
const deleteUser = async ( const deleteUser = async (
id: number, findBy: GetUserBy,
isAdmin: boolean, isAdmin: boolean,
{ password }: { password?: string } { password }: { password?: string }
) => { ) => {
const user = await User.findOne({ id }) const user = await User.findOne(findBy)
if (!user) throw new Error('User is not found.') if (!user) throw new Error('User is not found.')
if (!isAdmin) { if (!isAdmin) {
@@ -257,5 +339,5 @@ const deleteUser = async (
if (!validPass) throw new Error('Invalid password.') if (!validPass) throw new Error('Invalid password.')
} }
await User.deleteOne({ id }) await User.deleteOne(findBy)
} }

View File

@@ -49,10 +49,10 @@ export class WebController {
} }
/** /**
* @summary Accept a valid username/password * @summary Destroy the session stored in cookies
* *
*/ */
@Get('/logout') @Get('/SASLogon/logout')
public async logout(@Request() req: express.Request) { public async logout(@Request() req: express.Request) {
return new Promise((resolve) => { return new Promise((resolve) => {
req.session.destroy(() => { req.session.destroy(() => {
@@ -99,7 +99,8 @@ const login = async (
user: { user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
displayName: user.displayName displayName: user.displayName,
isAdmin: user.isAdmin
} }
} }
} }

View File

@@ -1,8 +1,14 @@
import { RequestHandler, Request, Response, NextFunction } from 'express' import { RequestHandler, Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { csrfProtection } from '../app' import { csrfProtection } from '../app'
import { fetchLatestAutoExec, ModeType, verifyTokenInDB } from '../utils' import {
fetchLatestAutoExec,
ModeType,
verifyTokenInDB,
isAuthorizingRoute
} from '../utils'
import { desktopUser } from './desktop' import { desktopUser } from './desktop'
import { authorize } from './authorize'
export const authenticateAccessToken: RequestHandler = async ( export const authenticateAccessToken: RequestHandler = async (
req, req,
@@ -15,6 +21,10 @@ export const authenticateAccessToken: RequestHandler = async (
return next() return next()
} }
const nextFunction = isAuthorizingRoute(req)
? () => authorize(req, res, next)
: next
// if request is coming from web and has valid session // if request is coming from web and has valid session
// it can be validated. // it can be validated.
if (req.session?.loggedIn) { if (req.session?.loggedIn) {
@@ -24,7 +34,7 @@ export const authenticateAccessToken: RequestHandler = async (
if (user) { if (user) {
if (user.isActive) { if (user.isActive) {
req.user = user req.user = user
return csrfProtection(req, res, next) return csrfProtection(req, res, nextFunction)
} else return res.sendStatus(401) } else return res.sendStatus(401)
} }
} }
@@ -34,8 +44,8 @@ export const authenticateAccessToken: RequestHandler = async (
authenticateToken( authenticateToken(
req, req,
res, res,
next, nextFunction,
process.env.ACCESS_TOKEN_SECRET as string, process.secrets.ACCESS_TOKEN_SECRET,
'accessToken' 'accessToken'
) )
} }
@@ -45,7 +55,7 @@ export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
req, req,
res, res,
next, next,
process.env.REFRESH_TOKEN_SECRET as string, process.secrets.REFRESH_TOKEN_SECRET,
'refreshToken' 'refreshToken'
) )
} }
@@ -58,7 +68,7 @@ const authenticateToken = (
tokenType: 'accessToken' | 'refreshToken' tokenType: 'accessToken' | 'refreshToken'
) => { ) => {
const { MODE } = process.env const { MODE } = process.env
if (MODE?.trim() !== 'server') { if (MODE === ModeType.Desktop) {
req.user = { req.user = {
userId: 1234, userId: 1234,
clientId: 'desktopModeClientId', clientId: 'desktopModeClientId',

View File

@@ -0,0 +1,36 @@
import { RequestHandler } from 'express'
import User from '../model/User'
import Permission from '../model/Permission'
import { PermissionSetting } from '../controllers/permission'
import { getUri } from '../utils'
export const authorize: RequestHandler = async (req, res, next) => {
const { user } = req
if (!user) {
return res.sendStatus(401)
}
// no need to check for permissions when user is admin
if (user.isAdmin) return next()
const dbUser = await User.findOne({ id: user.userId })
if (!dbUser) return res.sendStatus(401)
const uri = getUri(req)
// find permission w.r.t user
const permission = await Permission.findOne({ uri, user: dbUser._id })
if (permission) {
if (permission.setting === PermissionSetting.grant) return next()
else return res.sendStatus(401)
}
// find permission w.r.t user's groups
for (const group of dbUser.groups) {
const groupPermission = await Permission.findOne({ uri, group })
if (groupPermission?.setting === PermissionSetting.grant) return next()
}
return res.sendStatus(401)
}

View File

@@ -2,3 +2,4 @@ export * from './authenticateToken'
export * from './desktop' export * from './desktop'
export * from './verifyAdmin' export * from './verifyAdmin'
export * from './verifyAdminIfNeeded' export * from './verifyAdminIfNeeded'
export * from './authorize'

View File

@@ -1,8 +1,9 @@
import { RequestHandler } from 'express' import { RequestHandler } from 'express'
import { ModeType } from '../utils'
export const verifyAdmin: RequestHandler = (req, res, next) => { export const verifyAdmin: RequestHandler = (req, res, next) => {
const { MODE } = process.env const { MODE } = process.env
if (MODE?.trim() !== 'server') return next() if (MODE === ModeType.Desktop) return next()
const { user } = req const { user } = req
if (!user?.isAdmin) return res.status(401).send('Admin account required') if (!user?.isAdmin) return res.status(401).send('Admin account required')

View File

@@ -1,11 +1,22 @@
import { RequestHandler } from 'express' import { RequestHandler } from 'express'
// This middleware checks if a non-admin user trying to
// access information of other user
export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => { export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => {
const { user } = req const { user } = req
const userId = parseInt(req.params.userId)
if (!user?.isAdmin && user?.userId !== userId) { if (!user?.isAdmin) {
let adminAccountRequired: boolean = true
if (req.params.userId) {
adminAccountRequired = user?.userId !== parseInt(req.params.userId)
} else if (req.params.username) {
adminAccountRequired = user?.username !== req.params.username
}
if (adminAccountRequired)
return res.status(401).send('Admin account required') return res.status(401).send('Admin account required')
} }
next() next()
} }

View File

@@ -0,0 +1,45 @@
import mongoose, { Schema } from 'mongoose'
export interface ConfigurationType {
/**
* SecretOrPrivateKey to sign Access Token
* @example "someRandomCryptoString"
*/
ACCESS_TOKEN_SECRET: string
/**
* SecretOrPrivateKey to sign Refresh Token
* @example "someRandomCryptoString"
*/
REFRESH_TOKEN_SECRET: string
/**
* SecretOrPrivateKey to sign Auth Code
* @example "someRandomCryptoString"
*/
AUTH_CODE_SECRET: string
/**
* Secret used to sign the session cookie
* @example "someRandomCryptoString"
*/
SESSION_SECRET: string
}
const ConfigurationSchema = new Schema<ConfigurationType>({
ACCESS_TOKEN_SECRET: {
type: String,
required: true
},
REFRESH_TOKEN_SECRET: {
type: String,
required: true
},
AUTH_CODE_SECRET: {
type: String,
required: true
},
SESSION_SECRET: {
type: String,
required: true
}
})
export default mongoose.model('Configuration', ConfigurationSchema)

View File

@@ -1,4 +1,6 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose' import mongoose, { Schema, model, Document, Model } from 'mongoose'
import { GroupDetailsResponse } from '../controllers'
import User, { IUser } from './User'
const AutoIncrement = require('mongoose-sequence')(mongoose) const AutoIncrement = require('mongoose-sequence')(mongoose)
export interface GroupPayload { export interface GroupPayload {
@@ -26,15 +28,17 @@ interface IGroupDocument extends GroupPayload, Document {
} }
interface IGroup extends IGroupDocument { interface IGroup extends IGroupDocument {
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup> addUser(user: IUser): Promise<GroupDetailsResponse>
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup> removeUser(user: IUser): Promise<GroupDetailsResponse>
hasUser(user: IUser): boolean
} }
interface IGroupModel extends Model<IGroup> {} interface IGroupModel extends Model<IGroup> {}
const groupSchema = new Schema<IGroupDocument>({ const groupSchema = new Schema<IGroupDocument>({
name: { name: {
type: String, type: String,
required: true required: true,
unique: true
}, },
description: { description: {
type: String, type: String,
@@ -46,6 +50,7 @@ const groupSchema = new Schema<IGroupDocument>({
}, },
users: [{ type: Schema.Types.ObjectId, ref: 'User' }] users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
}) })
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' }) groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
// Hooks // Hooks
@@ -55,29 +60,43 @@ groupSchema.post('save', function (group: IGroup, next: Function) {
}) })
}) })
// pre remove hook to remove all references of group from users
groupSchema.pre('remove', async function () {
const userIds = this.users
await Promise.all(
userIds.map(async (userId) => {
const user = await User.findById(userId)
user?.removeGroup(this._id)
})
)
})
// Instance Methods // Instance Methods
groupSchema.method( groupSchema.method('addUser', async function (user: IUser) {
'addUser', const userObjectId = user._id
async function (userObjectId: Schema.Types.ObjectId) {
const userIdIndex = this.users.indexOf(userObjectId) const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex === -1) { if (userIdIndex === -1) {
this.users.push(userObjectId) this.users.push(userObjectId)
user.addGroup(this._id)
} }
this.markModified('users') this.markModified('users')
return this.save() return this.save()
} })
) groupSchema.method('removeUser', async function (user: IUser) {
groupSchema.method( const userObjectId = user._id
'removeUser',
async function (userObjectId: Schema.Types.ObjectId) {
const userIdIndex = this.users.indexOf(userObjectId) const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex > -1) { if (userIdIndex > -1) {
this.users.splice(userIdIndex, 1) this.users.splice(userIdIndex, 1)
user.removeGroup(this._id)
} }
this.markModified('users') this.markModified('users')
return this.save() return this.save()
} })
) groupSchema.method('hasUser', function (user: IUser) {
const userObjectId = user._id
const userIdIndex = this.users.indexOf(userObjectId)
return userIdIndex > -1
})
export const Group: IGroupModel = model<IGroup, IGroupModel>( export const Group: IGroupModel = model<IGroup, IGroupModel>(
'Group', 'Group',

View File

@@ -0,0 +1,36 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
const AutoIncrement = require('mongoose-sequence')(mongoose)
interface IPermissionDocument extends Document {
uri: string
setting: string
permissionId: number
user: Schema.Types.ObjectId
group: Schema.Types.ObjectId
}
interface IPermission extends IPermissionDocument {}
interface IPermissionModel extends Model<IPermission> {}
const permissionSchema = new Schema<IPermissionDocument>({
uri: {
type: String,
required: true
},
setting: {
type: String,
required: true
},
user: { type: Schema.Types.ObjectId, ref: 'User' },
group: { type: Schema.Types.ObjectId, ref: 'Group' }
})
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
export const Permission: IPermissionModel = model<
IPermission,
IPermissionModel
>('Permission', permissionSchema)
export default Permission

View File

@@ -35,6 +35,7 @@ export interface UserPayload {
} }
interface IUserDocument extends UserPayload, Document { interface IUserDocument extends UserPayload, Document {
_id: Schema.Types.ObjectId
id: number id: number
isAdmin: boolean isAdmin: boolean
isActive: boolean isActive: boolean
@@ -43,8 +44,10 @@ interface IUserDocument extends UserPayload, Document {
tokens: [{ [key: string]: string }] tokens: [{ [key: string]: string }]
} }
interface IUser extends IUserDocument { export interface IUser extends IUserDocument {
comparePassword(password: string): boolean comparePassword(password: string): boolean
addGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
removeGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
} }
interface IUserModel extends Model<IUser> { interface IUserModel extends Model<IUser> {
hashPassword(password: string): string hashPassword(password: string): string
@@ -106,6 +109,28 @@ userSchema.method('comparePassword', function (password: string): boolean {
if (bcrypt.compareSync(password, this.password)) return true if (bcrypt.compareSync(password, this.password)) return true
return false return false
}) })
userSchema.method(
'addGroup',
async function (groupObjectId: Schema.Types.ObjectId) {
const groupIdIndex = this.groups.indexOf(groupObjectId)
if (groupIdIndex === -1) {
this.groups.push(groupObjectId)
}
this.markModified('groups')
return this.save()
}
)
userSchema.method(
'removeGroup',
async function (groupObjectId: Schema.Types.ObjectId) {
const groupIdIndex = this.groups.indexOf(groupObjectId)
if (groupIdIndex > -1) {
this.groups.splice(groupIdIndex, 1)
}
this.markModified('groups')
return this.save()
}
)
export const User: IUserModel = model<IUser, IUserModel>('User', userSchema) export const User: IUserModel = model<IUser, IUserModel>('User', userSchema)

View File

@@ -1,5 +1,5 @@
import express from 'express' import express from 'express'
import { runSASValidation } from '../../utils' import { runCodeValidation } from '../../utils'
import { CodeController } from '../../controllers/' import { CodeController } from '../../controllers/'
const runRouter = express.Router() const runRouter = express.Router()
@@ -7,11 +7,11 @@ const runRouter = express.Router()
const controller = new CodeController() const controller = new CodeController()
runRouter.post('/execute', async (req, res) => { runRouter.post('/execute', async (req, res) => {
const { error, value: body } = runSASValidation(req.body) const { error, value: body } = runCodeValidation(req.body)
if (error) return res.status(400).send(error.details[0].message) if (error) return res.status(400).send(error.details[0].message)
try { try {
const response = await controller.executeSASCode(req, body) const response = await controller.executeCode(req, body)
if (response instanceof Buffer) { if (response instanceof Buffer) {
res.writeHead(200, (req as any).sasHeaders) res.writeHead(200, (req as any).sasHeaders)

View File

@@ -7,9 +7,12 @@ import { multerSingle } from '../../middlewares/multer'
import { DriveController } from '../../controllers/' import { DriveController } from '../../controllers/'
import { import {
deployValidation, deployValidation,
extractJSONFromZip,
extractName,
fileBodyValidation, fileBodyValidation,
fileParamValidation, fileParamValidation,
folderParamValidation folderParamValidation,
isZipFile
} from '../../utils' } from '../../utils'
const controller = new DriveController() const controller = new DriveController()
@@ -49,7 +52,24 @@ driveRouter.post(
async (req, res) => { async (req, res) => {
if (!req.file) return res.status(400).send('"file" is not present.') if (!req.file) return res.status(400).send('"file" is not present.')
const fileContent = await readFile(req.file.path) let fileContent: string = ''
const { value: zipFile } = isZipFile(req.file)
if (zipFile) {
fileContent = await extractJSONFromZip(zipFile)
const fileInZip = extractName(zipFile.originalname)
if (!fileContent) {
deleteFile(req.file.path)
return res
.status(400)
.send(
`No content present in ${fileInZip} of compressed file ${zipFile.originalname}`
)
}
} else {
fileContent = await readFile(req.file.path)
}
let jsonContent let jsonContent
try { try {

View File

@@ -1,7 +1,7 @@
import express from 'express' import express from 'express'
import { GroupController } from '../../controllers/' import { GroupController } from '../../controllers/'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares' import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
import { registerGroupValidation } from '../../utils' import { getGroupValidation, registerGroupValidation } from '../../utils'
const groupRouter = express.Router() const groupRouter = express.Router()
@@ -18,7 +18,11 @@ groupRouter.post(
const response = await controller.createGroup(body) const response = await controller.createGroup(body)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
} }
} }
) )
@@ -29,7 +33,11 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
const response = await controller.getAllGroups() const response = await controller.getAllGroups()
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
} }
}) })
@@ -41,10 +49,37 @@ groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
const response = await controller.getGroup(parseInt(groupId)) const response = await controller.getGroup(parseInt(groupId))
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
} }
}) })
groupRouter.get(
'/by/groupname/:name',
authenticateAccessToken,
async (req, res) => {
const { error, value: params } = getGroupValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
const { name } = params
const controller = new GroupController()
try {
const response = await controller.getGroupByGroupName(name)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
}
)
groupRouter.post( groupRouter.post(
'/:groupId/:userId', '/:groupId/:userId',
authenticateAccessToken, authenticateAccessToken,
@@ -60,7 +95,11 @@ groupRouter.post(
) )
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
} }
} }
) )
@@ -80,7 +119,11 @@ groupRouter.delete(
) )
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
} }
} }
) )
@@ -97,7 +140,11 @@ groupRouter.delete(
await controller.deleteGroup(parseInt(groupId)) await controller.deleteGroup(parseInt(groupId))
res.status(200).send('Group Deleted!') res.status(200).send('Group Deleted!')
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
} }
} }
) )

View File

@@ -17,6 +17,7 @@ import groupRouter from './group'
import clientRouter from './client' import clientRouter from './client'
import authRouter from './auth' import authRouter from './auth'
import sessionRouter from './session' import sessionRouter from './session'
import permissionRouter from './permission'
const router = express.Router() const router = express.Router()
@@ -35,6 +36,12 @@ router.use('/group', desktopRestrict, groupRouter)
router.use('/stp', authenticateAccessToken, stpRouter) router.use('/stp', authenticateAccessToken, stpRouter)
router.use('/code', authenticateAccessToken, codeRouter) router.use('/code', authenticateAccessToken, codeRouter)
router.use('/user', desktopRestrict, userRouter) router.use('/user', desktopRestrict, userRouter)
router.use(
'/permission',
desktopRestrict,
authenticateAccessToken,
permissionRouter
)
router.use( router.use(
'/', '/',

View File

@@ -13,4 +13,14 @@ infoRouter.get('/', async (req, res) => {
} }
}) })
infoRouter.get('/authorizedRoutes', async (req, res) => {
const controller = new InfoController()
try {
const response = controller.authorizedRoutes()
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
export default infoRouter export default infoRouter

View File

@@ -0,0 +1,69 @@
import express from 'express'
import { PermissionController } from '../../controllers/'
import { verifyAdmin } from '../../middlewares'
import {
registerPermissionValidation,
updatePermissionValidation
} from '../../utils'
const permissionRouter = express.Router()
const controller = new PermissionController()
permissionRouter.get('/', async (req, res) => {
try {
const response = await controller.getAllPermissions()
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
permissionRouter.post('/', verifyAdmin, async (req, res) => {
const { error, value: body } = registerPermissionValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.createPermission(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
const { permissionId } = req.params
const { error, value: body } = updatePermissionValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.updatePermission(permissionId, body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
})
permissionRouter.delete(
'/:permissionId',
verifyAdmin,
async (req: any, res) => {
const { permissionId } = req.params
try {
await controller.deletePermission(permissionId)
res.status(200).send('Permission Deleted!')
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
}
)
export default permissionRouter

View File

@@ -3,6 +3,7 @@ import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose' import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server' import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest' import request from 'supertest'
import AdmZip from 'adm-zip'
import { import {
folderExists, folderExists,
@@ -28,7 +29,12 @@ jest
.mockImplementation(() => path.join(tmpFolder, 'uploads')) .mockImplementation(() => path.join(tmpFolder, 'uploads'))
import appPromise from '../../../app' import appPromise from '../../../app'
import { UserController } from '../../../controllers/' import {
UserController,
PermissionController,
PermissionSetting,
PrincipalType
} from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal' import { getTreeExample } from '../../../controllers/internal'
import { generateAccessToken, saveTokensInDB } from '../../../utils/' import { generateAccessToken, saveTokensInDB } from '../../../utils/'
const { getFilesFolder } = fileUtilModules const { getFilesFolder } = fileUtilModules
@@ -47,6 +53,7 @@ describe('drive', () => {
let con: Mongoose let con: Mongoose
let mongoServer: MongoMemoryServer let mongoServer: MongoMemoryServer
const controller = new UserController() const controller = new UserController()
const permissionController = new PermissionController()
let accessToken: string let accessToken: string
@@ -57,11 +64,31 @@ describe('drive', () => {
con = await mongoose.connect(mongoServer.getUri()) con = await mongoose.connect(mongoServer.getUri())
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
accessToken = generateAccessToken({ accessToken = await generateAndSaveToken(dbUser.id)
clientId, await permissionController.createPermission({
userId: dbUser.id uri: '/SASjsApi/drive/deploy',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
})
await permissionController.createPermission({
uri: '/SASjsApi/drive/deploy/upload',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
})
await permissionController.createPermission({
uri: '/SASjsApi/drive/file',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
})
await permissionController.createPermission({
uri: '/SASjsApi/drive/folder',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
}) })
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
}) })
afterAll(async () => { afterAll(async () => {
@@ -72,11 +99,52 @@ describe('drive', () => {
}) })
describe('deploy', () => { describe('deploy', () => {
const shouldFailAssertion = async (payload: any) => { const makeRequest = async (payload: any, type: string = 'payload') => {
const res = await request(app) const requestUrl =
.post('/SASjsApi/drive/deploy') type === 'payload'
? '/SASjsApi/drive/deploy'
: '/SASjsApi/drive/deploy/upload'
if (type === 'payload') {
return await request(app)
.post(requestUrl)
.auth(accessToken, { type: 'bearer' }) .auth(accessToken, { type: 'bearer' })
.send({ appLoc: '/Public', fileTree: payload }) .send({ appLoc: '/Public', fileTree: payload })
}
if (type === 'file') {
const deployContents = JSON.stringify({
appLoc: '/Public',
fileTree: payload
})
return await request(app)
.post(requestUrl)
.auth(accessToken, { type: 'bearer' })
.attach('file', Buffer.from(deployContents), 'deploy.json')
} else {
const deployContents = JSON.stringify({
appLoc: '/Public',
fileTree: payload
})
const zip = new AdmZip()
// add file directly
zip.addFile(
'deploy.json',
Buffer.from(deployContents, 'utf8'),
'entry comment goes here'
)
return await request(app)
.post(requestUrl)
.auth(accessToken, { type: 'bearer' })
.attach('file', zip.toBuffer(), 'deploy.json.zip')
}
}
const shouldFailAssertion = async (
payload: any,
type: string = 'payload'
) => {
const res = await makeRequest(payload, type)
expect(res.statusCode).toEqual(400) expect(res.statusCode).toEqual(400)
@@ -176,6 +244,240 @@ describe('drive', () => {
await deleteFolder(path.join(getFilesFolder(), 'public')) await deleteFolder(path.join(getFilesFolder(), 'public'))
}) })
describe('upload', () => {
it('should respond with payload example if valid JSON file was not provided', async () => {
await shouldFailAssertion(null, 'file')
await shouldFailAssertion(undefined, 'file')
await shouldFailAssertion('data', 'file')
await shouldFailAssertion({}, 'file')
await shouldFailAssertion(
{
userId: 1,
title: 'test is cool'
},
'file'
)
await shouldFailAssertion(
{
membersWRONG: []
},
'file'
)
await shouldFailAssertion(
{
members: {}
},
'file'
)
await shouldFailAssertion(
{
members: [
{
nameWRONG: 'jobs',
type: 'folder',
members: []
}
]
},
'file'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'WRONG',
members: []
}
]
},
'file'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'folder',
members: [
{
name: 'extract',
type: 'folder',
members: [
{
name: 'makedata1',
type: 'service',
codeWRONG: '%put Hello World!;'
}
]
}
]
}
]
},
'file'
)
})
it('should successfully deploy if valid JSON file was provided', async () => {
const deployContents = JSON.stringify({
appLoc: '/public',
fileTree: getTreeExample()
})
const res = await request(app)
.post('/SASjsApi/drive/deploy/upload')
.auth(accessToken, { type: 'bearer' })
.attach('file', Buffer.from(deployContents), 'deploy.json')
expect(res.statusCode).toEqual(200)
expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
)
await expect(folderExists(getFilesFolder())).resolves.toEqual(true)
const testJobFolder = path.join(
getFilesFolder(),
'public',
'jobs',
'extract'
)
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
const exampleService = getExampleService()
const testJobFile =
path.join(testJobFolder, exampleService.name) + '.sas'
await expect(fileExists(testJobFile)).resolves.toEqual(true)
await expect(readFile(testJobFile)).resolves.toEqual(
exampleService.code
)
await deleteFolder(path.join(getFilesFolder(), 'public'))
})
})
describe('upload - zipped', () => {
it('should respond with payload example if valid Zipped file was not provided', async () => {
await shouldFailAssertion(null, 'zip')
await shouldFailAssertion(undefined, 'zip')
await shouldFailAssertion('data', 'zip')
await shouldFailAssertion({}, 'zip')
await shouldFailAssertion(
{
userId: 1,
title: 'test is cool'
},
'zip'
)
await shouldFailAssertion(
{
membersWRONG: []
},
'zip'
)
await shouldFailAssertion(
{
members: {}
},
'zip'
)
await shouldFailAssertion(
{
members: [
{
nameWRONG: 'jobs',
type: 'folder',
members: []
}
]
},
'zip'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'WRONG',
members: []
}
]
},
'zip'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'folder',
members: [
{
name: 'extract',
type: 'folder',
members: [
{
name: 'makedata1',
type: 'service',
codeWRONG: '%put Hello World!;'
}
]
}
]
}
]
},
'zip'
)
})
it('should successfully deploy if valid Zipped file was provided', async () => {
const deployContents = JSON.stringify({
appLoc: '/public',
fileTree: getTreeExample()
})
const zip = new AdmZip()
// add file directly
zip.addFile(
'deploy.json',
Buffer.from(deployContents, 'utf8'),
'entry comment goes here'
)
const res = await request(app)
.post('/SASjsApi/drive/deploy/upload')
.auth(accessToken, { type: 'bearer' })
.attach('file', zip.toBuffer(), 'deploy.json.zip')
expect(res.statusCode).toEqual(200)
expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
)
await expect(folderExists(getFilesFolder())).resolves.toEqual(true)
const testJobFolder = path.join(
getFilesFolder(),
'public',
'jobs',
'extract'
)
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
const exampleService = getExampleService()
const testJobFile =
path.join(testJobFolder, exampleService.name) + '.sas'
await expect(fileExists(testJobFile)).resolves.toEqual(true)
await expect(readFile(testJobFile)).resolves.toEqual(
exampleService.code
)
await deleteFolder(path.join(getFilesFolder(), 'public'))
})
})
}) })
describe('folder', () => { describe('folder', () => {
@@ -669,3 +971,12 @@ describe('drive', () => {
const getExampleService = (): ServiceMember => const getExampleService = (): ServiceMember =>
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember) ((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
.members[0] as ServiceMember .members[0] as ServiceMember
const generateAndSaveToken = async (userId: number) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
return adminAccessToken
}

View File

@@ -23,7 +23,7 @@ const user = {
} }
const group = { const group = {
name: 'DCGroup1', name: 'dcgroup1',
description: 'DC group for testing purposes.' description: 'DC group for testing purposes.'
} }
@@ -70,6 +70,32 @@ describe('group', () => {
expect(res.body.users).toEqual([]) expect(res.body.users).toEqual([])
}) })
it('should respond with Conflict when group already exists with same name', async () => {
await groupController.createGroup(group)
const res = await request(app)
.post('/SASjsApi/group')
.auth(adminAccessToken, { type: 'bearer' })
.send(group)
.expect(409)
expect(res.text).toEqual('Group name already exists.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request when group name does not match the group name schema', async () => {
const res = await request(app)
.post('/SASjsApi/group')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...group, name: 'Wrong Group Name' })
.expect(400)
expect(res.text).toEqual(
'"name" must only contain alpha-numeric characters'
)
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not present', async () => { it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).post('/SASjsApi/group').send().expect(401) const res = await request(app).post('/SASjsApi/group').send().expect(401)
@@ -125,14 +151,51 @@ describe('group', () => {
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
it('should respond with Forbidden if groupId is incorrect', async () => { it(`should delete group's reference from users' groups array`, async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser1 = await userController.createUser({
...user,
username: 'deletegroup1'
})
const dbUser2 = await userController.createUser({
...user,
username: 'deletegroup2'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id)
await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id)
await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res1 = await request(app)
.get(`/SASjsApi/user/${dbUser1.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res1.body.groups).toEqual([])
const res2 = await request(app)
.get(`/SASjsApi/user/${dbUser2.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res2.body.groups).toEqual([])
})
it('should respond with Not Found if groupId is incorrect', async () => {
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/1234`) .delete(`/SASjsApi/group/1234`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(403) .expect(404)
expect(res.text).toEqual('Error: No Group deleted!') expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
@@ -216,16 +279,76 @@ describe('group', () => {
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
it('should respond with Forbidden if groupId is incorrect', async () => { it('should respond with Not Found if groupId is incorrect', async () => {
const res = await request(app) const res = await request(app)
.get('/SASjsApi/group/1234') .get('/SASjsApi/group/1234')
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(403) .expect(404)
expect(res.text).toEqual('Error: Group not found.') expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
describe('by group name', () => {
it('should respond with group', async () => {
const { name } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/by/groupname/${name}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([])
})
it('should respond with group when access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'getbyname' + user.username
})
const { name } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/by/groupname/${name}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.get('/SASjsApi/group/by/groupname/dcgroup')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Not Found if groupname is incorrect', async () => {
const res = await request(app)
.get('/SASjsApi/group/by/groupname/randomCharacters')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({})
})
})
}) })
describe('getAll', () => { describe('getAll', () => {
@@ -245,8 +368,8 @@ describe('group', () => {
expect(res.body).toEqual([ expect(res.body).toEqual([
{ {
groupId: expect.anything(), groupId: expect.anything(),
name: 'DCGroup1', name: group.name,
description: 'DC group for testing purposes.' description: group.description
} }
]) ])
}) })
@@ -267,8 +390,8 @@ describe('group', () => {
expect(res.body).toEqual([ expect(res.body).toEqual([
{ {
groupId: expect.anything(), groupId: expect.anything(),
name: 'DCGroup1', name: group.name,
description: 'DC group for testing purposes.' description: group.description
} }
]) ])
}) })
@@ -309,6 +432,34 @@ describe('group', () => {
]) ])
}) })
it(`should add group to user's groups array`, async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({
...user,
username: 'addUserToGroup'
})
await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groups).toEqual([
{
groupId: expect.anything(),
name: group.name,
description: group.description
}
])
})
it('should respond with group without duplicating user', async () => { it('should respond with group without duplicating user', async () => {
const dbGroup = await groupController.createGroup(group) const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({ const dbUser = await userController.createUser({
@@ -362,26 +513,26 @@ describe('group', () => {
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
it('should respond with Forbidden if groupId is incorrect', async () => { it('should respond with Not Found if groupId is incorrect', async () => {
const res = await request(app) const res = await request(app)
.post('/SASjsApi/group/123/123') .post('/SASjsApi/group/123/123')
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(403) .expect(404)
expect(res.text).toEqual('Error: Group not found.') expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
it('should respond with Forbidden if userId is incorrect', async () => { it('should respond with Not Found if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group) const dbGroup = await groupController.createGroup(group)
const res = await request(app) const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/123`) .post(`/SASjsApi/group/${dbGroup.groupId}/123`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(403) .expect(404)
expect(res.text).toEqual('Error: User not found.') expect(res.text).toEqual('User not found.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
}) })
@@ -412,6 +563,29 @@ describe('group', () => {
expect(res.body.users).toEqual([]) expect(res.body.users).toEqual([])
}) })
it(`should remove group from user's groups array`, async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({
...user,
username: 'removeGroupFromUser'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groups).toEqual([])
})
it('should respond with Unauthorized if access token is not present', async () => { it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app) const res = await request(app)
.delete('/SASjsApi/group/123/123') .delete('/SASjsApi/group/123/123')
@@ -438,26 +612,26 @@ describe('group', () => {
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
it('should respond with Forbidden if groupId is incorrect', async () => { it('should respond with Not Found if groupId is incorrect', async () => {
const res = await request(app) const res = await request(app)
.delete('/SASjsApi/group/123/123') .delete('/SASjsApi/group/123/123')
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(403) .expect(404)
expect(res.text).toEqual('Error: Group not found.') expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
it('should respond with Forbidden if userId is incorrect', async () => { it('should respond with Not Found if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group) const dbGroup = await groupController.createGroup(group)
const res = await request(app) const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`) .delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
.auth(adminAccessToken, { type: 'bearer' }) .auth(adminAccessToken, { type: 'bearer' })
.send() .send()
.expect(403) .expect(404)
expect(res.text).toEqual('Error: User not found.') expect(res.text).toEqual('User not found.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
}) })

View File

@@ -0,0 +1,571 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import {
DriveController,
UserController,
GroupController,
ClientController,
PermissionController,
PrincipalType,
PermissionSetting
} from '../../../controllers/'
import {
UserDetailsResponse,
PermissionDetailsResponse
} from '../../../controllers'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
const deployPayload = {
appLoc: 'string',
streamWebFolder: 'string',
fileTree: {
members: [
{
name: 'string',
type: 'folder',
members: [
'string',
{
name: 'string',
type: 'service',
code: 'string'
}
]
}
]
}
}
const clientId = 'someclientID'
const adminUser = {
displayName: 'Test Admin',
username: 'testAdminUsername',
password: '12345678',
isAdmin: true,
isActive: true
}
const user = {
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
const permission = {
uri: '/SASjsApi/code/execute',
setting: PermissionSetting.grant,
principalType: PrincipalType.user,
principalId: 123
}
const group = {
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
const userController = new UserController()
const groupController = new GroupController()
const clientController = new ClientController()
const permissionController = new PermissionController()
describe('permission', () => {
let app: Express
let con: Mongoose
let mongoServer: MongoMemoryServer
let adminAccessToken: string
let dbUser: UserDetailsResponse
beforeAll(async () => {
app = await appPromise
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
adminAccessToken = await generateSaveTokenAndCreateUser()
dbUser = await userController.createUser(user)
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('create', () => {
afterEach(async () => {
await deleteAllPermissions()
})
it('should respond with new permission when principalType is user', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id })
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.uri).toEqual(permission.uri)
expect(res.body.setting).toEqual(permission.setting)
expect(res.body.user).toBeTruthy()
})
it('should respond with new permission when principalType is group', async () => {
const dbGroup = await groupController.createGroup(group)
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: 'group',
principalId: dbGroup.groupId
})
.expect(200)
expect(res.body.permissionId).toBeTruthy()
expect(res.body.uri).toEqual(permission.uri)
expect(res.body.setting).toEqual(permission.setting)
expect(res.body.group).toBeTruthy()
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.send(permission)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account even if user has permission', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
await permissionController.createPermission({
uri: '/SASjsApi/permission',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
})
const res = await request(app)
.post('/SASjsApi/permission')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if uri is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
uri: undefined
})
.expect(400)
expect(res.text).toEqual(`"uri" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if uri is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
uri: '/some/random/api/endpoint'
})
.expect(400)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
setting: undefined
})
.expect(400)
expect(res.text).toEqual(`"setting" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalType is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: undefined
})
.expect(400)
expect(res.text).toEqual(`"principalType" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalId is missing', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: undefined
})
.expect(400)
expect(res.text).toEqual(`"principalId" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principal type is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"principalType" must be one of [user, group]')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
setting: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if principalId is not a number', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: 'someCharacters'
})
.expect(400)
expect(res.text).toEqual('"principalId" must be a number')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if adding permission for admin user', async () => {
const adminUser = await userController.createUser({
...user,
username: 'adminUser',
isAdmin: true
})
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: adminUser.id
})
.expect(400)
expect(res.text).toEqual('Can not add permission for admin user.')
expect(res.body).toEqual({})
})
it('should respond with Not Found (404) if user is not found', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalId: 123
})
.expect(404)
expect(res.text).toEqual('User not found.')
expect(res.body).toEqual({})
})
it('should respond with Not Found (404) if group is not found', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
principalType: 'group'
})
.expect(404)
expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({})
})
it('should respond with Conflict (409) if permission already exists', async () => {
await permissionController.createPermission({
...permission,
principalId: dbUser.id
})
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...permission, principalId: dbUser.id })
.expect(409)
expect(res.text).toEqual(
'Permission already exists with provided URI and User.'
)
expect(res.body).toEqual({})
})
})
describe('update', () => {
let dbPermission: PermissionDetailsResponse | undefined
beforeAll(async () => {
dbPermission = await permissionController.createPermission({
...permission,
principalId: dbUser.id
})
})
afterEach(async () => {
await deleteAllPermissions()
})
it('should respond with updated permission', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ setting: 'Deny' })
.expect(200)
expect(res.body.setting).toEqual('Deny')
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.send(permission)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'update' + user.username
})
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is missing', async () => {
const res = await request(app)
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(400)
expect(res.text).toEqual(`"setting" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if setting is not valid', async () => {
const res = await request(app)
.post('/SASjsApi/permission')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...permission,
setting: 'invalid'
})
.expect(400)
expect(res.text).toEqual('"setting" must be one of [Grant, Deny]')
expect(res.body).toEqual({})
})
it('should respond with not found (404) if permission with provided id does not exists', async () => {
const res = await request(app)
.patch('/SASjsApi/permission/123')
.auth(adminAccessToken, { type: 'bearer' })
.send({
setting: PermissionSetting.deny
})
.expect(404)
expect(res.text).toEqual('Permission not found.')
expect(res.body).toEqual({})
})
})
describe('delete', () => {
it('should delete permission', async () => {
const dbPermission = await permissionController.createPermission({
...permission,
principalId: dbUser.id
})
const res = await request(app)
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.text).toEqual('Permission Deleted!')
})
it('should respond with not found (404) if permission with provided id does not exists', async () => {
const res = await request(app)
.delete('/SASjsApi/permission/123')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
expect(res.text).toEqual('Permission not found.')
})
})
describe('get', () => {
beforeAll(async () => {
await permissionController.createPermission({
...permission,
uri: '/test-1',
principalId: dbUser.id
})
await permissionController.createPermission({
...permission,
uri: '/test-2',
principalId: dbUser.id
})
})
it('should give a list of all permissions when user is admin', async () => {
const res = await request(app)
.get('/SASjsApi/permission/')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toHaveLength(2)
})
it('should give a list of all permissions when user is not admin', async () => {
const dbUser = await userController.createUser({
...user,
username: 'get' + user.username
})
const accessToken = await generateAndSaveToken(dbUser.id)
await permissionController.createPermission({
uri: '/SASjsApi/permission',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
})
const res = await request(app)
.get('/SASjsApi/permission/')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toHaveLength(3)
})
})
describe.only('verify', () => {
beforeAll(async () => {
await permissionController.createPermission({
...permission,
uri: '/SASjsApi/drive/deploy',
principalId: dbUser.id
})
})
beforeEach(() => {
jest
.spyOn(DriveController.prototype, 'deploy')
.mockImplementation((deployPayload) =>
Promise.resolve({
status: 'success',
message: 'Files deployed successfully to @sasjs/server.'
})
)
})
afterEach(() => {
jest.resetAllMocks()
})
it('should create files in SASJS drive', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
await request(app)
.get('/SASjsApi/drive/deploy')
.auth(accessToken, { type: 'bearer' })
.send(deployPayload)
.expect(200)
})
it('should respond unauthorized', async () => {
const accessToken = await generateAndSaveToken(dbUser.id)
await request(app)
.get('/SASjsApi/drive/deploy/upload')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
})
})
})
const generateSaveTokenAndCreateUser = async (
someUser?: any
): Promise<string> => {
const dbUser = await userController.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id)
}
const generateAndSaveToken = async (userId: number) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
return adminAccessToken
}
const deleteAllPermissions = async () => {
const { collections } = mongoose.connection
const collection = collections['permissions']
await collection.deleteMany({})
}

View File

@@ -0,0 +1,397 @@
import path from 'path'
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import {
UserController,
PermissionController,
PermissionSetting,
PrincipalType
} from '../../../controllers/'
import {
generateAccessToken,
saveTokensInDB,
getFilesFolder,
RunTimeType,
generateUniqueFileName,
getSessionsFolder
} from '../../../utils'
import { createFile, generateTimestamp, deleteFolder } from '@sasjs/utils'
import {
SASSessionController,
JSSessionController
} from '../../../controllers/internal'
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
import { Session } from '../../../types'
const clientId = 'someclientID'
const user = {
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
const sampleSasProgram = '%put hello world!;'
const sampleJsProgram = `console.log('hello world!/')`
const filesFolder = getFilesFolder()
describe('stp', () => {
let app: Express
let con: Mongoose
let mongoServer: MongoMemoryServer
let accessToken: string
const userController = new UserController()
const permissionController = new PermissionController()
beforeAll(async () => {
app = await appPromise
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
const dbUser = await userController.createUser(user)
accessToken = await generateAndSaveToken(dbUser.id)
await permissionController.createPermission({
uri: '/SASjsApi/stp/execute',
principalType: PrincipalType.user,
principalId: dbUser.id,
setting: PermissionSetting.grant
})
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('execute', () => {
const testFilesFolder = `test-stp-${generateTimestamp()}`
describe('get', () => {
describe('with runtime js', () => {
const testFilesFolder = `test-stp-${generateTimestamp()}`
beforeAll(() => {
process.runTimes = [RunTimeType.JS]
})
beforeEach(() => {
jest.resetModules() // it clears the cache
setupMocks()
})
afterEach(async () => {
jest.resetAllMocks()
await deleteFolder(path.join(filesFolder, testFilesFolder))
})
it('should execute js program when both js and sas program are present', async () => {
const programPath = path.join(testFilesFolder, 'program')
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
await createFile(sasProgramPath, sampleSasProgram)
await createFile(jsProgramPath, sampleJsProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
RunTimeType.JS,
expect.anything(),
undefined
)
})
it('should throw error when js program is not present but sas program exists', async () => {
const programPath = path.join(testFilesFolder, 'program')
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
await createFile(sasProgramPath, sampleSasProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(400)
})
})
describe('with runtime sas', () => {
beforeAll(() => {
process.runTimes = [RunTimeType.SAS]
})
beforeEach(() => {
jest.resetModules() // it clears the cache
setupMocks()
})
afterEach(async () => {
jest.resetAllMocks()
await deleteFolder(path.join(filesFolder, testFilesFolder))
})
it('should execute sas program when both sas and js programs are present', async () => {
const programPath = path.join(testFilesFolder, 'program')
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
await createFile(sasProgramPath, sampleSasProgram)
await createFile(jsProgramPath, sampleJsProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
RunTimeType.SAS,
expect.anything(),
undefined
)
})
it('should throw error when sas program do not exit but js exists', async () => {
const programPath = path.join(testFilesFolder, 'program')
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
await createFile(jsProgramPath, sampleJsProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(400)
})
})
describe('with runtime js and sas', () => {
beforeAll(() => {
process.runTimes = [RunTimeType.JS, RunTimeType.SAS]
})
beforeEach(() => {
jest.resetModules() // it clears the cache
setupMocks()
})
afterEach(async () => {
jest.resetAllMocks()
await deleteFolder(path.join(filesFolder, testFilesFolder))
})
it('should execute js program when both js and sas program are present', async () => {
const programPath = path.join(testFilesFolder, 'program')
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
await createFile(sasProgramPath, sampleSasProgram)
await createFile(jsProgramPath, sampleJsProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
RunTimeType.JS,
expect.anything(),
undefined
)
})
it('should execute sas program when js program is not present but sas program exists', async () => {
const programPath = path.join(testFilesFolder, 'program')
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
await createFile(sasProgramPath, sampleSasProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
RunTimeType.SAS,
expect.anything(),
undefined
)
})
it('should throw error when both sas and js programs do not exist', async () => {
const programPath = path.join(testFilesFolder, 'program')
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(400)
})
})
describe('with runtime sas and js', () => {
beforeAll(() => {
process.runTimes = [RunTimeType.SAS, RunTimeType.JS]
})
beforeEach(() => {
jest.resetModules() // it clears the cache
setupMocks()
})
afterEach(async () => {
jest.resetAllMocks()
await deleteFolder(path.join(filesFolder, testFilesFolder))
})
it('should execute sas program when both sas and js programs exist', async () => {
const programPath = path.join(testFilesFolder, 'program')
const sasProgramPath = path.join(filesFolder, `${programPath}.sas`)
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
await createFile(sasProgramPath, sampleSasProgram)
await createFile(jsProgramPath, sampleJsProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
RunTimeType.SAS,
expect.anything(),
undefined
)
})
it('should execute js program when sas program is not present but js program exists', async () => {
const programPath = path.join(testFilesFolder, 'program')
const jsProgramPath = path.join(filesFolder, `${programPath}.js`)
await createFile(jsProgramPath, sampleJsProgram)
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(ProcessProgramModule.processProgram).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
RunTimeType.JS,
expect.anything(),
undefined
)
})
it('should throw error when both sas and js programs do not exist', async () => {
const programPath = path.join(testFilesFolder, 'program')
await request(app)
.get(`/SASjsApi/stp/execute?_program=${programPath}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(400)
})
})
})
})
})
const generateSaveTokenAndCreateUser = async (
someUser: any
): Promise<string> => {
const userController = new UserController()
const dbUser = await userController.createUser(someUser)
return generateAndSaveToken(dbUser.id)
}
const generateAndSaveToken = async (userId: number) => {
const accessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, accessToken, 'refreshToken')
return accessToken
}
const setupMocks = async () => {
jest
.spyOn(SASSessionController.prototype, 'getSession')
.mockImplementation(mockedGetSession)
jest
.spyOn(JSSessionController.prototype, 'getSession')
.mockImplementation(mockedGetSession)
jest
.spyOn(ProcessProgramModule, 'processProgram')
.mockImplementation(() => Promise.resolve())
}
const mockedGetSession = async () => {
const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(getSessionsFolder(), sessionId)
const creationTimeStamp = sessionId.split('-').pop() as string
// death time of session is 15 mins from creation
const deathTimeStamp = (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
1000
).toString()
const session: Session = {
id: sessionId,
ready: true,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp,
deathTimeStamp,
path: sessionFolder
}
return session
}

View File

@@ -3,7 +3,7 @@ import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server' import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest' import request from 'supertest'
import appPromise from '../../../app' import appPromise from '../../../app'
import { UserController } from '../../../controllers/' import { UserController, GroupController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils' import { generateAccessToken, saveTokensInDB } from '../../../utils'
const clientId = 'someclientID' const clientId = 'someclientID'
@@ -270,6 +270,102 @@ describe('user', () => {
expect(res.text).toEqual('Error: Username already exists.') expect(res.text).toEqual('Error: Username already exists.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
describe('by username', () => {
it('should respond with updated user when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const newDisplayName = 'My new display Name'
const res = await request(app)
.patch(`/SASjsApi/user/by/username/${user.username}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName })
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(newDisplayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
})
it('should respond with updated user when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const newDisplayName = 'My new display Name'
const res = await request(app)
.patch(`/SASjsApi/user/by/username/${user.username}`)
.auth(accessToken, { type: 'bearer' })
.send({
displayName: newDisplayName,
username: user.username,
password: user.password
})
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(newDisplayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
})
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const newDisplayName = 'My new display Name'
await request(app)
.patch(`/SASjsApi/user/by/username/${user.username}`)
.auth(accessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName })
.expect(400)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.patch('/SASjsApi/user/by/username/1234')
.send(user)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not of an admin account or himself', async () => {
const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`)
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if username is already present', async () => {
const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({
...user,
username: 'randomuser'
})
const res = await request(app)
.patch(`/SASjsApi/user/by/username/${dbUser1.username}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ username: dbUser2.username })
.expect(403)
expect(res.text).toEqual('Error: Username already exists.')
expect(res.body).toEqual({})
})
})
}) })
describe('delete', () => { describe('delete', () => {
@@ -363,6 +459,89 @@ describe('user', () => {
expect(res.text).toEqual('Error: Invalid password.') expect(res.text).toEqual('Error: Invalid password.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
describe('by username', () => {
it('should respond with OK when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toEqual({})
})
it('should respond with OK when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
.auth(accessToken, { type: 'bearer' })
.send({ password: user.password })
.expect(200)
expect(res.body).toEqual({})
})
it('should respond with Bad Request when user himself requests and password is missing', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(400)
expect(res.text).toEqual(`"password" is required`)
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not present', async () => {
const res = await request(app)
.delete('/SASjsApi/user/by/username/RandomUsername')
.send(user)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not of an admin account or himself', async () => {
const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser1.username}`)
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden when user himself requests and password is incorrect', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
.auth(accessToken, { type: 'bearer' })
.send({ password: 'incorrectpassword' })
.expect(403)
expect(res.text).toEqual('Error: Invalid password.')
expect(res.body).toEqual({})
})
})
}) })
describe('get', () => { describe('get', () => {
@@ -392,6 +571,7 @@ describe('user', () => {
expect(res.body.isAdmin).toEqual(user.isAdmin) expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive) expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec) expect(res.body.autoExec).toEqual(user.autoExec)
expect(res.body.groups).toEqual([])
}) })
it('should respond with user autoExec when admin user requests', async () => { it('should respond with user autoExec when admin user requests', async () => {
@@ -409,6 +589,7 @@ describe('user', () => {
expect(res.body.isAdmin).toEqual(user.isAdmin) expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive) expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec) expect(res.body.autoExec).toEqual(user.autoExec)
expect(res.body.groups).toEqual([])
}) })
it('should respond with user when access token is not of an admin account', async () => { it('should respond with user when access token is not of an admin account', async () => {
@@ -431,6 +612,34 @@ describe('user', () => {
expect(res.body.isAdmin).toEqual(user.isAdmin) expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive) expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toBeUndefined() expect(res.body.autoExec).toBeUndefined()
expect(res.body.groups).toEqual([])
})
it('should respond with user along with associated groups', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const accessToken = await generateAndSaveToken(userId)
const group = {
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
const groupController = new GroupController()
const dbGroup = await groupController.createGroup(group)
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
const res = await request(app)
.get(`/SASjsApi/user/${userId}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec)
expect(res.body.groups.length).toBeGreaterThan(0)
}) })
it('should respond with Unauthorized if access token is not present', async () => { it('should respond with Unauthorized if access token is not present', async () => {
@@ -455,6 +664,86 @@ describe('user', () => {
expect(res.text).toEqual('Error: User is not found.') expect(res.text).toEqual('Error: User is not found.')
expect(res.body).toEqual({}) expect(res.body).toEqual({})
}) })
describe('by username', () => {
it('should respond with user autoExec when same user requests', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const accessToken = await generateAndSaveToken(userId)
const res = await request(app)
.get(`/SASjsApi/user/by/username/${dbUser.username}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec)
})
it('should respond with user autoExec when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const res = await request(app)
.get(`/SASjsApi/user/by/username/${dbUser.username}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec)
})
it('should respond with user when access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'randomUser'
})
const dbUser = await controller.createUser(user)
const res = await request(app)
.get(`/SASjsApi/user/by/username/${dbUser.username}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toBeUndefined()
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.get('/SASjsApi/user/by/username/randomUsername')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if username is incorrect', async () => {
await controller.createUser(user)
const res = await request(app)
.get('/SASjsApi/user/by/username/randomUsername')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: User is not found.')
expect(res.body).toEqual({})
})
})
}) })
describe('getAll', () => { describe('getAll', () => {
@@ -481,12 +770,14 @@ describe('user', () => {
{ {
id: expect.anything(), id: expect.anything(),
username: adminUser.username, username: adminUser.username,
displayName: adminUser.displayName displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
}, },
{ {
id: expect.anything(), id: expect.anything(),
username: user.username, username: user.username,
displayName: user.displayName displayName: user.displayName,
isAdmin: user.isAdmin
} }
]) ])
}) })
@@ -507,12 +798,14 @@ describe('user', () => {
{ {
id: expect.anything(), id: expect.anything(),
username: adminUser.username, username: adminUser.username,
displayName: adminUser.displayName displayName: adminUser.displayName,
isAdmin: adminUser.isAdmin
}, },
{ {
id: expect.anything(), id: expect.anything(),
username: 'randomUser', username: 'randomUser',
displayName: user.displayName displayName: user.displayName,
isAdmin: user.isAdmin
} }
]) ])
}) })

View File

@@ -79,7 +79,8 @@ describe('web', () => {
expect(res.body.user).toEqual({ expect(res.body.user).toEqual({
id: expect.any(Number), id: expect.any(Number),
username: user.username, username: user.username,
displayName: user.displayName displayName: user.displayName,
isAdmin: user.isAdmin
}) })
}) })
}) })

View File

@@ -35,16 +35,17 @@ stpRouter.post(
fileUploadController.preUploadMiddleware, fileUploadController.preUploadMiddleware,
fileUploadController.getMulterUploadObject().any(), fileUploadController.getMulterUploadObject().any(),
async (req, res: any) => { async (req, res: any) => {
const { error: errQ, value: query } = executeProgramRawValidation(req.query) // below validations are moved to preUploadMiddleware
const { error: errB, value: body } = executeProgramRawValidation(req.body) // const { error: errQ, value: query } = executeProgramRawValidation(req.query)
// const { error: errB, value: body } = executeProgramRawValidation(req.body)
if (errQ && errB) return res.status(400).send(errB.details[0].message) // if (errQ && errB) return res.status(400).send(errB.details[0].message)
try { try {
const response = await controller.executeReturnJson( const response = await controller.executeReturnJson(
req, req,
body, req.body,
query?._program req.query?._program as string
) )
// TODO: investigate if this code is required // TODO: investigate if this code is required

View File

@@ -7,6 +7,7 @@ import {
} from '../../middlewares' } from '../../middlewares'
import { import {
deleteUserValidation, deleteUserValidation,
getUserValidation,
registerUserValidation, registerUserValidation,
updateUserValidation updateUserValidation
} from '../../utils' } from '../../utils'
@@ -36,6 +37,25 @@ userRouter.get('/', authenticateAccessToken, async (req, res) => {
} }
}) })
userRouter.get(
'/by/username/:username',
authenticateAccessToken,
async (req, res) => {
const { error, value: params } = getUserValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
const { username } = params
const controller = new UserController()
try {
const response = await controller.getUserByUsername(req, username)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
userRouter.get('/:userId', authenticateAccessToken, async (req, res) => { userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
const { userId } = req.params const { userId } = req.params
@@ -48,6 +68,34 @@ userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
} }
}) })
userRouter.patch(
'/by/username/:username',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
const { user } = req
const { error: errorUsername, value: params } = getUserValidation(
req.params
)
if (errorUsername)
return res.status(400).send(errorUsername.details[0].message)
const { username } = params
// only an admin can update `isActive` and `isAdmin` fields
const { error, value: body } = updateUserValidation(req.body, user!.isAdmin)
if (error) return res.status(400).send(error.details[0].message)
const controller = new UserController()
try {
const response = await controller.updateUserByUsername(username, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
userRouter.patch( userRouter.patch(
'/:userId', '/:userId',
authenticateAccessToken, authenticateAccessToken,
@@ -70,6 +118,34 @@ userRouter.patch(
} }
) )
userRouter.delete(
'/by/username/:username',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
const { user } = req
const { error: errorUsername, value: params } = getUserValidation(
req.params
)
if (errorUsername)
return res.status(400).send(errorUsername.details[0].message)
const { username } = params
// only an admin can delete user without providing password
const { error, value: data } = deleteUserValidation(req.body, user!.isAdmin)
if (error) return res.status(400).send(error.details[0].message)
const controller = new UserController()
try {
await controller.deleteUserByUsername(username, data, user!.isAdmin)
res.status(200).send('Account Deleted!')
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
userRouter.delete( userRouter.delete(
'/:userId', '/:userId',
authenticateAccessToken, authenticateAccessToken,

View File

@@ -23,13 +23,21 @@ export const appStreamHtml = (appStreamConfig: AppStreamConfig) => `
${style} ${style}
</head> </head>
<body> <body>
<header>
<a href="/"><img src="/logo.png" alt="logo" class="logo"></a>
<h1>App Stream</h1> <h1>App Stream</h1>
</header>
<div class="app-container"> <div class="app-container">
${Object.entries(appStreamConfig) ${Object.entries(appStreamConfig)
.map(([streamServiceName, entry]) => .map(([streamServiceName, entry]) =>
singleAppStreamHtml(streamServiceName, entry.appLoc, entry.streamLogo) singleAppStreamHtml(
streamServiceName,
entry.appLoc,
entry.streamLogo
)
) )
.join('')} .join('')}
<a class="app" title="Upload build.json"> <a class="app" title="Upload build.json">
<input id="fileId" type="file" hidden /> <input id="fileId" type="file" hidden />
<button id="uploadButton" style="margin-bottom: 5px; cursor: pointer"> <button id="uploadButton" style="margin-bottom: 5px; cursor: pointer">

View File

@@ -1,13 +1,16 @@
import path from 'path' import path from 'path'
import express from 'express' import express, { Request } from 'express'
import { authenticateAccessToken } from '../../middlewares'
import { folderExists } from '@sasjs/utils' import { folderExists } from '@sasjs/utils'
import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils' import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils'
import { appStreamHtml } from './appStreamHtml' import { appStreamHtml } from './appStreamHtml'
const appStreams: { [key: string]: string } = {}
const router = express.Router() const router = express.Router()
router.get('/', async (req, res) => { router.get('/', authenticateAccessToken, async (req, res) => {
const content = appStreamHtml(process.appStreamConfig) const content = appStreamHtml(process.appStreamConfig)
res.cookie('XSRF-TOKEN', req.csrfToken()) res.cookie('XSRF-TOKEN', req.csrfToken())
@@ -44,7 +47,7 @@ export const publishAppStream = async (
streamServiceName = `AppStreamName${appCount + 1}` streamServiceName = `AppStreamName${appCount + 1}`
} }
router.use(`/${streamServiceName}`, express.static(pathToDeployment)) appStreams[streamServiceName] = pathToDeployment
addEntryToAppStreamConfig( addEntryToAppStreamConfig(
streamServiceName, streamServiceName,
@@ -64,4 +67,26 @@ export const publishAppStream = async (
return {} return {}
} }
router.get(`/*`, authenticateAccessToken, function (req: Request, res, next) {
const reqPath = req.path.replace(/^\//, '')
// Redirecting to url with trailing slash for appStream base URL only
if (reqPath.split('/').length === 1 && !reqPath.endsWith('/'))
// navigating to same url with slash at start
return res.redirect(301, `${reqPath}/`)
const appStream = reqPath.split('/')[0]
const appStreamFilesPath = appStreams[appStream]
if (appStreamFilesPath) {
// resourcePath is without appStream base path
const resourcePath = reqPath.split('/').slice(1).join('/') || 'index.html'
req.url = resourcePath
return express.static(appStreamFilesPath)(req, res, next)
}
return res.send("There's no App Stream available here.")
})
export default router export default router

View File

@@ -5,18 +5,71 @@ export const style = `<style>
.app-container { .app-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: center;
justify-content: center; justify-content: center;
padding-top: 50px;
} }
.app-container .app { .app-container .app {
width: 150px; width: 150px;
height: 180px;
margin: 10px; margin: 10px;
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;
text-decoration: none;
color: black;
background: #efefef;
padding: 10px;
border-radius: 7px;
border: 1px solid #d7d7d7;
} }
.app-container .app img{ .app-container .app img{
width: 100%; width: 100%;
margin-bottom: 10px; margin-bottom: 10px;
border-radius: 10px; border-radius: 10px;
} }
#uploadButton {
border: 0
}
#uploadButton:focus {
outline: 0
}
#uploadMessage {
position: relative;
bottom: -5px;
}
header {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
box-shadow: rgb(0 0 0 / 20%) 0px 2px 4px -1px, rgb(0 0 0 / 14%) 0px 4px 5px 0px, rgb(0 0 0 / 12%) 0px 1px 10px 0px;
display: flex;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
position: fixed;
top: 0px;
left: auto;
right: 0px;
background-color: rgb(0, 0, 0);
color: rgb(255, 255, 255);
z-index: 1201;
}
header h1 {
margin: 13px;
font-size: 20px;
}
header a {
align-self: center;
}
header .logo {
width: 35px;
margin-left: 10px;
align-self: center;
}
</style>` </style>`

View File

@@ -48,7 +48,7 @@ webRouter.post(
} }
) )
webRouter.get('/logout', desktopRestrict, async (req, res) => { webRouter.get('/SASLogon/logout', desktopRestrict, async (req, res) => {
try { try {
await controller.logout(req) await controller.logout(req)
res.status(200).send('OK!') res.status(200).send('OK!')

View File

@@ -16,9 +16,9 @@ appPromise.then(async (app) => {
) )
}) })
} else { } else {
const { key, cert } = await getCertificates() const { key, cert, ca } = await getCertificates()
const httpsServer = createServer({ key, cert }, app) const httpsServer = createServer({ key, cert, ca }, app)
httpsServer.listen(sasJsPort, () => { httpsServer.listen(sasJsPort, () => {
console.log( console.log(
`⚡️[server]: Server is running at https://localhost:${sasJsPort}` `⚡️[server]: Server is running at https://localhost:${sasJsPort}`

View File

@@ -2,6 +2,6 @@ declare namespace Express {
export interface Request { export interface Request {
accessToken?: string accessToken?: string
user?: import('../').RequestUser user?: import('../').RequestUser
sasSession?: import('../').Session sasjsSession?: import('../').Session
} }
} }

View File

@@ -1,9 +1,14 @@
declare namespace NodeJS { declare namespace NodeJS {
export interface Process { export interface Process {
sasLoc: string sasLoc?: string
nodeLoc?: string
driveLoc: string driveLoc: string
sessionController?: import('../../controllers/internal').SessionController logsLoc: string
sasSessionController?: import('../../controllers/internal').SASSessionController
jsSessionController?: import('../../controllers/internal').JSSessionController
appStreamConfig: import('../').AppStreamConfig appStreamConfig: import('../').AppStreamConfig
logger: import('@sasjs/utils/logger').Logger logger: import('@sasjs/utils/logger').Logger
runTimes: import('../../utils').RunTimeType[]
secrets: import('../../model/Configuration').ConfigurationType
} }
} }

View File

@@ -5,6 +5,8 @@ import { AppStreamConfig } from '../types'
import { getAppStreamConfigPath } from './file' import { getAppStreamConfigPath } from './file'
export const loadAppStreamConfig = async () => { export const loadAppStreamConfig = async () => {
process.appStreamConfig = {}
if (process.env.NODE_ENV === 'test') return if (process.env.NODE_ENV === 'test') return
const appStreamConfigPath = getAppStreamConfigPath() const appStreamConfigPath = getAppStreamConfigPath()
@@ -21,7 +23,6 @@ export const loadAppStreamConfig = async () => {
} catch (_) { } catch (_) {
appStreamConfig = {} appStreamConfig = {}
} }
process.appStreamConfig = {}
for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) { for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) {
const { appLoc, streamWebFolder, streamLogo } = entry const { appLoc, streamWebFolder, streamLogo } = entry

View File

@@ -9,7 +9,5 @@ export const connectDB = async () => {
} }
console.log('Connected to DB!') console.log('Connected to DB!')
await seedDB() return seedDB()
return mongoose.connection
} }

View File

@@ -0,0 +1,6 @@
import path from 'path'
export const extractName = (filePath: string) => {
const extension = path.extname(filePath)
return path.basename(filePath, extension)
}

View File

@@ -1,5 +1,6 @@
import path from 'path' import path from 'path'
import { homedir } from 'os' import { homedir } from 'os'
import fs from 'fs-extra'
export const apiRoot = path.join(__dirname, '..', '..') export const apiRoot = path.join(__dirname, '..', '..')
export const codebaseRoot = path.join(apiRoot, '..') export const codebaseRoot = path.join(apiRoot, '..')
@@ -21,6 +22,8 @@ export const getDesktopUserAutoExecPath = () =>
export const getSasjsRootFolder = () => process.driveLoc export const getSasjsRootFolder = () => process.driveLoc
export const getLogFolder = () => process.logsLoc
export const getAppStreamConfigPath = () => export const getAppStreamConfigPath = () =>
path.join(getSasjsRootFolder(), 'appStreamConfig.json') path.join(getSasjsRootFolder(), 'appStreamConfig.json')
@@ -31,8 +34,6 @@ export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files') export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
export const getLogFolder = () => path.join(getSasjsRootFolder(), 'logs')
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts') export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
export const getSessionsFolder = () => export const getSessionsFolder = () =>
@@ -47,3 +48,6 @@ export const generateUniqueFileName = (fileName: string, extension = '') =>
new Date().getTime(), new Date().getTime(),
extension extension
].join('') ].join('')
export const createReadStream = async (filePath: string) =>
fs.createReadStream(filePath)

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types' import { InfoJWT } from '../types'
export const generateAccessToken = (data: InfoJWT) => export const generateAccessToken = (data: InfoJWT) =>
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, { jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
expiresIn: '1day' expiresIn: '1day'
}) })

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types' import { InfoJWT } from '../types'
export const generateAuthCode = (data: InfoJWT) => export const generateAuthCode = (data: InfoJWT) =>
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, { jwt.sign(data, process.secrets.AUTH_CODE_SECRET, {
expiresIn: '30s' expiresIn: '30s'
}) })

View File

@@ -2,6 +2,6 @@ import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types' import { InfoJWT } from '../types'
export const generateRefreshToken = (data: InfoJWT) => export const generateRefreshToken = (data: InfoJWT) =>
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, { jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
expiresIn: '30 days' expiresIn: '30 days'
}) })

View File

@@ -0,0 +1,35 @@
import { Request } from 'express'
const StaticAuthorizedRoutes = [
'/AppStream',
'/SASjsApi/code/execute',
'/SASjsApi/stp/execute',
'/SASjsApi/drive/deploy',
'/SASjsApi/drive/deploy/upload',
'/SASjsApi/drive/file',
'/SASjsApi/drive/folder',
'/SASjsApi/drive/fileTree',
'/SASjsApi/permission'
]
export const getAuthorizedRoutes = () => {
const streamingApps = Object.keys(process.appStreamConfig)
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
}
export const getUri = (req: Request) => {
const { baseUrl, path: reqPath } = req
if (baseUrl === '/AppStream') {
const appStream = reqPath.split('/')[1]
// removing trailing slash of URLs
return (baseUrl + '/' + appStream).replace(/\/$/, '')
}
return (baseUrl + reqPath).replace(/\/$/, '')
}
export const isAuthorizingRoute = (req: Request): boolean =>
getAuthorizedRoutes().includes(getUri(req))

View File

@@ -2,22 +2,32 @@ import path from 'path'
import { fileExists, getString, readFile } from '@sasjs/utils' import { fileExists, getString, readFile } from '@sasjs/utils'
export const getCertificates = async () => { export const getCertificates = async () => {
const { PRIVATE_KEY, FULL_CHAIN } = process.env const { PRIVATE_KEY, CERT_CHAIN, CA_ROOT } = process.env
let ca
const keyPath = PRIVATE_KEY ?? (await getFileInput('Private Key (PEM)')) const keyPath = PRIVATE_KEY ?? (await getFileInput('Private Key (PEM)'))
const certPath = FULL_CHAIN ?? (await getFileInput('Full Chain (PEM)')) const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
const caPath = CA_ROOT
console.log('keyPath: ', keyPath) console.log('keyPath: ', keyPath)
console.log('certPath: ', certPath) console.log('certPath: ', certPath)
if (caPath) console.log('caPath: ', caPath)
const key = await readFile(keyPath) const key = await readFile(keyPath)
const cert = await readFile(certPath) const cert = await readFile(certPath)
if (caPath) ca = await readFile(caPath)
return { key, cert } return { key, cert, ca }
} }
const getFileInput = async (filename: string): Promise<string> => { const getFileInput = async (
filename: string,
required: boolean = true
): Promise<string> => {
const validator = async (filePath: string) => { const validator = async (filePath: string) => {
if (!required) return true
if (!filePath) return `Path to ${filename} is required.` if (!filePath) return `Path to ${filename} is required.`
if (!(await fileExists(path.join(process.cwd(), filePath)))) { if (!(await fileExists(path.join(process.cwd(), filePath)))) {

View File

@@ -1,16 +1,22 @@
import path from 'path' import path from 'path'
import { getString } from '@sasjs/utils/input' import { getString } from '@sasjs/utils/input'
import { createFolder, fileExists, folderExists } from '@sasjs/utils' import { createFolder, fileExists, folderExists, isWindows } from '@sasjs/utils'
import { RunTimeType } from './verifyEnvVariables'
const isWindows = () => process.platform === 'win32'
export const getDesktopFields = async () => { export const getDesktopFields = async () => {
const { SAS_PATH } = process.env const { SAS_PATH, NODE_PATH } = process.env
const sasLoc = SAS_PATH ?? (await getSASLocation()) let sasLoc, nodeLoc
// const driveLoc = DRIVE_PATH ?? (await getDriveLocation())
return { sasLoc } if (process.runTimes.includes(RunTimeType.SAS)) {
sasLoc = SAS_PATH ?? (await getSASLocation())
}
if (process.runTimes.includes(RunTimeType.JS)) {
nodeLoc = NODE_PATH ?? (await getNodeLocation())
}
return { sasLoc, nodeLoc }
} }
const getDriveLocation = async (): Promise<string> => { const getDriveLocation = async (): Promise<string> => {
@@ -54,7 +60,31 @@ const getSASLocation = async (): Promise<string> => {
: '/opt/sas/sas9/SASHome/SASFoundation/9.4/sasexe/sas' : '/opt/sas/sas9/SASHome/SASFoundation/9.4/sasexe/sas'
const targetName = await getString( const targetName = await getString(
'Please enter path to SAS executable (absolute path): ', 'Please enter full path to a SAS executable with UTF-8 encoding: ',
validator,
defaultLocation
)
return targetName
}
const getNodeLocation = async (): Promise<string> => {
const validator = async (filePath: string) => {
if (!filePath) return 'Path to NodeJS executable is required.'
if (!(await fileExists(filePath))) {
return 'No file found at provided path.'
}
return true
}
const defaultLocation = isWindows()
? 'C:\\Program Files\\nodejs\\node.exe'
: '/usr/local/nodejs/bin/node.sh'
const targetName = await getString(
'Please enter full path to a NodeJS executable: ',
validator, validator,
defaultLocation defaultLocation
) )

View File

@@ -0,0 +1,33 @@
import path from 'path'
import { fileExists } from '@sasjs/utils'
import { getFilesFolder } from './file'
import { RunTimeType } from '.'
export const getRunTimeAndFilePath = async (programPath: string) => {
const ext = path.extname(programPath)
// If programPath (_program) is provided with a ".sas" or ".js" extension
// we should use that extension to determine the appropriate runTime
if (ext && Object.values(RunTimeType).includes(ext.slice(1) as RunTimeType)) {
const runTime = ext.slice(1)
const codePath = path
.join(getFilesFolder(), programPath)
.replace(new RegExp('/', 'g'), path.sep)
if (await fileExists(codePath)) {
return { codePath, runTime: runTime as RunTimeType }
}
} else {
for (const runTime of process.runTimes) {
const codePath =
path
.join(getFilesFolder(), programPath)
.replace(new RegExp('/', 'g'), path.sep) +
'.' +
runTime
if (await fileExists(codePath)) return { codePath, runTime }
}
}
throw `The Program at (${programPath}) does not exist.`
}

View File

@@ -0,0 +1,15 @@
import express from 'express'
import url from 'url'
export const getFullUrl = (req: express.Request) =>
url.format({
protocol: req.protocol,
host: req.get('host'),
pathname: req.originalUrl
})
export const getServerUrl = (req: express.Request) =>
url.format({
protocol: req.protocol,
host: req.get('x-forwarded-host') || req.get('host')
})

View File

@@ -3,15 +3,20 @@ export * from './connectDB'
export * from './copySASjsCore' export * from './copySASjsCore'
export * from './desktopAutoExec' export * from './desktopAutoExec'
export * from './extractHeaders' export * from './extractHeaders'
export * from './extractName'
export * from './file' export * from './file'
export * from './generateAccessToken' export * from './generateAccessToken'
export * from './generateAuthCode' export * from './generateAuthCode'
export * from './generateRefreshToken' export * from './generateRefreshToken'
export * from './getAuthorizedRoutes'
export * from './getCertificates' export * from './getCertificates'
export * from './getDesktopFields' export * from './getDesktopFields'
export * from './getPreProgramVariables' export * from './getPreProgramVariables'
export * from './getRunTimeAndFilePath'
export * from './getServerUrl'
export * from './instantiateLogger' export * from './instantiateLogger'
export * from './isDebugOn' export * from './isDebugOn'
export * from './zipped'
export * from './parseLogToArray' export * from './parseLogToArray'
export * from './removeTokensInDB' export * from './removeTokensInDB'
export * from './saveTokensInDB' export * from './saveTokensInDB'

View File

@@ -1,6 +1,73 @@
import Client from '../model/Client' import Client from '../model/Client'
import Group from '../model/Group'
import User from '../model/User' import User from '../model/User'
import Configuration, { ConfigurationType } from '../model/Configuration'
import { randomBytes } from 'crypto'
export const SECRETS: ConfigurationType = {
ACCESS_TOKEN_SECRET: randomBytes(64).toString('hex'),
REFRESH_TOKEN_SECRET: randomBytes(64).toString('hex'),
AUTH_CODE_SECRET: randomBytes(64).toString('hex'),
SESSION_SECRET: randomBytes(64).toString('hex')
}
export const seedDB = async (): Promise<ConfigurationType> => {
// Checking if client is already in the database
const clientExist = await Client.findOne({ clientId: CLIENT.clientId })
if (!clientExist) {
const client = new Client(CLIENT)
await client.save()
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
}
// Checking if 'AllUsers' Group is already in the database
let groupExist = await Group.findOne({ name: GROUP.name })
if (!groupExist) {
const group = new Group(GROUP)
groupExist = await group.save()
console.log(`DB Seed - Group created: ${GROUP.name}`)
}
// Checking if user is already in the database
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
if (!usernameExist) {
const user = new User(ADMIN_USER)
usernameExist = await user.save()
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
}
if (!groupExist.hasUser(usernameExist)) {
groupExist.addUser(usernameExist)
console.log(
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${GROUP.name}'`
)
}
// checking if configuration is present in the database
let configExist = await Configuration.findOne()
if (!configExist) {
const configuration = new Configuration(SECRETS)
configExist = await configuration.save()
console.log('DB Seed - configuration added')
}
return {
ACCESS_TOKEN_SECRET: configExist.ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET: configExist.REFRESH_TOKEN_SECRET,
AUTH_CODE_SECRET: configExist.AUTH_CODE_SECRET,
SESSION_SECRET: configExist.SESSION_SECRET
}
}
const GROUP = {
name: 'AllUsers',
description: 'Group contains all users'
}
const CLIENT = { const CLIENT = {
clientId: 'clientID1', clientId: 'clientID1',
clientSecret: 'clientSecret' clientSecret: 'clientSecret'
@@ -13,23 +80,3 @@ const ADMIN_USER = {
isAdmin: true, isAdmin: true,
isActive: true isActive: true
} }
export const seedDB = async () => {
// Checking if client is already in the database
const clientExist = await Client.findOne({ clientId: CLIENT.clientId })
if (!clientExist) {
const client = new Client(CLIENT)
await client.save()
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
}
// Checking if user is already in the database
const usernameExist = await User.findOne({ username: ADMIN_USER.username })
if (!usernameExist) {
const user = new User(ADMIN_USER)
await user.save()
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
}
}

View File

@@ -1,22 +1,38 @@
import path from 'path' import path from 'path'
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils' import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
import { getDesktopFields, ModeType } from '.' import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
export const setProcessVariables = async () => { export const setProcessVariables = async () => {
const { MODE, RUN_TIMES } = process.env
if (MODE === ModeType.Server) {
// NOTE: when exporting app.js as agent for supertest
// it should prevent connecting to the real database
if (process.env.NODE_ENV !== 'test') {
const secrets = await connectDB()
process.secrets = secrets
} else {
process.secrets = SECRETS
}
}
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
process.driveLoc = path.join(process.cwd(), 'sasjs_root') process.driveLoc = path.join(process.cwd(), 'sasjs_root')
return return
} }
const { MODE } = process.env process.runTimes = (RUN_TIMES?.split(',') as RunTimeType[]) ?? []
if (MODE === ModeType.Server) { if (MODE === ModeType.Server) {
process.sasLoc = process.env.SAS_PATH as string process.sasLoc = process.env.SAS_PATH
process.nodeLoc = process.env.NODE_PATH
} else { } else {
const { sasLoc } = await getDesktopFields() const { sasLoc, nodeLoc } = await getDesktopFields()
process.sasLoc = sasLoc process.sasLoc = sasLoc
process.nodeLoc = nodeLoc
} }
const { SASJS_ROOT } = process.env const { SASJS_ROOT } = process.env
@@ -24,6 +40,16 @@ export const setProcessVariables = async () => {
await createFolder(absPath) await createFolder(absPath)
process.driveLoc = getRealPath(absPath) process.driveLoc = getRealPath(absPath)
const { LOG_LOCATION } = process.env
const absLogsPath = getAbsolutePath(
LOG_LOCATION ?? `sasjs_root${path.sep}logs`,
process.cwd()
)
await createFolder(absLogsPath)
process.logsLoc = getRealPath(absLogsPath)
console.log('sasLoc: ', process.sasLoc) console.log('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc) console.log('sasDrive: ', process.driveLoc)
console.log('sasLogs: ', process.logsLoc)
console.log('runTimes: ', process.runTimes)
} }

View File

@@ -1,4 +1,4 @@
import { extractHeaders } from '..' import { extractHeaders } from '../extractHeaders'
describe('extractHeaders', () => { describe('extractHeaders', () => {
it('should return valid http headers', () => { it('should return valid http headers', () => {

View File

@@ -1,4 +1,4 @@
import { parseLogToArray } from '..' import { parseLogToArray } from '../parseLogToArray'
describe('parseLogToArray', () => { describe('parseLogToArray', () => {
it('should parse log to array type', () => { it('should parse log to array type', () => {

View File

@@ -1,5 +1,6 @@
import path from 'path'
import { MulterFile } from '../types/Upload' import { MulterFile } from '../types/Upload'
import { listFilesInFolder } from '@sasjs/utils' import { listFilesInFolder, readFileBinary } from '@sasjs/utils'
interface FilenameMapSingle { interface FilenameMapSingle {
fieldName: string fieldName: string
@@ -98,3 +99,34 @@ export const generateFileUploadSasCode = async (
return uploadSasCode return uploadSasCode
} }
/**
* Generates the js code that references uploaded files in the concurrent request
* @param filesNamesMap object that maps hashed file names and original file names
* @param sessionFolder name of the folder that is created for the purpose of files in concurrent request
* @returns generated js code
*/
export const generateFileUploadJSCode = async (
filesNamesMap: FilenamesMap,
sessionFolder: string
) => {
let uploadCode = ''
let fileCount = 0
const sessionFolderList: string[] = await listFilesInFolder(sessionFolder)
sessionFolderList.forEach(async (fileName) => {
if (fileName.includes('req_file')) {
fileCount++
const filePath = path.join(sessionFolder, fileName)
uploadCode += `\nconst _WEBIN_FILEREF${fileCount} = fs.readFileSync('${filePath}')`
uploadCode += `\nconst _WEBIN_FILENAME${fileCount} = '${filesNamesMap[fileName].originalName}'`
uploadCode += `\nconst _WEBIN_NAME${fileCount} = '${filesNamesMap[fileName].fieldName}'`
}
})
if (fileCount) {
uploadCode = `\nconst _WEBIN_FILE_COUNT = ${fileCount}` + uploadCode
}
return uploadCode
}

View File

@@ -1,10 +1,18 @@
import Joi from 'joi' import Joi from 'joi'
import { PermissionSetting, PrincipalType } from '../controllers/permission'
import { getAuthorizedRoutes } from './getAuthorizedRoutes'
const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16) const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
const passwordSchema = Joi.string().min(6).max(1024) const passwordSchema = Joi.string().min(6).max(1024)
const groupnameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
export const blockFileRegex = /\.(exe|sh|htaccess)$/i export const blockFileRegex = /\.(exe|sh|htaccess)$/i
export const getUserValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required()
}).validate(data)
export const loginWebValidation = (data: any): Joi.ValidationResult => export const loginWebValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
username: usernameSchema.required(), username: usernameSchema.required(),
@@ -24,11 +32,16 @@ export const tokenValidation = (data: any): Joi.ValidationResult =>
export const registerGroupValidation = (data: any): Joi.ValidationResult => export const registerGroupValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
name: Joi.string().min(6).required(), name: groupnameSchema.required(),
description: Joi.string(), description: Joi.string(),
isActive: Joi.boolean() isActive: Joi.boolean()
}).validate(data) }).validate(data)
export const getGroupValidation = (data: any): Joi.ValidationResult =>
Joi.object({
name: groupnameSchema.required()
}).validate(data)
export const registerUserValidation = (data: any): Joi.ValidationResult => export const registerUserValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
displayName: Joi.string().min(6).required(), displayName: Joi.string().min(6).required(),
@@ -74,6 +87,27 @@ export const registerClientValidation = (data: any): Joi.ValidationResult =>
clientSecret: Joi.string().required() clientSecret: Joi.string().required()
}).validate(data) }).validate(data)
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
Joi.object({
uri: Joi.string()
.required()
.valid(...getAuthorizedRoutes()),
setting: Joi.string()
.required()
.valid(...Object.values(PermissionSetting)),
principalType: Joi.string()
.required()
.valid(...Object.values(PrincipalType)),
principalId: Joi.number().required()
}).validate(data)
export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
Joi.object({
setting: Joi.string()
.required()
.valid(...Object.values(PermissionSetting))
}).validate(data)
export const deployValidation = (data: any): Joi.ValidationResult => export const deployValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
appLoc: Joi.string().pattern(/^\//).required().min(2), appLoc: Joi.string().pattern(/^\//).required().min(2),
@@ -109,9 +143,10 @@ export const folderParamValidation = (data: any): Joi.ValidationResult =>
_folderPath: Joi.string() _folderPath: Joi.string()
}).validate(data) }).validate(data)
export const runSASValidation = (data: any): Joi.ValidationResult => export const runCodeValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
code: Joi.string().required() code: Joi.string().required(),
runTime: Joi.string().valid(...process.runTimes)
}).validate(data) }).validate(data)
export const executeProgramRawValidation = (data: any): Joi.ValidationResult => export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>

View File

@@ -26,6 +26,11 @@ export enum LOG_FORMAT_MORGANType {
tiny = 'tiny' tiny = 'tiny'
} }
export enum RunTimeType {
SAS = 'sas',
JS = 'js'
}
export enum ReturnCode { export enum ReturnCode {
Success, Success,
InvalidEnv InvalidEnv
@@ -46,6 +51,10 @@ export const verifyEnvVariables = (): ReturnCode => {
errors.push(...verifyLOG_FORMAT_MORGAN()) errors.push(...verifyLOG_FORMAT_MORGAN())
errors.push(...verifyRUN_TIMES())
errors.push(...verifyExecutablePaths())
if (errors.length) { if (errors.length) {
process.logger?.error( process.logger?.error(
`Invalid environment variable(s) provided: \n${errors.join('\n')}` `Invalid environment variable(s) provided: \n${errors.join('\n')}`
@@ -69,33 +78,7 @@ const verifyMODE = (): string[] => {
} }
if (process.env.MODE === ModeType.Server) { if (process.env.MODE === ModeType.Server) {
const { const { DB_CONNECT } = process.env
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET,
AUTH_CODE_SECRET,
SESSION_SECRET,
DB_CONNECT
} = process.env
if (!ACCESS_TOKEN_SECRET)
errors.push(
`- ACCESS_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!REFRESH_TOKEN_SECRET)
errors.push(
`- REFRESH_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!AUTH_CODE_SECRET)
errors.push(
`- AUTH_CODE_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!SESSION_SECRET)
errors.push(
`- SESSION_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (process.env.NODE_ENV !== 'test') if (process.env.NODE_ENV !== 'test')
if (!DB_CONNECT) if (!DB_CONNECT)
@@ -120,16 +103,16 @@ const verifyPROTOCOL = (): string[] => {
} }
if (process.env.PROTOCOL === ProtocolType.HTTPS) { if (process.env.PROTOCOL === ProtocolType.HTTPS) {
const { PRIVATE_KEY, FULL_CHAIN } = process.env const { PRIVATE_KEY, CERT_CHAIN } = process.env
if (!PRIVATE_KEY) if (!PRIVATE_KEY)
errors.push( errors.push(
`- PRIVATE_KEY is required for PROTOCOL '${ProtocolType.HTTPS}'` `- PRIVATE_KEY is required for PROTOCOL '${ProtocolType.HTTPS}'`
) )
if (!FULL_CHAIN) if (!CERT_CHAIN)
errors.push( errors.push(
`- FULL_CHAIN is required for PROTOCOL '${ProtocolType.HTTPS}'` `- CERT_CHAIN is required for PROTOCOL '${ProtocolType.HTTPS}'`
) )
} }
@@ -202,10 +185,52 @@ const verifyLOG_FORMAT_MORGAN = (): string[] => {
return errors return errors
} }
const verifyRUN_TIMES = (): string[] => {
const errors: string[] = []
const { RUN_TIMES } = process.env
if (RUN_TIMES) {
const runTimes = RUN_TIMES.split(',')
const runTimeTypes = Object.values(RunTimeType)
runTimes.forEach((runTime) => {
if (!runTimeTypes.includes(runTime as RunTimeType)) {
errors.push(
`- Invalid '${runTime}' runtime\n - valid options ${runTimeTypes}`
)
}
})
} else {
process.env.RUN_TIMES = DEFAULTS.RUN_TIMES
}
return errors
}
const verifyExecutablePaths = () => {
const errors: string[] = []
const { RUN_TIMES, SAS_PATH, NODE_PATH, MODE } = process.env
if (MODE === ModeType.Server) {
const runTimes = RUN_TIMES?.split(',')
if (runTimes?.includes(RunTimeType.SAS) && !SAS_PATH) {
errors.push(`- SAS_PATH is required for ${RunTimeType.SAS} run time`)
}
if (runTimes?.includes(RunTimeType.JS) && !NODE_PATH) {
errors.push(`- NODE_PATH is required for ${RunTimeType.JS} run time`)
}
}
return errors
}
const DEFAULTS = { const DEFAULTS = {
MODE: ModeType.Desktop, MODE: ModeType.Desktop,
PROTOCOL: ProtocolType.HTTP, PROTOCOL: ProtocolType.HTTP,
PORT: '5000', PORT: '5000',
HELMET_COEP: HelmetCoepType.TRUE, HELMET_COEP: HelmetCoepType.TRUE,
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
RUN_TIMES: RunTimeType.SAS
} }

41
api/src/utils/zipped.ts Normal file
View File

@@ -0,0 +1,41 @@
import path from 'path'
import unZipper from 'unzipper'
import { extractName } from './extractName'
import { createReadStream } from './file'
export const isZipFile = (
file: Express.Multer.File
): { error?: string; value?: Express.Multer.File } => {
const fileExtension = path.extname(file.originalname)
if (fileExtension.toUpperCase() !== '.ZIP')
return { error: `"file" has invalid extension ${fileExtension}` }
const allowedMimetypes = ['application/zip', 'application/x-zip-compressed']
if (!allowedMimetypes.includes(file.mimetype))
return { error: `"file" has invalid type ${file.mimetype}` }
return { value: file }
}
export const extractJSONFromZip = async (zipFile: Express.Multer.File) => {
let fileContent: string = ''
const fileInZip = extractName(zipFile.originalname)
const zip = (await createReadStream(zipFile.path)).pipe(
unZipper.Parse({ forceStream: true })
)
for await (const entry of zip) {
const fileName = entry.path as string
// grab the first json found in .zip
if (fileName.toUpperCase().endsWith('.JSON')) {
fileContent = await entry.buffer()
break
} else {
entry.autodrain()
}
}
return fileContent
}

View File

@@ -13,7 +13,7 @@
"tags": [ "tags": [
{ {
"name": "Info", "name": "Info",
"description": "Get Server Info" "description": "Get Server Information"
}, },
{ {
"name": "Session", "name": "Session",
@@ -21,7 +21,11 @@
}, },
{ {
"name": "User", "name": "User",
"description": "Operations about users" "description": "Operations with users"
},
{
"name": "Permission",
"description": "Operations about permissions"
}, },
{ {
"name": "Client", "name": "Client",
@@ -33,19 +37,19 @@
}, },
{ {
"name": "Drive", "name": "Drive",
"description": "Operations about drive" "description": "Operations on SASjs Drive"
}, },
{ {
"name": "Group", "name": "Group",
"description": "Operations about group" "description": "Operations on groups and group memberships"
}, },
{ {
"name": "STP", "name": "STP",
"description": "Operations about STP" "description": "Execution of Stored Programs"
}, },
{ {
"name": "CODE", "name": "CODE",
"description": "Operations on SAS code" "description": "Execution of code (various runtimes are supported)"
}, },
{ {
"name": "Web", "name": "Web",

122
package-lock.json generated
View File

@@ -2770,9 +2770,9 @@
} }
}, },
"node_modules/npm": { "node_modules/npm": {
"version": "8.10.0", "version": "8.12.2",
"resolved": "https://registry.npmjs.org/npm/-/npm-8.10.0.tgz", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.2.tgz",
"integrity": "sha512-6oo65q9Quv9mRPGZJufmSH+C/UFdgelwzRXiglT/2mDB50zdy/lZK5dFY0TJ9fJ/8gHqnxcX1NM206KLjTBMlQ==", "integrity": "sha512-TArexqro9wpl/6wz6t6YdYhOoiy/UArqiSsSsqI7fieEhQEswDQSJcgt/LuCDjl6mfCDi0So7S2UZ979qLYRPg==",
"bundleDependencies": [ "bundleDependencies": [
"@isaacs/string-locale-compare", "@isaacs/string-locale-compare",
"@npmcli/arborist", "@npmcli/arborist",
@@ -2858,7 +2858,7 @@
"@npmcli/run-script": "^3.0.1", "@npmcli/run-script": "^3.0.1",
"abbrev": "~1.1.1", "abbrev": "~1.1.1",
"archy": "~1.0.0", "archy": "~1.0.0",
"cacache": "^16.0.7", "cacache": "^16.1.1",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"chownr": "^2.0.0", "chownr": "^2.0.0",
"cli-columns": "^4.0.0", "cli-columns": "^4.0.0",
@@ -2883,7 +2883,7 @@
"libnpmsearch": "^5.0.2", "libnpmsearch": "^5.0.2",
"libnpmteam": "^4.0.2", "libnpmteam": "^4.0.2",
"libnpmversion": "^3.0.1", "libnpmversion": "^3.0.1",
"make-fetch-happen": "^10.1.3", "make-fetch-happen": "^10.1.7",
"minipass": "^3.1.6", "minipass": "^3.1.6",
"minipass-pipeline": "^1.2.4", "minipass-pipeline": "^1.2.4",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
@@ -2900,7 +2900,7 @@
"npm-user-validate": "^1.0.1", "npm-user-validate": "^1.0.1",
"npmlog": "^6.0.2", "npmlog": "^6.0.2",
"opener": "^1.5.2", "opener": "^1.5.2",
"pacote": "^13.3.0", "pacote": "^13.6.0",
"parse-conflict-json": "^2.0.2", "parse-conflict-json": "^2.0.2",
"proc-log": "^2.0.1", "proc-log": "^2.0.1",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
@@ -2910,7 +2910,7 @@
"readdir-scoped-modules": "^1.1.0", "readdir-scoped-modules": "^1.1.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"semver": "^7.3.7", "semver": "^7.3.7",
"ssri": "^9.0.0", "ssri": "^9.0.1",
"tar": "^6.1.11", "tar": "^6.1.11",
"text-table": "~0.2.0", "text-table": "~0.2.0",
"tiny-relative-date": "^1.3.0", "tiny-relative-date": "^1.3.0",
@@ -2965,7 +2965,7 @@
"peer": true "peer": true
}, },
"node_modules/npm/node_modules/@npmcli/arborist": { "node_modules/npm/node_modules/@npmcli/arborist": {
"version": "5.2.0", "version": "5.2.1",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -3389,7 +3389,7 @@
} }
}, },
"node_modules/npm/node_modules/cacache": { "node_modules/npm/node_modules/cacache": {
"version": "16.0.7", "version": "16.1.1",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -3759,7 +3759,7 @@
} }
}, },
"node_modules/npm/node_modules/glob": { "node_modules/npm/node_modules/glob": {
"version": "8.0.1", "version": "8.0.3",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -3769,8 +3769,7 @@
"inflight": "^1.0.4", "inflight": "^1.0.4",
"inherits": "2", "inherits": "2",
"minimatch": "^5.0.1", "minimatch": "^5.0.1",
"once": "^1.3.0", "once": "^1.3.0"
"path-is-absolute": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -4121,7 +4120,7 @@
} }
}, },
"node_modules/npm/node_modules/libnpmexec": { "node_modules/npm/node_modules/libnpmexec": {
"version": "4.0.5", "version": "4.0.6",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -4186,7 +4185,7 @@
} }
}, },
"node_modules/npm/node_modules/libnpmpack": { "node_modules/npm/node_modules/libnpmpack": {
"version": "4.0.3", "version": "4.1.0",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -4194,7 +4193,7 @@
"dependencies": { "dependencies": {
"@npmcli/run-script": "^3.0.0", "@npmcli/run-script": "^3.0.0",
"npm-package-arg": "^9.0.1", "npm-package-arg": "^9.0.1",
"pacote": "^13.0.5" "pacote": "^13.5.0"
}, },
"engines": { "engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
@@ -4272,14 +4271,14 @@
} }
}, },
"node_modules/npm/node_modules/make-fetch-happen": { "node_modules/npm/node_modules/make-fetch-happen": {
"version": "10.1.3", "version": "10.1.7",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"agentkeepalive": "^4.2.1", "agentkeepalive": "^4.2.1",
"cacache": "^16.0.2", "cacache": "^16.1.0",
"http-cache-semantics": "^4.1.0", "http-cache-semantics": "^4.1.0",
"http-proxy-agent": "^5.0.0", "http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0",
@@ -4292,7 +4291,7 @@
"minipass-pipeline": "^1.2.4", "minipass-pipeline": "^1.2.4",
"negotiator": "^0.6.3", "negotiator": "^0.6.3",
"promise-retry": "^2.0.1", "promise-retry": "^2.0.1",
"socks-proxy-agent": "^6.1.1", "socks-proxy-agent": "^7.0.0",
"ssri": "^9.0.0" "ssri": "^9.0.0"
}, },
"engines": { "engines": {
@@ -4300,7 +4299,7 @@
} }
}, },
"node_modules/npm/node_modules/minimatch": { "node_modules/npm/node_modules/minimatch": {
"version": "5.0.1", "version": "5.1.0",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -4509,7 +4508,7 @@
} }
}, },
"node_modules/npm/node_modules/node-gyp/node_modules/glob": { "node_modules/npm/node_modules/node-gyp/node_modules/glob": {
"version": "7.2.0", "version": "7.2.3",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -4518,7 +4517,7 @@
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
"inherits": "2", "inherits": "2",
"minimatch": "^3.0.4", "minimatch": "^3.1.1",
"once": "^1.3.0", "once": "^1.3.0",
"path-is-absolute": "^1.0.0" "path-is-absolute": "^1.0.0"
}, },
@@ -4633,7 +4632,7 @@
} }
}, },
"node_modules/npm/node_modules/npm-packlist": { "node_modules/npm/node_modules/npm-packlist": {
"version": "5.0.3", "version": "5.1.0",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -4760,7 +4759,7 @@
} }
}, },
"node_modules/npm/node_modules/pacote": { "node_modules/npm/node_modules/pacote": {
"version": "13.3.0", "version": "13.6.0",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -4777,7 +4776,7 @@
"minipass": "^3.1.6", "minipass": "^3.1.6",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"npm-package-arg": "^9.0.0", "npm-package-arg": "^9.0.0",
"npm-packlist": "^5.0.0", "npm-packlist": "^5.1.0",
"npm-pick-manifest": "^7.0.0", "npm-pick-manifest": "^7.0.0",
"npm-registry-fetch": "^13.0.1", "npm-registry-fetch": "^13.0.1",
"proc-log": "^2.0.0", "proc-log": "^2.0.0",
@@ -5009,7 +5008,7 @@
} }
}, },
"node_modules/npm/node_modules/rimraf/node_modules/glob": { "node_modules/npm/node_modules/rimraf/node_modules/glob": {
"version": "7.2.0", "version": "7.2.3",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -5018,7 +5017,7 @@
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
"inherits": "2", "inherits": "2",
"minimatch": "^3.0.4", "minimatch": "^3.1.1",
"once": "^1.3.0", "once": "^1.3.0",
"path-is-absolute": "^1.0.0" "path-is-absolute": "^1.0.0"
}, },
@@ -5141,7 +5140,7 @@
} }
}, },
"node_modules/npm/node_modules/socks-proxy-agent": { "node_modules/npm/node_modules/socks-proxy-agent": {
"version": "6.2.0", "version": "7.0.0",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "MIT", "license": "MIT",
@@ -5192,7 +5191,7 @@
"peer": true "peer": true
}, },
"node_modules/npm/node_modules/ssri": { "node_modules/npm/node_modules/ssri": {
"version": "9.0.0", "version": "9.0.1",
"dev": true, "dev": true,
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
@@ -5911,9 +5910,9 @@
"peer": true "peer": true
}, },
"node_modules/semantic-release": { "node_modules/semantic-release": {
"version": "19.0.2", "version": "19.0.3",
"resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-19.0.2.tgz", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-19.0.3.tgz",
"integrity": "sha512-7tPonjZxukKECmClhsfyMKDt0GR38feIC2HxgyYaBi+9tDySBLjK/zYDLhh+m6yjnHIJa9eBTKYE7k63ZQcYbw==", "integrity": "sha512-HaFbydST1cDKZHuFZxB8DTrBLJVK/AnDExpK0s3EqLIAAUAHUgnd+VSJCUtTYQKkAkauL8G9CucODrVCc7BuAA==",
"dev": true, "dev": true,
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -9019,9 +9018,9 @@
"peer": true "peer": true
}, },
"npm": { "npm": {
"version": "8.10.0", "version": "8.12.2",
"resolved": "https://registry.npmjs.org/npm/-/npm-8.10.0.tgz", "resolved": "https://registry.npmjs.org/npm/-/npm-8.12.2.tgz",
"integrity": "sha512-6oo65q9Quv9mRPGZJufmSH+C/UFdgelwzRXiglT/2mDB50zdy/lZK5dFY0TJ9fJ/8gHqnxcX1NM206KLjTBMlQ==", "integrity": "sha512-TArexqro9wpl/6wz6t6YdYhOoiy/UArqiSsSsqI7fieEhQEswDQSJcgt/LuCDjl6mfCDi0So7S2UZ979qLYRPg==",
"dev": true, "dev": true,
"peer": true, "peer": true,
"requires": { "requires": {
@@ -9035,7 +9034,7 @@
"@npmcli/run-script": "^3.0.1", "@npmcli/run-script": "^3.0.1",
"abbrev": "~1.1.1", "abbrev": "~1.1.1",
"archy": "~1.0.0", "archy": "~1.0.0",
"cacache": "^16.0.7", "cacache": "^16.1.1",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"chownr": "^2.0.0", "chownr": "^2.0.0",
"cli-columns": "^4.0.0", "cli-columns": "^4.0.0",
@@ -9060,7 +9059,7 @@
"libnpmsearch": "^5.0.2", "libnpmsearch": "^5.0.2",
"libnpmteam": "^4.0.2", "libnpmteam": "^4.0.2",
"libnpmversion": "^3.0.1", "libnpmversion": "^3.0.1",
"make-fetch-happen": "^10.1.3", "make-fetch-happen": "^10.1.7",
"minipass": "^3.1.6", "minipass": "^3.1.6",
"minipass-pipeline": "^1.2.4", "minipass-pipeline": "^1.2.4",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
@@ -9077,7 +9076,7 @@
"npm-user-validate": "^1.0.1", "npm-user-validate": "^1.0.1",
"npmlog": "^6.0.2", "npmlog": "^6.0.2",
"opener": "^1.5.2", "opener": "^1.5.2",
"pacote": "^13.3.0", "pacote": "^13.6.0",
"parse-conflict-json": "^2.0.2", "parse-conflict-json": "^2.0.2",
"proc-log": "^2.0.1", "proc-log": "^2.0.1",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
@@ -9087,7 +9086,7 @@
"readdir-scoped-modules": "^1.1.0", "readdir-scoped-modules": "^1.1.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"semver": "^7.3.7", "semver": "^7.3.7",
"ssri": "^9.0.0", "ssri": "^9.0.1",
"tar": "^6.1.11", "tar": "^6.1.11",
"text-table": "~0.2.0", "text-table": "~0.2.0",
"tiny-relative-date": "^1.3.0", "tiny-relative-date": "^1.3.0",
@@ -9117,7 +9116,7 @@
"peer": true "peer": true
}, },
"@npmcli/arborist": { "@npmcli/arborist": {
"version": "5.2.0", "version": "5.2.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -9432,7 +9431,7 @@
} }
}, },
"cacache": { "cacache": {
"version": "16.0.7", "version": "16.1.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -9704,7 +9703,7 @@
} }
}, },
"glob": { "glob": {
"version": "8.0.1", "version": "8.0.3",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -9713,8 +9712,7 @@
"inflight": "^1.0.4", "inflight": "^1.0.4",
"inherits": "2", "inherits": "2",
"minimatch": "^5.0.1", "minimatch": "^5.0.1",
"once": "^1.3.0", "once": "^1.3.0"
"path-is-absolute": "^1.0.0"
} }
}, },
"graceful-fs": { "graceful-fs": {
@@ -9970,7 +9968,7 @@
} }
}, },
"libnpmexec": { "libnpmexec": {
"version": "4.0.5", "version": "4.0.6",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -10019,14 +10017,14 @@
} }
}, },
"libnpmpack": { "libnpmpack": {
"version": "4.0.3", "version": "4.1.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
"requires": { "requires": {
"@npmcli/run-script": "^3.0.0", "@npmcli/run-script": "^3.0.0",
"npm-package-arg": "^9.0.1", "npm-package-arg": "^9.0.1",
"pacote": "^13.0.5" "pacote": "^13.5.0"
} }
}, },
"libnpmpublish": { "libnpmpublish": {
@@ -10081,13 +10079,13 @@
"peer": true "peer": true
}, },
"make-fetch-happen": { "make-fetch-happen": {
"version": "10.1.3", "version": "10.1.7",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
"requires": { "requires": {
"agentkeepalive": "^4.2.1", "agentkeepalive": "^4.2.1",
"cacache": "^16.0.2", "cacache": "^16.1.0",
"http-cache-semantics": "^4.1.0", "http-cache-semantics": "^4.1.0",
"http-proxy-agent": "^5.0.0", "http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0",
@@ -10100,12 +10098,12 @@
"minipass-pipeline": "^1.2.4", "minipass-pipeline": "^1.2.4",
"negotiator": "^0.6.3", "negotiator": "^0.6.3",
"promise-retry": "^2.0.1", "promise-retry": "^2.0.1",
"socks-proxy-agent": "^6.1.1", "socks-proxy-agent": "^7.0.0",
"ssri": "^9.0.0" "ssri": "^9.0.0"
} }
}, },
"minimatch": { "minimatch": {
"version": "5.0.1", "version": "5.1.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -10254,7 +10252,7 @@
} }
}, },
"glob": { "glob": {
"version": "7.2.0", "version": "7.2.3",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -10262,7 +10260,7 @@
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
"inherits": "2", "inherits": "2",
"minimatch": "^3.0.4", "minimatch": "^3.1.1",
"once": "^1.3.0", "once": "^1.3.0",
"path-is-absolute": "^1.0.0" "path-is-absolute": "^1.0.0"
} }
@@ -10344,7 +10342,7 @@
} }
}, },
"npm-packlist": { "npm-packlist": {
"version": "5.0.3", "version": "5.1.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -10435,7 +10433,7 @@
} }
}, },
"pacote": { "pacote": {
"version": "13.3.0", "version": "13.6.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -10451,7 +10449,7 @@
"minipass": "^3.1.6", "minipass": "^3.1.6",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"npm-package-arg": "^9.0.0", "npm-package-arg": "^9.0.0",
"npm-packlist": "^5.0.0", "npm-packlist": "^5.1.0",
"npm-pick-manifest": "^7.0.0", "npm-pick-manifest": "^7.0.0",
"npm-registry-fetch": "^13.0.1", "npm-registry-fetch": "^13.0.1",
"proc-log": "^2.0.0", "proc-log": "^2.0.0",
@@ -10615,7 +10613,7 @@
} }
}, },
"glob": { "glob": {
"version": "7.2.0", "version": "7.2.3",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -10623,7 +10621,7 @@
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
"inherits": "2", "inherits": "2",
"minimatch": "^3.0.4", "minimatch": "^3.1.1",
"once": "^1.3.0", "once": "^1.3.0",
"path-is-absolute": "^1.0.0" "path-is-absolute": "^1.0.0"
} }
@@ -10701,7 +10699,7 @@
} }
}, },
"socks-proxy-agent": { "socks-proxy-agent": {
"version": "6.2.0", "version": "7.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -10744,7 +10742,7 @@
"peer": true "peer": true
}, },
"ssri": { "ssri": {
"version": "9.0.0", "version": "9.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"peer": true, "peer": true,
@@ -11270,9 +11268,9 @@
"peer": true "peer": true
}, },
"semantic-release": { "semantic-release": {
"version": "19.0.2", "version": "19.0.3",
"resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-19.0.2.tgz", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-19.0.3.tgz",
"integrity": "sha512-7tPonjZxukKECmClhsfyMKDt0GR38feIC2HxgyYaBi+9tDySBLjK/zYDLhh+m6yjnHIJa9eBTKYE7k63ZQcYbw==", "integrity": "sha512-HaFbydST1cDKZHuFZxB8DTrBLJVK/AnDExpK0s3EqLIAAUAHUgnd+VSJCUtTYQKkAkauL8G9CucODrVCc7BuAA==",
"dev": true, "dev": true,
"peer": true, "peer": true,
"requires": { "requires": {

View File

@@ -1 +1 @@
v16.14.0 v16.15.1

View File

@@ -3,8 +3,8 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "npx webpack-dev-server --config webpack.dev.ts --hot", "start": "webpack-dev-server --config webpack.dev.ts --hot",
"build": "npx webpack --config webpack.prod.ts" "build": "webpack --config webpack.prod.ts"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.4.1", "@emotion/react": "^11.4.1",

View File

@@ -0,0 +1,35 @@
import React, { Dispatch, SetStateAction } from 'react'
import DialogTitle from '@mui/material/DialogTitle'
import IconButton from '@mui/material/IconButton'
import CloseIcon from '@mui/icons-material/Close'
export interface DialogTitleProps {
id: string
children?: React.ReactNode
handleOpen: Dispatch<SetStateAction<boolean>>
}
export const BootstrapDialogTitle = (props: DialogTitleProps) => {
const { children, handleOpen, ...other } = props
return (
<DialogTitle sx={{ m: 0, p: 2 }} {...other}>
{children}
{handleOpen ? (
<IconButton
aria-label="close"
onClick={() => handleOpen(false)}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500]
}}
>
<CloseIcon />
</IconButton>
) : null}
</DialogTitle>
)
}

View File

@@ -144,6 +144,18 @@ const Header = (props: any) => {
open={!!anchorEl} open={!!anchorEl}
onClose={handleClose} onClose={handleClose}
> >
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
href={'https://server.sasjs.io'}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
>
Documentation
</Button>
</MenuItem>
<MenuItem sx={{ justifyContent: 'center' }}> <MenuItem sx={{ justifyContent: 'center' }}>
<Button <Button
component={Link} component={Link}

View File

@@ -9,8 +9,8 @@ const Home = () => {
<CssBaseline /> <CssBaseline />
<h2>Welcome to SASjs Server!</h2> <h2>Welcome to SASjs Server!</h2>
<p> <p>
This portal provides an interface for executing Stored Programs (drive) SASjs Server provides a REST interface for executing Stored Programs and
and ad hoc code (studio) against a SAS executable. The source code is ad hoc code (studio) against SAS and JS executables. The source is
available on{' '} available on{' '}
<a <a
href="https://github.com/sasjs/server" href="https://github.com/sasjs/server"

View File

@@ -22,7 +22,7 @@ const Login = () => {
username, username,
password password
}).catch((err: any) => { }).catch((err: any) => {
setErrorMessage(err.response.data) setErrorMessage(err.response?.data || err.toString())
return {} return {}
}) })
@@ -30,6 +30,7 @@ const Login = () => {
appContext.setUserId?.(user.id) appContext.setUserId?.(user.id)
appContext.setUsername?.(user.username) appContext.setUsername?.(user.username)
appContext.setDisplayName?.(user.displayName) appContext.setDisplayName?.(user.displayName)
appContext.setIsAdmin?.(user.isAdmin)
appContext.setLoggedIn?.(loggedIn) appContext.setLoggedIn?.(loggedIn)
} }
} }

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { Typography, Dialog, DialogContent } from '@mui/material'
import { styled } from '@mui/material/styles'
import { BootstrapDialogTitle } from './dialogTitle'
const BootstrapDialog = styled(Dialog)(({ theme }) => ({
'& .MuiDialogContent-root': {
padding: theme.spacing(2)
},
'& .MuiDialogActions-root': {
padding: theme.spacing(1)
}
}))
export interface ModalProps {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
title: string
payload: string
}
const Modal = (props: ModalProps) => {
const { open, setOpen, title, payload } = props
return (
<div>
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
{title}
</BootstrapDialogTitle>
<DialogContent dividers>
<Typography gutterBottom>
<span style={{ fontFamily: 'monospace' }}>{payload}</span>
</Typography>
</DialogContent>
</BootstrapDialog>
</div>
)
}
export default Modal

View File

@@ -0,0 +1,62 @@
import React, { Dispatch, SetStateAction } from 'react'
import Snackbar from '@mui/material/Snackbar'
import MuiAlert, { AlertProps } from '@mui/material/Alert'
import Slide, { SlideProps } from '@mui/material/Slide'
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
props,
ref
) {
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
})
const Transition = (props: SlideProps) => {
return <Slide {...props} direction="up" />
}
export enum AlertSeverityType {
Success = 'success',
Warning = 'warning',
Info = 'info',
Error = 'error'
}
type BootstrapSnackbarProps = {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
message: string
severity: AlertSeverityType
}
const BootstrapSnackbar = ({
open,
setOpen,
message,
severity
}: BootstrapSnackbarProps) => {
const handleClose = (
event: React.SyntheticEvent | Event,
reason?: string
) => {
if (reason === 'clickaway') {
return
}
setOpen(false)
}
return (
<Snackbar
open={open}
autoHideDuration={3000}
onClose={handleClose}
TransitionComponent={Transition}
>
<Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}>
{message}
</Alert>
</Snackbar>
)
}
export default BootstrapSnackbar

View File

@@ -94,10 +94,7 @@ const Main = (props: Props) => {
setEditMode(false) setEditMode(false)
} else { } else {
window.open( window.open(
`${baseUrl}/SASjsApi/stp/execute?_program=${props.selectedFilePath.replace( `${baseUrl}/SASjsApi/stp/execute?_program=${props.selectedFilePath}`
/.sas$/,
''
)}`
) )
} }
} }

Some files were not shown because too many files have changed in this diff Show More