mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c261745f1d | ||
|
|
d6e527ecf2 | ||
|
|
bc2cff1d0d | ||
|
|
66aa9b5891 | ||
|
|
ca17e7c192 | ||
|
|
73df102422 | ||
|
|
48a9a4dd0e | ||
|
|
4f6f735f5b | ||
|
|
6b6546c7ad | ||
|
|
f94ddc0352 | ||
|
|
03670cf0d6 | ||
|
|
ea2ec97c1c | ||
|
|
832f1156e8 | ||
|
|
5cda9cd5d8 | ||
|
|
5d576aff91 | ||
|
|
a044176054 | ||
|
|
deee34f5fd | ||
|
|
b0723f1444 | ||
|
|
e9519cb3c6 | ||
|
|
0838b8112e | ||
|
|
441f8b7726 | ||
|
|
049a7f4b80 | ||
|
|
3053c68bdf | ||
|
|
76750e864d | ||
|
|
ffcf193b87 | ||
|
|
aa2a1cbe13 | ||
|
|
6f2c53555c | ||
|
|
73d965daf5 | ||
|
|
4f1763db67 | ||
|
|
28222add04 | ||
|
|
068edfd6a5 | ||
|
|
7e8cbbf377 | ||
|
|
1fc1431442 | ||
|
|
3387efbb9a | ||
|
|
e2996b495f | ||
|
|
41c627f93a | ||
|
|
49f5dc7555 | ||
|
|
f6e77f99a4 | ||
|
|
b57dfa429b | ||
| 9586dbb2d0 | |||
|
|
a4f78ab48d | ||
|
|
2f47a2213b | ||
|
|
0f91395fbb | ||
|
|
167b14fed0 | ||
|
|
8940f4dc47 | ||
|
|
48c1ada1b6 | ||
|
|
0532488b55 | ||
|
|
d458b5bb81 | ||
|
|
958ab9cad2 | ||
|
|
78ceed13e1 | ||
|
|
a17814fc90 | ||
|
|
9aaffce820 | ||
|
|
e78f87f5c0 | ||
|
|
bd1b58086d | ||
|
|
9f521634d9 | ||
|
|
a696168443 | ||
|
|
31df72ad88 | ||
|
|
d2239f75c2 | ||
|
|
45428892cc | ||
| ac27a9b894 | |||
| dba53de646 | |||
|
|
eb42683fff | ||
|
|
d2de9dc13e | ||
|
|
6dd2f4f876 | ||
|
|
c0f38ba7c9 | ||
|
|
d2f011e8a9 | ||
|
|
5215633e96 | ||
|
|
64b156f762 | ||
|
|
9c5acd6de3 | ||
|
|
3e72384a63 | ||
|
|
df5d40b445 | ||
|
|
c44ec35b3d | ||
|
|
77fac663c5 | ||
|
|
3848bb0add | ||
|
|
56a522c07c | ||
|
|
87e9172cfc | ||
| 7df9588e66 | |||
| 6a520f5b26 | |||
|
|
777b3a55be | ||
|
|
70c3834022 | ||
|
|
dbf6c7de08 | ||
|
|
d49ea47bd7 | ||
|
|
a38a9f9c3d | ||
|
|
be4951d112 | ||
|
|
c116b263d9 | ||
|
|
b4436bad0d | ||
|
|
57b7f954a1 | ||
|
|
8254b78955 | ||
|
|
75f5a3c0b3 | ||
|
|
c72ecc7e59 | ||
|
|
e04300ad2a | ||
|
|
c7a73991a7 | ||
|
|
02e2b060f9 | ||
|
|
3b1e4a128b | ||
|
|
7b12591595 | ||
|
|
3a887dec55 | ||
|
|
7c1c1e2410 | ||
|
|
15774eca34 | ||
|
|
5e325522f4 | ||
|
|
e576fad8f4 | ||
| eda8e56bb0 | |||
|
|
bee4f215d2 | ||
|
|
100f138f98 | ||
| 6ffaa7e9e2 | |||
|
|
a433786011 | ||
|
|
1adff9a783 | ||
| 1435e380be | |||
| e099f2e678 | |||
| ddd155ba01 | |||
| 9936241815 | |||
| 570995e572 | |||
| 462829fd9a | |||
| c1c0554de2 | |||
| bd3aff9a7b | |||
| a1e255e0c7 | |||
| 0dae034f17 | |||
| 89048ce943 | |||
| a82cabb001 | |||
| c4066d32a0 | |||
|
|
6a44cd69d9 | ||
|
|
e607115995 | ||
| edab51c519 | |||
|
|
081cc3102c | ||
|
|
b19aa1eba4 | ||
| 2c31922f58 | |||
|
|
4d7a571a6e | ||
|
|
a373a4eb5f | ||
| 5e3ce8a98f | |||
|
|
737b34567e | ||
|
|
6373442f83 | ||
|
|
3de59ac4f8 | ||
|
|
941988cd7c | ||
| 158f044363 | |||
|
|
02ae041a81 | ||
|
|
c4c84b1537 | ||
| b3402ea80a | |||
|
|
abe942e697 | ||
|
|
faf2edb111 | ||
| 5bec453e89 | |||
| 7f2174dd2c | |||
| 2bae52e307 | |||
|
|
b243e62ece | ||
|
|
88c3056e97 | ||
| 203303b659 | |||
| 835709bd36 | |||
| 69f2576ee6 | |||
|
|
305077f36e | ||
|
|
96eca3a35d | ||
|
|
0f5c815c25 | ||
|
|
acccef1e99 | ||
| abc34ea047 | |||
| 71c429b093 | |||
|
|
c126f2d5d9 | ||
|
|
34dd95d16e | ||
| 1192583843 | |||
|
|
518815acf1 | ||
|
|
80b7e14ed5 | ||
| 23c997b3be | |||
| 39ba995355 | |||
|
|
0e081e024b | ||
|
|
6a84bd0387 | ||
| 98d177a691 | |||
| 4dcee4b3c3 | |||
|
|
4ffc1ec6a9 | ||
|
|
5a1d168e83 | ||
|
|
515c976685 | ||
| 112431a1b7 | |||
| c26485afec | |||
| 1d48f8856b | |||
| 68758aa616 | |||
| 8b8c43c21b | |||
| 4581f32534 | |||
| b47e74a7e1 | |||
| b27d684145 | |||
|
|
6b666d5554 | ||
|
|
b5f0911858 | ||
| b86ba5b8a3 | |||
| 200f6c596a | |||
|
|
1b7ccda6e9 | ||
|
|
532035d835 | ||
|
|
7ae862c5ce | ||
|
|
ab5858b8af | ||
|
|
a39f5dd9f1 | ||
|
|
3ea444756c | ||
|
|
96399ecbbe | ||
| bb054938c5 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -56,4 +56,4 @@ jobs:
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
GITHUB_TOKEN=${{ secrets.GH_TOKEN }} semantic-release
|
||||
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} semantic-release
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"autoexec"
|
||||
]
|
||||
"cSpell.words": ["autoexec", "initialising"]
|
||||
}
|
||||
|
||||
284
CHANGELOG.md
284
CHANGELOG.md
@@ -1,3 +1,287 @@
|
||||
# [0.39.0](https://github.com/sasjs/server/compare/v0.38.0...v0.39.0) (2024-10-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** fixed condition in processProgram ([48a9a4d](https://github.com/sasjs/server/commit/48a9a4dd0e31f84209635382be4ec4bb2c3a9c0c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** added session state endpoint ([6b6546c](https://github.com/sasjs/server/commit/6b6546c7ad0833347f8dc4cdba6ad19132f7aaef))
|
||||
|
||||
# [0.38.0](https://github.com/sasjs/server/compare/v0.37.0...v0.38.0) (2024-10-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** enabled query params in stp/trigger endpoint ([5cda9cd](https://github.com/sasjs/server/commit/5cda9cd5d8623b7ea2ecd989d7808f47ec866672))
|
||||
|
||||
# [0.37.0](https://github.com/sasjs/server/compare/v0.36.0...v0.37.0) (2024-10-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **stp:** added trigger endpoint ([b0723f1](https://github.com/sasjs/server/commit/b0723f14448d60ffce4f2175cf8a73fc4d4dd0ee))
|
||||
|
||||
# [0.36.0](https://github.com/sasjs/server/compare/v0.35.4...v0.36.0) (2024-10-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **code:** added code/trigger API endpoint ([ffcf193](https://github.com/sasjs/server/commit/ffcf193b87d811b166d79af74013776a253b50b0))
|
||||
|
||||
## [0.35.4](https://github.com/sasjs/server/compare/v0.35.3...v0.35.4) (2024-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** fixed env issue in MacOS executable ([73d965d](https://github.com/sasjs/server/commit/73d965daf54b16c0921e4b18d11a1e6f8650884d))
|
||||
|
||||
## [0.35.3](https://github.com/sasjs/server/compare/v0.35.2...v0.35.3) (2023-11-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable embedded LFs in JS STP vars ([7e8cbbf](https://github.com/sasjs/server/commit/7e8cbbf377b27a7f5dd9af0bc6605c01f302f5d9))
|
||||
|
||||
## [0.35.2](https://github.com/sasjs/server/compare/v0.35.1...v0.35.2) (2023-08-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add _debug as optional query param in swagger apis for GET stp/execute ([9586dbb](https://github.com/sasjs/server/commit/9586dbb2d0d6611061c9efdfb84030144f62c2ee))
|
||||
|
||||
## [0.35.1](https://github.com/sasjs/server/compare/v0.35.0...v0.35.1) (2023-07-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **log-separator:** log separator should always wrap log ([8940f4d](https://github.com/sasjs/server/commit/8940f4dc47abae2036b4fcdeb772c31a0ca07cca))
|
||||
|
||||
# [0.35.0](https://github.com/sasjs/server/compare/v0.34.2...v0.35.0) (2023-05-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** fixed log/webout/print tabs ([d2de9dc](https://github.com/sasjs/server/commit/d2de9dc13ef2e980286dd03cca5e22cea443ed0c))
|
||||
* **execute:** added atribute indicating stp api ([e78f87f](https://github.com/sasjs/server/commit/e78f87f5c00038ea11261dffb525ac8f1024e40b))
|
||||
* **execute:** fixed adding print output ([9aaffce](https://github.com/sasjs/server/commit/9aaffce82051d81bf39adb69942bb321e9795141))
|
||||
* **execution:** removed empty webout from response ([6dd2f4f](https://github.com/sasjs/server/commit/6dd2f4f87673336135bc7a6de0d2e143e192c025))
|
||||
* **webout:** fixed adding empty webout to response payload ([31df72a](https://github.com/sasjs/server/commit/31df72ad88fe2c771d0ef8445d6db9dd147c40c9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** parse print output in response payload ([eb42683](https://github.com/sasjs/server/commit/eb42683fff701bd5b4d2b68760fe0c3ecad573dd))
|
||||
|
||||
## [0.34.2](https://github.com/sasjs/server/compare/v0.34.1...v0.34.2) (2023-05-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use custom logic for handling sequence ids ([dba53de](https://github.com/sasjs/server/commit/dba53de64664c9d8a40fe69de6281c53d1c73641))
|
||||
|
||||
## [0.34.1](https://github.com/sasjs/server/compare/v0.34.0...v0.34.1) (2023-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **css:** fixed css loading ([9c5acd6](https://github.com/sasjs/server/commit/9c5acd6de32afdbc186f79ae5b35375dda2e49b0))
|
||||
* **log:** fixed chunk collapsing ([64b156f](https://github.com/sasjs/server/commit/64b156f7627969b7f13022726f984fbbfe1a33ef))
|
||||
|
||||
# [0.34.0](https://github.com/sasjs/server/compare/v0.33.3...v0.34.0) (2023-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **log:** fixed checks for errors and warnings ([02e2b06](https://github.com/sasjs/server/commit/02e2b060f9bedf4806f45f5205fd87bfa2ecae90))
|
||||
* **log:** fixed default runtime ([e04300a](https://github.com/sasjs/server/commit/e04300ad2ac237be7b28a6332fa87a3bcf761c7b))
|
||||
* **log:** fixed parsing log for different runtime ([3b1e4a1](https://github.com/sasjs/server/commit/3b1e4a128b1f22ff6f3069f5aaada6bfb1b40d12))
|
||||
* **log:** fixed scrolling issue ([56a522c](https://github.com/sasjs/server/commit/56a522c07c6f6d4c26c6d3b7cd6e9ef7007067a9))
|
||||
* **log:** fixed single chunk display ([8254b78](https://github.com/sasjs/server/commit/8254b789555cb8bbb169f52b754b4ce24e876dd2))
|
||||
* **log:** fixed single chunk scrolling ([57b7f95](https://github.com/sasjs/server/commit/57b7f954a17936f39aa9b757998b5b25e9442601))
|
||||
* **log:** fixed switching runtime ([c7a7399](https://github.com/sasjs/server/commit/c7a73991a7aa25d0c75d0c00e712bdc78769300b))
|
||||
* **log:** fixing switching from SAS to other runtime ([c72ecc7](https://github.com/sasjs/server/commit/c72ecc7e5943af9536ee31cfa85398e016d5354f))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **log:** added download chunk and entire log ([a38a9f9](https://github.com/sasjs/server/commit/a38a9f9c3dfe36bd55d32024c166147318216995))
|
||||
* **log:** added logComponent and LogTabWithIcons ([3a887de](https://github.com/sasjs/server/commit/3a887dec55371b6a00b92291bb681e4cccb770c0))
|
||||
* **log:** added parseErrorsAndWarnings utility ([7c1c1e2](https://github.com/sasjs/server/commit/7c1c1e241002313c10f94dd61702584b9f148010))
|
||||
* **log:** added time to downloaded log name ([3848bb0](https://github.com/sasjs/server/commit/3848bb0added69ca81a5c9419ea414bdd1c294bb))
|
||||
* **log:** put download log icon into log tab ([777b3a5](https://github.com/sasjs/server/commit/777b3a55be1ecf5b05bf755ce8b14735496509e1))
|
||||
* **log:** split large log into chunks ([75f5a3c](https://github.com/sasjs/server/commit/75f5a3c0b39665bef8b83dc7e1e8b3e5f23fc303))
|
||||
* **log:** use improved log for SAS run time only ([7b12591](https://github.com/sasjs/server/commit/7b12591595cdd5144d9311ffa06a80c5dab79364))
|
||||
|
||||
## [0.33.3](https://github.com/sasjs/server/compare/v0.33.2...v0.33.3) (2023-04-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use RateLimiterMemory instead of RateLimiterMongo ([6a520f5](https://github.com/sasjs/server/commit/6a520f5b26a3e2ed6345721b30ff4e3d9bfa903d))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
31
README.md
31
README.md
@@ -137,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=
|
||||
|
||||
@@ -155,7 +158,7 @@ CORS=
|
||||
WHITELIST=
|
||||
|
||||
# HELMET Cross Origin Embedder Policy
|
||||
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
|
||||
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
|
||||
# options: [true|false] default: true
|
||||
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
|
||||
HELMET_COEP=
|
||||
@@ -172,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 24 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=
|
||||
|
||||
@@ -14,6 +14,7 @@ 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]
|
||||
|
||||
@@ -23,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
|
||||
|
||||
3414
api/package-lock.json
generated
3414
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@
|
||||
"author": "4GL Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/core": "^4.40.1",
|
||||
"@sasjs/utils": "2.48.1",
|
||||
"@sasjs/utils": "3.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-mongo": "^4.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
@@ -61,9 +61,9 @@
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"ldapjs": "2.3.3",
|
||||
"mongoose": "^6.0.12",
|
||||
"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",
|
||||
@@ -79,7 +79,6 @@
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/jsonwebtoken": "^8.5.5",
|
||||
"@types/ldapjs": "^2.2.4",
|
||||
"@types/mongoose-sequence": "^3.0.6",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^15.12.2",
|
||||
@@ -89,10 +88,10 @@
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "0.27.2",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"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",
|
||||
|
||||
@@ -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,16 +72,16 @@ components:
|
||||
type: string
|
||||
description: 'Client Secret'
|
||||
example: someRandomCryptoString
|
||||
accessTokenExpiryDays:
|
||||
accessTokenExpiration:
|
||||
type: number
|
||||
format: double
|
||||
description: 'Number of days in which access token will expire'
|
||||
example: 1
|
||||
refreshTokenExpiryDays:
|
||||
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 days in which access token will expire'
|
||||
example: 30
|
||||
description: 'Number of seconds after which access token will expire. Default is 2592000 (30 days)'
|
||||
example: 2592000
|
||||
required:
|
||||
- clientId
|
||||
- clientSecret
|
||||
@@ -83,17 +98,47 @@ components:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'Code of program'
|
||||
example: '* Code HERE;'
|
||||
description: 'The code to be executed'
|
||||
example: '* Your Code HERE;'
|
||||
runTime:
|
||||
$ref: '#/components/schemas/RunTimeType'
|
||||
description: 'runtime for program'
|
||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
||||
example: js
|
||||
required:
|
||||
- code
|
||||
- runTime
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerCodeResponse:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerCodePayload:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'The code to be executed'
|
||||
example: '* Your Code HERE;'
|
||||
runTime:
|
||||
$ref: '#/components/schemas/RunTimeType'
|
||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
||||
example: sas
|
||||
expiresAfterMins:
|
||||
type: number
|
||||
format: double
|
||||
description: "Amount of minutes after the completion of the job when the session must be\ndestroyed."
|
||||
example: 15
|
||||
required:
|
||||
- code
|
||||
- runTime
|
||||
type: object
|
||||
additionalProperties: false
|
||||
MemberType.folder:
|
||||
enum:
|
||||
- folder
|
||||
@@ -519,6 +564,35 @@ 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
|
||||
SessionState:
|
||||
enum:
|
||||
- initialising
|
||||
- pending
|
||||
- running
|
||||
- completed
|
||||
- failed
|
||||
type: string
|
||||
ExecutePostRequestPayload:
|
||||
properties:
|
||||
_program:
|
||||
@@ -527,6 +601,16 @@ components:
|
||||
example: /Public/somefolder/some.file
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerProgramResponse:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
LoginPayload:
|
||||
properties:
|
||||
username:
|
||||
@@ -632,6 +716,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
|
||||
@@ -689,8 +792,8 @@ paths:
|
||||
$ref: '#/components/schemas/ClientPayload'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiryDays: 1, refreshTokenExpiryDays: 30}
|
||||
summary: "Admin only task. Create client with the following attributes:\nClientId,\nClientSecret,\naccessTokenExpiryDays (optional),\nrefreshTokenExpiryDays (optional)"
|
||||
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:
|
||||
@@ -703,6 +806,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
|
||||
@@ -716,7 +840,7 @@ paths:
|
||||
- {type: string}
|
||||
- {type: string, format: byte}
|
||||
description: 'Execute Code on the Specified Runtime'
|
||||
summary: 'Run Code and Return Webout Content and Log'
|
||||
summary: "Run Code and Return Webout Content, Log and Print output\nThe order of returned parts of the payload is:\n1. Webout (if present)\n2. Logs UUID (used as separator)\n3. Log\n4. Logs UUID (used as separator)\n5. Print (if present and if the runtime is SAS)\nPlease see"
|
||||
tags:
|
||||
- Code
|
||||
security:
|
||||
@@ -729,6 +853,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecuteCodePayload'
|
||||
/SASjsApi/code/trigger:
|
||||
post:
|
||||
operationId: TriggerCode
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerCodeResponse'
|
||||
description: 'Trigger Code on the Specified Runtime'
|
||||
summary: 'Triggers code and returns SessionId immediately - does not wait for job completion'
|
||||
tags:
|
||||
- Code
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerCodePayload'
|
||||
/SASjsApi/drive/deploy:
|
||||
post:
|
||||
operationId: Deploy
|
||||
@@ -1690,7 +1838,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}
|
||||
@@ -1701,6 +1849,30 @@ paths:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
'/SASjsApi/session/{sessionId}/state':
|
||||
get:
|
||||
operationId: SessionState
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionState'
|
||||
description: "The polling endpoint is currently implemented for single-server deployments only.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\nIf your site requires this, please reach out to SASjs Support."
|
||||
summary: 'Get session state (initialising, pending, running, completed, failed).'
|
||||
tags:
|
||||
- Session
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
in: path
|
||||
name: sessionId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/SASjsApi/stp/execute:
|
||||
get:
|
||||
operationId: ExecuteGetRequest
|
||||
@@ -1713,7 +1885,7 @@ paths:
|
||||
anyOf:
|
||||
- {type: string}
|
||||
- {type: string, format: byte}
|
||||
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
|
||||
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts additional URL parameters (converted to session variables)\nand file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
|
||||
summary: 'Execute a Stored Program, returns _webout and (optionally) log.'
|
||||
tags:
|
||||
- STP
|
||||
@@ -1722,13 +1894,22 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of code in SASjs Drive'
|
||||
description: 'Location of Stored Program in SASjs Drive.'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Projects/myApp/some/program
|
||||
-
|
||||
description: 'Optional query param for setting debug mode (returns the session log in the response body).'
|
||||
in: query
|
||||
name: _debug
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 131
|
||||
post:
|
||||
operationId: ExecutePostRequest
|
||||
responses:
|
||||
@@ -1762,6 +1943,50 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecutePostRequestPayload'
|
||||
/SASjsApi/stp/trigger:
|
||||
post:
|
||||
operationId: TriggerProgram
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerProgramResponse'
|
||||
description: 'Trigger Program on the Specified Runtime.'
|
||||
summary: 'Triggers program and returns SessionId immediately - does not wait for program completion.'
|
||||
tags:
|
||||
- STP
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of code in SASjs Drive.'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Projects/myApp/some/program
|
||||
-
|
||||
description: 'Optional query param for setting debug mode.'
|
||||
in: query
|
||||
name: _debug
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 131
|
||||
-
|
||||
description: 'Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.'
|
||||
in: query
|
||||
name: expiresAfterMins
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 15
|
||||
/:
|
||||
get:
|
||||
operationId: Home
|
||||
@@ -1787,7 +2012,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
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,27 @@ import mongoose from 'mongoose'
|
||||
import session from 'express-session'
|
||||
import MongoStore from 'connect-mongo'
|
||||
|
||||
import { ModeType, ProtocolType } from '../utils'
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@ import dotenv from 'dotenv'
|
||||
|
||||
import {
|
||||
copySASjsCore,
|
||||
createWeboutSasFile,
|
||||
getFilesFolder,
|
||||
getPackagesFolder,
|
||||
getWebBuildFolder,
|
||||
instantiateLogger,
|
||||
loadAppStreamConfig,
|
||||
ReturnCode,
|
||||
setProcessVariables,
|
||||
setupFolders,
|
||||
setupFilesFolder,
|
||||
setupPackagesFolder,
|
||||
setupUserAutoExec,
|
||||
verifyEnvVariables
|
||||
} from './utils'
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
configureLogger,
|
||||
configureSecurity
|
||||
} from './app-modules'
|
||||
import { folderExists } from '@sasjs/utils'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -30,7 +35,7 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
|
||||
const app = express()
|
||||
|
||||
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
console.error(err.stack)
|
||||
process.logger.error(err.stack)
|
||||
res.status(500).send('Something broke!')
|
||||
}
|
||||
|
||||
@@ -65,9 +70,18 @@ export default setProcessVariables().then(async () => {
|
||||
|
||||
await setupUserAutoExec()
|
||||
|
||||
if (process.driveLoc === path.join(process.sasjsRoot, 'drive')) {
|
||||
await setupFolders()
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
@@ -9,6 +21,7 @@ import {
|
||||
saveTokensInDB
|
||||
} from '../utils'
|
||||
import Client from '../model/Client'
|
||||
import User from '../model/User'
|
||||
|
||||
@Route('SASjsApi/auth')
|
||||
@Tags('Auth')
|
||||
@@ -62,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> => {
|
||||
@@ -89,11 +114,11 @@ const token = async (data: any): Promise<TokenResponse> => {
|
||||
|
||||
const accessToken = generateAccessToken(
|
||||
userInfo,
|
||||
client.accessTokenExpiryDays
|
||||
client.accessTokenExpiration
|
||||
)
|
||||
const refreshToken = generateRefreshToken(
|
||||
userInfo,
|
||||
client.refreshTokenExpiryDays
|
||||
client.refreshTokenExpiration
|
||||
)
|
||||
|
||||
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
||||
@@ -107,11 +132,11 @@ const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
||||
|
||||
const accessToken = generateAccessToken(
|
||||
userInfo,
|
||||
client.accessTokenExpiryDays
|
||||
client.accessTokenExpiration
|
||||
)
|
||||
const refreshToken = generateRefreshToken(
|
||||
userInfo,
|
||||
client.refreshTokenExpiryDays
|
||||
client.refreshTokenExpiration
|
||||
)
|
||||
|
||||
await saveTokensInDB(
|
||||
@@ -128,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
|
||||
@@ -154,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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')
|
||||
@@ -10,15 +13,15 @@ export class ClientController {
|
||||
* @summary Admin only task. Create client with the following attributes:
|
||||
* ClientId,
|
||||
* ClientSecret,
|
||||
* accessTokenExpiryDays (optional),
|
||||
* refreshTokenExpiryDays (optional)
|
||||
* accessTokenExpiration (optional),
|
||||
* refreshTokenExpiration (optional)
|
||||
*
|
||||
*/
|
||||
@Example<ClientPayload>({
|
||||
clientId: 'someFormattedClientID1234',
|
||||
clientSecret: 'someRandomCryptoString',
|
||||
accessTokenExpiryDays: 1,
|
||||
refreshTokenExpiryDays: 30
|
||||
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
})
|
||||
@Post('/')
|
||||
public async createClient(
|
||||
@@ -26,14 +29,36 @@ 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: ClientPayload): Promise<ClientPayload> => {
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenExpiryDays,
|
||||
refreshTokenExpiryDays
|
||||
accessTokenExpiration,
|
||||
refreshTokenExpiration
|
||||
} = data
|
||||
|
||||
// Checking if client is already in the database
|
||||
@@ -44,7 +69,8 @@ const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
||||
const client = new Client({
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenExpiryDays
|
||||
accessTokenExpiration,
|
||||
refreshTokenExpiration
|
||||
})
|
||||
|
||||
const savedClient = await client.save()
|
||||
@@ -52,7 +78,17 @@ const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
||||
return {
|
||||
clientId: savedClient.clientId,
|
||||
clientSecret: savedClient.clientSecret,
|
||||
accessTokenExpiryDays: savedClient.accessTokenExpiryDays,
|
||||
refreshTokenExpiryDays: savedClient.refreshTokenExpiryDays
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,34 +1,71 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
||||
import { ExecutionController } from './internal'
|
||||
import { ExecutionController, getSessionController } from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
getUserAutoExec,
|
||||
ModeType,
|
||||
parseLogToArray,
|
||||
RunTimeType
|
||||
} from '../utils'
|
||||
|
||||
interface ExecuteCodePayload {
|
||||
/**
|
||||
* Code of program
|
||||
* @example "* Code HERE;"
|
||||
* The code to be executed
|
||||
* @example "* Your Code HERE;"
|
||||
*/
|
||||
code: string
|
||||
/**
|
||||
* runtime for program
|
||||
* The runtime for the code - eg SAS, JS, PY or R
|
||||
* @example "js"
|
||||
*/
|
||||
runTime: RunTimeType
|
||||
}
|
||||
|
||||
interface TriggerCodePayload {
|
||||
/**
|
||||
* The code to be executed
|
||||
* @example "* Your Code HERE;"
|
||||
*/
|
||||
code: string
|
||||
/**
|
||||
* The runtime for the code - eg SAS, JS, PY or R
|
||||
* @example "sas"
|
||||
*/
|
||||
runTime: RunTimeType
|
||||
/**
|
||||
* Amount of minutes after the completion of the job when the session must be
|
||||
* destroyed.
|
||||
* @example 15
|
||||
*/
|
||||
expiresAfterMins?: number
|
||||
}
|
||||
|
||||
interface TriggerCodeResponse {
|
||||
/**
|
||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
||||
* used to store code outputs.<br><br>
|
||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
||||
* `sessionId` can be used to poll session state using the
|
||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
||||
* @example "20241028074744-54132-1730101664824"
|
||||
*/
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/code')
|
||||
@Tags('Code')
|
||||
export class CodeController {
|
||||
/**
|
||||
* Execute Code on the Specified Runtime
|
||||
* @summary Run Code and Return Webout Content and Log
|
||||
* @summary Run Code and Return Webout Content, Log and Print output
|
||||
* The order of returned parts of the payload is:
|
||||
* 1. Webout (if present)
|
||||
* 2. Logs UUID (used as separator)
|
||||
* 3. Log
|
||||
* 4. Logs UUID (used as separator)
|
||||
* 5. Print (if present and if the runtime is SAS)
|
||||
* Please see @sasjs/server/api/src/controllers/internal/Execution.ts for more information
|
||||
*/
|
||||
@Post('/execute')
|
||||
public async executeCode(
|
||||
@@ -37,6 +74,18 @@ export class CodeController {
|
||||
): Promise<string | Buffer> {
|
||||
return executeCode(request, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Code on the Specified Runtime
|
||||
* @summary Triggers code and returns SessionId immediately - does not wait for job completion
|
||||
*/
|
||||
@Post('/trigger')
|
||||
public async triggerCode(
|
||||
@Request() request: express.Request,
|
||||
@Body() body: TriggerCodePayload
|
||||
): Promise<TriggerCodeResponse> {
|
||||
return triggerCode(request, body)
|
||||
}
|
||||
}
|
||||
|
||||
const executeCode = async (
|
||||
@@ -55,7 +104,8 @@ const executeCode = async (
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, _debug: 131 },
|
||||
otherArgs: { userAutoExec },
|
||||
runTime: runTime
|
||||
runTime: runTime,
|
||||
includePrintOutput: true
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -68,3 +118,49 @@ const executeCode = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerCode = async (
|
||||
req: express.Request,
|
||||
{ code, runTime, expiresAfterMins }: TriggerCodePayload
|
||||
): Promise<TriggerCodeResponse> => {
|
||||
const { user } = req
|
||||
const userAutoExec =
|
||||
process.env.MODE === ModeType.Server
|
||||
? user?.autoExec
|
||||
: await getUserAutoExec()
|
||||
|
||||
// get session controller based on runTime
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
// get session
|
||||
const session = await sessionController.getSession()
|
||||
|
||||
// add expiresAfterMins to session if provided
|
||||
if (expiresAfterMins) {
|
||||
// expiresAfterMins.used is set initially to false
|
||||
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
|
||||
}
|
||||
|
||||
try {
|
||||
// call executeProgram method of ExecutionController without awaiting
|
||||
new ExecutionController().executeProgram({
|
||||
program: code,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, _debug: 131 },
|
||||
otherArgs: { userAutoExec },
|
||||
runTime: runTime,
|
||||
includePrintOutput: true,
|
||||
session // session is provided
|
||||
})
|
||||
|
||||
// return session id
|
||||
return { sessionId: session.id }
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getSessionController, processProgram } from './'
|
||||
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session, TreeNode } from '../../types'
|
||||
import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
|
||||
import {
|
||||
extractHeaders,
|
||||
getFilesFolder,
|
||||
@@ -33,6 +33,7 @@ interface ExecuteFileParams {
|
||||
|
||||
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
||||
program: string
|
||||
includePrintOutput?: boolean
|
||||
}
|
||||
|
||||
export class ExecutionController {
|
||||
@@ -67,14 +68,14 @@ export class ExecutionController {
|
||||
otherArgs,
|
||||
session: sessionByFileUpload,
|
||||
runTime,
|
||||
forceStringResult
|
||||
forceStringResult,
|
||||
includePrintOutput
|
||||
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
const session =
|
||||
sessionByFileUpload ?? (await sessionController.getSession())
|
||||
session.inUse = true
|
||||
session.consumed = true
|
||||
session.state = SessionState.running
|
||||
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
@@ -105,6 +106,11 @@ 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))
|
||||
@@ -114,13 +120,32 @@ export class ExecutionController {
|
||||
: ''
|
||||
|
||||
// it should be deleted by scheduleSessionDestroy
|
||||
session.inUse = false
|
||||
session.state = SessionState.completed
|
||||
|
||||
const resultParts = []
|
||||
|
||||
// INFO: webout can be a Buffer, that is why it's length should be checked to determine if it is empty
|
||||
if (webout && webout.length !== 0) resultParts.push(webout)
|
||||
|
||||
// INFO: log separator wraps the log from the beginning and the end
|
||||
resultParts.push(process.logsUUID)
|
||||
resultParts.push(log)
|
||||
resultParts.push(process.logsUUID)
|
||||
|
||||
if (includePrintOutput && runTime === RunTimeType.SAS) {
|
||||
const printOutputPath = path.join(session.path, 'output.lst')
|
||||
const printOutput = (await fileExists(printOutputPath))
|
||||
? await readFile(printOutputPath)
|
||||
: ''
|
||||
|
||||
if (printOutput) resultParts.push(printOutput)
|
||||
}
|
||||
|
||||
return {
|
||||
httpHeaders,
|
||||
result:
|
||||
isDebugOn(vars) || session.crashed
|
||||
? `${webout}\n${process.logsUUID}\n${log}`
|
||||
isDebugOn(vars) || session.failureReason
|
||||
? resultParts.join(`\n`)
|
||||
: webout
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express'
|
||||
import multer from 'multer'
|
||||
import { uuidv4 } from '@sasjs/utils'
|
||||
import { getSessionController } from '.'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
getRunTimeAndFilePath,
|
||||
RunTimeType
|
||||
} from '../../utils'
|
||||
import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
|
||||
import { SessionState } from '../../types'
|
||||
|
||||
export class FileUploadController {
|
||||
private storage = multer.diskStorage({
|
||||
@@ -56,9 +53,8 @@ export class FileUploadController {
|
||||
}
|
||||
|
||||
const session = await sessionController.getSession()
|
||||
// marking consumed true, so that it's not available
|
||||
// as readySession for any other request
|
||||
session.consumed = true
|
||||
// change session state to 'running', so that it's not available for any other request
|
||||
session.state = SessionState.running
|
||||
|
||||
req.sasjsSession = session
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import { Session } from '../../types'
|
||||
import { Session, SessionState } from '../../types'
|
||||
import { promisify } from 'util'
|
||||
import { execFile } from 'child_process'
|
||||
import {
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
createFile,
|
||||
fileExists,
|
||||
generateTimestamp,
|
||||
readFile,
|
||||
isWindows
|
||||
readFile
|
||||
} from '@sasjs/utils'
|
||||
|
||||
const execFilePromise = promisify(execFile)
|
||||
@@ -24,7 +23,9 @@ export class SessionController {
|
||||
protected sessions: Session[] = []
|
||||
|
||||
protected getReadySessions = (): Session[] =>
|
||||
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
|
||||
this.sessions.filter(
|
||||
(session: Session) => session.state === SessionState.pending
|
||||
)
|
||||
|
||||
protected async createSession(): Promise<Session> {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
@@ -40,19 +41,18 @@ export class SessionController {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ export class SessionController {
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
public getSessionById(id: string) {
|
||||
return this.sessions.find((session) => session.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export class SASSessionController extends SessionController {
|
||||
@@ -84,17 +88,14 @@ export class SASSessionController extends SessionController {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: false,
|
||||
inUse: false,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.initialising,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
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
|
||||
@@ -134,23 +135,31 @@ ${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)
|
||||
session.state = SessionState.completed
|
||||
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
console.log('session crashed', session.id, session.crashed)
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// we have a triggered session - add to array
|
||||
@@ -167,12 +176,19 @@ ${autoExecContent}`
|
||||
const codeFilePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// TODO: don't wait forever
|
||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||
while (
|
||||
(await fileExists(codeFilePath)) &&
|
||||
session.state !== SessionState.failed
|
||||
) {}
|
||||
|
||||
if (session.crashed)
|
||||
console.log('session crashed! while waiting to be ready', session.crashed)
|
||||
|
||||
session.ready = true
|
||||
if (session.state === SessionState.failed) {
|
||||
process.logger.error(
|
||||
'session crashed! while waiting to be ready',
|
||||
session.failureReason
|
||||
)
|
||||
} else {
|
||||
session.state = SessionState.pending
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSession(session: Session) {
|
||||
@@ -186,29 +202,52 @@ ${autoExecContent}`
|
||||
}
|
||||
|
||||
private scheduleSessionDestroy(session: Session) {
|
||||
setTimeout(async () => {
|
||||
if (session.inUse) {
|
||||
// adding 10 more minutes
|
||||
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
setTimeout(
|
||||
async () => {
|
||||
if (session.state === SessionState.running) {
|
||||
// adding 10 more minutes
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
await this.deleteSession(session)
|
||||
}
|
||||
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100)
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
const { expiresAfterMins } = session
|
||||
|
||||
// delay session destroy if expiresAfterMins present
|
||||
if (expiresAfterMins && session.state !== SessionState.completed) {
|
||||
// calculate session death time using expiresAfterMins
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) +
|
||||
expiresAfterMins.mins * 60 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
|
||||
// set expiresAfterMins to true to avoid using it again
|
||||
session.expiresAfterMins!.used = true
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
await this.deleteSession(session)
|
||||
}
|
||||
}
|
||||
},
|
||||
parseInt(session.deathTimeStamp) - new Date().getTime() - 100
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const createJSProgram = async (
|
||||
) => {
|
||||
const varStatments = Object.keys(vars).reduce(
|
||||
(computed: string, key: string) =>
|
||||
`${computed}const ${key} = '${vars[key]}';\n`,
|
||||
`${computed}const ${key} = \`${vars[key]}\`;\n`,
|
||||
''
|
||||
)
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ export const createSASProgram = async (
|
||||
%mend;
|
||||
%_sasjs_server_init()
|
||||
|
||||
proc printto print="%sysfunc(getoption(log))";
|
||||
run;
|
||||
`
|
||||
|
||||
program = `
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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'
|
||||
import { PreProgramVars, Session, SessionState } from '../../types'
|
||||
import { RunTimeType } from '../../utils'
|
||||
import {
|
||||
ExecutionVars,
|
||||
@@ -49,7 +49,7 @@ export const processProgram = async (
|
||||
await moveFile(codePath + '.bkp', codePath)
|
||||
|
||||
// we now need to poll the session status
|
||||
while (!session.completed) {
|
||||
while (session.state !== SessionState.completed) {
|
||||
await delay(50)
|
||||
}
|
||||
} else {
|
||||
@@ -105,26 +105,65 @@ 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)
|
||||
// waiting for the open event so that we can have underlying file descriptor
|
||||
await once(writeStream, 'open')
|
||||
execFileSync(executablePath, [codePath], {
|
||||
stdio: ['ignore', writeStream, writeStream]
|
||||
// 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')
|
||||
|
||||
await execFilePromise(executablePath, [codePath], writeStream)
|
||||
.then(() => {
|
||||
session.state = SessionState.completed
|
||||
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
// 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)
|
||||
}
|
||||
.catch((err) => {
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MockSas9Controller {
|
||||
content: result.result as string
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('err', err)
|
||||
process.logger.error('err', err)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -168,7 +168,7 @@ export class MockSas9Controller {
|
||||
content: result.result as string
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('err', err)
|
||||
process.logger.error('err', err)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -269,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
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||
import { UserResponse } from './user'
|
||||
import { getSessionController } from './internal'
|
||||
import { SessionState } from '../types'
|
||||
|
||||
interface SessionResponse extends UserResponse {
|
||||
needsToUpdatePassword: boolean
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/session')
|
||||
@@ -19,14 +25,47 @@ export class SessionController {
|
||||
@Get('/')
|
||||
public async session(
|
||||
@Request() request: express.Request
|
||||
): Promise<UserResponse> {
|
||||
): Promise<SessionResponse> {
|
||||
return session(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* The polling endpoint is currently implemented for single-server deployments only.<br>
|
||||
* Load balanced / grid topologies will be supported in a future release.<br>
|
||||
* If your site requires this, please reach out to SASjs Support.
|
||||
* @summary Get session state (initialising, pending, running, completed, failed).
|
||||
* @example completed
|
||||
*/
|
||||
@Get('/:sessionId/state')
|
||||
public async sessionState(sessionId: string): Promise<SessionState> {
|
||||
return sessionState(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const sessionState = (sessionId: string): SessionState => {
|
||||
for (let runTime of process.runTimes) {
|
||||
// get session controller for each available runTime
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
// get session by sessionId
|
||||
const session = sessionController.getSessionById(sessionId)
|
||||
|
||||
// return session state if session was found
|
||||
if (session) {
|
||||
return session.state
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
code: 404,
|
||||
message: `Session with ID '${sessionId}' was not found.`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
||||
import { ExecutionController, ExecutionVars } from './internal'
|
||||
import {
|
||||
ExecutionController,
|
||||
ExecutionVars,
|
||||
getSessionController
|
||||
} from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
HTTPHeaders,
|
||||
LogLine,
|
||||
makeFilesNamesMap,
|
||||
getRunTimeAndFilePath
|
||||
} from '../utils'
|
||||
@@ -18,6 +20,36 @@ interface ExecutePostRequestPayload {
|
||||
_program?: string
|
||||
}
|
||||
|
||||
interface TriggerProgramPayload {
|
||||
/**
|
||||
* Location of SAS program.
|
||||
* @example "/Public/somefolder/some.file"
|
||||
*/
|
||||
_program: string
|
||||
/**
|
||||
* Amount of minutes after the completion of the program when the session must be
|
||||
* destroyed.
|
||||
* @example 15
|
||||
*/
|
||||
expiresAfterMins?: number
|
||||
/**
|
||||
* Query param for setting debug mode.
|
||||
*/
|
||||
_debug?: number
|
||||
}
|
||||
|
||||
interface TriggerProgramResponse {
|
||||
/**
|
||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
||||
* used to store program outputs.<br><br>
|
||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
||||
* `sessionId` can be used to poll session state using the
|
||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
||||
* @example "20241028074744-54132-1730101664824"
|
||||
*/
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/stp')
|
||||
@Tags('STP')
|
||||
@@ -25,20 +57,31 @@ export class STPController {
|
||||
/**
|
||||
* Trigger a Stored Program using the _program URL parameter.
|
||||
*
|
||||
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||
* Accepts additional URL parameters (converted to session variables)
|
||||
* and file uploads. For more details, see docs:
|
||||
*
|
||||
* https://server.sasjs.io/storedprograms
|
||||
*
|
||||
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
||||
* @param _program Location of code in SASjs Drive
|
||||
* @param _program Location of Stored Program in SASjs Drive.
|
||||
* @param _debug Optional query param for setting debug mode (returns the session log in the response body).
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
* @example _debug 131
|
||||
*/
|
||||
@Get('/execute')
|
||||
public async executeGetRequest(
|
||||
@Request() request: express.Request,
|
||||
@Query() _program: string
|
||||
@Query() _program: string,
|
||||
@Query() _debug?: number
|
||||
): Promise<string | Buffer> {
|
||||
const vars = request.query as ExecutionVars
|
||||
let vars = request.query as ExecutionVars
|
||||
if (_debug) {
|
||||
vars = {
|
||||
...vars,
|
||||
_debug
|
||||
}
|
||||
}
|
||||
|
||||
return execute(request, _program, vars)
|
||||
}
|
||||
|
||||
@@ -69,6 +112,26 @@ export class STPController {
|
||||
|
||||
return execute(request, program!, vars, otherArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Program on the Specified Runtime.
|
||||
* @summary Triggers program and returns SessionId immediately - does not wait for program completion.
|
||||
* @param _program Location of code in SASjs Drive.
|
||||
* @param expiresAfterMins Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.
|
||||
* @param _debug Optional query param for setting debug mode.
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
* @example _debug 131
|
||||
* @example expiresAfterMins 15
|
||||
*/
|
||||
@Post('/trigger')
|
||||
public async triggerProgram(
|
||||
@Request() request: express.Request,
|
||||
@Query() _program: string,
|
||||
@Query() _debug?: number,
|
||||
@Query() expiresAfterMins?: number
|
||||
): Promise<TriggerProgramResponse> {
|
||||
return triggerProgram(request, { _program, _debug, expiresAfterMins })
|
||||
}
|
||||
}
|
||||
|
||||
const execute = async (
|
||||
@@ -91,6 +154,8 @@ const execute = async (
|
||||
}
|
||||
)
|
||||
|
||||
req.res?.header(httpHeaders)
|
||||
|
||||
if (result instanceof Buffer) {
|
||||
;(req as any).sasHeaders = httpHeaders
|
||||
}
|
||||
@@ -105,3 +170,52 @@ const execute = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerProgram = async (
|
||||
req: express.Request,
|
||||
{ _program, _debug, expiresAfterMins }: TriggerProgramPayload
|
||||
): Promise<TriggerProgramResponse> => {
|
||||
try {
|
||||
// put _program query param into vars object
|
||||
const vars: { [key: string]: string | number } = { _program }
|
||||
|
||||
// if present add _debug query param to vars object
|
||||
if (_debug) {
|
||||
vars._debug = _debug
|
||||
}
|
||||
|
||||
// get code path and runTime
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
||||
|
||||
// get session controller based on runTime
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
// get session
|
||||
const session = await sessionController.getSession()
|
||||
|
||||
// add expiresAfterMins to session if provided
|
||||
if (expiresAfterMins) {
|
||||
// expiresAfterMins.used is set initially to false
|
||||
session.expiresAfterMins = { mins: expiresAfterMins, used: false }
|
||||
}
|
||||
|
||||
// call executeFile method of ExecutionController without awaiting
|
||||
new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
runTime,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars,
|
||||
session
|
||||
})
|
||||
|
||||
// return session id
|
||||
return { sessionId: session.id }
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -276,7 +285,7 @@ const getUser = async (
|
||||
username: user.username,
|
||||
isActive: user.isActive,
|
||||
isAdmin: user.isAdmin,
|
||||
autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
|
||||
autoExec: getAutoExec ? (user.autoExec ?? '') : undefined,
|
||||
groups: user.groups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ const authenticateToken = async (
|
||||
username: 'desktopModeUsername',
|
||||
displayName: 'desktopModeDisplayName',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
req.accessToken = 'desktopModeAccessToken'
|
||||
return next()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
22
api/src/middlewares/bruteForceProtection.ts
Normal file
22
api/src/middlewares/bruteForceProtection.ts
Normal 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()
|
||||
}
|
||||
@@ -33,5 +33,6 @@ export const desktopUser: RequestUser = {
|
||||
username: userInfo().username,
|
||||
displayName: userInfo().username,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './csrfProtection'
|
||||
export * from './desktop'
|
||||
export * from './verifyAdmin'
|
||||
export * from './verifyAdminIfNeeded'
|
||||
export * from './bruteForceProtection'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
export const NUMBER_OF_SECONDS_IN_A_DAY = 86400
|
||||
export interface ClientPayload {
|
||||
/**
|
||||
* Client ID
|
||||
@@ -12,15 +13,15 @@ export interface ClientPayload {
|
||||
*/
|
||||
clientSecret: string
|
||||
/**
|
||||
* Number of days in which access token will expire
|
||||
* @example 1
|
||||
* Number of seconds after which access token will expire. Default is 86400 (1 day)
|
||||
* @example 86400
|
||||
*/
|
||||
accessTokenExpiryDays?: number
|
||||
accessTokenExpiration?: number
|
||||
/**
|
||||
* Number of days in which access token will expire
|
||||
* @example 30
|
||||
* Number of seconds after which access token will expire. Default is 2592000 (30 days)
|
||||
* @example 2592000
|
||||
*/
|
||||
refreshTokenExpiryDays?: number
|
||||
refreshTokenExpiration?: number
|
||||
}
|
||||
|
||||
const ClientSchema = new Schema<ClientPayload>({
|
||||
@@ -32,13 +33,13 @@ const ClientSchema = new Schema<ClientPayload>({
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
accessTokenExpiryDays: {
|
||||
accessTokenExpiration: {
|
||||
type: Number,
|
||||
default: 1
|
||||
default: NUMBER_OF_SECONDS_IN_A_DAY
|
||||
},
|
||||
refreshTokenExpiryDays: {
|
||||
refreshTokenExpiration: {
|
||||
type: Number,
|
||||
default: 30
|
||||
default: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
15
api/src/model/Counter.ts
Normal file
15
api/src/model/Counter.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
const CounterSchema = new Schema({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
seq: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
export default mongoose.model('Counter', CounterSchema)
|
||||
@@ -1,8 +1,7 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { GroupDetailsResponse } from '../controllers'
|
||||
import User, { IUser } from './User'
|
||||
import { AuthProviderType } from '../utils'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||
|
||||
export const PUBLIC_GROUP_NAME = 'Public'
|
||||
|
||||
@@ -44,6 +43,10 @@ const groupSchema = new Schema<IGroupDocument>({
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
groupId: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Group description.'
|
||||
@@ -59,9 +62,13 @@ const groupSchema = new Schema<IGroupDocument>({
|
||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||
})
|
||||
|
||||
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
|
||||
|
||||
// Hooks
|
||||
groupSchema.pre('save', async function () {
|
||||
if (this.isNew) {
|
||||
this.groupId = await getSequenceNextValue('groupId')
|
||||
}
|
||||
})
|
||||
|
||||
groupSchema.post('save', function (group: IGroup, next: Function) {
|
||||
group.populate('users', 'id username displayName -_id').then(function () {
|
||||
next()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { PermissionDetailsResponse } from '../controllers'
|
||||
import { getSequenceNextValue } from '../utils'
|
||||
|
||||
interface GetPermissionBy {
|
||||
user?: Schema.Types.ObjectId
|
||||
@@ -23,6 +23,10 @@ interface IPermissionModel extends Model<IPermission> {
|
||||
}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>({
|
||||
permissionId: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -39,7 +43,12 @@ const permissionSchema = new Schema<IPermissionDocument>({
|
||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||
})
|
||||
|
||||
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
|
||||
// Hooks
|
||||
permissionSchema.pre('save', async function () {
|
||||
if (this.isNew) {
|
||||
this.permissionId = await getSequenceNextValue('permissionId')
|
||||
}
|
||||
})
|
||||
|
||||
// Static Methods
|
||||
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AuthProviderType } from '../utils'
|
||||
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||
|
||||
export interface UserPayload {
|
||||
/**
|
||||
@@ -40,6 +39,7 @@ interface IUserDocument extends UserPayload, Document {
|
||||
id: number
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
autoExec: string
|
||||
groups: Schema.Types.ObjectId[]
|
||||
tokens: [{ [key: string]: string }]
|
||||
@@ -65,6 +65,10 @@ const userSchema = new Schema<IUserDocument>({
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -81,6 +85,10 @@ const userSchema = new Schema<IUserDocument>({
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
needsToUpdatePassword: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoExec: {
|
||||
type: String
|
||||
},
|
||||
@@ -102,7 +110,15 @@ const userSchema = new Schema<IUserDocument>({
|
||||
}
|
||||
]
|
||||
})
|
||||
userSchema.plugin(AutoIncrement, { inc_field: 'id' })
|
||||
|
||||
// Hooks
|
||||
userSchema.pre('save', async function (next) {
|
||||
if (this.isNew) {
|
||||
this.id = await getSequenceNextValue('id')
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// Static Methods
|
||||
userSchema.static('hashPassword', (password: string): string => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express'
|
||||
import { runCodeValidation } from '../../utils'
|
||||
import { runCodeValidation, triggerCodeValidation } from '../../utils'
|
||||
import { CodeController } from '../../controllers/'
|
||||
|
||||
const runRouter = express.Router()
|
||||
@@ -28,4 +28,22 @@ runRouter.post('/execute', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
runRouter.post('/trigger', async (req, res) => {
|
||||
const { error, value: body } = triggerCodeValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.triggerCode(req, body)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default runRouter
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import express from 'express'
|
||||
import { SessionController } from '../../controllers'
|
||||
import { sessionIdValidation } from '../../utils'
|
||||
|
||||
const sessionRouter = express.Router()
|
||||
|
||||
const controller = new SessionController()
|
||||
|
||||
sessionRouter.get('/', async (req, res) => {
|
||||
const controller = new SessionController()
|
||||
try {
|
||||
const response = await controller.session(req)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sessionRouter.get('/:sessionId/state', async (req, res) => {
|
||||
const { error, value: params } = sessionIdValidation(req.params)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.sessionState(params.sessionId)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default sessionRouter
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SASSessionController
|
||||
} from '../../../controllers/internal'
|
||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||
import { Session } from '../../../types'
|
||||
import { Session, SessionState } from '../../../types'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
|
||||
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
|
||||
@@ -47,72 +47,6 @@ describe('web', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/login', () => {
|
||||
let csrfToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ csrfToken } = await getCSRF(app))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with successful login', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.loggedIn).toBeTruthy()
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is not present', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is invalid', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', 'INVALID_CSRF_TOKEN')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/authorize', () => {
|
||||
let csrfToken: string
|
||||
let authCookies: string
|
||||
@@ -183,6 +117,147 @@ describe('web', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/login', () => {
|
||||
let csrfToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ csrfToken } = await getCSRF(app))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with successful login', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.loggedIn).toBeTruthy()
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
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)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is invalid', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', 'INVALID_CSRF_TOKEN')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const getCSRF = async (app: Express) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import express from 'express'
|
||||
import { executeProgramRawValidation } from '../../utils'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
triggerProgramValidation
|
||||
} from '../../utils'
|
||||
import { STPController } from '../../controllers/'
|
||||
import { FileUploadController } from '../../controllers/internal'
|
||||
|
||||
@@ -13,7 +16,11 @@ stpRouter.get('/execute', async (req, res) => {
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.executeGetRequest(req, query._program)
|
||||
const response = await controller.executeGetRequest(
|
||||
req,
|
||||
query._program,
|
||||
query._debug
|
||||
)
|
||||
|
||||
if (response instanceof Buffer) {
|
||||
res.writeHead(200, (req as any).sasHeaders)
|
||||
@@ -65,4 +72,28 @@ stpRouter.post(
|
||||
}
|
||||
)
|
||||
|
||||
stpRouter.post('/trigger', async (req, res) => {
|
||||
const { error, value: query } = triggerProgramValidation(req.query)
|
||||
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.triggerProgram(
|
||||
req,
|
||||
query._program,
|
||||
query._debug,
|
||||
query.expiresAfterMins
|
||||
)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default stpRouter
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -27,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',
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,5 +5,6 @@ export interface RequestUser {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
autoExec?: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export enum SessionState {
|
||||
initialising = 'initialising', // session is initialising and not ready to be used yet
|
||||
pending = 'pending', // session is ready to be used
|
||||
running = 'running', // session is in use
|
||||
completed = 'completed', // session is completed and can be destroyed
|
||||
failed = 'failed' // session failed
|
||||
}
|
||||
export interface Session {
|
||||
id: string
|
||||
ready: boolean
|
||||
state: SessionState
|
||||
creationTimeStamp: string
|
||||
deathTimeStamp: string
|
||||
path: string
|
||||
inUse: boolean
|
||||
consumed: boolean
|
||||
completed: boolean
|
||||
crashed?: string
|
||||
expiresAfterMins?: { mins: number; used: boolean }
|
||||
failureReason?: string
|
||||
}
|
||||
|
||||
1
api/src/types/system/process.d.ts
vendored
1
api/src/types/system/process.d.ts
vendored
@@ -9,6 +9,7 @@ declare namespace NodeJS {
|
||||
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[]
|
||||
|
||||
@@ -36,7 +36,7 @@ export const loadAppStreamConfig = async () => {
|
||||
)
|
||||
}
|
||||
|
||||
console.log('App Stream Config loaded!')
|
||||
process.logger.info('App Stream Config loaded!')
|
||||
}
|
||||
|
||||
export const addEntryToAppStreamConfig = (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.')
|
||||
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)
|
||||
}
|
||||
|
||||
18
api/src/utils/createWeboutSasFile.ts
Normal file
18
api/src/utils/createWeboutSasFile.ts
Normal 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)
|
||||
}
|
||||
@@ -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, expiry?: number) =>
|
||||
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
|
||||
expiresIn: expiry ? `${expiry}d` : '1d'
|
||||
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||
})
|
||||
|
||||
@@ -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, expiry?: number) =>
|
||||
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
|
||||
expiresIn: expiry ? `${expiry}d` : '30d'
|
||||
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
15
api/src/utils/getSequenceNextValue.ts
Normal file
15
api/src/utils/getSequenceNextValue.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Counter from '../model/Counter'
|
||||
|
||||
export const getSequenceNextValue = async (seqName: string) => {
|
||||
const seqDoc = await Counter.findOne({ id: seqName })
|
||||
if (!seqDoc) {
|
||||
await Counter.create({ id: seqName, seq: 1 })
|
||||
return 1
|
||||
}
|
||||
|
||||
seqDoc.seq += 1
|
||||
|
||||
await seqDoc.save()
|
||||
|
||||
return seqDoc.seq
|
||||
}
|
||||
@@ -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'
|
||||
@@ -13,14 +14,15 @@ export * from './getCertificates'
|
||||
export * from './getDesktopFields'
|
||||
export * from './getPreProgramVariables'
|
||||
export * from './getRunTimeAndFilePath'
|
||||
export * from './getSequenceNextValue'
|
||||
export * from './getServerUrl'
|
||||
export * from './getTokensFromDB'
|
||||
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'
|
||||
@@ -31,3 +33,4 @@ export * from './upload'
|
||||
export * from './validation'
|
||||
export * from './verifyEnvVariables'
|
||||
export * from './verifyTokenInDB'
|
||||
export * from './zipped'
|
||||
|
||||
@@ -27,5 +27,6 @@ export const publicUser: RequestUser = {
|
||||
username: 'publicUser',
|
||||
displayName: 'Public User',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
123
api/src/utils/rateLimiter.ts
Normal file
123
api/src/utils/rateLimiter.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
export class RateLimiter {
|
||||
private static instance: RateLimiter
|
||||
private limiterSlowBruteByIP: RateLimiterMemory
|
||||
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMemory
|
||||
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 RateLimiterMemory({
|
||||
keyPrefix: 'login_fail_ip_per_day',
|
||||
points: this.maxWrongAttemptsByIpPerDay,
|
||||
duration: 60 * 60 * 24,
|
||||
blockDuration: 60 * 60 * 24 // Block for 1 day
|
||||
})
|
||||
|
||||
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMemory({
|
||||
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
||||
points: this.maxConsecutiveFailsByUsernameAndIp,
|
||||
duration: 60 * 60 * 24 * 24, // Store number for 24 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, 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 RateLimiterMemory
|
||||
// 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
|
||||
// or see https://github.com/animir/node-rate-limiter-flexible#ratelimiterres-object
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import path from 'path'
|
||||
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
|
||||
|
||||
import {
|
||||
createFolder,
|
||||
getAbsolutePath,
|
||||
getRealPath,
|
||||
fileExists
|
||||
} from '@sasjs/utils'
|
||||
import dotenv from 'dotenv'
|
||||
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
|
||||
|
||||
export const setProcessVariables = async () => {
|
||||
const { execPath } = process
|
||||
|
||||
// Check if execPath ends with 'api-macos' to determine executable for MacOS.
|
||||
// This is needed to fix picking .env file issue in MacOS executable.
|
||||
if (execPath) {
|
||||
const envPathSplitted = execPath.split(path.sep)
|
||||
|
||||
if (envPathSplitted.pop() === 'api-macos') {
|
||||
const envPath = path.join(envPathSplitted.join(path.sep), '.env')
|
||||
|
||||
// Override environment variables from envPath if file exists
|
||||
if (await fileExists(envPath)) {
|
||||
dotenv.config({ path: envPath, override: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { MODE, RUN_TIMES } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
@@ -21,6 +43,7 @@ export const setProcessVariables = async () => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,7 +64,9 @@ export const setProcessVariables = async () => {
|
||||
|
||||
const { SASJS_ROOT } = process.env
|
||||
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
||||
|
||||
await createFolder(absPath)
|
||||
|
||||
process.sasjsRoot = getRealPath(absPath)
|
||||
|
||||
const { DRIVE_LOCATION } = process.env
|
||||
@@ -49,6 +74,7 @@ export const setProcessVariables = async () => {
|
||||
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
||||
process.cwd()
|
||||
)
|
||||
|
||||
await createFolder(absDrivePath)
|
||||
process.driveLoc = getRealPath(absDrivePath)
|
||||
|
||||
@@ -57,13 +83,15 @@ export const setProcessVariables = async () => {
|
||||
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
||||
process.cwd()
|
||||
)
|
||||
|
||||
await createFolder(absLogsPath)
|
||||
|
||||
process.logsLoc = getRealPath(absLogsPath)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFolder } from '@sasjs/utils'
|
||||
import { getFilesFolder, getPackagesFolder } from './file'
|
||||
|
||||
export const setupFolders = async () => {
|
||||
await createFolder(getFilesFolder())
|
||||
export const setupFilesFolder = async () => await createFolder(getFilesFolder())
|
||||
|
||||
export const setupPackagesFolder = async () =>
|
||||
await createFolder(getPackagesFolder())
|
||||
}
|
||||
|
||||
@@ -51,9 +51,8 @@ export const generateFileUploadSasCode = async (
|
||||
let fileCount = 0
|
||||
const uploadedFiles: UploadedFiles[] = []
|
||||
|
||||
const sasSessionFolderList: string[] = await listFilesInFolder(
|
||||
sasSessionFolder
|
||||
)
|
||||
const sasSessionFolderList: string[] =
|
||||
await listFilesInFolder(sasSessionFolder)
|
||||
sasSessionFolderList.forEach((fileName) => {
|
||||
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
|
||||
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
|
||||
|
||||
@@ -85,12 +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(),
|
||||
accessTokenExpiryDays: Joi.number(),
|
||||
refreshTokenExpiryDays: Joi.number()
|
||||
accessTokenExpiration: Joi.number(),
|
||||
refreshTokenExpiration: Joi.number()
|
||||
}).validate(data)
|
||||
|
||||
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
@@ -172,9 +178,31 @@ export const runCodeValidation = (data: any): Joi.ValidationResult =>
|
||||
runTime: Joi.string().valid(...process.runTimes)
|
||||
}).validate(data)
|
||||
|
||||
export const triggerCodeValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
code: Joi.string().required(),
|
||||
runTime: Joi.string().valid(...process.runTimes),
|
||||
expiresAfterMins: Joi.number().greater(0)
|
||||
}).validate(data)
|
||||
|
||||
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required()
|
||||
_program: Joi.string().required(),
|
||||
_debug: Joi.number()
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required(),
|
||||
_debug: Joi.number(),
|
||||
expiresAfterMins: Joi.number().greater(0)
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
sessionId: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
@@ -47,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[] = []
|
||||
|
||||
@@ -70,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')}`
|
||||
@@ -342,11 +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,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
1617
web/package-lock.json
generated
1617
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
||||
"react": "^17.0.2",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-monaco-editor": "^0.48.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-toastify": "^9.0.1"
|
||||
@@ -41,6 +42,7 @@
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-highlight": "^0.12.5",
|
||||
"@types/react-router-dom": "^5.3.1",
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-plugin-prismjs": "^2.1.0",
|
||||
@@ -59,6 +61,7 @@
|
||||
"style-loader": "^3.3.1",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^4.5.2",
|
||||
"typescript-plugin-css-modules": "^5.0.1",
|
||||
"webpack": "5.64.3",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-dev-server": "4.7.4"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
145
web/src/components/passwordModal.tsx
Normal file
145
web/src/components/passwordModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import Snackbar from '@mui/material/Snackbar'
|
||||
import MuiAlert, { AlertProps } from '@mui/material/Alert'
|
||||
import Slide, { SlideProps } from '@mui/material/Slide'
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
||||
})
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
function Alert(props, ref) {
|
||||
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
||||
}
|
||||
)
|
||||
|
||||
const Transition = (props: SlideProps) => {
|
||||
return <Slide {...props} direction="up" />
|
||||
|
||||
109
web/src/components/updatePassword.tsx
Normal file
109
web/src/components/updatePassword.tsx
Normal 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
|
||||
@@ -47,7 +47,7 @@ const AuthCode = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="main">
|
||||
<Box className="container">
|
||||
<CssBaseline />
|
||||
<br />
|
||||
<h2>Authorization Code</h2>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PermissionsContext } from '../../../../context/permissionsContext'
|
||||
import {
|
||||
findExistingPermission,
|
||||
findUpdatingPermission
|
||||
} from '../../../../utils/helper'
|
||||
} from '../../../../utils'
|
||||
|
||||
const useAddPermission = () => {
|
||||
const {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Dispatch, SetStateAction } from 'react'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import {
|
||||
Backdrop,
|
||||
@@ -17,10 +17,14 @@ import { TabContext, TabList, TabPanel } from '@mui/lab'
|
||||
import FilePathInputModal from '../../components/filePathInputModal'
|
||||
import FileMenu from './internal/components/fileMenu'
|
||||
import RunMenu from './internal/components/runMenu'
|
||||
import LogComponent from './internal/components/log/logComponent'
|
||||
import LogTabWithIcons from './internal/components/log/logTabWithIcons'
|
||||
|
||||
import { usePrompt } from '../../utils/hooks'
|
||||
import { getLanguageFromExtension } from './internal/helper'
|
||||
import useEditor from './internal/hooks/useEditor'
|
||||
import { RunTimeType } from '../../context/appContext'
|
||||
import { LogObject } from '../../utils'
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)(() => ({
|
||||
padding: '10px'
|
||||
@@ -58,6 +62,7 @@ const SASjsEditor = ({
|
||||
selectedRunTime,
|
||||
showDiff,
|
||||
webout,
|
||||
printOutput,
|
||||
Dialog,
|
||||
handleChangeRunTime,
|
||||
handleDiffEditorDidMount,
|
||||
@@ -108,6 +113,10 @@ const SASjsEditor = ({
|
||||
/>
|
||||
)
|
||||
|
||||
// INFO: variable indicating if selected run type is SAS if there are any errors or warnings in the log
|
||||
const logWithErrorsOrWarnings =
|
||||
selectedRunTime === RunTimeType.SAS && log && typeof log === 'object'
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}>
|
||||
<Backdrop
|
||||
@@ -145,15 +154,35 @@ const SASjsEditor = ({
|
||||
>
|
||||
<TabList onChange={handleTabChange} centered>
|
||||
<StyledTab label="Code" value="code" />
|
||||
<StyledTab label="Log" value="log" />
|
||||
<StyledTab
|
||||
label={
|
||||
<Tooltip title="Displays content from the _webout fileref">
|
||||
<Typography>Webout</Typography>
|
||||
</Tooltip>
|
||||
}
|
||||
value="webout"
|
||||
/>
|
||||
{log && (
|
||||
<StyledTab
|
||||
label={logWithErrorsOrWarnings ? '' : 'log'}
|
||||
value="log"
|
||||
icon={
|
||||
logWithErrorsOrWarnings ? (
|
||||
<LogTabWithIcons log={log as LogObject} />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
const logWrapper = document.querySelector(`#logWrapper`)
|
||||
|
||||
if (logWrapper) logWrapper.scrollTop = 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{webout && (
|
||||
<StyledTab
|
||||
label={
|
||||
<Tooltip title="Displays content from the _webout fileref">
|
||||
<Typography>Webout</Typography>
|
||||
</Tooltip>
|
||||
}
|
||||
value="webout"
|
||||
/>
|
||||
)}
|
||||
{printOutput && <StyledTab label="print" value="printOutput" />}
|
||||
</TabList>
|
||||
</Box>
|
||||
|
||||
@@ -195,18 +224,24 @@ const SASjsEditor = ({
|
||||
</Paper>
|
||||
</StyledTabPanel>
|
||||
<StyledTabPanel value="log">
|
||||
<div>
|
||||
<h2>Log</h2>
|
||||
<pre id="log" style={{ overflow: 'auto', height: '75vh' }}>
|
||||
{log}
|
||||
</pre>
|
||||
</div>
|
||||
</StyledTabPanel>
|
||||
<StyledTabPanel value="webout">
|
||||
<div>
|
||||
<pre>{webout}</pre>
|
||||
</div>
|
||||
{log && (
|
||||
<LogComponent log={log} selectedRunTime={selectedRunTime} />
|
||||
)}
|
||||
</StyledTabPanel>
|
||||
{webout && (
|
||||
<StyledTabPanel value="webout">
|
||||
<div>
|
||||
<pre>{webout}</pre>
|
||||
</div>
|
||||
</StyledTabPanel>
|
||||
)}
|
||||
{printOutput && (
|
||||
<StyledTabPanel value="printOutput">
|
||||
<div>
|
||||
<pre>{printOutput}</pre>
|
||||
</div>
|
||||
</StyledTabPanel>
|
||||
)}
|
||||
</TabContext>
|
||||
)}
|
||||
<Dialog />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
.ChunkHeader {
|
||||
color: #444;
|
||||
cursor: pointer;
|
||||
padding: 18px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: 0.4s;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.2) 0px 2px 1px -1px,
|
||||
rgba(0, 0, 0, 0.14) 0px 1px 1px 0px,
|
||||
rgba(0, 0, 0, 0.12) 0px 1px 3px 0px;
|
||||
}
|
||||
|
||||
.ChunkDetails {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ChunkExpandIcon {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ChunkBody {
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ChunksContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.LogContainer {
|
||||
background-color: #fbfbfb;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 3px;
|
||||
min-height: 50px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
white-space: pre-wrap;
|
||||
font-family: Monaco, Courier, monospace;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.LogWrapper {
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 130px);
|
||||
}
|
||||
|
||||
.LogBody {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.TreeContainer {
|
||||
background-color: white;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.TabContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.TabDownloadIcon {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.HighlightedLine {
|
||||
background-color: #f6e30599;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.GreenIcon {
|
||||
color: green;
|
||||
}
|
||||
171
web/src/containers/Studio/internal/components/log/logChunk.tsx
Normal file
171
web/src/containers/Studio/internal/components/log/logChunk.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useEffect, SyntheticEvent } from 'react'
|
||||
import { Typography } from '@mui/material'
|
||||
import Highlight from 'react-highlight'
|
||||
import { ErrorOutline, Warning } from '@mui/icons-material'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import {
|
||||
defaultChunkSize,
|
||||
parseErrorsAndWarnings,
|
||||
LogInstance,
|
||||
clearErrorsAndWarningsHtmlWrapping,
|
||||
download
|
||||
} from '../../../../../utils'
|
||||
import { logStyles } from './logComponent'
|
||||
import classes from './log.module.css'
|
||||
|
||||
interface LogChunkProps {
|
||||
id: number
|
||||
text: string
|
||||
expanded: boolean
|
||||
logLineCount: number
|
||||
onClick: (evt: any, id: number) => void
|
||||
scrollToLogInstance?: LogInstance
|
||||
updated: number
|
||||
}
|
||||
|
||||
const LogChunk = (props: LogChunkProps) => {
|
||||
const { id, text, logLineCount } = props
|
||||
const [scrollToLogInstance, setScrollToLogInstance] = useState(
|
||||
props.scrollToLogInstance
|
||||
)
|
||||
const rowText = clearErrorsAndWarningsHtmlWrapping(text)
|
||||
const styles = logStyles()
|
||||
const [expanded, setExpanded] = useState(props.expanded)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(props.expanded)
|
||||
}, [props.expanded])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.expanded !== expanded) {
|
||||
setExpanded(props.expanded)
|
||||
}
|
||||
|
||||
if (
|
||||
props.scrollToLogInstance &&
|
||||
props.scrollToLogInstance !== scrollToLogInstance
|
||||
) {
|
||||
setScrollToLogInstance(props.scrollToLogInstance)
|
||||
}
|
||||
}, [props])
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && scrollToLogInstance) {
|
||||
const { type, id } = scrollToLogInstance
|
||||
const line = document.getElementById(`${type}_${id}`)
|
||||
const logWrapper: HTMLDivElement | null =
|
||||
document.querySelector(`#logWrapper`)
|
||||
const logContainer: HTMLHeadElement | null =
|
||||
document.querySelector(`#log_container`)
|
||||
|
||||
if (line && logWrapper && logContainer) {
|
||||
line.className = classes.HighlightedLine
|
||||
|
||||
line.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
|
||||
setTimeout(() => {
|
||||
line.classList.remove(classes.HighlightedLine)
|
||||
|
||||
setScrollToLogInstance(undefined)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}, [expanded, scrollToLogInstance, props])
|
||||
|
||||
const { errors, warnings } = parseErrorsAndWarnings(text)
|
||||
|
||||
const getLineRange = (separator = ' ... ') =>
|
||||
`${id * defaultChunkSize}${separator}${
|
||||
(id + 1) * defaultChunkSize < logLineCount
|
||||
? (id + 1) * defaultChunkSize
|
||||
: logLineCount
|
||||
}`
|
||||
|
||||
return (
|
||||
<div onClick={(evt) => props.onClick(evt, id)}>
|
||||
<button className={classes.ChunkHeader}>
|
||||
<Typography variant="subtitle1">
|
||||
<div className={classes.ChunkDetails}>
|
||||
<span>{`Lines: ${getLineRange()}`}</span>
|
||||
{copied ? (
|
||||
<CheckIcon
|
||||
className={[classes.Icon, classes.GreenIcon].join(' ')}
|
||||
/>
|
||||
) : (
|
||||
<ContentCopyIcon
|
||||
className={classes.Icon}
|
||||
onClick={(evt: SyntheticEvent) => {
|
||||
evt.stopPropagation()
|
||||
|
||||
navigator.clipboard.writeText(rowText)
|
||||
|
||||
setCopied(true)
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<FileDownloadIcon
|
||||
onClick={(evt: SyntheticEvent) => {
|
||||
download(evt, rowText, `.${getLineRange('-')}`)
|
||||
}}
|
||||
/>
|
||||
{errors && errors.length !== 0 && (
|
||||
<ErrorOutline
|
||||
color="error"
|
||||
className={classes.Icon}
|
||||
onClick={() => {
|
||||
setScrollToLogInstance(errors[0])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{warnings && warnings.length !== 0 && (
|
||||
<Warning
|
||||
className={[classes.Icon, classes.GreenIcon].join(' ')}
|
||||
onClick={(evt) => {
|
||||
if (expanded) evt.stopPropagation()
|
||||
|
||||
setScrollToLogInstance(warnings[0])
|
||||
}}
|
||||
/>
|
||||
)}{' '}
|
||||
<ExpandMoreIcon
|
||||
className={classes.ChunkExpandIcon}
|
||||
style={{
|
||||
transform: expanded ? 'rotate(180deg)' : 'unset'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Typography>
|
||||
</button>
|
||||
<div
|
||||
className={classes.ChunkBody}
|
||||
style={{
|
||||
display: expanded ? 'block' : 'none'
|
||||
}}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id={`log_container`}
|
||||
className={[styles.expansionDescription, classes.LogContainer].join(
|
||||
' '
|
||||
)}
|
||||
>
|
||||
<Highlight className={'html'} innerHTML={true}>
|
||||
{expanded ? text : ''}
|
||||
</Highlight>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogChunk
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import TreeView from '@mui/lab/TreeView'
|
||||
import TreeItem from '@mui/lab/TreeItem'
|
||||
import { ChevronRight, ExpandMore } from '@mui/icons-material'
|
||||
import { Typography } from '@mui/material'
|
||||
import { ListItemText } from '@mui/material'
|
||||
import { makeStyles } from '@mui/styles'
|
||||
import Highlight from 'react-highlight'
|
||||
import { LogObject, defaultChunkSize } from '../../../../../utils'
|
||||
import { RunTimeType } from '../../../../../context/appContext'
|
||||
import { splitIntoChunks, LogInstance } from '../../../../../utils'
|
||||
import LogChunk from './logChunk'
|
||||
import classes from './log.module.css'
|
||||
|
||||
export const logStyles: any = makeStyles((theme: any) => ({
|
||||
expansionDescription: {
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
fontSize: theme.typography.pxToRem(12)
|
||||
},
|
||||
[theme.breakpoints.up('md')]: {
|
||||
fontSize: theme.typography.pxToRem(16)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
interface LogComponentProps {
|
||||
log: LogObject | string
|
||||
selectedRunTime: RunTimeType | string
|
||||
}
|
||||
|
||||
const LogComponent = (props: LogComponentProps) => {
|
||||
const { log, selectedRunTime } = props
|
||||
const logObject = log as LogObject
|
||||
const logChunks = splitIntoChunks(logObject?.body || '')
|
||||
const [logChunksState, setLogChunksState] = useState<boolean[]>(
|
||||
new Array(logChunks.length).fill(false)
|
||||
)
|
||||
const [scrollToLogInstance, setScrollToLogInstance] = useState<LogInstance>()
|
||||
const [oldestExpandedChunk, setOldestExpandedChunk] = useState<number>(
|
||||
logChunksState.length - 1
|
||||
)
|
||||
const maxOpenedChunks = 2
|
||||
|
||||
const styles = logStyles()
|
||||
|
||||
const goToLogLine = (logInstance: LogInstance, ind: number) => {
|
||||
let chunkNumber = 0
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i <= Math.ceil(logObject.linesCount / defaultChunkSize);
|
||||
i++
|
||||
) {
|
||||
if (logInstance.line < (i + 1) * defaultChunkSize) {
|
||||
chunkNumber = i
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
setLogChunksState((prevState) => {
|
||||
const newState = [...prevState]
|
||||
newState[chunkNumber] = true
|
||||
|
||||
const chunkToCollapse = getChunkToAutoCollapse()
|
||||
|
||||
if (chunkToCollapse !== undefined) {
|
||||
newState[chunkToCollapse] = false
|
||||
}
|
||||
|
||||
return newState
|
||||
})
|
||||
|
||||
setScrollToLogInstance(logInstance)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// INFO: expand the last chunk by default
|
||||
setLogChunksState((prevState) => {
|
||||
const lastChunk = prevState.length - 1
|
||||
|
||||
const newState = [...prevState]
|
||||
newState[lastChunk] = true
|
||||
|
||||
return newState
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToTheBottom()
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
// INFO: scroll to the bottom of the log
|
||||
const scrollToTheBottom = () => {
|
||||
const logWrapper: HTMLDivElement | null =
|
||||
document.querySelector(`#logWrapper`)
|
||||
|
||||
if (logWrapper) {
|
||||
logWrapper.scrollTop = logWrapper.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const getChunkToAutoCollapse = () => {
|
||||
const openedChunks = logChunksState
|
||||
.map((chunkState: boolean, id: number) => (chunkState ? id : undefined))
|
||||
.filter((chunk) => chunk !== undefined)
|
||||
|
||||
if (openedChunks.length < maxOpenedChunks) return undefined
|
||||
else {
|
||||
const chunkToCollapse = oldestExpandedChunk
|
||||
const newOldestChunk = openedChunks.filter(
|
||||
(chunk) => chunk !== chunkToCollapse
|
||||
)[0]
|
||||
|
||||
if (newOldestChunk !== undefined) {
|
||||
setOldestExpandedChunk(newOldestChunk)
|
||||
|
||||
return chunkToCollapse
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrorsOrWarnings =
|
||||
logObject.errors?.length !== 0 || logObject.warnings?.length !== 0
|
||||
const logBody = typeof log === 'string' ? log : log.body
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedRunTime === RunTimeType.SAS && logObject.body ? (
|
||||
<div id="logWrapper" className={classes.LogWrapper}>
|
||||
<div>
|
||||
{hasErrorsOrWarnings && (
|
||||
<div className={classes.TreeContainer}>
|
||||
<TreeView
|
||||
defaultCollapseIcon={<ExpandMore />}
|
||||
defaultExpandIcon={<ChevronRight />}
|
||||
>
|
||||
{logObject.errors && logObject.errors.length !== 0 && (
|
||||
<TreeItem
|
||||
nodeId="errors"
|
||||
label={
|
||||
<Typography color="error">
|
||||
{`Errors (${logObject.errors.length})`}
|
||||
</Typography>
|
||||
}
|
||||
>
|
||||
{logObject.errors &&
|
||||
logObject.errors.map((error, ind) => (
|
||||
<TreeItem
|
||||
nodeId={`error_${ind}`}
|
||||
label={<ListItemText primary={error.body} />}
|
||||
key={`error_${ind}`}
|
||||
onClick={() => goToLogLine(error, ind)}
|
||||
/>
|
||||
))}
|
||||
</TreeItem>
|
||||
)}
|
||||
{logObject.warnings && logObject.warnings.length !== 0 && (
|
||||
<TreeItem
|
||||
nodeId="warnings"
|
||||
label={
|
||||
<Typography>{`Warnings (${logObject.warnings.length})`}</Typography>
|
||||
}
|
||||
>
|
||||
{logObject.warnings &&
|
||||
logObject.warnings.map((warning, ind) => (
|
||||
<TreeItem
|
||||
nodeId={`warning_${ind}`}
|
||||
label={<ListItemText primary={warning.body} />}
|
||||
key={`warning_${ind}`}
|
||||
onClick={() => goToLogLine(warning, ind)}
|
||||
/>
|
||||
))}
|
||||
</TreeItem>
|
||||
)}
|
||||
</TreeView>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.ChunksContainer}>
|
||||
{Array.isArray(logChunks) ? (
|
||||
logChunks.map((chunk: string, id: number) => (
|
||||
<LogChunk
|
||||
id={id}
|
||||
text={chunk}
|
||||
expanded={logChunksState[id]}
|
||||
key={`log-chunk-${id}`}
|
||||
logLineCount={logObject.linesCount}
|
||||
scrollToLogInstance={scrollToLogInstance}
|
||||
updated={Date.now()}
|
||||
onClick={(_, chunkNumber) => {
|
||||
setLogChunksState((prevState) => {
|
||||
const newState = [...prevState]
|
||||
const expand = !newState[chunkNumber]
|
||||
|
||||
newState[chunkNumber] = expand
|
||||
|
||||
if (expand) {
|
||||
const chunkToCollapse = getChunkToAutoCollapse()
|
||||
|
||||
if (chunkToCollapse !== undefined) {
|
||||
newState[chunkToCollapse] = false
|
||||
}
|
||||
}
|
||||
|
||||
return newState
|
||||
})
|
||||
|
||||
setScrollToLogInstance(undefined)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography
|
||||
id={`log_container`}
|
||||
variant="h5"
|
||||
className={[
|
||||
styles.expansionDescription,
|
||||
classes.LogContainer
|
||||
].join(' ')}
|
||||
>
|
||||
<Highlight className={'html'} innerHTML={true}>
|
||||
{logChunks}
|
||||
</Highlight>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h2>Log</h2>
|
||||
<pre id="log" className={classes.LogBody}>
|
||||
{logBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogComponent
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ErrorOutline, Warning } from '@mui/icons-material'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import {
|
||||
LogObject,
|
||||
download,
|
||||
clearErrorsAndWarningsHtmlWrapping
|
||||
} from '../../../../../utils'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import classes from './log.module.css'
|
||||
|
||||
interface LogTabProps {
|
||||
log: LogObject
|
||||
}
|
||||
|
||||
const LogTabWithIcons = (props: LogTabProps) => {
|
||||
const { errors, warnings, body } = props.log
|
||||
|
||||
return (
|
||||
<div className={classes.TabContainer}>
|
||||
<span>log</span>
|
||||
{errors && errors.length !== 0 && (
|
||||
<ErrorOutline color="error" className={classes.Icon} />
|
||||
)}
|
||||
{warnings && warnings.length !== 0 && (
|
||||
<Warning className={[classes.Icon, classes.GreenIcon].join(' ')} />
|
||||
)}
|
||||
<Tooltip
|
||||
title="Download entire log"
|
||||
onClick={(evt) => {
|
||||
download(evt, clearErrorsAndWarningsHtmlWrapping(body))
|
||||
}}
|
||||
>
|
||||
<FileDownloadIcon
|
||||
className={[classes.Icon, classes.TabDownloadIcon].join(' ')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogTabWithIcons
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
useSnackbar,
|
||||
useStateWithCallback
|
||||
} from '../../../../utils/hooks'
|
||||
import { parseErrorsAndWarnings, LogObject } from '../../../../utils'
|
||||
|
||||
const SASJS_LOGS_SEPARATOR =
|
||||
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||
@@ -38,13 +39,15 @@ const useEditor = ({
|
||||
const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } =
|
||||
useSnackbar()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [log, setLog] = useState('')
|
||||
const [webout, setWebout] = useState('')
|
||||
const [log, setLog] = useState<LogObject | string>()
|
||||
const [webout, setWebout] = useState<string>()
|
||||
const [printOutput, setPrintOutput] = useState<string>()
|
||||
const [runTimes, setRunTimes] = useState<string[]>([])
|
||||
const [selectedRunTime, setSelectedRunTime] = useState('')
|
||||
const [selectedRunTime, setSelectedRunTime] = useState<RunTimeType>(
|
||||
RunTimeType.SAS
|
||||
)
|
||||
const [selectedFileExtension, setSelectedFileExtension] = useState('')
|
||||
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
|
||||
const [showDiff, setShowDiff] = useState(false)
|
||||
@@ -150,6 +153,13 @@ const useEditor = ({
|
||||
const runCode = useCallback(
|
||||
(code: string) => {
|
||||
setIsLoading(true)
|
||||
|
||||
// Scroll to bottom of log
|
||||
const logElement = document.getElementById('log')
|
||||
if (logElement) logElement.scrollTop = logElement.scrollHeight
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
axios
|
||||
.post(`/SASjsApi/code/execute`, {
|
||||
code: programPathInjection(
|
||||
@@ -159,9 +169,30 @@ const useEditor = ({
|
||||
),
|
||||
runTime: selectedRunTime
|
||||
})
|
||||
.then((res: any) => {
|
||||
setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '')
|
||||
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '')
|
||||
.then((res: { data: string }) => {
|
||||
// INFO: the order of payload parts is set in @sasjs/server/api/src/controllers/internal/Execution.ts
|
||||
const resDataSplitted = res.data.split(SASJS_LOGS_SEPARATOR)
|
||||
const webout = resDataSplitted[0]
|
||||
const log = resDataSplitted[1]
|
||||
const printOutput = resDataSplitted[2]
|
||||
|
||||
if (selectedRunTime === RunTimeType.SAS) {
|
||||
const { errors, warnings, logLines } = parseErrorsAndWarnings(log)
|
||||
|
||||
const logObject: LogObject = {
|
||||
body: logLines.join(`\n`),
|
||||
errors,
|
||||
warnings,
|
||||
linesCount: logLines.length
|
||||
}
|
||||
|
||||
setLog(logObject)
|
||||
} else {
|
||||
setLog(log)
|
||||
}
|
||||
|
||||
setWebout(webout)
|
||||
setPrintOutput(printOutput)
|
||||
setTab('log')
|
||||
|
||||
// Scroll to bottom of log
|
||||
@@ -249,7 +280,7 @@ const useEditor = ({
|
||||
}, [appContext.runTimes])
|
||||
|
||||
useEffect(() => {
|
||||
if (runTimes.length) setSelectedRunTime(runTimes[0])
|
||||
if (runTimes.length) setSelectedRunTime(runTimes[0] as RunTimeType)
|
||||
}, [runTimes])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -261,8 +292,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')
|
||||
@@ -278,7 +311,6 @@ const useEditor = ({
|
||||
const content = localStorage.getItem('fileContent') ?? ''
|
||||
setFileContent(content)
|
||||
}
|
||||
setLog('')
|
||||
setWebout('')
|
||||
setTab('code')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -292,7 +324,9 @@ const useEditor = ({
|
||||
|
||||
useEffect(() => {
|
||||
const fileExtension = selectedFileExtension.toLowerCase()
|
||||
if (runTimes.includes(fileExtension)) setSelectedRunTime(fileExtension)
|
||||
|
||||
if (runTimes.includes(fileExtension))
|
||||
setSelectedRunTime(fileExtension as RunTimeType)
|
||||
}, [selectedFileExtension, runTimes])
|
||||
|
||||
return {
|
||||
@@ -306,6 +340,7 @@ const useEditor = ({
|
||||
selectedRunTime,
|
||||
showDiff,
|
||||
webout,
|
||||
printOutput,
|
||||
Dialog,
|
||||
handleChangeRunTime,
|
||||
handleDiffEditorDidMount,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
web/src/types/declaration.d.ts
vendored
Normal file
4
web/src/types/declaration.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: { [key: string]: string }
|
||||
export default classes
|
||||
}
|
||||
3
web/src/utils/index.ts
Normal file
3
web/src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './log'
|
||||
export * from './types'
|
||||
export * from './helper'
|
||||
133
web/src/utils/log.ts
Normal file
133
web/src/utils/log.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { SyntheticEvent } from 'react'
|
||||
import { LogInstance } from './'
|
||||
|
||||
export const parseErrorsAndWarnings = (log: string) => {
|
||||
const logLines = log.split('\n')
|
||||
const errorLines: LogInstance[] = []
|
||||
const warningLines: LogInstance[] = []
|
||||
|
||||
logLines.forEach((line: string, index: number) => {
|
||||
// INFO: check if content in element starts with ERROR
|
||||
if (/<.*>ERROR/gm.test(line)) {
|
||||
const errorLine = line.substring(line.indexOf('E'), line.length - 1)
|
||||
|
||||
errorLines.push({
|
||||
body: errorLine,
|
||||
line: index,
|
||||
type: 'error',
|
||||
id: errorLines.length
|
||||
})
|
||||
}
|
||||
|
||||
// INFO: check if line starts with ERROR
|
||||
else if (/^ERROR/gm.test(line)) {
|
||||
errorLines.push({
|
||||
body: line,
|
||||
line: index,
|
||||
type: 'error',
|
||||
id: errorLines.length
|
||||
})
|
||||
|
||||
logLines[index] =
|
||||
`<font id="error_${
|
||||
errorLines.length - 1
|
||||
}" style="color: red;" ref={scrollTo}>` +
|
||||
logLines[index] +
|
||||
'</font>'
|
||||
}
|
||||
|
||||
// INFO: check if content in element starts with WARNING
|
||||
else if (/<.*>WARNING/gm.test(line)) {
|
||||
const warningLine = line.substring(line.indexOf('W'), line.length - 1)
|
||||
|
||||
warningLines.push({
|
||||
body: warningLine,
|
||||
line: index,
|
||||
type: 'warning',
|
||||
id: warningLines.length
|
||||
})
|
||||
}
|
||||
|
||||
// INFO: check if line starts with WARNING
|
||||
else if (/^WARNING/gm.test(line)) {
|
||||
warningLines.push({
|
||||
body: line,
|
||||
line: index,
|
||||
type: 'warning',
|
||||
id: warningLines.length
|
||||
})
|
||||
|
||||
logLines[index] =
|
||||
`<font id="warning_${warningLines.length - 1}" style="color: green;">` +
|
||||
logLines[index] +
|
||||
'</font>'
|
||||
}
|
||||
})
|
||||
|
||||
return { errors: errorLines, warnings: warningLines, logLines }
|
||||
}
|
||||
|
||||
export const defaultChunkSize = 20000
|
||||
|
||||
export const isTheLastChunk = (
|
||||
lineCount: number,
|
||||
chunkNumber: number,
|
||||
chunkSize = defaultChunkSize
|
||||
) => {
|
||||
if (lineCount <= chunkSize) return true
|
||||
|
||||
const chunksNumber = Math.ceil(lineCount / chunkSize)
|
||||
|
||||
return chunkNumber === chunksNumber
|
||||
}
|
||||
|
||||
export const splitIntoChunks = (log: string, chunkSize = defaultChunkSize) => {
|
||||
if (!log) return []
|
||||
|
||||
const logLines: string[] = log.split(`\n`)
|
||||
|
||||
if (logLines.length <= chunkSize) return [log]
|
||||
|
||||
const chunks: string[] = []
|
||||
|
||||
while (logLines.length) {
|
||||
const chunk = logLines.splice(0, chunkSize)
|
||||
|
||||
chunks.push(chunk.join(`\n`))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
export const clearErrorsAndWarningsHtmlWrapping = (log: string) =>
|
||||
log.replace(/^<font[^>]*>/gm, '').replace(/<\/font>/gm, '')
|
||||
|
||||
export const download = (evt: SyntheticEvent, log: string, fileName = '') => {
|
||||
evt.stopPropagation()
|
||||
|
||||
const padWithZero = (num: number) => (num < 9 ? `0${num}` : `${num}`)
|
||||
|
||||
const date = new Date()
|
||||
const datePrefix = [
|
||||
date.getFullYear(),
|
||||
padWithZero(date.getMonth() + 1),
|
||||
padWithZero(date.getDate()),
|
||||
padWithZero(date.getHours()),
|
||||
padWithZero(date.getMinutes()),
|
||||
padWithZero(date.getSeconds())
|
||||
].join('')
|
||||
|
||||
const file = new Blob([log])
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${datePrefix}${fileName}.log`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}, 0)
|
||||
}
|
||||
@@ -39,3 +39,18 @@ export interface TreeNode {
|
||||
isFolder: boolean
|
||||
children: Array<TreeNode>
|
||||
}
|
||||
|
||||
export interface LogInstance {
|
||||
body: string
|
||||
line: number
|
||||
type: 'error' | 'warning'
|
||||
id: number
|
||||
ref?: any
|
||||
}
|
||||
|
||||
export interface LogObject {
|
||||
body: string
|
||||
errors?: LogInstance[]
|
||||
warnings?: LogInstance[]
|
||||
linesCount: number
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -33,9 +33,23 @@ const config: Configuration = {
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
exclude: ['/node_modules/'],
|
||||
exclude: ['/node_modules/', /\.module\.css$/],
|
||||
use: ['style-loader', 'css-loader']
|
||||
},
|
||||
{
|
||||
test: /\.module\.css$/i,
|
||||
use: [
|
||||
'style-loader',
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: {
|
||||
localIdentName: '[local]--[hash:base64:5]'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
exclude: ['/node_modules/'],
|
||||
|
||||
Reference in New Issue
Block a user