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

Compare commits

..

133 Commits

Author SHA1 Message Date
semantic-release-bot
70c3834022 chore(release): 0.33.2 [skip ci]
## [0.33.2](https://github.com/sasjs/server/compare/v0.33.1...v0.33.2) (2023-04-24)

### Bug Fixes

* removing print redirection pending full [#274](https://github.com/sasjs/server/issues/274) fix ([d49ea47](d49ea47bd7))
2023-04-24 21:13:55 +00:00
Allan Bowe
dbf6c7de08 Merge pull request #355 from sasjs/issue274
fix: removing print redirection pending full #274 fix
2023-04-24 21:59:41 +01:00
allan
d49ea47bd7 fix: removing print redirection pending full #274 fix 2023-04-24 21:58:13 +01:00
semantic-release-bot
be4951d112 chore(release): 0.33.1 [skip ci]
## [0.33.1](https://github.com/sasjs/server/compare/v0.33.0...v0.33.1) (2023-04-20)

### Bug Fixes

* applying nologo only for sas.exe ([b4436ba](b4436bad0d)), closes [#352](https://github.com/sasjs/server/issues/352)
2023-04-20 08:26:33 +00:00
Allan Bowe
c116b263d9 Merge pull request #353 from sasjs/issue352
fix: applying nologo only for sas.exe
2023-04-20 09:22:29 +01:00
allan
b4436bad0d fix: applying nologo only for sas.exe
Closes #352
2023-04-20 09:16:22 +01:00
semantic-release-bot
5e325522f4 chore(release): 0.33.0 [skip ci]
# [0.33.0](https://github.com/sasjs/server/compare/v0.32.0...v0.33.0) (2023-04-05)

### Features

* option to reset admin password on startup ([eda8e56](eda8e56bb0))
2023-04-05 22:07:50 +00:00
Allan Bowe
e576fad8f4 Merge pull request #350 from sasjs/issue-348
feat: option to reset admin password on startup
2023-04-05 23:03:21 +01:00
eda8e56bb0 feat: option to reset admin password on startup 2023-04-05 23:05:38 +05:00
semantic-release-bot
bee4f215d2 chore(release): 0.32.0 [skip ci]
# [0.32.0](https://github.com/sasjs/server/compare/v0.31.0...v0.32.0) (2023-04-05)

### Features

* add an api endpoint for admin to get list of client ids ([6ffaa7e](6ffaa7e9e2))
2023-04-05 09:44:13 +00:00
Allan Bowe
100f138f98 Merge pull request #349 from sasjs/issue-347
feat: add an api endpoint for admin to get list of client ids
2023-04-05 10:39:01 +01:00
6ffaa7e9e2 feat: add an api endpoint for admin to get list of client ids 2023-04-04 23:57:01 +05:00
semantic-release-bot
a433786011 chore(release): 0.31.0 [skip ci]
# [0.31.0](https://github.com/sasjs/server/compare/v0.30.3...v0.31.0) (2023-03-30)

### Features

* prevent brute force attack by rate limiting login endpoint ([a82cabb](a82cabb001))
2023-03-30 15:34:12 +00:00
Allan Bowe
1adff9a783 Merge pull request #345 from sasjs/issue-344
feat: prevent brute force attack against authorization
2023-03-30 16:29:15 +01:00
1435e380be chore: put comments on top of example in readme and .env.example 2023-03-30 15:35:16 +05:00
e099f2e678 chore: put comments on top of example in readme and .env.example 2023-03-30 15:34:50 +05:00
ddd155ba01 chore: combine scattered errors into a single object 2023-03-30 14:58:54 +05:00
9936241815 chore: fix failing specs 2023-03-29 23:46:25 +05:00
570995e572 chore: quick fix 2023-03-29 23:22:32 +05:00
462829fd9a chore: remove unused function 2023-03-29 22:10:16 +05:00
c1c0554de2 chore: quick fix 2023-03-29 22:05:29 +05:00
bd3aff9a7b chore: move secondsToHms to @sasjs/utils 2023-03-29 20:10:55 +05:00
a1e255e0c7 chore: removed unused file 2023-03-29 15:39:05 +05:00
0dae034f17 chore: revert change in package.json 2023-03-29 15:35:40 +05:00
89048ce943 chore: move brute force protection logic to middleware and a singleton class 2023-03-29 15:33:32 +05:00
a82cabb001 feat: prevent brute force attack by rate limiting login endpoint 2023-03-28 21:43:10 +05:00
c4066d32a0 chore: npm audit fix 2023-03-27 16:23:54 +05:00
semantic-release-bot
6a44cd69d9 chore(release): 0.30.3 [skip ci]
## [0.30.3](https://github.com/sasjs/server/compare/v0.30.2...v0.30.3) (2023-03-07)

### Bug Fixes

* add location.pathname to location.origin conditionally ([edab51c](edab51c519))
2023-03-07 10:45:49 +00:00
Allan Bowe
e607115995 Merge pull request #343 from sasjs/quick-fix
fix: add location.pathname to location.origin conditionally
2023-03-07 10:42:07 +00:00
edab51c519 fix: add location.pathname to location.origin conditionally 2023-03-07 15:37:22 +05:00
semantic-release-bot
081cc3102c chore(release): 0.30.2 [skip ci]
## [0.30.2](https://github.com/sasjs/server/compare/v0.30.1...v0.30.2) (2023-03-07)

### Bug Fixes

* **web:** add path to base in launch program url ([2c31922](2c31922f58))
2023-03-07 09:40:13 +00:00
Allan Bowe
b19aa1eba4 Merge pull request #342 from sasjs/quick-fix
fix(web): add path to base in launch program url
2023-03-07 09:35:09 +00:00
2c31922f58 fix(web): add path to base in launch program url 2023-03-07 09:05:29 +05:00
semantic-release-bot
4d7a571a6e chore(release): 0.30.1 [skip ci]
## [0.30.1](https://github.com/sasjs/server/compare/v0.30.0...v0.30.1) (2023-03-01)

### Bug Fixes

* **web:** add proper base url in axios.defaults ([5e3ce8a](5e3ce8a98f))
2023-03-01 18:38:43 +00:00
Allan Bowe
a373a4eb5f Merge pull request #341 from sasjs/base-url
fix(web): add proper base url in axios.defaults
2023-03-01 18:34:55 +00:00
5e3ce8a98f fix(web): add proper base url in axios.defaults 2023-03-01 21:45:18 +05:00
semantic-release-bot
737b34567e chore(release): 0.30.0 [skip ci]
# [0.30.0](https://github.com/sasjs/server/compare/v0.29.0...v0.30.0) (2023-02-28)

### Bug Fixes

* lint + remove default settings ([3de59ac](3de59ac4f8))

### Features

* add new env config DB_TYPE ([158f044](158f044363))
2023-02-28 21:08:30 +00:00
Allan Bowe
6373442f83 Merge pull request #340 from sasjs/issue-339
feat: add new env config DB_TYPE
2023-02-28 21:04:25 +00:00
munja
3de59ac4f8 fix: lint + remove default settings 2023-02-28 21:01:39 +00:00
Allan Bowe
941988cd7c chore(docs): linking to official docs 2023-02-28 20:55:32 +00:00
158f044363 feat: add new env config DB_TYPE 2023-03-01 01:41:08 +05:00
semantic-release-bot
02ae041a81 chore(release): 0.29.0 [skip ci]
# [0.29.0](https://github.com/sasjs/server/compare/v0.28.7...v0.29.0) (2023-02-06)

### Features

* Add /SASjsApi endpoint in permissions ([b3402ea](b3402ea80a))
2023-02-06 13:07:06 +00:00
Allan Bowe
c4c84b1537 Merge pull request #338 from sasjs/issue-224
feat: Add /SASjsApi endpoint in permissions
2023-02-06 13:02:49 +00:00
b3402ea80a feat: Add /SASjsApi endpoint in permissions 2023-02-06 15:29:24 +05:00
semantic-release-bot
abe942e697 chore(release): 0.28.7 [skip ci]
## [0.28.7](https://github.com/sasjs/server/compare/v0.28.6...v0.28.7) (2023-02-03)

### Bug Fixes

* add user to all users group on user creation ([2bae52e](2bae52e307))
2023-02-03 13:48:40 +00:00
Allan Bowe
faf2edb111 Merge pull request #337 from sasjs/issue-336
fix: add user to all users group on user creation
2023-02-03 13:44:46 +00:00
5bec453e89 chore: quick fix 2023-02-03 18:39:35 +05:00
7f2174dd2c chore: quick fix 2023-02-03 16:48:18 +05:00
2bae52e307 fix: add user to all users group on user creation 2023-02-03 16:47:18 +05:00
semantic-release-bot
b243e62ece chore(release): 0.28.6 [skip ci]
## [0.28.6](https://github.com/sasjs/server/compare/v0.28.5...v0.28.6) (2023-01-26)

### Bug Fixes

* show loading spinner on login screen while request is in process ([69f2576](69f2576ee6))
2023-01-26 18:20:28 +00:00
Sabir Hassan
88c3056e97 Merge pull request #335 from sasjs/issue-330
fix: show loading spinner on login screen while request is in process
2023-01-26 23:16:25 +05:00
203303b659 chore: bump the version of mongodb-memory-server 2023-01-26 23:12:46 +05:00
835709bd36 chore: npm audit fix 2023-01-26 23:10:20 +05:00
69f2576ee6 fix: show loading spinner on login screen while request is in process 2023-01-26 22:27:43 +05:00
semantic-release-bot
305077f36e chore(release): 0.28.5 [skip ci]
## [0.28.5](https://github.com/sasjs/server/compare/v0.28.4...v0.28.5) (2023-01-01)

### Bug Fixes

* adding NOPRNGETLIST system option for faster startup ([96eca3a](96eca3a35d))
2023-01-01 16:55:09 +00:00
Allan Bowe
96eca3a35d fix: adding NOPRNGETLIST system option for faster startup 2023-01-01 16:49:48 +00:00
semantic-release-bot
0f5c815c25 chore(release): 0.28.4 [skip ci]
## [0.28.4](https://github.com/sasjs/server/compare/v0.28.3...v0.28.4) (2022-12-07)

### Bug Fixes

* replace main class with container class ([71c429b](71c429b093))
2022-12-07 16:08:32 +00:00
Allan Bowe
acccef1e99 Merge pull request #334 from sasjs/issue-332
fix: Studio Editor autocomplete invisible
2022-12-07 16:04:42 +00:00
abc34ea047 chore: npm audit fix 2022-12-07 20:26:31 +05:00
71c429b093 fix: replace main class with container class 2022-12-07 20:25:06 +05:00
semantic-release-bot
c126f2d5d9 chore(release): 0.28.3 [skip ci]
## [0.28.3](https://github.com/sasjs/server/compare/v0.28.2...v0.28.3) (2022-12-06)

### Bug Fixes

* stringify json file ([1192583](1192583843))
2022-12-06 14:17:01 +00:00
Allan Bowe
34dd95d16e Merge pull request #333 from sasjs/issue-331
fix: stringify json file
2022-12-06 14:11:37 +00:00
1192583843 fix: stringify json file 2022-12-06 18:55:01 +05:00
semantic-release-bot
518815acf1 chore(release): 0.28.2 [skip ci]
## [0.28.2](https://github.com/sasjs/server/compare/v0.28.1...v0.28.2) (2022-12-05)

### Bug Fixes

* execute child process asyncronously ([23c997b](23c997b3be))
* JS / Python / R session folders should be NEW folders, not existing SAS folders ([39ba995](39ba995355))
2022-12-05 16:25:38 +00:00
Allan Bowe
80b7e14ed5 Merge pull request #329 from sasjs/issue-326
fix: non sas programs shouldn't use sas session folder
2022-12-05 16:21:58 +00:00
23c997b3be fix: execute child process asyncronously 2022-12-01 23:27:40 +05:00
39ba995355 fix: JS / Python / R session folders should be NEW folders, not existing SAS folders 2022-12-01 23:26:30 +05:00
semantic-release-bot
0e081e024b chore(release): 0.28.1 [skip ci]
## [0.28.1](https://github.com/sasjs/server/compare/v0.28.0...v0.28.1) (2022-11-28)

### Bug Fixes

* update the content type header after the program has been executed ([4dcee4b](4dcee4b3c3))
2022-11-28 23:25:10 +00:00
Allan Bowe
6a84bd0387 Merge pull request #327 from sasjs/issue-325
fix: default response header fixed when debug is ON
2022-11-28 23:20:30 +00:00
98d177a691 chore: audit fix 2022-11-28 23:55:21 +05:00
4dcee4b3c3 fix: update the content type header after the program has been executed 2022-11-28 23:53:36 +05:00
semantic-release-bot
4ffc1ec6a9 chore(release): 0.28.0 [skip ci]
# [0.28.0](https://github.com/sasjs/server/compare/v0.27.0...v0.28.0) (2022-11-28)

### Bug Fixes

* update the response header of request to stp/execute routes ([112431a](112431a1b7))

### Features

* **api:** add the api endpoint for updating user password ([4581f32](4581f32534))
* ask for updated password on first login ([1d48f88](1d48f8856b))
* **web:** add the UI for updating user password ([8b8c43c](8b8c43c21b))
2022-11-28 17:43:05 +00:00
Allan Bowe
5a1d168e83 Merge pull request #324 from sasjs/issue-322
fix: update the response header of request to stp/execute routes
2022-11-28 17:38:05 +00:00
Allan Bowe
515c976685 Merge pull request #323 from sasjs/issue-222
feat: force user to change password on first login
2022-11-28 17:37:17 +00:00
112431a1b7 fix: update the response header of request to stp/execute routes 2022-11-27 21:57:26 +05:00
c26485afec chore: fix specs 2022-11-22 20:15:26 +05:00
1d48f8856b feat: ask for updated password on first login 2022-11-22 19:58:17 +05:00
68758aa616 chore: new password should be different to current password 2022-11-22 15:26:22 +05:00
8b8c43c21b feat(web): add the UI for updating user password 2022-11-22 00:03:25 +05:00
4581f32534 feat(api): add the api endpoint for updating user password 2022-11-22 00:02:59 +05:00
b47e74a7e1 chore: styles fix 2022-11-22 00:01:58 +05:00
b27d684145 chore: use process.logger instead of condole.log 2022-11-17 23:03:33 +05:00
semantic-release-bot
6b666d5554 chore(release): 0.27.0 [skip ci]
# [0.27.0](https://github.com/sasjs/server/compare/v0.26.2...v0.27.0) (2022-11-17)

### Features

* on startup add webout.sas file in sasautos folder ([200f6c5](200f6c596a))
2022-11-17 13:21:44 +00:00
Allan Bowe
b5f0911858 Merge pull request #321 from sasjs/issue-318
feat: on startup add webout.sas file in sasautos folder
2022-11-17 13:17:35 +00:00
b86ba5b8a3 chore: lint fix 2022-11-17 17:49:00 +05:00
200f6c596a feat: on startup add webout.sas file in sasautos folder 2022-11-17 17:03:23 +05:00
semantic-release-bot
1b7ccda6e9 chore(release): 0.26.2 [skip ci]
## [0.26.2](https://github.com/sasjs/server/compare/v0.26.1...v0.26.2) (2022-11-15)

### Bug Fixes

* comments ([7ae862c](7ae862c5ce))
2022-11-15 13:06:36 +00:00
Allan Bowe
532035d835 Merge pull request #317 from sasjs/docfix
fix: comments
2022-11-15 13:01:45 +00:00
Allan Bowe
7ae862c5ce fix: comments 2022-11-15 13:01:13 +00:00
semantic-release-bot
ab5858b8af chore(release): 0.26.1 [skip ci]
## [0.26.1](https://github.com/sasjs/server/compare/v0.26.0...v0.26.1) (2022-11-15)

### Bug Fixes

* change the expiration of access/refresh tokens from days to seconds ([bb05493](bb054938c5))
2022-11-15 12:31:03 +00:00
Allan Bowe
a39f5dd9f1 Merge pull request #316 from sasjs/access-token-expiration
fix: change the expiration of access/refresh tokens from days to seconds
2022-11-15 12:25:41 +00:00
Allan Bowe
3ea444756c Update Client.ts 2022-11-15 11:00:42 +00:00
Allan Bowe
96399ecbbe Update swagger.yaml 2022-11-15 10:54:52 +00:00
bb054938c5 fix: change the expiration of access/refresh tokens from days to seconds 2022-11-15 15:48:03 +05:00
semantic-release-bot
fb6a556630 chore(release): 0.26.0 [skip ci]
# [0.26.0](https://github.com/sasjs/server/compare/v0.25.1...v0.26.0) (2022-11-13)

### Bug Fixes

* **web:** dispose monaco editor actions in return of useEffect ([acc25cb](acc25cbd68))

### Features

* make access token duration configurable when creating client/secret ([2413c05](2413c05fea))
* make refresh token duration configurable ([abd5c64](abd5c64b4a))
2022-11-13 14:04:03 +00:00
Allan Bowe
9dbd8e16bd Merge pull request #315 from sasjs/issue-307
feat: make access token duration configurable when creating client
2022-11-13 14:00:03 +00:00
fe07c41f5f chore: update header 2022-11-11 15:35:24 +05:00
acc25cbd68 fix(web): dispose monaco editor actions in return of useEffect 2022-11-11 15:27:12 +05:00
4ca61feda6 chore: npm audit fix 2022-11-10 21:05:41 +05:00
abd5c64b4a feat: make refresh token duration configurable 2022-11-10 21:02:20 +05:00
2413c05fea feat: make access token duration configurable when creating client/secret 2022-11-10 19:43:06 +05:00
semantic-release-bot
4c874c2c39 chore(release): 0.25.1 [skip ci]
## [0.25.1](https://github.com/sasjs/server/compare/v0.25.0...v0.25.1) (2022-11-07)

### Bug Fixes

* **web:** use mui treeView instead of custom implementation ([c51b504](c51b50428f))
2022-11-07 15:50:02 +00:00
Allan Bowe
d819d79bc9 Merge pull request #313 from sasjs/tree-view
fix(web): use mui treeView instead of custom implementation
2022-11-07 15:46:14 +00:00
c51b50428f fix(web): use mui treeView instead of custom implementation 2022-11-06 01:14:58 +05:00
semantic-release-bot
e10a0554f0 chore(release): 0.25.0 [skip ci]
# [0.25.0](https://github.com/sasjs/server/compare/v0.24.0...v0.25.0) (2022-11-02)

### Features

* Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server ([1c9d167](1c9d167f86))
2022-11-02 15:24:25 +00:00
Allan Bowe
337e2eb2a0 Merge pull request #311 from sasjs/issue-310
feat: Enable DRIVE_LOCATION setting for deploying multiple instances
2022-11-02 15:19:54 +00:00
66f8e7840b chore: update readme 2022-11-02 20:18:28 +05:00
1c9d167f86 feat: Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server 2022-11-02 20:05:12 +05:00
semantic-release-bot
7e684b54a6 chore(release): 0.24.0 [skip ci]
# [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28)

### Features

* cli mock testing ([6434123](6434123401))
* mocking sas9 responses with JS STP ([36be3a7](36be3a7d5e))
2022-10-28 10:05:48 +00:00
Sabir Hassan
aafda2922b Merge pull request #306 from sasjs/sas9-tests-mock-dynamic
feat: cli mock testing
2022-10-28 15:01:00 +05:00
418bf41e38 style: lint 2022-10-28 11:53:42 +02:00
81f0b03b09 chore: comments address 2022-10-28 11:53:25 +02:00
fe5ae44aab chore: typo 2022-10-17 18:32:58 +02:00
36be3a7d5e feat: mocking sas9 responses with JS STP 2022-10-17 18:31:08 +02:00
6434123401 feat: cli mock testing 2022-10-11 18:37:20 +02:00
semantic-release-bot
0a6b972c65 chore(release): 0.23.4 [skip ci]
## [0.23.4](https://github.com/sasjs/server/compare/v0.23.3...v0.23.4) (2022-10-11)

### Bug Fixes

* add action to editor ref for running code ([2412622](2412622367))
2022-10-11 15:26:38 +00:00
Allan Bowe
be11707042 Merge pull request #303 from sasjs/issue-301
fix: add action to editor ref for running code
2022-10-11 16:08:57 +01:00
2412622367 fix: add action to editor ref for running code 2022-10-10 16:51:46 +05:00
semantic-release-bot
de3a190a8d chore(release): 0.23.3 [skip ci]
## [0.23.3](https://github.com/sasjs/server/compare/v0.23.2...v0.23.3) (2022-10-09)

### Bug Fixes

* added domain for session cookies ([94072c3](94072c3d24))
2022-10-09 20:32:07 +00:00
Allan Bowe
d5daafc6ed Merge pull request #302 from sasjs/cookies-with-domain
fix: added domain for session cookies
2022-10-09 21:26:40 +01:00
Saad Jutt
b1a2677b8c chore: specified domain for cookie for csrf as well 2022-10-10 00:48:13 +05:00
Saad Jutt
94072c3d24 fix: added domain for session cookies 2022-10-09 22:08:01 +05:00
semantic-release-bot
b64c0c12da chore(release): 0.23.2 [skip ci]
## [0.23.2](https://github.com/sasjs/server/compare/v0.23.1...v0.23.2) (2022-10-06)

### Bug Fixes

* bump in correct place ([14731e8](14731e8824))
* bumping sasjs/score ([258cc35](258cc35f14))
* reverting commit ([fda0e0b](fda0e0b57d))
2022-10-06 12:41:15 +00:00
Allan Bowe
79bc7b0e28 Merge pull request #300 from sasjs/corebump
fix: bumping sasjs/score
2022-10-06 13:36:20 +01:00
Allan Bowe
fda0e0b57d fix: reverting commit 2022-10-06 12:35:59 +00:00
Allan Bowe
14731e8824 fix: bump in correct place 2022-10-06 12:34:48 +00:00
Allan Bowe
258cc35f14 fix: bumping sasjs/score 2022-10-06 12:32:13 +00:00
semantic-release-bot
2295a518f0 chore(release): 0.23.1 [skip ci]
## [0.23.1](https://github.com/sasjs/server/compare/v0.23.0...v0.23.1) (2022-10-04)

### Bug Fixes

* ldap issues ([4d64420](4d64420c45))
2022-10-04 16:54:37 +00:00
Allan Bowe
1e5d621817 Merge pull request #298 from sasjs/fix-ldap
fix: ldap issues
2022-10-03 18:49:53 +01:00
4d64420c45 fix: ldap issues
logic fixed for updating user created by external auth provider
remove internal from AuthProviderType
replace AUTH_MECHANISM with AUTH_PROVIDERS
2022-10-03 21:24:10 +05:00
semantic-release-bot
799339de30 chore(release): 0.23.0 [skip ci]
# [0.23.0](https://github.com/sasjs/server/compare/v0.22.1...v0.23.0) (2022-10-03)

### Features

* Enable SAS_PACKAGES in SASjs Server ([424f0fc](424f0fc1fa))
2022-10-03 15:13:11 +00:00
Allan Bowe
042ed41189 Merge pull request #297 from sasjs/issue-292
feat: Enable SAS_PACKAGES in SASjs Server
2022-10-03 16:08:30 +01:00
424f0fc1fa feat: Enable SAS_PACKAGES in SASjs Server 2022-10-03 19:43:02 +05:00
90 changed files with 5796 additions and 1291 deletions

2
.gitignore vendored
View File

@@ -5,8 +5,6 @@ node_modules/
.env*
sas/
sasjs_root/
api/mocks/custom/*
!api/mocks/custom/.keep
tmp/
build/
sasjsbuild/

View File

@@ -1,3 +1,235 @@
## [0.33.2](https://github.com/sasjs/server/compare/v0.33.1...v0.33.2) (2023-04-24)
### Bug Fixes
* removing print redirection pending full [#274](https://github.com/sasjs/server/issues/274) fix ([d49ea47](https://github.com/sasjs/server/commit/d49ea47bd7a2add42bdb9a717082201f29e16597))
## [0.33.1](https://github.com/sasjs/server/compare/v0.33.0...v0.33.1) (2023-04-20)
### Bug Fixes
* applying nologo only for sas.exe ([b4436ba](https://github.com/sasjs/server/commit/b4436bad0d24d5b5a402272632db1739b1018c90)), closes [#352](https://github.com/sasjs/server/issues/352)
# [0.33.0](https://github.com/sasjs/server/compare/v0.32.0...v0.33.0) (2023-04-05)
### Features
* option to reset admin password on startup ([eda8e56](https://github.com/sasjs/server/commit/eda8e56bb0ea20fdaacabbbe7dcf1e3ea7bd215a))
# [0.32.0](https://github.com/sasjs/server/compare/v0.31.0...v0.32.0) (2023-04-05)
### Features
* add an api endpoint for admin to get list of client ids ([6ffaa7e](https://github.com/sasjs/server/commit/6ffaa7e9e2a62c083bb9fcc3398dcbed10cebdb1))
# [0.31.0](https://github.com/sasjs/server/compare/v0.30.3...v0.31.0) (2023-03-30)
### Features
* prevent brute force attack by rate limiting login endpoint ([a82cabb](https://github.com/sasjs/server/commit/a82cabb00134c79c5ee77afd1b1628a1f768e050))
## [0.30.3](https://github.com/sasjs/server/compare/v0.30.2...v0.30.3) (2023-03-07)
### Bug Fixes
* add location.pathname to location.origin conditionally ([edab51c](https://github.com/sasjs/server/commit/edab51c51997f17553e037dc7c2b5e5fa6ea8ffe))
## [0.30.2](https://github.com/sasjs/server/compare/v0.30.1...v0.30.2) (2023-03-07)
### Bug Fixes
* **web:** add path to base in launch program url ([2c31922](https://github.com/sasjs/server/commit/2c31922f58a8aa20d7fa6bfc95b53a350f90c798))
## [0.30.1](https://github.com/sasjs/server/compare/v0.30.0...v0.30.1) (2023-03-01)
### Bug Fixes
* **web:** add proper base url in axios.defaults ([5e3ce8a](https://github.com/sasjs/server/commit/5e3ce8a98f1825e14c1d26d8da0c9821beeff7b3))
# [0.30.0](https://github.com/sasjs/server/compare/v0.29.0...v0.30.0) (2023-02-28)
### Bug Fixes
* lint + remove default settings ([3de59ac](https://github.com/sasjs/server/commit/3de59ac4f8e3d95cad31f09e6963bd04c4811f26))
### Features
* add new env config DB_TYPE ([158f044](https://github.com/sasjs/server/commit/158f044363abf2576c8248f0ca9da4bc9cb7e9d8))
# [0.29.0](https://github.com/sasjs/server/compare/v0.28.7...v0.29.0) (2023-02-06)
### Features
* Add /SASjsApi endpoint in permissions ([b3402ea](https://github.com/sasjs/server/commit/b3402ea80afb8802eee8b8b6cbbbcc29903424bc))
## [0.28.7](https://github.com/sasjs/server/compare/v0.28.6...v0.28.7) (2023-02-03)
### Bug Fixes
* add user to all users group on user creation ([2bae52e](https://github.com/sasjs/server/commit/2bae52e307327d7ee4a94b19d843abdc0ccec9d1))
## [0.28.6](https://github.com/sasjs/server/compare/v0.28.5...v0.28.6) (2023-01-26)
### Bug Fixes
* show loading spinner on login screen while request is in process ([69f2576](https://github.com/sasjs/server/commit/69f2576ee6d3d7b7f3325922a88656d511e3ac88))
## [0.28.5](https://github.com/sasjs/server/compare/v0.28.4...v0.28.5) (2023-01-01)
### Bug Fixes
* adding NOPRNGETLIST system option for faster startup ([96eca3a](https://github.com/sasjs/server/commit/96eca3a35dce4521150257ee019beb4488c8a08f))
## [0.28.4](https://github.com/sasjs/server/compare/v0.28.3...v0.28.4) (2022-12-07)
### Bug Fixes
* replace main class with container class ([71c429b](https://github.com/sasjs/server/commit/71c429b093b91e2444ae75d946579dccc2e48636))
## [0.28.3](https://github.com/sasjs/server/compare/v0.28.2...v0.28.3) (2022-12-06)
### Bug Fixes
* stringify json file ([1192583](https://github.com/sasjs/server/commit/1192583843d7efd1a6ab6943207f394c3ae966be))
## [0.28.2](https://github.com/sasjs/server/compare/v0.28.1...v0.28.2) (2022-12-05)
### Bug Fixes
* execute child process asyncronously ([23c997b](https://github.com/sasjs/server/commit/23c997b3beabeb6b733ae893031d2f1a48f28ad2))
* JS / Python / R session folders should be NEW folders, not existing SAS folders ([39ba995](https://github.com/sasjs/server/commit/39ba995355daa24bb7ab22720f8fc57d2dc85f40))
## [0.28.1](https://github.com/sasjs/server/compare/v0.28.0...v0.28.1) (2022-11-28)
### Bug Fixes
* update the content type header after the program has been executed ([4dcee4b](https://github.com/sasjs/server/commit/4dcee4b3c3950d402220b8f451c50ad98a317d83))
# [0.28.0](https://github.com/sasjs/server/compare/v0.27.0...v0.28.0) (2022-11-28)
### Bug Fixes
* update the response header of request to stp/execute routes ([112431a](https://github.com/sasjs/server/commit/112431a1b7461989c04100418d67d975a2a8f354))
### Features
* **api:** add the api endpoint for updating user password ([4581f32](https://github.com/sasjs/server/commit/4581f325344eb68c5df5a28492f132312f15bb5c))
* ask for updated password on first login ([1d48f88](https://github.com/sasjs/server/commit/1d48f8856b1fbbf3ef868914558333190e04981f))
* **web:** add the UI for updating user password ([8b8c43c](https://github.com/sasjs/server/commit/8b8c43c21bde5379825c5ec44ecd81a92425f605))
# [0.27.0](https://github.com/sasjs/server/compare/v0.26.2...v0.27.0) (2022-11-17)
### Features
* on startup add webout.sas file in sasautos folder ([200f6c5](https://github.com/sasjs/server/commit/200f6c596a6e732d799ed408f1f0fd92f216ba58))
## [0.26.2](https://github.com/sasjs/server/compare/v0.26.1...v0.26.2) (2022-11-15)
### Bug Fixes
* comments ([7ae862c](https://github.com/sasjs/server/commit/7ae862c5ce720e9483d4728f4295dede4f849436))
## [0.26.1](https://github.com/sasjs/server/compare/v0.26.0...v0.26.1) (2022-11-15)
### Bug Fixes
* change the expiration of access/refresh tokens from days to seconds ([bb05493](https://github.com/sasjs/server/commit/bb054938c5bd0535ae6b9da93ba0b14f9b80ddcd))
# [0.26.0](https://github.com/sasjs/server/compare/v0.25.1...v0.26.0) (2022-11-13)
### Bug Fixes
* **web:** dispose monaco editor actions in return of useEffect ([acc25cb](https://github.com/sasjs/server/commit/acc25cbd686952d3f1c65e57aefcebe1cb859cc7))
### Features
* make access token duration configurable when creating client/secret ([2413c05](https://github.com/sasjs/server/commit/2413c05fea3960f7e5c3c8b7b2f85d61314f08db))
* make refresh token duration configurable ([abd5c64](https://github.com/sasjs/server/commit/abd5c64b4a726e3f17594a98111b6aa269b71fee))
## [0.25.1](https://github.com/sasjs/server/compare/v0.25.0...v0.25.1) (2022-11-07)
### Bug Fixes
* **web:** use mui treeView instead of custom implementation ([c51b504](https://github.com/sasjs/server/commit/c51b50428f32608bc46438e9d7964429b2d595da))
# [0.25.0](https://github.com/sasjs/server/compare/v0.24.0...v0.25.0) (2022-11-02)
### Features
* Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server ([1c9d167](https://github.com/sasjs/server/commit/1c9d167f86bbbb108b96e9bc30efaf8de65d82ff))
# [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28)
### Features
* cli mock testing ([6434123](https://github.com/sasjs/server/commit/643412340162e854f31fba2f162d83b7ab1751d8))
* mocking sas9 responses with JS STP ([36be3a7](https://github.com/sasjs/server/commit/36be3a7d5e7df79f9a1f3f00c3661b925f462383))
## [0.23.4](https://github.com/sasjs/server/compare/v0.23.3...v0.23.4) (2022-10-11)
### Bug Fixes
* add action to editor ref for running code ([2412622](https://github.com/sasjs/server/commit/2412622367eb46c40f388e988ae4606a7ec239b2))
## [0.23.3](https://github.com/sasjs/server/compare/v0.23.2...v0.23.3) (2022-10-09)
### Bug Fixes
* added domain for session cookies ([94072c3](https://github.com/sasjs/server/commit/94072c3d24a4d0d4c97900dc31bfbf1c9d2559b7))
## [0.23.2](https://github.com/sasjs/server/compare/v0.23.1...v0.23.2) (2022-10-06)
### Bug Fixes
* bump in correct place ([14731e8](https://github.com/sasjs/server/commit/14731e8824fa9f3d1daf89fd62f9916d5e3fcae4))
* bumping sasjs/score ([258cc35](https://github.com/sasjs/server/commit/258cc35f14cf50f2160f607000c60de27593fd79))
* reverting commit ([fda0e0b](https://github.com/sasjs/server/commit/fda0e0b57d56e3b5231e626a8d933343ac0c5cdc))
## [0.23.1](https://github.com/sasjs/server/compare/v0.23.0...v0.23.1) (2022-10-04)
### Bug Fixes
* ldap issues ([4d64420](https://github.com/sasjs/server/commit/4d64420c45424134b4d2014a2d5dd6e846ed03b3))
# [0.23.0](https://github.com/sasjs/server/compare/v0.22.1...v0.23.0) (2022-10-03)
### Features
* Enable SAS_PACKAGES in SASjs Server ([424f0fc](https://github.com/sasjs/server/commit/424f0fc1faec765eb7a14619584e649454105b70))
## [0.22.1](https://github.com/sasjs/server/compare/v0.22.0...v0.22.1) (2022-10-03)

View File

@@ -93,6 +93,10 @@ R_PATH=/usr/bin/Rscript
SASJS_ROOT=./sasjs_root
# This location is for files, sasjs packages and appStreamConfig.json
DRIVE_LOCATION=./sasjs_root/drive
# options: [http|https] default: http
PROTOCOL=
@@ -103,6 +107,11 @@ PORT=
# If not present, mocking function is disabled
MOCK_SERVERTYPE=
# default: /api/mocks
# Path to mocking folder, for generic responses, it's sub directories should be: sas9, viya, sasjs
# Server will automatically use subdirectory accordingly
STATIC_MOCK_LOCATION=
#
## Additional SAS Options
#
@@ -128,6 +137,9 @@ CA_ROOT=fullchain.pem (optional)
## ENV variables required for MODE: `server`
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# options: [mongodb|cosmos_mongodb] default: mongodb
DB_TYPE=
# AUTH_PROVIDERS options: [ldap] default: ``
AUTH_PROVIDERS=
@@ -163,6 +175,32 @@ HELMET_COEP=
# }
HELMET_CSP_CONFIG_PATH=./csp.config.json
# To prevent brute force attack on login route we have implemented rate limiter
# Only valid for MODE: server
# Following are configurable env variable rate limiter
# After this, access is blocked for 1 day
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
# After this, access is blocked for an hour
# Store number for 90 days since first fail
# Once a successful login is attempted, it resets
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
# Name of the admin user that will be created on startup if not exists already
# Default is `secretuser`
ADMIN_USERNAME=secretuser
# Temporary password for the ADMIN_USERNAME, which is in place until the first login
# Default is `secretpassword`
ADMIN_PASSWORD_INITIAL=secretpassword
# Specify whether app has to reset the ADMIN_USERNAME's password or not
# Default is NO. Possible options are YES and NO
# If ADMIN_PASSWORD_RESET is YES then the ADMIN_USERNAME will be prompted to change the password from ADMIN_PASSWORD_INITIAL on their next login. This will repeat on every server restart, unless the option is removed / set to NO.
ADMIN_PASSWORD_RESET=NO
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
LOG_FORMAT_MORGAN=

View File

@@ -1,5 +1,6 @@
MODE=[desktop|server] default considered as desktop
CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE
ALLOWED_DOMAIN=<just domain e.g. example.com >
WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
PROTOCOL=[http|https] default considered as http
@@ -13,8 +14,9 @@ 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
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
DB_TYPE=[mongodb|cosmos_mongodb] default considered as mongodb
AUTH_PROVIDERS=[ldap|internal] default considered as internal
AUTH_PROVIDERS=[ldap]
LDAP_URL= <LDAP_SERVER_URL>
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
@@ -22,6 +24,16 @@ LDAP_BIND_PASSWORD = <password>
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
#default value is 100
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
#default value is 10
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
ADMIN_USERNAME=secretuser
ADMIN_PASSWORD_INITIAL=secretpassword
ADMIN_PASSWORD_RESET=NO
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
@@ -29,6 +41,7 @@ PYTHON_PATH=/usr/bin/python
R_PATH=/usr/bin/Rscript
SASJS_ROOT=./sasjs_root
DRIVE_LOCATION=./sasjs_root/drive
LOG_FORMAT_MORGAN=common
LOG_LOCATION=./sasjs_root/logs

View File

View File

@@ -9,7 +9,7 @@
<div class="content">
<form id="credentials" class="minimal" action="/SASLogon/login?service=http%3A%2F%2Flocalhost:5004%2FSASStoredProcess%2Fj_spring_cas_security_check" method="post">
<!--form container-->
<input type="hidden" name="lt" value="LT-8-WGkt9EXwICBihaVbxGc92opjufTK1D" aria-hidden="true" />
<input type="hidden" name="lt" value="validtoken" aria-hidden="true" />
<input type="hidden" name="execution" value="e2s1" aria-hidden="true" />
<input type="hidden" name="_eventId" value="submit" aria-hidden="true" />

3489
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Api of SASjs server",
"main": "./src/server.ts",
"scripts": {
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore",
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
"prestart": "npm run initial",
"prebuild": "npm run initial",
"start": "NODE_ENV=development nodemon ./src/server.ts",
@@ -17,20 +17,21 @@
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"exe": "npm run build && pkg .",
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sasjscore:copy && npm run web:copy",
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
"public:copy": "cp -r ./public/ ./build/public/",
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
"sasjscore:copy": "cp -r ./sasjscore/ ./build/sasjscore/",
"sas:copy": "cp -r ./sas/ ./build/sas/",
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts"
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
},
"bin": "./build/src/server.js",
"pkg": {
"assets": [
"./build/public/**/*",
"./build/sasjsbuild/**/*",
"./build/sasjscore/**/*",
"./build/sas/**/*",
"./web/build/**/*"
],
"targets": [
@@ -47,8 +48,8 @@
},
"author": "4GL Ltd",
"dependencies": {
"@sasjs/core": "^4.31.3",
"@sasjs/utils": "2.48.1",
"@sasjs/core": "^4.40.1",
"@sasjs/utils": "3.2.0",
"bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0",
"cookie-parser": "^1.4.6",
@@ -63,6 +64,7 @@
"mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"rate-limiter-flexible": "2.4.1",
"rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11",
@@ -86,11 +88,13 @@
"@types/swagger-ui-express": "^4.1.3",
"@types/unzipper": "^0.10.5",
"adm-zip": "^0.5.9",
"axios": "0.27.2",
"csrf": "^3.1.0",
"dotenv": "^10.0.0",
"http-headers-validation": "^0.0.1",
"jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0",
"mongodb-memory-server": "8.11.4",
"nodejs-file-downloader": "4.10.2",
"nodemon": "^2.0.7",
"pkg": "5.6.0",
"prettier": "^2.3.1",

View File

@@ -47,6 +47,21 @@ components:
- userId
type: object
additionalProperties: false
UpdatePasswordPayload:
properties:
currentPassword:
type: string
description: 'Current Password'
example: currentPasswordString
newPassword:
type: string
description: 'New Password'
example: newPassword
required:
- currentPassword
- newPassword
type: object
additionalProperties: false
ClientPayload:
properties:
clientId:
@@ -57,6 +72,16 @@ components:
type: string
description: 'Client Secret'
example: someRandomCryptoString
accessTokenExpiration:
type: number
format: double
description: 'Number of seconds after which access token will expire. Default is 86400 (1 day)'
example: 86400
refreshTokenExpiration:
type: number
format: double
description: 'Number of seconds after which access token will expire. Default is 2592000 (30 days)'
example: 2592000
required:
- clientId
- clientSecret
@@ -509,6 +534,27 @@ components:
- setting
type: object
additionalProperties: false
SessionResponse:
properties:
id:
type: number
format: double
username:
type: string
displayName:
type: string
isAdmin:
type: boolean
needsToUpdatePassword:
type: boolean
required:
- id
- username
- displayName
- isAdmin
- needsToUpdatePassword
type: object
additionalProperties: false
ExecutePostRequestPayload:
properties:
_program:
@@ -622,6 +668,25 @@ paths:
-
bearerAuth: []
parameters: []
/SASjsApi/auth/updatePassword:
patch:
operationId: UpdatePassword
responses:
'204':
description: 'No content'
summary: 'Update user''s password.'
tags:
- Auth
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdatePasswordPayload'
/SASjsApi/authConfig:
get:
operationId: GetDetail
@@ -679,8 +744,8 @@ paths:
$ref: '#/components/schemas/ClientPayload'
examples:
'Example 1':
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString}
summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.'
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}
summary: "Admin only task. Create client with the following attributes:\nClientId,\nClientSecret,\naccessTokenExpiration (optional),\nrefreshTokenExpiration (optional)"
tags:
- Client
security:
@@ -693,6 +758,27 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ClientPayload'
get:
operationId: GetAllClients
responses:
'200':
description: Ok
content:
application/json:
schema:
items:
$ref: '#/components/schemas/ClientPayload'
type: array
examples:
'Example 1':
value: [{clientId: someClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}, {clientId: someOtherClientID, clientSecret: someOtherRandomCryptoString, accessTokenExpiration: 86400}]
summary: 'Admin only task. Returns the list of all the clients *'
tags:
- Client
security:
-
bearerAuth: []
parameters: []
/SASjsApi/code/execute:
post:
operationId: ExecuteCode
@@ -1680,7 +1766,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
$ref: '#/components/schemas/SessionResponse'
examples:
'Example 1':
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
@@ -1777,7 +1863,7 @@ paths:
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}
user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
loggedIn: {type: boolean}
required:
- user

View File

@@ -0,0 +1,39 @@
import axios from 'axios'
import Downloader from 'nodejs-file-downloader'
import { createFile, listFilesInFolder } from '@sasjs/utils'
import { sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils/file'
export const downloadMacros = async () => {
const url =
'https://api.github.com/repos/yabwon/SAS_PACKAGES/contents/SPF/Macros'
console.info(`Downloading macros from ${url}`)
await axios
.get(url)
.then(async (res) => {
await downloadFiles(res.data)
})
.catch((err) => {
throw new Error(err)
})
}
const downloadFiles = async function (fileList: any) {
for (const file of fileList) {
const downloader = new Downloader({
url: file.download_url,
directory: sasJSCoreMacros,
fileName: file.path.replace(/^SPF\/Macros/, ''),
cloneFiles: false
})
await downloader.download()
}
const fileNames = await listFilesInFolder(sasJSCoreMacros)
await createFile(sasJSCoreMacrosInfo, fileNames.join('\n'))
}
downloadMacros()

View File

@@ -15,7 +15,7 @@ export const configureCors = (app: Express) => {
whiteList.push(url.replace(/\/$/, ''))
})
console.log('All CORS Requests are enabled for:', whiteList)
process.logger.info('All CORS Requests are enabled for:', whiteList)
app.use(cors({ credentials: true, origin: whiteList }))
}
}

View File

@@ -1,22 +1,38 @@
import { Express } from 'express'
import { Express, CookieOptions } from 'express'
import mongoose from 'mongoose'
import session from 'express-session'
import MongoStore from 'connect-mongo'
import { ModeType } from '../utils'
import { cookieOptions } from '../app'
import { DatabaseType, ModeType, ProtocolType } from '../utils'
export const configureExpressSession = (app: Express) => {
const { MODE } = process.env
const { MODE, DB_TYPE } = 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'
})
if (DB_TYPE === DatabaseType.COSMOS_MONGODB) {
// COSMOS DB requires specific connection options (compatibility mode)
// See: https://www.npmjs.com/package/connect-mongo#set-the-compatibility-mode
store = MongoStore.create({
client: mongoose.connection!.getClient() as any,
autoRemove: 'interval'
})
} else {
store = MongoStore.create({
client: mongoose.connection!.getClient() as any
})
}
}
const { PROTOCOL, ALLOWED_DOMAIN } = process.env
const cookieOptions: CookieOptions = {
secure: PROTOCOL === ProtocolType.HTTPS,
httpOnly: true,
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
domain: ALLOWED_DOMAIN?.trim() || undefined
}
app.use(

View File

@@ -23,7 +23,7 @@ export const configureLogger = (app: Express) => {
path: logsFolder
})
console.log('Writing Logs to :', path.join(logsFolder, filename))
process.logger.info('Writing Logs to :', path.join(logsFolder, filename))
options = { stream: accessLogStream }
}

View File

@@ -1,17 +1,21 @@
import path from 'path'
import express, { ErrorRequestHandler, CookieOptions } from 'express'
import express, { ErrorRequestHandler } from 'express'
import cookieParser from 'cookie-parser'
import dotenv from 'dotenv'
import {
copySASjsCore,
createWeboutSasFile,
getFilesFolder,
getPackagesFolder,
getWebBuildFolder,
instantiateLogger,
loadAppStreamConfig,
ProtocolType,
ReturnCode,
setProcessVariables,
setupFolders,
setupFilesFolder,
setupPackagesFolder,
setupUserAutoExec,
verifyEnvVariables
} from './utils'
import {
@@ -20,6 +24,7 @@ import {
configureLogger,
configureSecurity
} from './app-modules'
import { folderExists } from '@sasjs/utils'
dotenv.config()
@@ -29,17 +34,8 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
const app = express()
const { PROTOCOL } = process.env
export const cookieOptions: CookieOptions = {
secure: PROTOCOL === ProtocolType.HTTPS,
httpOnly: true,
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
const onError: ErrorRequestHandler = (err, req, res, next) => {
console.error(err.stack)
process.logger.error(err.stack)
res.status(500).send('Something broke!')
}
@@ -72,8 +68,21 @@ export default setProcessVariables().then(async () => {
// Currently only place we use it is SAS9 Mock - POST /SASLogon/login
app.use(express.urlencoded({ extended: true }))
await setupFolders()
await copySASjsCore()
await setupUserAutoExec()
if (!(await folderExists(getFilesFolder()))) await setupFilesFolder()
if (!(await folderExists(getPackagesFolder()))) await setupPackagesFolder()
const sasautosPath = path.join(process.driveLoc, 'sas', 'sasautos')
if (await folderExists(sasautosPath)) {
process.logger.warn(
`SASAUTOS was not refreshed. To force a refresh, delete the ${sasautosPath} folder`
)
} else {
await copySASjsCore()
await createWeboutSasFile()
}
// loading these modules after setting up variables due to
// multer's usage of process var process.driveLoc

View File

@@ -1,4 +1,16 @@
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
import express from 'express'
import {
Security,
Route,
Tags,
Example,
Post,
Patch,
Request,
Body,
Query,
Hidden
} from 'tsoa'
import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
import {
@@ -8,6 +20,8 @@ import {
removeTokensInDB,
saveTokensInDB
} from '../utils'
import Client from '../model/Client'
import User from '../model/User'
@Route('SASjsApi/auth')
@Tags('Auth')
@@ -61,6 +75,18 @@ export class AuthController {
public async logout(@Query() @Hidden() data?: InfoJWT) {
return logout(data!)
}
/**
* @summary Update user's password.
*/
@Security('bearerAuth')
@Patch('updatePassword')
public async updatePassword(
@Request() req: express.Request,
@Body() body: UpdatePasswordPayload
) {
return updatePassword(req, body)
}
}
const token = async (data: any): Promise<TokenResponse> => {
@@ -83,8 +109,17 @@ const token = async (data: any): Promise<TokenResponse> => {
}
}
const accessToken = generateAccessToken(userInfo)
const refreshToken = generateRefreshToken(userInfo)
const client = await Client.findOne({ clientId })
if (!client) throw new Error('Invalid clientId.')
const accessToken = generateAccessToken(
userInfo,
client.accessTokenExpiration
)
const refreshToken = generateRefreshToken(
userInfo,
client.refreshTokenExpiration
)
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
@@ -92,8 +127,17 @@ const token = async (data: any): Promise<TokenResponse> => {
}
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
const accessToken = generateAccessToken(userInfo)
const refreshToken = generateRefreshToken(userInfo)
const client = await Client.findOne({ clientId: userInfo.clientId })
if (!client) throw new Error('Invalid clientId.')
const accessToken = generateAccessToken(
userInfo,
client.accessTokenExpiration
)
const refreshToken = generateRefreshToken(
userInfo,
client.refreshTokenExpiration
)
await saveTokensInDB(
userInfo.userId,
@@ -109,6 +153,40 @@ const logout = async (userInfo: InfoJWT) => {
await removeTokensInDB(userInfo.userId, userInfo.clientId)
}
const updatePassword = async (
req: express.Request,
data: UpdatePasswordPayload
) => {
const { currentPassword, newPassword } = data
const userId = req.user?.userId
const dbUser = await User.findOne({ id: userId })
if (!dbUser)
throw {
code: 404,
message: `User not found!`
}
if (dbUser?.authProvider) {
throw {
code: 405,
message:
'Can not update password of user that is created by an external auth provider.'
}
}
const validPass = dbUser.comparePassword(currentPassword)
if (!validPass)
throw {
code: 403,
message: `Invalid current password!`
}
dbUser.password = User.hashPassword(newPassword)
dbUser.needsToUpdatePassword = false
await dbUser.save()
}
interface TokenPayload {
/**
* Client ID
@@ -135,6 +213,19 @@ interface TokenResponse {
refreshToken: string
}
interface UpdatePasswordPayload {
/**
* Current Password
* @example "currentPasswordString"
*/
currentPassword: string
/**
* New Password
* @example "newPassword"
*/
newPassword: string
}
const verifyAuthCode = async (
clientId: string,
code: string

View File

@@ -74,7 +74,8 @@ const synchroniseWithLDAP = async () => {
displayName: user.displayName,
username: user.username,
password: hashPassword,
authProvider: AuthProviderType.LDAP
authProvider: AuthProviderType.LDAP,
needsToUpdatePassword: false
})
importedUsers.push(user)

View File

@@ -1,18 +1,27 @@
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
import { Security, Route, Tags, Example, Post, Body, Get } from 'tsoa'
import Client, { ClientPayload } from '../model/Client'
import Client, {
ClientPayload,
NUMBER_OF_SECONDS_IN_A_DAY
} from '../model/Client'
@Security('bearerAuth')
@Route('SASjsApi/client')
@Tags('Client')
export class ClientController {
/**
* @summary Create client with the following attributes: ClientId, ClientSecret. Admin only task.
* @summary Admin only task. Create client with the following attributes:
* ClientId,
* ClientSecret,
* accessTokenExpiration (optional),
* refreshTokenExpiration (optional)
*
*/
@Example<ClientPayload>({
clientId: 'someFormattedClientID1234',
clientSecret: 'someRandomCryptoString'
clientSecret: 'someRandomCryptoString',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
})
@Post('/')
public async createClient(
@@ -20,10 +29,37 @@ export class ClientController {
): Promise<ClientPayload> {
return createClient(body)
}
/**
* @summary Admin only task. Returns the list of all the clients
*/
@Example<ClientPayload[]>([
{
clientId: 'someClientID1234',
clientSecret: 'someRandomCryptoString',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
},
{
clientId: 'someOtherClientID',
clientSecret: 'someOtherRandomCryptoString',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
}
])
@Get('/')
public async getAllClients(): Promise<ClientPayload[]> {
return getAllClients()
}
}
const createClient = async (data: any): Promise<ClientPayload> => {
const { clientId, clientSecret } = data
const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
const {
clientId,
clientSecret,
accessTokenExpiration,
refreshTokenExpiration
} = data
// Checking if client is already in the database
const clientExist = await Client.findOne({ clientId })
@@ -32,13 +68,27 @@ const createClient = async (data: any): Promise<ClientPayload> => {
// Create a new client
const client = new Client({
clientId,
clientSecret
clientSecret,
accessTokenExpiration,
refreshTokenExpiration
})
const savedClient = await client.save()
return {
clientId: savedClient.clientId,
clientSecret: savedClient.clientSecret
clientSecret: savedClient.clientSecret,
accessTokenExpiration: savedClient.accessTokenExpiration,
refreshTokenExpiration: savedClient.refreshTokenExpiration
}
}
const getAllClients = async (): Promise<ClientPayload[]> => {
return Client.find({}).select({
_id: 0,
clientId: 1,
clientSecret: 1,
accessTokenExpiration: 1,
refreshTokenExpiration: 1
})
}

View File

@@ -251,7 +251,7 @@ const updateUsersListInGroup = async (
message: `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
}
if (group.authProvider !== AuthProviderType.Internal)
if (group.authProvider)
throw {
code: 405,
status: 'Method Not Allowed',
@@ -266,7 +266,7 @@ const updateUsersListInGroup = async (
message: 'User not found.'
}
if (user.authProvider !== AuthProviderType.Internal)
if (user.authProvider)
throw {
code: 405,
status: 'Method Not Allowed',

View File

@@ -28,6 +28,7 @@ interface ExecuteFileParams {
returnJson?: boolean
session?: Session
runTime: RunTimeType
forceStringResult?: boolean
}
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
@@ -42,7 +43,8 @@ export class ExecutionController {
otherArgs,
returnJson,
session,
runTime
runTime,
forceStringResult
}: ExecuteFileParams) {
const program = await readFile(programPath)
@@ -53,7 +55,8 @@ export class ExecutionController {
otherArgs,
returnJson,
session,
runTime
runTime,
forceStringResult
})
}
@@ -63,7 +66,8 @@ export class ExecutionController {
vars,
otherArgs,
session: sessionByFileUpload,
runTime
runTime,
forceStringResult
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
const sessionController = getSessionController(runTime)
@@ -74,6 +78,7 @@ export class ExecutionController {
const logPath = path.join(session.path, 'log.log')
const headersPath = path.join(session.path, 'stpsrv_header.txt')
const weboutPath = path.join(session.path, 'webout.txt')
const tokenFile = path.join(session.path, 'reqHeaders.txt')
@@ -101,10 +106,15 @@ export class ExecutionController {
? await readFile(headersPath)
: ''
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
if (isDebugOn(vars)) {
httpHeaders['content-type'] = 'text/plain'
}
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
const webout = (await fileExists(weboutPath))
? fileResponse
? fileResponse && !forceStringResult
? await readFileBinary(weboutPath)
: await readFile(weboutPath)
: ''

View File

@@ -3,6 +3,7 @@ import { Session } from '../../types'
import { promisify } from 'util'
import { execFile } from 'child_process'
import {
getPackagesFolder,
getSessionsFolder,
generateUniqueFileName,
sysInitCompiledPath,
@@ -49,7 +50,7 @@ export class SessionController {
}
const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'Content-type: text/plain')
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
this.sessions.push(session)
return session
@@ -93,7 +94,7 @@ export class SASSessionController extends SessionController {
}
const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'Content-type: text/plain')
await createFile(headersPath, 'content-type: text/html; charset=utf-8\n')
// we do not want to leave sessions running forever
// we clean them up after a predefined period, if unused
@@ -104,7 +105,8 @@ export class SASSessionController extends SessionController {
// the autoexec file is executed on SAS startup
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
const contentForAutoExec = `/* compiled systemInit */
const contentForAutoExec = `filename packages "${getPackagesFolder()}";
/* compiled systemInit */
${compiledSystemInitContent}
/* autoexec */
${autoExecContent}`
@@ -132,23 +134,24 @@ ${autoExecContent}`
session.path,
'-AUTOEXEC',
autoExecPath,
isWindows() ? '-nologo' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nologo' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
process.sasLoc!.endsWith('sas.exe') ? '-NOPRNGETLIST' : '',
process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '',
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
])
.then(() => {
session.completed = true
console.log('session completed', session)
process.logger.info('session completed', session)
})
.catch((err) => {
session.completed = true
session.crashed = err.toString()
console.log('session crashed', session.id, session.crashed)
process.logger.error('session crashed', session.id, session.crashed)
})
// we have a triggered session - add to array
@@ -168,7 +171,10 @@ ${autoExecContent}`
while ((await fileExists(codeFilePath)) && !session.crashed) {}
if (session.crashed)
console.log('session crashed! while waiting to be ready', session.crashed)
process.logger.error(
'session crashed! while waiting to be ready',
session.crashed
)
session.ready = true
}
@@ -201,12 +207,15 @@ ${autoExecContent}`
export const getSessionController = (
runTime: RunTimeType
): SessionController => {
if (process.sessionController) return process.sessionController
if (runTime === RunTimeType.SAS) {
process.sasSessionController =
process.sasSessionController || new SASSessionController()
return process.sasSessionController
}
process.sessionController =
runTime === RunTimeType.SAS
? new SASSessionController()
: new SessionController()
process.sessionController || new SessionController()
return process.sessionController
}

View File

@@ -40,8 +40,6 @@ export const createSASProgram = async (
%mend;
%_sasjs_server_init()
proc printto print="%sysfunc(getoption(log))";
run;
`
program = `

View File

@@ -1,6 +1,6 @@
import path from 'path'
import fs from 'fs'
import { execFileSync } from 'child_process'
import { WriteStream, createWriteStream } from 'fs'
import { execFile } from 'child_process'
import { once } from 'stream'
import { createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types'
@@ -105,30 +105,58 @@ export const processProgram = async (
throw new Error('Invalid runtime!')
}
try {
await createFile(codePath, program)
await createFile(codePath, program)
// create a stream that will write to console outputs to log file
const writeStream = fs.createWriteStream(logPath)
// create a stream that will write to console outputs to log file
const writeStream = createWriteStream(logPath)
// waiting for the open event so that we can have underlying file descriptor
await once(writeStream, 'open')
// waiting for the open event so that we can have underlying file descriptor
await once(writeStream, 'open')
execFileSync(executablePath, [codePath], {
stdio: ['ignore', writeStream, writeStream]
await execFilePromise(executablePath, [codePath], writeStream)
.then(() => {
session.completed = true
process.logger.info('session completed', session)
})
.catch((err) => {
session.completed = true
session.crashed = err.toString()
process.logger.error('session crashed', session.id, session.crashed)
})
// copy the code file 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)
}
// copy the code file to log and end write stream
writeStream.end(program)
}
}
/**
* Promisified child_process.execFile
*
* @param file - The name or path of the executable file to run.
* @param args - List of string arguments.
* @param writeStream - Child process stdout and stderr will be piped to it.
*
* @returns {Promise<{ stdout: string, stderr: string }>}
*/
const execFilePromise = (
file: string,
args: string[],
writeStream: WriteStream
): Promise<{ stdout: string; stderr: string }> => {
return new Promise((resolve, reject) => {
const child = execFile(file, args, (err, stdout, stderr) => {
if (err) reject(err)
resolve({ stdout, stderr })
})
child.stdout?.on('data', (data) => {
writeStream.write(data)
})
child.stderr?.on('data', (data) => {
writeStream.write(data)
})
})
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -2,6 +2,16 @@ import { readFile } from '@sasjs/utils'
import express from 'express'
import path from 'path'
import { Request, Post, Get } from 'tsoa'
import dotenv from 'dotenv'
import { ExecutionController } from './internal'
import {
getPreProgramVariables,
getRunTimeAndFilePath,
makeFilesNamesMap
} from '../utils'
import { MulterFile } from '../types/Upload'
dotenv.config()
export interface Sas9Response {
content: string
@@ -16,9 +26,17 @@ export interface MockFileRead {
export class MockSas9Controller {
private loggedIn: string | undefined
private mocksPath = process.env.STATIC_MOCK_LOCATION || 'mocks'
@Get('/SASStoredProcess')
public async sasStoredProcess(): Promise<Sas9Response> {
public async sasStoredProcess(
@Request() req: express.Request
): Promise<Sas9Response> {
const username = req.query._username?.toString() || undefined
const password = req.query._password?.toString() || undefined
if (username && password) this.loggedIn = req.body.username
if (!this.loggedIn) {
return {
content: '',
@@ -26,17 +44,87 @@ export class MockSas9Controller {
}
}
let program = req.query._program?.toString() || undefined
const filePath: string[] = program
? program.replace('/', '').split('/')
: ['generic', 'sas-stored-process']
if (program) {
return await getMockResponseFromFile([
process.cwd(),
this.mocksPath,
'sas9',
...filePath
])
}
return await getMockResponseFromFile([
process.cwd(),
'mocks',
'generic',
'sas9',
'sas-stored-process'
...filePath
])
}
@Get('/SASStoredProcess/do')
public async sasStoredProcessDoGet(
@Request() req: express.Request
): Promise<Sas9Response> {
const username = req.query._username?.toString() || undefined
const password = req.query._password?.toString() || undefined
if (username && password) this.loggedIn = username
if (!this.loggedIn) {
return {
content: '',
redirect: '/SASLogon/login'
}
}
const program = req.query._program ?? req.body?._program
const filePath: string[] = ['generic', 'sas-stored-process']
if (program) {
const vars = { ...req.query, ...req.body, _requestMethod: req.method }
const otherArgs = {}
try {
const { codePath, runTime } = await getRunTimeAndFilePath(
program + '.js'
)
const result = await new ExecutionController().executeFile({
programPath: codePath,
preProgramVariables: getPreProgramVariables(req),
vars: vars,
otherArgs: otherArgs,
runTime,
forceStringResult: true
})
return {
content: result.result as string
}
} catch (err) {
process.logger.error('err', err)
}
return {
content: 'No webout returned.'
}
}
return await getMockResponseFromFile([
process.cwd(),
'mocks',
'sas9',
...filePath
])
}
@Post('/SASStoredProcess/do/')
public async sasStoredProcessDo(
public async sasStoredProcessDoPost(
@Request() req: express.Request
): Promise<Sas9Response> {
if (!this.loggedIn) {
@@ -53,23 +141,38 @@ export class MockSas9Controller {
}
}
let program = req.query._program?.toString() || ''
program = program.replace('/', '')
const program = req.query._program ?? req.body?._program
const vars = {
...req.query,
...req.body,
_requestMethod: req.method,
_driveLoc: process.driveLoc
}
const filesNamesMap = req.files?.length
? makeFilesNamesMap(req.files as MulterFile[])
: null
const otherArgs = { filesNamesMap: filesNamesMap }
const { codePath, runTime } = await getRunTimeAndFilePath(program + '.js')
try {
const result = await new ExecutionController().executeFile({
programPath: codePath,
preProgramVariables: getPreProgramVariables(req),
vars: vars,
otherArgs: otherArgs,
runTime,
session: req.sasjsSession,
forceStringResult: true
})
const content = await getMockResponseFromFile([
process.cwd(),
'mocks',
...program.split('/')
])
if (content.error) {
return content
return {
content: result.result as string
}
} catch (err) {
process.logger.error('err', err)
}
const parsedContent = parseJsonIfValid(content.content)
return {
content: parsedContent
content: 'No webout returned.'
}
}
@@ -85,8 +188,8 @@ export class MockSas9Controller {
return await getMockResponseFromFile([
process.cwd(),
'mocks',
'generic',
'sas9',
'generic',
'logged-in'
])
}
@@ -95,21 +198,27 @@ export class MockSas9Controller {
return await getMockResponseFromFile([
process.cwd(),
'mocks',
'generic',
'sas9',
'generic',
'login'
])
}
@Post('/SASLogon/login')
public async loginPost(req: express.Request): Promise<Sas9Response> {
if (req.body.lt && req.body.lt !== 'validtoken')
return {
content: '',
redirect: '/SASLogon/login'
}
this.loggedIn = req.body.username
return await getMockResponseFromFile([
process.cwd(),
'mocks',
'generic',
'sas9',
'generic',
'logged-in'
])
}
@@ -122,8 +231,8 @@ export class MockSas9Controller {
return await getMockResponseFromFile([
process.cwd(),
'mocks',
'generic',
'sas9',
'generic',
'public-access-denied'
])
}
@@ -131,8 +240,8 @@ export class MockSas9Controller {
return await getMockResponseFromFile([
process.cwd(),
'mocks',
'generic',
'sas9',
'generic',
'logged-out'
])
}
@@ -152,23 +261,6 @@ export class MockSas9Controller {
private isPublicAccount = () => this.loggedIn?.toLowerCase() === 'public'
}
/**
* If JSON is valid it will be parsed otherwise will return text unaltered
* @param content string to be parsed
* @returns JSON or string
*/
const parseJsonIfValid = (content: string) => {
let fileContent = ''
try {
fileContent = JSON.parse(content)
} catch (err: any) {
fileContent = content
}
return fileContent
}
const getMockResponseFromFile = async (
filePath: string[]
): Promise<MockFileRead> => {
@@ -177,7 +269,7 @@ const getMockResponseFromFile = async (
let file = await readFile(filePathParsed).catch((err: any) => {
const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}`
console.error(errMsg)
process.logger.error(errMsg)
error = true

View File

@@ -2,6 +2,10 @@ import express from 'express'
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
import { UserResponse } from './user'
interface SessionResponse extends UserResponse {
needsToUpdatePassword: boolean
}
@Security('bearerAuth')
@Route('SASjsApi/session')
@Tags('Session')
@@ -19,7 +23,7 @@ export class SessionController {
@Get('/')
public async session(
@Request() request: express.Request
): Promise<UserResponse> {
): Promise<SessionResponse> {
return session(request)
}
}
@@ -28,5 +32,6 @@ const session = (req: express.Request) => ({
id: req.user!.userId,
username: req.user!.username,
displayName: req.user!.displayName,
isAdmin: req.user!.isAdmin
isAdmin: req.user!.isAdmin,
needsToUpdatePassword: req.user!.needsToUpdatePassword
})

View File

@@ -91,6 +91,8 @@ const execute = async (
}
)
req.res?.header(httpHeaders)
if (result instanceof Buffer) {
;(req as any).sasHeaders = httpHeaders
}

View File

@@ -21,9 +21,9 @@ import {
getUserAutoExec,
updateUserAutoExec,
ModeType,
AuthProviderType
ALL_USERS_GROUP
} from '../utils'
import { GroupResponse } from './group'
import { GroupController, GroupResponse } from './group'
export interface UserResponse {
id: number
@@ -237,6 +237,15 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const savedUser = await user.save()
const groupController = new GroupController()
const allUsersGroup = await groupController
.getGroupByGroupName(ALL_USERS_GROUP.name)
.catch(() => {})
if (allUsersGroup) {
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
}
return {
id: savedUser.id,
displayName: savedUser.displayName,
@@ -299,14 +308,19 @@ const updateUser = async (
const user = await User.findOne(findBy)
if (
user?.authProvider !== AuthProviderType.Internal &&
(username !== user?.username || displayName !== user?.displayName)
) {
if (username && username !== user?.username && user?.authProvider) {
throw {
code: 405,
message:
'Can not update username and display name of user that is created by an external auth provider.'
'Can not update username of user that is created by an external auth provider.'
}
}
if (displayName && displayName !== user?.displayName && user?.authProvider) {
throw {
code: 405,
message:
'Can not update display name of user that is created by an external auth provider.'
}
}

View File

@@ -1,13 +1,14 @@
import path from 'path'
import express from 'express'
import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
import { readFile } from '@sasjs/utils'
import { readFile, convertSecondsToHms } from '@sasjs/utils'
import User from '../model/User'
import Client from '../model/Client'
import {
getWebBuildFolder,
generateAuthCode,
RateLimiter,
AuthProviderType,
LDAPClient
} from '../utils'
@@ -83,19 +84,38 @@ const login = async (
) => {
// Authenticate User
const user = await User.findOne({ username })
if (!user) throw new Error('Username is not found.')
if (
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
user.authProvider === AuthProviderType.LDAP
) {
const ldapClient = await LDAPClient.init()
await ldapClient.verifyUser(username, password)
} else {
const validPass = user.comparePassword(password)
if (!validPass) throw new Error('Invalid password.')
let validPass = false
if (user) {
if (
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
user.authProvider === AuthProviderType.LDAP
) {
const ldapClient = await LDAPClient.init()
validPass = await ldapClient
.verifyUser(username, password)
.catch(() => false)
} else {
validPass = user.comparePassword(password)
}
}
// code to prevent brute force attack
const rateLimiter = RateLimiter.getInstance()
if (!validPass) {
const retrySecs = await rateLimiter.consume(req.ip, user?.username)
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
}
if (!user) throw errors.userNotFound
if (!validPass) throw errors.invalidPassword
// Reset on successful authorization
rateLimiter.resetOnSuccess(req.ip, user.username)
req.session.loggedIn = true
req.session.user = {
userId: user.id,
@@ -104,7 +124,8 @@ const login = async (
displayName: user.displayName,
isAdmin: user.isAdmin,
isActive: user.isActive,
autoExec: user.autoExec
autoExec: user.autoExec,
needsToUpdatePassword: user.needsToUpdatePassword
}
return {
@@ -113,7 +134,8 @@ const login = async (
id: user.id,
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin
isAdmin: user.isAdmin,
needsToUpdatePassword: user.needsToUpdatePassword
}
}
}
@@ -170,3 +192,18 @@ interface AuthorizeResponse {
*/
code: string
}
const errors = {
invalidPassword: {
code: 401,
message: 'Invalid Password.'
},
userNotFound: {
code: 401,
message: 'Username is not found.'
},
tooManyRequests: (seconds: number) => ({
code: 429,
message: `Too Many Requests! Retry after ${convertSecondsToHms(seconds)}`
})
}

View File

@@ -81,7 +81,8 @@ const authenticateToken = async (
username: 'desktopModeUsername',
displayName: 'desktopModeDisplayName',
isAdmin: true,
isActive: true
isActive: true,
needsToUpdatePassword: false
}
req.accessToken = 'desktopModeAccessToken'
return next()

View File

@@ -5,7 +5,7 @@ import {
PermissionSettingForRoute,
PermissionType
} from '../controllers/permission'
import { getPath, isPublicRoute } from '../utils'
import { getPath, isPublicRoute, TopLevelRoutes } from '../utils'
export const authorize: RequestHandler = async (req, res, next) => {
const { user } = req
@@ -22,6 +22,9 @@ export const authorize: RequestHandler = async (req, res, next) => {
if (!dbUser) return res.sendStatus(401)
const path = getPath(req)
const { baseUrl } = req
const topLevelRoute =
TopLevelRoutes.find((route) => baseUrl.startsWith(route)) || baseUrl
// find permission w.r.t user
const permission = await Permission.findOne({
@@ -35,6 +38,21 @@ export const authorize: RequestHandler = async (req, res, next) => {
else return res.sendStatus(401)
}
// find permission w.r.t user on top level
const topLevelPermission = await Permission.findOne({
path: topLevelRoute,
type: PermissionType.route,
user: dbUser._id
})
if (topLevelPermission) {
if (topLevelPermission.setting === PermissionSettingForRoute.grant)
return next()
else return res.sendStatus(401)
}
let isPermissionDenied = false
// find permission w.r.t user's groups
for (const group of dbUser.groups) {
const groupPermission = await Permission.findOne({
@@ -42,8 +60,28 @@ export const authorize: RequestHandler = async (req, res, next) => {
type: PermissionType.route,
group
})
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return next()
if (groupPermission) {
if (groupPermission.setting === PermissionSettingForRoute.grant) {
return next()
} else {
isPermissionDenied = true
}
}
}
if (!isPermissionDenied) {
// find permission w.r.t user's groups on top level
for (const group of dbUser.groups) {
const groupPermission = await Permission.findOne({
path: topLevelRoute,
type: PermissionType.route,
group
})
if (groupPermission?.setting === PermissionSettingForRoute.grant)
return next()
}
}
return res.sendStatus(401)
}

View File

@@ -0,0 +1,22 @@
import { RequestHandler } from 'express'
import { convertSecondsToHms } from '@sasjs/utils'
import { RateLimiter } from '../utils'
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
const ip = req.ip
const username = req.body.username
const rateLimiter = RateLimiter.getInstance()
const retrySecs = await rateLimiter.check(ip, username)
if (retrySecs > 0) {
res
.status(429)
.send(`Too Many Requests! Retry after ${convertSecondsToHms(retrySecs)}`)
return
}
next()
}

View File

@@ -33,5 +33,6 @@ export const desktopUser: RequestUser = {
username: userInfo().username,
displayName: userInfo().username,
isAdmin: true,
isActive: true
isActive: true,
needsToUpdatePassword: false
}

View File

@@ -4,3 +4,4 @@ export * from './csrfProtection'
export * from './desktop'
export * from './verifyAdmin'
export * from './verifyAdminIfNeeded'
export * from './bruteForceProtection'

View File

@@ -1,5 +1,6 @@
import mongoose, { Schema } from 'mongoose'
export const NUMBER_OF_SECONDS_IN_A_DAY = 86400
export interface ClientPayload {
/**
* Client ID
@@ -11,6 +12,16 @@ export interface ClientPayload {
* @example "someRandomCryptoString"
*/
clientSecret: string
/**
* Number of seconds after which access token will expire. Default is 86400 (1 day)
* @example 86400
*/
accessTokenExpiration?: number
/**
* Number of seconds after which access token will expire. Default is 2592000 (30 days)
* @example 2592000
*/
refreshTokenExpiration?: number
}
const ClientSchema = new Schema<ClientPayload>({
@@ -21,6 +32,14 @@ const ClientSchema = new Schema<ClientPayload>({
clientSecret: {
type: String,
required: true
},
accessTokenExpiration: {
type: Number,
default: NUMBER_OF_SECONDS_IN_A_DAY
},
refreshTokenExpiration: {
type: Number,
default: NUMBER_OF_SECONDS_IN_A_DAY * 30
}
})

View File

@@ -50,8 +50,7 @@ const groupSchema = new Schema<IGroupDocument>({
},
authProvider: {
type: String,
enum: AuthProviderType,
default: 'internal'
enum: AuthProviderType
},
isActive: {
type: Boolean,

View File

@@ -40,6 +40,7 @@ interface IUserDocument extends UserPayload, Document {
id: number
isAdmin: boolean
isActive: boolean
needsToUpdatePassword: boolean
autoExec: string
groups: Schema.Types.ObjectId[]
tokens: [{ [key: string]: string }]
@@ -71,8 +72,7 @@ const userSchema = new Schema<IUserDocument>({
},
authProvider: {
type: String,
enum: AuthProviderType,
default: 'internal'
enum: AuthProviderType
},
isAdmin: {
type: Boolean,
@@ -82,6 +82,10 @@ const userSchema = new Schema<IUserDocument>({
type: Boolean,
default: true
},
needsToUpdatePassword: {
type: Boolean,
default: true
},
autoExec: {
type: String
},

View File

@@ -7,12 +7,28 @@ import {
authenticateRefreshToken
} from '../../middlewares'
import { tokenValidation } from '../../utils'
import { tokenValidation, updatePasswordValidation } from '../../utils'
import { InfoJWT } from '../../types'
const authRouter = express.Router()
const controller = new AuthController()
authRouter.patch(
'/updatePassword',
authenticateAccessToken,
async (req, res) => {
const { error, value: body } = updatePasswordValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
await controller.updatePassword(req, body)
res.sendStatus(204)
} catch (err: any) {
res.status(err.code).send(err.message)
}
}
)
authRouter.post('/token', async (req, res) => {
const { error, value: body } = tokenValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)

View File

@@ -1,6 +1,7 @@
import express from 'express'
import { ClientController } from '../../controllers'
import { registerClientValidation } from '../../utils'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
const clientRouter = express.Router()
@@ -17,4 +18,19 @@ clientRouter.post('/', async (req, res) => {
}
})
clientRouter.get(
'/',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
const controller = new ClientController()
try {
const response = await controller.getAllClients()
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
export default clientRouter

View File

@@ -5,6 +5,7 @@ import request from 'supertest'
import appPromise from '../../../app'
import { UserController, ClientController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../../../model/Client'
const client = {
clientId: 'someclientID',
@@ -26,6 +27,7 @@ describe('client', () => {
let app: Express
let con: Mongoose
let mongoServer: MongoMemoryServer
let adminAccessToken: string
const userController = new UserController()
const clientController = new ClientController()
@@ -34,6 +36,18 @@ describe('client', () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
const dbUser = await userController.createUser(adminUser)
adminAccessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.id
})
await saveTokensInDB(
dbUser.id,
client.clientId,
adminAccessToken,
'refreshToken'
)
})
afterAll(async () => {
@@ -43,22 +57,6 @@ describe('client', () => {
})
describe('create', () => {
let adminAccessToken: string
beforeAll(async () => {
const dbUser = await userController.createUser(adminUser)
adminAccessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.id
})
await saveTokensInDB(
dbUser.id,
client.clientId,
adminAccessToken,
'refreshToken'
)
})
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['clients']
@@ -157,4 +155,80 @@ describe('client', () => {
expect(res.body).toEqual({})
})
})
describe('get', () => {
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['clients']
await collection.deleteMany({})
})
it('should respond with an array of all clients', async () => {
await clientController.createClient(newClient)
await clientController.createClient({
clientId: 'clientID',
clientSecret: 'clientSecret'
})
const res = await request(app)
.get('/SASjsApi/client')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const expected = [
{
clientId: 'newClientID',
clientSecret: 'newClientSecret',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
},
{
clientId: 'clientID',
clientSecret: 'clientSecret',
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
}
]
expect(res.body).toEqual(expected)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).get('/SASjsApi/client').send().expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbideen if access token is not of an admin account', async () => {
const user = {
displayName: 'User 2',
username: 'username2',
password: '12345678',
isAdmin: false,
isActive: true
}
const dbUser = await userController.createUser(user)
const accessToken = generateAccessToken({
clientId: client.clientId,
userId: dbUser.id
})
await saveTokensInDB(
dbUser.id,
client.clientId,
accessToken,
'refreshToken'
)
const res = await request(app)
.get('/SASjsApi/client')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
})
})

View File

@@ -77,10 +77,85 @@ describe('web', () => {
id: expect.any(Number),
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin
isAdmin: user.isAdmin,
needsToUpdatePassword: true
})
})
it('should respond with too many requests when attempting with invalid password for a same user too many times', async () => {
await userController.createUser(user)
const promises: request.Test[] = []
const maxConsecutiveFailsByUsernameAndIp = Number(
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
)
Array(maxConsecutiveFailsByUsernameAndIp + 1)
.fill(0)
.map((_, i) => {
promises.push(
request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: 'invalid-password'
})
)
})
await Promise.all(promises)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(429)
expect(res.text).toContain('Too Many Requests!')
})
it('should respond with too many requests when attempting with invalid credentials for different users but with same ip too many times', async () => {
await userController.createUser(user)
const promises: request.Test[] = []
const maxWrongAttemptsByIpPerDay = Number(
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
)
Array(maxWrongAttemptsByIpPerDay + 1)
.fill(0)
.map((_, i) => {
promises.push(
request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: `user${i}`,
password: 'invalid-password'
})
)
})
await Promise.all(promises)
const res = await request(app)
.post('/SASLogon/login')
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(429)
expect(res.text).toContain('Too Many Requests!')
})
it('should respond with Bad Request if CSRF Token is not present', async () => {
await userController.createUser(user)
@@ -118,6 +193,7 @@ describe('web', () => {
let authCookies: string
beforeAll(async () => {
await deleteDocumentsFromLimitersCollections()
;({ csrfToken } = await getCSRF(app))
await userController.createUser(user)
@@ -209,3 +285,12 @@ const extractCSRF = (text: string) =>
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
text
)![1]
const deleteDocumentsFromLimitersCollections = async () => {
const { collections } = mongoose.connection
const login_fail_ip_per_day_collection = collections['login_fail_ip_per_day']
await login_fail_ip_per_day_collection.deleteMany({})
const login_fail_consecutive_username_and_ip_collection =
collections['login_fail_consecutive_username_and_ip']
await login_fail_consecutive_username_and_ip_collection.deleteMany({})
}

View File

@@ -58,7 +58,7 @@ export const publishAppStream = async (
)
const sasJsPort = process.env.PORT || 5000
console.log(
process.logger.info(
'Serving Stream App: ',
`http://localhost:${sasJsPort}/AppStream/${streamServiceName}`
)

View File

@@ -15,5 +15,5 @@ export const setupRoutes = (app: Express) => {
appStreamRouter(req, res, next)
})
app.use('/', csrfProtection, webRouter)
app.use('/', webRouter)
}

View File

@@ -3,6 +3,7 @@ import sas9WebRouter from './sas9-web'
import sasViyaWebRouter from './sasviya-web'
import webRouter from './web'
import { MOCK_SERVERTYPEType } from '../../utils'
import { csrfProtection } from '../../middlewares'
const router = express.Router()
@@ -18,7 +19,7 @@ switch (MOCK_SERVERTYPE) {
break
}
default: {
router.use('/', webRouter)
router.use('/', csrfProtection, webRouter)
}
}

View File

@@ -2,12 +2,25 @@ import express from 'express'
import { generateCSRFToken } from '../../middlewares'
import { WebController } from '../../controllers'
import { MockSas9Controller } from '../../controllers/mock-sas9'
import multer from 'multer'
import path from 'path'
import dotenv from 'dotenv'
import { FileUploadController } from '../../controllers/internal'
dotenv.config()
const sas9WebRouter = express.Router()
const webController = new WebController()
// Mock controller must be singleton because it keeps the states
// for example `isLoggedIn` and potentially more in future mocks
const controller = new MockSas9Controller()
const fileUploadController = new FileUploadController()
const mockPath = process.env.STATIC_MOCK_LOCATION || 'mocks'
const upload = multer({
dest: path.join(process.cwd(), mockPath, 'sas9', 'files-received')
})
sas9WebRouter.get('/', async (req, res) => {
let response
@@ -27,7 +40,7 @@ sas9WebRouter.get('/', async (req, res) => {
})
sas9WebRouter.get('/SASStoredProcess', async (req, res) => {
const response = await controller.sasStoredProcess()
const response = await controller.sasStoredProcess(req)
if (response.redirect) {
res.redirect(response.redirect)
@@ -41,8 +54,8 @@ sas9WebRouter.get('/SASStoredProcess', async (req, res) => {
}
})
sas9WebRouter.post('/SASStoredProcess/do/', async (req, res) => {
const response = await controller.sasStoredProcessDo(req)
sas9WebRouter.get('/SASStoredProcess/do/', async (req, res) => {
const response = await controller.sasStoredProcessDoGet(req)
if (response.redirect) {
res.redirect(response.redirect)
@@ -56,6 +69,26 @@ sas9WebRouter.post('/SASStoredProcess/do/', async (req, res) => {
}
})
sas9WebRouter.post(
'/SASStoredProcess/do/',
fileUploadController.preUploadMiddleware,
fileUploadController.getMulterUploadObject().any(),
async (req, res) => {
const response = await controller.sasStoredProcessDoPost(req)
if (response.redirect) {
res.redirect(response.redirect)
return
}
try {
res.send(response.content)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
sas9WebRouter.get('/SASLogon/login', async (req, res) => {
const response = await controller.loginGet()

View File

@@ -1,7 +1,11 @@
import express from 'express'
import { generateCSRFToken } from '../../middlewares'
import { WebController } from '../../controllers/web'
import { authenticateAccessToken, desktopRestrict } from '../../middlewares'
import {
authenticateAccessToken,
bruteForceProtection,
desktopRestrict
} from '../../middlewares'
import { authorizeValidation, loginWebValidation } from '../../utils'
const webRouter = express.Router()
@@ -14,7 +18,10 @@ webRouter.get('/', async (req, res) => {
} catch (_) {
response = '<html><head></head><body>Web Build is not present</body></html>'
} finally {
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${generateCSRFToken()}; Max-Age=86400; SameSite=Strict; Path=/;'</script>`
const { ALLOWED_DOMAIN } = process.env
const allowedDomain = ALLOWED_DOMAIN?.trim()
const domain = allowedDomain ? ` Domain=${allowedDomain};` : ''
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${generateCSRFToken()};${domain} Max-Age=86400; SameSite=Strict; Path=/;'</script>`
const injectedContent = response?.replace(
'</head>',
`${codeToInject}</head>`
@@ -24,17 +31,26 @@ webRouter.get('/', async (req, res) => {
}
})
webRouter.post('/SASLogon/login', desktopRestrict, async (req, res) => {
const { error, value: body } = loginWebValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
webRouter.post(
'/SASLogon/login',
desktopRestrict,
bruteForceProtection,
async (req, res) => {
const { error, value: body } = loginWebValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.login(req, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
try {
const response = await controller.login(req, body)
res.send(response)
} catch (err: any) {
if (err instanceof Error) {
res.status(500).send(err.toString())
} else {
res.status(err.code).send(err.message)
}
}
}
})
)
webRouter.post(
'/SASLogon/authorize',

View File

@@ -7,11 +7,11 @@ appPromise.then(async (app) => {
const protocol = process.env.PROTOCOL || 'http'
const sasJsPort = process.env.PORT || 5000
console.log('PROTOCOL: ', protocol)
process.logger.info('PROTOCOL: ', protocol)
if (protocol !== 'https') {
app.listen(sasJsPort, () => {
console.log(
process.logger.info(
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
)
})
@@ -20,7 +20,7 @@ appPromise.then(async (app) => {
const httpsServer = createServer({ key, cert, ca }, app)
httpsServer.listen(sasJsPort, () => {
console.log(
process.logger.info(
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
)
})

View File

@@ -5,5 +5,6 @@ export interface RequestUser {
displayName: string
isAdmin: boolean
isActive: boolean
needsToUpdatePassword: boolean
autoExec?: string
}

View File

@@ -5,9 +5,11 @@ declare namespace NodeJS {
pythonLoc?: string
rLoc?: string
driveLoc: string
sasjsRoot: string
logsLoc: string
logsUUID: string
sessionController?: import('../../controllers/internal').SessionController
sasSessionController?: import('../../controllers/internal').SASSessionController
appStreamConfig: import('../').AppStreamConfig
logger: import('@sasjs/utils/logger').Logger
runTimes: import('../../utils').RunTimeType[]

View File

@@ -36,7 +36,7 @@ export const loadAppStreamConfig = async () => {
)
}
console.log('App Stream Config loaded!')
process.logger.info('App Stream Config loaded!')
}
export const addEntryToAppStreamConfig = (

View File

@@ -8,6 +8,6 @@ export const connectDB = async () => {
throw new Error('Unable to connect to DB!')
}
console.log('Connected to DB!')
process.logger.success('Connected to DB!')
return seedDB()
}

View File

@@ -12,7 +12,7 @@ import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
export const copySASjsCore = async () => {
if (process.env.NODE_ENV === 'test') return
console.log('Copying Macros from container to drive(tmp).')
process.logger.log('Copying Macros from container to drive.')
const macrosDrivePath = getMacrosFolder()
@@ -30,5 +30,5 @@ export const copySASjsCore = async () => {
await createFile(macroFileDestPath, macroContent)
})
console.log('Macros Drive Path:', macrosDrivePath)
process.logger.info('Macros Drive Path:', macrosDrivePath)
}

View File

@@ -0,0 +1,18 @@
import path from 'path'
import { createFile } from '@sasjs/utils'
import { getMacrosFolder } from './file'
const fileContent = `%macro webout(action,ds,dslabel=,fmt=,missing=NULL,showmeta=NO,maxobs=MAX);
%ms_webout(&action,ds=&ds,dslabel=&dslabel,fmt=&fmt
,missing=&missing
,showmeta=&showmeta
,maxobs=&maxobs
)
%mend;`
export const createWeboutSasFile = async () => {
const macrosDrivePath = getMacrosFolder()
process.logger.log(`Creating webout.sas at ${macrosDrivePath}`)
const filePath = path.join(macrosDrivePath, 'webout.sas')
await createFile(filePath, fileContent)
}

View File

@@ -10,7 +10,7 @@ export const sysInitCompiledPath = path.join(
'systemInitCompiled.sas'
)
export const sasJSCoreMacros = path.join(apiRoot, 'sasjscore')
export const sasJSCoreMacros = path.join(apiRoot, 'sas', 'sasautos')
export const sasJSCoreMacrosInfo = path.join(sasJSCoreMacros, '.macrolist')
export const getWebBuildFolder = () => path.join(codebaseRoot, 'web', 'build')
@@ -20,19 +20,24 @@ export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server')
export const getDesktopUserAutoExecPath = () =>
path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
export const getSasjsRootFolder = () => process.driveLoc
export const getSasjsRootFolder = () => process.sasjsRoot
export const getSasjsDriveFolder = () => process.driveLoc
export const getLogFolder = () => process.logsLoc
export const getAppStreamConfigPath = () =>
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
path.join(getSasjsDriveFolder(), 'appStreamConfig.json')
export const getMacrosFolder = () =>
path.join(getSasjsRootFolder(), 'sasjscore')
path.join(getSasjsDriveFolder(), 'sas', 'sasautos')
export const getPackagesFolder = () =>
path.join(getSasjsDriveFolder(), 'sas', 'sas_packages')
export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
export const getFilesFolder = () => path.join(getSasjsDriveFolder(), 'files')
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')

View File

@@ -1,7 +1,8 @@
import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
export const generateAccessToken = (data: InfoJWT) =>
export const generateAccessToken = (data: InfoJWT, expiry?: number) =>
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
expiresIn: '1day'
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
})

View File

@@ -1,7 +1,8 @@
import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
export const generateRefreshToken = (data: InfoJWT) =>
export const generateRefreshToken = (data: InfoJWT, expiry?: number) =>
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
expiresIn: '30 days'
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
})

View File

@@ -1,7 +1,8 @@
import { Request } from 'express'
export const TopLevelRoutes = ['/AppStream', '/SASjsApi']
const StaticAuthorizedRoutes = [
'/AppStream',
'/SASjsApi/code/execute',
'/SASjsApi/stp/execute',
'/SASjsApi/drive/deploy',
@@ -15,7 +16,7 @@ const StaticAuthorizedRoutes = [
export const getAuthorizedRoutes = () => {
const streamingApps = Object.keys(process.appStreamConfig)
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
return [...TopLevelRoutes, ...StaticAuthorizedRoutes, ...streamingAppsRoutes]
}
export const getPath = (req: Request) => {

View File

@@ -10,9 +10,9 @@ export const getCertificates = async () => {
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
const caPath = CA_ROOT
console.log('keyPath: ', keyPath)
console.log('certPath: ', certPath)
if (caPath) console.log('caPath: ', caPath)
process.logger.info('keyPath: ', keyPath)
process.logger.info('certPath: ', certPath)
if (caPath) process.logger.info('caPath: ', caPath)
const key = await readFile(keyPath)
const cert = await readFile(certPath)

View File

@@ -18,10 +18,12 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
if (cookies.length) httpHeaders.push(`cookie: ${cookies.join('; ')}`)
//In desktop mode when mocking mode is enabled, user was undefined.
//So this is workaround.
return {
username: user!.username,
userId: user!.userId,
displayName: user!.displayName,
username: user ? user.username : 'demo',
userId: user ? user.userId : 0,
displayName: user ? user.displayName : 'demo',
serverUrl: protocol + host,
httpHeaders
}

View File

@@ -1,6 +1,7 @@
export * from './appStreamConfig'
export * from './connectDB'
export * from './copySASjsCore'
export * from './createWeboutSasFile'
export * from './desktopAutoExec'
export * from './extractHeaders'
export * from './extractName'
@@ -19,14 +20,16 @@ export * from './instantiateLogger'
export * from './isDebugOn'
export * from './isPublicRoute'
export * from './ldapClient'
export * from './zipped'
export * from './parseLogToArray'
export * from './rateLimiter'
export * from './removeTokensInDB'
export * from './saveTokensInDB'
export * from './seedDB'
export * from './setProcessVariables'
export * from './setupFolders'
export * from './setupUserAutoExec'
export * from './upload'
export * from './validation'
export * from './verifyEnvVariables'
export * from './verifyTokenInDB'
export * from './zipped'

View File

@@ -27,5 +27,6 @@ export const publicUser: RequestUser = {
username: 'publicUser',
displayName: 'Public User',
isAdmin: false,
isActive: true
isActive: true,
needsToUpdatePassword: false
}

View File

@@ -22,12 +22,12 @@ export const getEnvCSPDirectives = (
try {
cspConfigJson = JSON.parse(file)
} catch (e) {
console.error(
process.logger.error(
'Parsing Content Security Policy JSON config failed. Make sure it is valid json'
)
}
} catch (e) {
console.error('Error reading HELMET CSP config file', e)
process.logger.error('Error reading HELMET CSP config file', e)
}
}

View File

@@ -0,0 +1,126 @@
import mongoose from 'mongoose'
import { RateLimiterMongo } from 'rate-limiter-flexible'
export class RateLimiter {
private static instance: RateLimiter
private limiterSlowBruteByIP: RateLimiterMongo
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMongo
private maxWrongAttemptsByIpPerDay: number
private maxConsecutiveFailsByUsernameAndIp: number
private constructor() {
const {
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
} = process.env
this.maxWrongAttemptsByIpPerDay = Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY)
this.maxConsecutiveFailsByUsernameAndIp = Number(
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
)
this.limiterSlowBruteByIP = new RateLimiterMongo({
storeClient: mongoose.connection,
keyPrefix: 'login_fail_ip_per_day',
points: this.maxWrongAttemptsByIpPerDay,
duration: 60 * 60 * 24,
blockDuration: 60 * 60 * 24 // Block for 1 day
})
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMongo({
storeClient: mongoose.connection,
keyPrefix: 'login_fail_consecutive_username_and_ip',
points: this.maxConsecutiveFailsByUsernameAndIp,
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
blockDuration: 60 * 60 // Block for 1 hour
})
}
public static getInstance() {
if (!RateLimiter.instance) {
RateLimiter.instance = new RateLimiter()
}
return RateLimiter.instance
}
private getUsernameIPKey(ip: string, username: string) {
return `${username}_${ip}`
}
/**
* This method checks for brute force attack
* If attack is detected then returns the number of seconds after which user can make another request
* Else returns 0
*/
public async check(ip: string, username: string) {
const usernameIPkey = this.getUsernameIPKey(ip, username)
const [resSlowByIP, resUsernameAndIP] = await Promise.all([
this.limiterSlowBruteByIP.get(ip),
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
])
// NOTE: To make use of blockDuration option from RateLimiterMongo
// comparison in both following if statements should have greater than symbol
// otherwise, blockDuration option will not work
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
// Check if IP or Username + IP is already blocked
if (
resSlowByIP !== null &&
resSlowByIP.consumedPoints > this.maxWrongAttemptsByIpPerDay
) {
return Math.ceil(resSlowByIP.msBeforeNext / 1000)
} else if (
resUsernameAndIP !== null &&
resUsernameAndIP.consumedPoints > this.maxConsecutiveFailsByUsernameAndIp
) {
return Math.ceil(resUsernameAndIP.msBeforeNext / 1000)
}
return 0
}
/**
* Consume 1 point from limiters on wrong attempt and block if limits reached
* If limit is reached, return the number of seconds after which user can make another request
* Else return 0
*/
public async consume(ip: string, username?: string) {
try {
const promises = [this.limiterSlowBruteByIP.consume(ip)]
if (username) {
const usernameIPkey = this.getUsernameIPKey(ip, username)
// Count failed attempts by Username + IP only for registered users
promises.push(
this.limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey)
)
}
await Promise.all(promises)
} catch (rlRejected: any) {
if (rlRejected instanceof Error) {
throw rlRejected
} else {
// based upon the implementation of consume method of RateLimiterMongo
// we are sure that rlRejected will contain msBeforeNext
// for further reference,
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
return Math.ceil(rlRejected.msBeforeNext / 1000)
}
}
return 0
}
public async resetOnSuccess(ip: string, username: string) {
const usernameIPkey = this.getUsernameIPKey(ip, username)
const resUsernameAndIP =
await this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
await this.limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey)
}
}
}

View File

@@ -1,7 +1,9 @@
import bcrypt from 'bcryptjs'
import Client from '../model/Client'
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
import User from '../model/User'
import User, { IUser } from '../model/User'
import Configuration, { ConfigurationType } from '../model/Configuration'
import { ResetAdminPasswordType } from './verifyEnvVariables'
import { randomBytes } from 'crypto'
@@ -19,16 +21,16 @@ export const seedDB = async (): Promise<ConfigurationType> => {
const client = new Client(CLIENT)
await client.save()
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
process.logger.success(`DB Seed - client created: ${CLIENT.clientId}`)
}
// Checking if 'AllUsers' Group is already in the database
let groupExist = await Group.findOne({ name: GROUP.name })
let groupExist = await Group.findOne({ name: ALL_USERS_GROUP.name })
if (!groupExist) {
const group = new Group(GROUP)
const group = new Group(ALL_USERS_GROUP)
groupExist = await group.save()
console.log(`DB Seed - Group created: ${GROUP.name}`)
process.logger.success(`DB Seed - Group created: ${ALL_USERS_GROUP.name}`)
}
// Checking if 'Public' Group is already in the database
@@ -37,22 +39,28 @@ export const seedDB = async (): Promise<ConfigurationType> => {
const group = new Group(PUBLIC_GROUP)
await group.save()
console.log(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
process.logger.success(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
}
const ADMIN_USER = getAdminUser()
// Checking if user is already in the database
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
if (!usernameExist) {
if (usernameExist) {
usernameExist = await resetAdminPassword(usernameExist, ADMIN_USER.password)
} else {
const user = new User(ADMIN_USER)
usernameExist = await user.save()
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
process.logger.success(
`DB Seed - admin account created: ${ADMIN_USER.username}`
)
}
if (!groupExist.hasUser(usernameExist)) {
if (usernameExist.isAdmin && !groupExist.hasUser(usernameExist)) {
groupExist.addUser(usernameExist)
console.log(
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${GROUP.name}'`
process.logger.success(
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
)
}
@@ -62,7 +70,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
const configuration = new Configuration(SECRETS)
configExist = await configuration.save()
console.log('DB Seed - configuration added')
process.logger.success('DB Seed - configuration added')
}
return {
@@ -73,7 +81,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
}
}
const GROUP = {
export const ALL_USERS_GROUP = {
name: 'AllUsers',
description: 'Group contains all users'
}
@@ -88,11 +96,52 @@ const CLIENT = {
clientId: 'clientID1',
clientSecret: 'clientSecret'
}
const ADMIN_USER = {
id: 1,
displayName: 'Super Admin',
username: 'secretuser',
password: '$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO',
isAdmin: true,
isActive: true
const getAdminUser = () => {
const { ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL } = process.env
const salt = bcrypt.genSaltSync(10)
const hashedPassword = bcrypt.hashSync(ADMIN_PASSWORD_INITIAL as string, salt)
return {
displayName: 'Super Admin',
username: ADMIN_USERNAME,
password: hashedPassword,
isAdmin: true,
isActive: true
}
}
const resetAdminPassword = async (user: IUser, password: string) => {
const { ADMIN_PASSWORD_RESET } = process.env
if (ADMIN_PASSWORD_RESET === ResetAdminPasswordType.YES) {
if (!user.isAdmin) {
process.logger.error(
`Can not reset the password of non-admin user (${user.username}) on startup.`
)
return user
}
if (user.authProvider) {
process.logger.error(
`Can not reset the password of admin (${user.username}) with ${user.authProvider} as authentication mechanism.`
)
return user
}
process.logger.info(
`DB Seed - resetting password for admin user: ${user.username}`
)
user.password = password
user.needsToUpdatePassword = true
user = await user.save()
process.logger.success(`DB Seed - successfully reset the password`)
}
return user
}

View File

@@ -19,7 +19,8 @@ export const setProcessVariables = async () => {
}
if (process.env.NODE_ENV === 'test') {
process.driveLoc = path.join(process.cwd(), 'sasjs_root')
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
return
}
@@ -32,7 +33,6 @@ export const setProcessVariables = async () => {
process.rLoc = process.env.R_PATH
} else {
const { sasLoc, nodeLoc, pythonLoc, rLoc } = await getDesktopFields()
process.sasLoc = sasLoc
process.nodeLoc = nodeLoc
process.pythonLoc = pythonLoc
@@ -42,11 +42,19 @@ export const setProcessVariables = async () => {
const { SASJS_ROOT } = process.env
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
await createFolder(absPath)
process.driveLoc = getRealPath(absPath)
process.sasjsRoot = getRealPath(absPath)
const { DRIVE_LOCATION } = process.env
const absDrivePath = getAbsolutePath(
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
process.cwd()
)
await createFolder(absDrivePath)
process.driveLoc = getRealPath(absDrivePath)
const { LOG_LOCATION } = process.env
const absLogsPath = getAbsolutePath(
LOG_LOCATION ?? `sasjs_root${path.sep}logs`,
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
process.cwd()
)
await createFolder(absLogsPath)
@@ -54,8 +62,8 @@ export const setProcessVariables = async () => {
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
console.log('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc)
console.log('sasLogs: ', process.logsLoc)
console.log('runTimes: ', process.runTimes)
process.logger.info('sasLoc: ', process.sasLoc)
process.logger.info('sasDrive: ', process.driveLoc)
process.logger.info('sasLogs: ', process.logsLoc)
process.logger.info('runTimes: ', process.runTimes)
}

View File

@@ -1,14 +1,7 @@
import { createFile, createFolder, fileExists } from '@sasjs/utils'
import { getDesktopUserAutoExecPath, getFilesFolder } from './file'
import { ModeType } from './verifyEnvVariables'
import { createFolder } from '@sasjs/utils'
import { getFilesFolder, getPackagesFolder } from './file'
export const setupFolders = async () => {
const drivePath = getFilesFolder()
await createFolder(drivePath)
export const setupFilesFolder = async () => await createFolder(getFilesFolder())
if (process.env.MODE === ModeType.Desktop) {
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
await createFile(getDesktopUserAutoExecPath(), '')
}
}
}
export const setupPackagesFolder = async () =>
await createFolder(getPackagesFolder())

View File

@@ -0,0 +1,11 @@
import { createFile, fileExists } from '@sasjs/utils'
import { getDesktopUserAutoExecPath } from './file'
import { ModeType } from './verifyEnvVariables'
export const setupUserAutoExec = async () => {
if (process.env.MODE === ModeType.Desktop) {
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
await createFile(getDesktopUserAutoExecPath(), '')
}
}
}

View File

@@ -85,10 +85,18 @@ export const updateUserValidation = (
return Joi.object(validationChecks).validate(data)
}
export const updatePasswordValidation = (data: any): Joi.ValidationResult =>
Joi.object({
currentPassword: Joi.string().required(),
newPassword: passwordSchema.required()
}).validate(data)
export const registerClientValidation = (data: any): Joi.ValidationResult =>
Joi.object({
clientId: Joi.string().required(),
clientSecret: Joi.string().required()
clientSecret: Joi.string().required(),
accessTokenExpiration: Joi.number(),
refreshTokenExpiration: Joi.number()
}).validate(data)
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>

View File

@@ -9,8 +9,7 @@ export enum ModeType {
}
export enum AuthProviderType {
LDAP = 'ldap',
Internal = 'internal'
LDAP = 'ldap'
}
export enum ProtocolType {
@@ -48,6 +47,16 @@ export enum ReturnCode {
InvalidEnv
}
export enum DatabaseType {
MONGO = 'mongodb',
COSMOS_MONGODB = 'cosmos_mongodb'
}
export enum ResetAdminPasswordType {
YES = 'YES',
NO = 'NO'
}
export const verifyEnvVariables = (): ReturnCode => {
const errors: string[] = []
@@ -71,6 +80,12 @@ export const verifyEnvVariables = (): ReturnCode => {
errors.push(...verifyLDAPVariables())
errors.push(...verifyDbType())
errors.push(...verifyRateLimiter())
errors.push(...verifyAdminUserConfig())
if (errors.length) {
process.logger?.error(
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
@@ -111,7 +126,7 @@ const verifyMODE = (): string[] => {
}
if (process.env.MODE === ModeType.Server) {
const { DB_CONNECT, AUTH_MECHANISM } = process.env
const { DB_CONNECT, AUTH_PROVIDERS } = process.env
if (process.env.NODE_ENV !== 'test') {
if (!DB_CONNECT)
@@ -119,14 +134,12 @@ const verifyMODE = (): string[] => {
`- DB_CONNECT is required for PROTOCOL '${ModeType.Server}'`
)
if (AUTH_MECHANISM) {
const authMechanismTypes = Object.values(AuthProviderType)
if (!authMechanismTypes.includes(AUTH_MECHANISM as AuthProviderType))
if (AUTH_PROVIDERS) {
const authProvidersType = Object.values(AuthProviderType)
if (!authProvidersType.includes(AUTH_PROVIDERS as AuthProviderType))
errors.push(
`- AUTH_MECHANISM '${AUTH_MECHANISM}'\n - valid options ${authMechanismTypes}`
`- AUTH_PROVIDERS '${AUTH_PROVIDERS}'\n - valid options ${authProvidersType}`
)
} else {
process.env.AUTH_MECHANISM = DEFAULTS.AUTH_MECHANISM
}
}
}
@@ -270,7 +283,7 @@ const verifyRUN_TIMES = (): string[] => {
return errors
}
const verifyExecutablePaths = () => {
const verifyExecutablePaths = (): string[] => {
const errors: string[] = []
const { RUN_TIMES, SAS_PATH, NODE_PATH, PYTHON_PATH, R_PATH, MODE } =
process.env
@@ -307,37 +320,37 @@ const verifyLDAPVariables = () => {
LDAP_USERS_BASE_DN,
LDAP_GROUPS_BASE_DN,
MODE,
AUTH_MECHANISM
AUTH_PROVIDERS
} = process.env
if (MODE === ModeType.Server && AUTH_MECHANISM === AuthProviderType.LDAP) {
if (MODE === ModeType.Server && AUTH_PROVIDERS === AuthProviderType.LDAP) {
if (!LDAP_URL) {
errors.push(
`- LDAP_URL is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
`- LDAP_URL is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
)
}
if (!LDAP_BIND_DN) {
errors.push(
`- LDAP_BIND_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
`- LDAP_BIND_DN is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
)
}
if (!LDAP_BIND_PASSWORD) {
errors.push(
`- LDAP_BIND_PASSWORD is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
`- LDAP_BIND_PASSWORD is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
)
}
if (!LDAP_USERS_BASE_DN) {
errors.push(
`- LDAP_USERS_BASE_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
`- LDAP_USERS_BASE_DN is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
)
}
if (!LDAP_GROUPS_BASE_DN) {
errors.push(
`- LDAP_GROUPS_BASE_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
`- LDAP_GROUPS_BASE_DN is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
)
}
}
@@ -345,12 +358,111 @@ const verifyLDAPVariables = () => {
return errors
}
const verifyDbType = () => {
const errors: string[] = []
const { MODE, DB_TYPE } = process.env
if (MODE === ModeType.Server) {
if (DB_TYPE) {
const dbTypes = Object.values(DatabaseType)
if (!dbTypes.includes(DB_TYPE as DatabaseType))
errors.push(`- DB_TYPE '${DB_TYPE}'\n - valid options ${dbTypes}`)
} else {
process.env.DB_TYPE = DEFAULTS.DB_TYPE
}
}
return errors
}
const verifyRateLimiter = () => {
const errors: string[] = []
const {
MODE,
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
} = process.env
if (MODE === ModeType.Server) {
if (MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) {
if (
!isNumeric(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) ||
Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) < 1
) {
errors.push(
`- Invalid value for 'MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY' - Only positive number is acceptable`
)
}
} else {
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY =
DEFAULTS.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
}
if (MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) {
if (
!isNumeric(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) ||
Number(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) < 1
) {
errors.push(
`- Invalid value for 'MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP' - Only positive number is acceptable`
)
}
} else {
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP =
DEFAULTS.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
}
}
return errors
}
const verifyAdminUserConfig = () => {
const errors: string[] = []
const { MODE, ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL, ADMIN_PASSWORD_RESET } =
process.env
if (MODE === ModeType.Server) {
if (ADMIN_USERNAME) {
process.env.ADMIN_USERNAME = ADMIN_USERNAME.toLowerCase()
} else {
process.env.ADMIN_USERNAME = DEFAULTS.ADMIN_USERNAME
}
if (!ADMIN_PASSWORD_INITIAL)
process.env.ADMIN_PASSWORD_INITIAL = DEFAULTS.ADMIN_PASSWORD_INITIAL
if (ADMIN_PASSWORD_RESET) {
const resetPasswordTypes = Object.values(ResetAdminPasswordType)
if (
!resetPasswordTypes.includes(
ADMIN_PASSWORD_RESET as ResetAdminPasswordType
)
)
errors.push(
`- ADMIN_PASSWORD_RESET '${ADMIN_PASSWORD_RESET}'\n - valid options ${resetPasswordTypes}`
)
} else {
process.env.ADMIN_PASSWORD_RESET = DEFAULTS.ADMIN_PASSWORD_RESET
}
}
return errors
}
const isNumeric = (val: string): boolean => {
return !isNaN(Number(val))
}
const DEFAULTS = {
MODE: ModeType.Desktop,
AUTH_MECHANISM: AuthProviderType.Internal,
PROTOCOL: ProtocolType.HTTP,
PORT: '5000',
HELMET_COEP: HelmetCoepType.TRUE,
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
RUN_TIMES: RunTimeType.SAS
RUN_TIMES: RunTimeType.SAS,
DB_TYPE: DatabaseType.MONGO,
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10',
ADMIN_USERNAME: 'secretuser',
ADMIN_PASSWORD_INITIAL: 'secretpassword',
ADMIN_PASSWORD_RESET: ResetAdminPasswordType.NO
}

View File

@@ -15,6 +15,7 @@ export const fetchLatestAutoExec = async (
displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive,
needsToUpdatePassword: dbUser.needsToUpdatePassword,
autoExec: dbUser.autoExec
}
}
@@ -41,6 +42,7 @@ export const verifyTokenInDB = async (
displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive,
needsToUpdatePassword: dbUser.needsToUpdatePassword,
autoExec: dbUser.autoExec
}
: undefined

680
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import Header from './components/header'
import Home from './components/home'
import Studio from './containers/Studio'
import Settings from './containers/Settings'
import UpdatePassword from './components/updatePassword'
import { AppContext } from './context/appContext'
import AuthCode from './containers/AuthCode'
@@ -29,6 +30,20 @@ function App() {
)
}
if (appContext.needsToUpdatePassword) {
return (
<ThemeProvider theme={theme}>
<HashRouter>
<Header />
<Routes>
<Route path="*" element={<UpdatePassword />} />
</Routes>
<ToastContainer />
</HashRouter>
</ThemeProvider>
)
}
return (
<ThemeProvider theme={theme}>
<HashRouter>

View File

@@ -31,14 +31,24 @@ const DeleteConfirmationModal = ({
message,
_delete
}: DeleteConfirmationModalProps) => {
const handleDeleteClick = (event: React.MouseEvent) => {
event.stopPropagation()
_delete()
}
const handleClose = (event: any) => {
event.stopPropagation()
setOpen(false)
}
return (
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<BootstrapDialog onClose={handleClose} open={open}>
<DialogContent dividers>
<Typography gutterBottom>{message}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button color="error" onClick={() => _delete()}>
<Button onClick={handleClose}>Cancel</Button>
<Button color="error" onClick={handleDeleteClick}>
Delete
</Button>
</DialogActions>

View File

@@ -5,7 +5,7 @@ import Box from '@mui/material/Box'
const Home = () => {
return (
<Box className="main">
<Box className="container">
<CssBaseline />
<h2>Welcome to SASjs Server!</h2>
<p>

View File

@@ -2,7 +2,14 @@ import axios from 'axios'
import React, { useState, useContext } from 'react'
import PropTypes from 'prop-types'
import { CssBaseline, Box, TextField, Button } from '@mui/material'
import {
Backdrop,
CircularProgress,
CssBaseline,
Box,
TextField,
Button
} from '@mui/material'
import { AppContext } from '../context/appContext'
const login = async (payload: { username: string; password: string }) =>
@@ -10,21 +17,27 @@ const login = async (payload: { username: string; password: string }) =>
const Login = () => {
const appContext = useContext(AppContext)
const [isLoading, setIsLoading] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const handleSubmit = async (e: any) => {
setIsLoading(true)
setErrorMessage('')
e.preventDefault()
const { loggedIn, user } = await login({
username,
password
}).catch((err: any) => {
setErrorMessage(err.response?.data || err.toString())
return {}
})
.catch((err: any) => {
setErrorMessage(err.response?.data || err.toString())
return {}
})
.finally(() => {
setIsLoading(false)
})
if (loggedIn) {
appContext.setUserId?.(user.id)
@@ -32,46 +45,56 @@ const Login = () => {
appContext.setDisplayName?.(user.displayName)
appContext.setIsAdmin?.(user.isAdmin)
appContext.setLoggedIn?.(loggedIn)
appContext.setNeedsToUpdatePassword?.(user.needsToUpdatePassword)
}
}
return (
<Box
className="main"
component="form"
onSubmit={handleSubmit}
sx={{
'& > :not(style)': { m: 1, width: '25ch' }
}}
>
<CssBaseline />
<br />
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
<TextField
id="username"
label="Username"
type="text"
variant="outlined"
onChange={(e: any) => setUsername(e.target.value)}
required
/>
<TextField
id="password"
label="Password"
type="password"
variant="outlined"
onChange={(e: any) => setPassword(e.target.value)}
required
/>
{errorMessage && <span>{errorMessage}</span>}
<Button
type="submit"
variant="outlined"
disabled={!appContext.setLoggedIn}
<>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={isLoading}
>
Submit
</Button>
</Box>
<CircularProgress color="inherit" />
</Backdrop>
<Box
className="container"
component="form"
onSubmit={handleSubmit}
sx={{
'& > :not(style)': { m: 1, width: '25ch' }
}}
>
<CssBaseline />
<br />
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
<TextField
id="username"
label="Username"
type="text"
variant="outlined"
onChange={(e: any) => setUsername(e.target.value)}
required
/>
<TextField
id="password"
label="Password"
type="password"
variant="outlined"
onChange={(e: any) => setPassword(e.target.value)}
required
/>
{errorMessage && <span>{errorMessage}</span>}
<Button
type="submit"
variant="outlined"
disabled={!appContext.setLoggedIn}
>
Submit
</Button>
</Box>
</>
)
}

View File

@@ -69,8 +69,18 @@ const NameInputModal = ({
action(name)
}
const handleActionClick = (event: React.MouseEvent) => {
event.stopPropagation()
action(name)
}
const handleClose = (event: any) => {
event.stopPropagation()
setOpen(false)
}
return (
<BootstrapDialog fullWidth onClose={() => setOpen(false)} open={open}>
<BootstrapDialog fullWidth onClose={handleClose} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
{title}
</BootstrapDialogTitle>
@@ -91,12 +101,12 @@ const NameInputModal = ({
</form>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setOpen(false)}>
<Button variant="contained" onClick={handleClose}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => action(name)}
onClick={handleActionClick}
disabled={hasError || !name}
>
{actionLabel}

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react'
import {
Grid,
DialogContent,
DialogActions,
Button,
OutlinedInput,
InputAdornment,
IconButton,
FormControl,
InputLabel,
FormHelperText
} from '@mui/material'
import Visibility from '@mui/icons-material/Visibility'
import VisibilityOff from '@mui/icons-material/VisibilityOff'
import { BootstrapDialogTitle } from './dialogTitle'
import { BootstrapDialog } from './modal'
type Props = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
title: string
updatePassword: (currentPassword: string, newPassword: string) => void
}
const UpdatePasswordModal = (props: Props) => {
const { open, setOpen, title, updatePassword } = props
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [hasError, setHasError] = useState(false)
const [errorText, setErrorText] = useState('')
useEffect(() => {
if (
currentPassword.length > 0 &&
newPassword.length > 0 &&
newPassword === currentPassword
) {
setErrorText('New password should be different to current password.')
setHasError(true)
} else if (newPassword.length >= 6) {
setErrorText('')
setHasError(false)
}
}, [currentPassword, newPassword])
const handleBlur = () => {
if (newPassword.length < 6) {
setErrorText('Password length should be at least 6 characters.')
setHasError(true)
}
}
return (
<div>
<BootstrapDialog onClose={() => setOpen(false)} open={open}>
<BootstrapDialogTitle id="abort-modal" handleOpen={setOpen}>
{title}
</BootstrapDialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid item xs={12}>
<PasswordInput
label="Current Password"
password={currentPassword}
setPassword={setCurrentPassword}
/>
</Grid>
<Grid item xs={12}>
<PasswordInput
label="New Password"
password={newPassword}
setPassword={setNewPassword}
hasError={hasError}
errorText={errorText}
handleBlur={handleBlur}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="contained"
onClick={() => updatePassword(currentPassword, newPassword)}
disabled={hasError || !currentPassword || !newPassword}
>
Update
</Button>
</DialogActions>
</BootstrapDialog>
</div>
)
}
export default UpdatePasswordModal
type PasswordInputProps = {
label: string
password: string
setPassword: React.Dispatch<React.SetStateAction<string>>
hasError?: boolean
errorText?: string
handleBlur?: () => void
}
export const PasswordInput = ({
label,
password,
setPassword,
hasError,
errorText,
handleBlur
}: PasswordInputProps) => {
const [showPassword, setShowPassword] = useState(false)
return (
<FormControl sx={{ width: '100%' }} variant="outlined" error={hasError}>
<InputLabel htmlFor="outlined-adornment-password">{label}</InputLabel>
<OutlinedInput
id="outlined-adornment-password"
type={showPassword ? 'text' : 'password'}
label={label}
value={password}
onChange={(e) => setPassword(e.target.value)}
onBlur={handleBlur}
endAdornment={
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword((val) => !val)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
}
/>
{errorText && <FormHelperText>{errorText}</FormHelperText>}
</FormControl>
)
}

View File

@@ -1,67 +1,79 @@
import React, { useEffect, useState } from 'react'
import { Menu, MenuItem } from '@mui/material'
import React, { useState } from 'react'
import { Menu, MenuItem, Typography } from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import MuiTreeView from '@mui/lab/TreeView'
import MuiTreeItem from '@mui/lab/TreeItem'
import DeleteConfirmationModal from './deleteConfirmationModal'
import NameInputModal from './nameInputModal'
import { TreeNode } from '../utils/types'
type Props = {
interface Props {
node: TreeNode
selectedFilePath: string
handleSelect: (filePath: string) => void
deleteNode: (path: string, isFolder: boolean) => void
addFile: (path: string) => void
addFolder: (path: string) => void
rename: (oldPath: string, newPath: string) => void
}
interface TreeViewProps extends Props {
defaultExpanded?: string[]
}
const TreeView = ({
node,
selectedFilePath,
handleSelect,
deleteNode,
addFile,
addFolder,
rename,
defaultExpanded
}: Props) => {
return (
<ul
style={{
listStyle: 'none',
padding: '0.25rem 0.85rem',
width: 'max-content'
}}
}: TreeViewProps) => {
const renderTree = (nodes: TreeNode) => (
<MuiTreeItem
key={nodes.relativePath}
nodeId={nodes.relativePath}
label={
<TreeItemWithContextMenu
node={nodes}
handleSelect={handleSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
/>
}
>
<TreeViewNode
node={node}
selectedFilePath={selectedFilePath}
handleSelect={handleSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
defaultExpanded={defaultExpanded}
/>
</ul>
{Array.isArray(nodes.children)
? nodes.children.map((node) => renderTree(node))
: null}
</MuiTreeItem>
)
return (
<MuiTreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
defaultExpanded={defaultExpanded}
sx={{ flexGrow: 1, maxWidth: 400, overflowY: 'auto' }}
>
{renderTree(node)}
</MuiTreeView>
)
}
export default TreeView
const TreeViewNode = ({
const TreeItemWithContextMenu = ({
node,
selectedFilePath,
handleSelect,
deleteNode,
addFile,
addFolder,
rename,
defaultExpanded
rename
}: Props) => {
const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] =
useState(false)
@@ -72,18 +84,19 @@ const TreeViewNode = ({
const [nameInputModalTitle, setNameInputModalTitle] = useState('')
const [nameInputModalActionLabel, setNameInputModalActionLabel] = useState('')
const [nameInputModalForFolder, setNameInputModalForFolder] = useState(false)
const [childVisible, setChildVisibility] = useState(false)
const [contextMenu, setContextMenu] = useState<{
mouseX: number
mouseY: number
} | null>(null)
const launchProgram = () => {
const launchProgram = (event: React.MouseEvent) => {
event.stopPropagation()
const baseUrl = window.location.origin
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}`)
}
const launchProgramWithDebug = () => {
const launchProgramWithDebug = (event: React.MouseEvent) => {
event.stopPropagation()
const baseUrl = window.location.origin
window.open(
`${baseUrl}/SASjsApi/stp/execute?_program=${node.relativePath}&_debug=131`
@@ -103,25 +116,18 @@ const TreeViewNode = ({
)
}
const hasChild = node.children.length ? true : false
const handleItemClick = () => {
if (node.children.length) {
setChildVisibility((v) => !v)
return
}
const handleClose = (event: any) => {
event.stopPropagation()
setContextMenu(null)
}
const handleItemClick = (event: React.MouseEvent) => {
if (node.children.length) return
handleSelect(node.relativePath)
}
useEffect(() => {
if (defaultExpanded && defaultExpanded[0] === node.relativePath) {
setChildVisibility(true)
defaultExpanded.shift()
}
}, [defaultExpanded, node.relativePath])
const handleDeleteItemClick = () => {
const handleDeleteItemClick = (event: React.MouseEvent) => {
event.stopPropagation()
setContextMenu(null)
setDeleteConfirmationModalOpen(true)
setDeleteConfirmationModalMessage(
@@ -136,7 +142,8 @@ const TreeViewNode = ({
deleteNode(node.relativePath, node.isFolder)
}
const handleNewFolderItemClick = () => {
const handleNewFolderItemClick = (event: React.MouseEvent) => {
event.stopPropagation()
setContextMenu(null)
setNameInputModalOpen(true)
setNameInputModalTitle('Add Folder')
@@ -145,7 +152,8 @@ const TreeViewNode = ({
setDefaultInputModalName('')
}
const handleNewFileItemClick = () => {
const handleNewFileItemClick = (event: React.MouseEvent) => {
event.stopPropagation()
setContextMenu(null)
setNameInputModalOpen(true)
setNameInputModalTitle('Add File')
@@ -161,7 +169,8 @@ const TreeViewNode = ({
else addFile(path)
}
const handleRenameItemClick = () => {
const handleRenameItemClick = (event: React.MouseEvent) => {
event.stopPropagation()
setContextMenu(null)
setNameInputModalOpen(true)
setNameInputModalTitle('Rename')
@@ -181,34 +190,7 @@ const TreeViewNode = ({
return (
<div onContextMenu={handleContextMenu} style={{ cursor: 'context-menu' }}>
<li style={{ display: 'list-item' }}>
<div
className={`tree-item-label ${
selectedFilePath === node.relativePath ? 'selected' : ''
}`}
onClick={() => handleItemClick()}
>
{hasChild &&
(childVisible ? <ExpandMoreIcon /> : <ChevronRightIcon />)}
<div>{node.name}</div>
</div>
{hasChild &&
childVisible &&
node.children.map((child, index) => (
<TreeView
key={node.relativePath + '-' + index}
node={child}
selectedFilePath={selectedFilePath}
handleSelect={handleSelect}
deleteNode={deleteNode}
addFile={addFile}
addFolder={addFolder}
rename={rename}
defaultExpanded={defaultExpanded}
/>
))}
</li>
<Typography onClick={handleItemClick}>{node.name}</Typography>
<DeleteConfirmationModal
open={deleteConfirmationModalOpen}
setOpen={setDeleteConfirmationModalOpen}
@@ -228,7 +210,7 @@ const TreeViewNode = ({
/>
<Menu
open={contextMenu !== null}
onClose={() => setContextMenu(null)}
onClose={handleClose}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect, useContext } from 'react'
import axios from 'axios'
import { Box, CssBaseline, Button, CircularProgress } from '@mui/material'
import { toast } from 'react-toastify'
import { PasswordInput } from './passwordModal'
import { AppContext } from '../context/appContext'
const UpdatePassword = () => {
const appContext = useContext(AppContext)
const [isLoading, setIsLoading] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [hasError, setHasError] = useState(false)
const [errorText, setErrorText] = useState('')
useEffect(() => {
if (
currentPassword.length > 0 &&
newPassword.length > 0 &&
newPassword === currentPassword
) {
setErrorText('New password should be different to current password.')
setHasError(true)
} else if (newPassword.length >= 6) {
setErrorText('')
setHasError(false)
}
}, [currentPassword, newPassword])
const handleBlur = () => {
if (newPassword.length < 6) {
setErrorText('Password length should be at least 6 characters.')
setHasError(true)
}
}
const handleSubmit = async (e: any) => {
e.preventDefault()
if (hasError || !currentPassword || !newPassword) return
setIsLoading(true)
axios
.patch(`/SASjsApi/auth/updatePassword`, {
currentPassword,
newPassword
})
.then((res: any) => {
appContext.setNeedsToUpdatePassword?.(false)
toast.success('Password updated', {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.catch((err) => {
toast.error('Failed: ' + err.response?.data || err.text, {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.finally(() => {
setIsLoading(false)
})
}
return isLoading ? (
<CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }}
/>
) : (
<Box
className="container"
component="form"
onSubmit={handleSubmit}
sx={{
'& > :not(style)': { m: 1, width: '25ch' }
}}
>
<CssBaseline />
<h2>Welcome to SASjs Server!</h2>
<p style={{ width: 'auto' }}>
This is your first time login to SASjs server. Therefore, you need to
update your password.
</p>
<PasswordInput
label="Current Password"
password={currentPassword}
setPassword={setCurrentPassword}
/>
<PasswordInput
label="New Password"
password={newPassword}
setPassword={setNewPassword}
hasError={hasError}
errorText={errorText}
handleBlur={handleBlur}
/>
<Button
type="submit"
variant="outlined"
disabled={hasError || !currentPassword || !newPassword}
>
Update
</Button>
</Box>
)
}
export default UpdatePassword

View File

@@ -47,7 +47,7 @@ const AuthCode = () => {
}
return (
<Box className="main">
<Box className="container">
<CssBaseline />
<br />
<h2>Authorization Code</h2>

View File

@@ -17,11 +17,13 @@ import {
import { toast } from 'react-toastify'
import { AppContext, ModeType } from '../../context/appContext'
import UpdatePasswordModal from '../../components/passwordModal'
const Profile = () => {
const [isLoading, setIsLoading] = useState(false)
const appContext = useContext(AppContext)
const [user, setUser] = useState({} as any)
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
useEffect(() => {
setIsLoading(true)
@@ -36,7 +38,7 @@ const Profile = () => {
.finally(() => {
setIsLoading(false)
})
}, [])
}, [appContext.userId])
const handleChange = (event: any) => {
const { name, value } = event.target
@@ -68,82 +70,124 @@ const Profile = () => {
})
}
const updatePassword = (currentPassword: string, newPassword: string) => {
setIsLoading(true)
setIsPasswordModalOpen(false)
axios
.patch(`/SASjsApi/auth/updatePassword`, {
currentPassword,
newPassword
})
.then((res: any) => {
toast.success('Password updated', {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.catch((err) => {
toast.error('Failed: ' + err.response?.data || err.text, {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.finally(() => {
setIsLoading(false)
})
}
return isLoading ? (
<CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }}
/>
) : (
<Card>
<CardHeader title="Profile Information" />
<Divider />
<CardContent>
<Grid container spacing={4}>
<Grid item md={6} xs={12}>
<TextField
fullWidth
error={user.displayName?.length === 0}
helperText="Please specify display name"
label="Display Name"
name="displayName"
onChange={handleChange}
required
value={user.displayName}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/>
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
error={user.username?.length === 0}
helperText="Please specify username"
label="Username"
name="username"
onChange={handleChange}
required
value={user.username}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/>
</Grid>
<Grid item lg={6} md={8} sm={12} xs={12}>
<TextField
fullWidth
label="autoExec"
name="autoExec"
onChange={handleChange}
multiline
rows="10"
value={user.autoExec}
variant="outlined"
/>
</Grid>
<Grid item xs={6}>
<FormGroup row>
<FormControlLabel
disabled
control={<Checkbox checked={user.isActive} />}
label="isActive"
<>
<Card>
<CardHeader title="Profile Information" />
<Divider />
<CardContent>
<Grid container spacing={4}>
<Grid item md={6} xs={12}>
<TextField
fullWidth
error={user.displayName?.length === 0}
helperText="Please specify display name"
label="Display Name"
name="displayName"
onChange={handleChange}
required
value={user.displayName}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/>
<FormControlLabel
disabled
control={<Checkbox checked={user.isAdmin} />}
label="isAdmin"
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
error={user.username?.length === 0}
helperText="Please specify username"
label="Username"
name="username"
onChange={handleChange}
required
value={user.username}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/>
</FormGroup>
</Grid>
<Grid item lg={6} md={8} sm={12} xs={12}>
<TextField
fullWidth
label="autoExec"
name="autoExec"
onChange={handleChange}
multiline
rows="10"
value={user.autoExec}
variant="outlined"
/>
</Grid>
<Grid item xs={6}>
<FormGroup row>
<FormControlLabel
disabled
control={<Checkbox checked={user.isActive} />}
label="isActive"
/>
<FormControlLabel
disabled
control={<Checkbox checked={user.isAdmin} />}
label="isAdmin"
/>
</FormGroup>
</Grid>
<Grid item xs={12}>
<Button
variant="contained"
onClick={() => setIsPasswordModalOpen(true)}
>
Update Password
</Button>
</Grid>
</Grid>
</Grid>
</CardContent>
<Divider />
<CardActions>
<Button type="submit" variant="contained" onClick={handleSubmit}>
Save Changes
</Button>
</CardActions>
</Card>
</CardContent>
<Divider />
<CardActions>
<Button type="submit" variant="contained" onClick={handleSubmit}>
Save Changes
</Button>
</CardActions>
</Card>
<UpdatePasswordModal
open={isPasswordModalOpen}
setOpen={setIsPasswordModalOpen}
title="Update Password"
updatePassword={updatePassword}
/>
</>
)
}

View File

@@ -48,7 +48,6 @@ const SASjsEditor = ({
setTab
}: SASjsEditorProps) => {
const {
ctrlPressed,
fileContent,
isLoading,
log,
@@ -64,8 +63,6 @@ const SASjsEditor = ({
handleDiffEditorDidMount,
handleEditorDidMount,
handleFilePathInput,
handleKeyDown,
handleKeyUp,
handleRunBtnClick,
handleTabChange,
saveFile,
@@ -99,7 +96,6 @@ const SASjsEditor = ({
original={prevFileContent}
value={fileContent}
editorDidMount={handleDiffEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => setFileContent(val)}
/>
) : (
@@ -108,7 +104,6 @@ const SASjsEditor = ({
language={getLanguageFromExtension(selectedFileExtension)}
value={fileContent}
editorDidMount={handleEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => setFileContent(val)}
/>
)
@@ -176,8 +171,6 @@ const SASjsEditor = ({
{fileMenu}
</Box>
<Paper
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
sx={{
height: 'calc(100vh - 170px)',
padding: '10px',
@@ -204,7 +197,10 @@ const SASjsEditor = ({
<StyledTabPanel value="log">
<div>
<h2>Log</h2>
<pre id="log" style={{ overflow: 'auto', height: '75vh' }}>
<pre
id="log"
style={{ overflow: 'auto', height: 'calc(100vh - 220px)' }}
>
{log}
</pre>
</div>

View File

@@ -31,7 +31,10 @@ const RunMenu = ({
handleRunBtnClick
}: RunMenuProps) => {
const launchProgram = () => {
const baseUrl = window.location.origin
const pathName =
window.location.pathname === '/' ? '' : window.location.pathname
const baseUrl = window.location.origin + pathName
window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`)
}

View File

@@ -42,7 +42,6 @@ const useEditor = ({
const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
const [fileContent, setFileContent] = useState('')
const [log, setLog] = useState('')
const [ctrlPressed, setCtrlPressed] = useState(false)
const [webout, setWebout] = useState('')
const [runTimes, setRunTimes] = useState<string[]>([])
const [selectedRunTime, setSelectedRunTime] = useState('')
@@ -50,7 +49,7 @@ const useEditor = ({
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
const [showDiff, setShowDiff] = useState(false)
const editorRef = useRef(null as any)
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
const handleEditorDidMount: EditorDidMount = (editor) => {
editorRef.current = editor
@@ -148,53 +147,47 @@ const useEditor = ({
const handleRunBtnClick = () =>
runCode(getSelection(editorRef.current as any) || fileContent)
const runCode = (code: string) => {
setIsLoading(true)
axios
.post(`/SASjsApi/code/execute`, {
code: programPathInjection(
code,
selectedFilePath,
selectedRunTime as RunTimeType
),
runTime: selectedRunTime
})
.then((res: any) => {
setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '')
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '')
setTab('log')
const runCode = useCallback(
(code: string) => {
setIsLoading(true)
axios
.post(`/SASjsApi/code/execute`, {
code: programPathInjection(
code,
selectedFilePath,
selectedRunTime as RunTimeType
),
runTime: selectedRunTime
})
.then((res: any) => {
setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '')
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '')
setTab('log')
// Scroll to bottom of log
const logElement = document.getElementById('log')
if (logElement) logElement.scrollTop = logElement.scrollHeight
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
}
const handleKeyDown = (event: any) => {
if (event.ctrlKey) {
if (event.key === 'v') {
setCtrlPressed(false)
}
if (event.key === 'Enter')
runCode(getSelection(editorRef.current as any) || fileContent)
if (!ctrlPressed) setCtrlPressed(true)
}
}
const handleKeyUp = (event: any) => {
if (!event.ctrlKey && ctrlPressed) setCtrlPressed(false)
}
// Scroll to bottom of log
const logElement = document.getElementById('log')
if (logElement) logElement.scrollTop = logElement.scrollHeight
})
.catch((err) => {
setModalTitle('Abort')
setModalPayload(
typeof err.response.data === 'object'
? JSON.stringify(err.response.data)
: err.response.data
)
setOpenModal(true)
})
.finally(() => setIsLoading(false))
},
[
selectedFilePath,
selectedRunTime,
setModalPayload,
setModalTitle,
setOpenModal,
setTab
]
)
const handleChangeRunTime = (event: SelectChangeEvent) => {
setSelectedRunTime(event.target.value as RunTimeType)
@@ -206,7 +199,7 @@ const useEditor = ({
}
useEffect(() => {
editorRef.current.addAction({
const saveFileAction = editorRef.current?.addAction({
// An unique identifier of the contributed action.
id: 'save-file',
@@ -216,6 +209,8 @@ const useEditor = ({
// An optional array of keybindings for the action.
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
contextMenuGroupId: '9_cutcopypaste',
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: () => {
@@ -223,7 +218,31 @@ const useEditor = ({
if (prevFileContent !== fileContent) return saveFile()
}
})
}, [fileContent, prevFileContent, selectedFilePath, saveFile])
const runCodeAction = editorRef.current?.addAction({
// An unique identifier of the contributed action.
id: 'run-code',
// A label of the action that will be presented to the user.
label: 'Run Code',
// An optional array of keybindings for the action.
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
contextMenuGroupId: 'navigation',
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: function () {
runCode(getSelection(editorRef.current as any) || fileContent)
}
})
return () => {
saveFileAction?.dispose()
runCodeAction?.dispose()
}
}, [fileContent, prevFileContent, selectedFilePath, saveFile, runCode])
useEffect(() => {
setRunTimes(Object.values(appContext.runTimes))
@@ -242,8 +261,10 @@ const useEditor = ({
axios
.get(`/SASjsApi/drive/file?_filePath=${selectedFilePath}`)
.then((res: any) => {
setPrevFileContent(res.data)
setFileContent(res.data)
const content =
typeof res.data === 'object' ? JSON.stringify(res.data) : res.data
setPrevFileContent(content)
setFileContent(content)
})
.catch((err) => {
setModalTitle('Abort')
@@ -277,7 +298,6 @@ const useEditor = ({
}, [selectedFileExtension, runTimes])
return {
ctrlPressed,
fileContent,
isLoading,
log,
@@ -293,8 +313,6 @@ const useEditor = ({
handleDiffEditorDidMount,
handleEditorDidMount,
handleFilePathInput,
handleKeyDown,
handleKeyUp,
handleRunBtnClick,
handleTabChange,
saveFile,

View File

@@ -180,7 +180,6 @@ const SideBar = ({
{directoryData && (
<TreeView
node={directoryData}
selectedFilePath={selectedFilePath}
handleSelect={handleFileSelect}
deleteNode={deleteNode}
addFile={addFile}

View File

@@ -25,6 +25,8 @@ interface AppContextProps {
checkingSession: boolean
loggedIn: boolean
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
needsToUpdatePassword: boolean
setNeedsToUpdatePassword: Dispatch<SetStateAction<boolean>> | null
userId: number
setUserId: Dispatch<SetStateAction<number>> | null
username: string
@@ -42,6 +44,8 @@ export const AppContext = createContext<AppContextProps>({
checkingSession: false,
loggedIn: false,
setLoggedIn: null,
needsToUpdatePassword: false,
setNeedsToUpdatePassword: null,
userId: 0,
setUserId: null,
username: '',
@@ -59,6 +63,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
const { children } = props
const [checkingSession, setCheckingSession] = useState(false)
const [loggedIn, setLoggedIn] = useState(false)
const [needsToUpdatePassword, setNeedsToUpdatePassword] = useState(false)
const [userId, setUserId] = useState(0)
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
@@ -79,6 +84,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
setDisplayName(data.displayName)
setIsAdmin(data.isAdmin)
setLoggedIn(true)
setNeedsToUpdatePassword(data.needsToUpdatePassword)
})
.catch(() => {
setLoggedIn(false)
@@ -120,6 +126,8 @@ const AppContextProvider = (props: { children: ReactNode }) => {
checkingSession,
loggedIn,
setLoggedIn,
needsToUpdatePassword,
setNeedsToUpdatePassword,
userId,
setUserId,
username,

View File

@@ -12,7 +12,7 @@ code {
monospace;
}
.main {
.container {
margin: 50px 10px 0 10px;
display: flex;
flex-direction: column;
@@ -25,15 +25,3 @@ code {
padding: '5px 10px';
margin-top: '10px';
}
.tree-item-label {
display: flex;
}
.tree-item-label.selected {
background: lightgoldenrodyellow;
}
.tree-item-label:hover {
background: lightgray;
}

View File

@@ -9,7 +9,9 @@ import axios from 'axios'
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
NODE_ENV === 'development'
? `http://localhost:${PORT_API ?? 5000}`
: window.location.origin + window.location.pathname
axios.defaults = Object.assign(axios.defaults, {
withCredentials: true,