mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b5abcd661 | ||
|
|
48e8cb7b2d | ||
|
|
225f381bdf | ||
|
|
3f49186e3b | ||
|
|
ab96653564 | ||
|
|
471c28eaa2 | ||
|
|
584ffe9e0e | ||
|
|
e51b20421a | ||
|
|
631e95604b | ||
|
|
198cd79354 | ||
|
|
379ea604bc | ||
|
|
9ffa403bcb | ||
|
|
6d123c3e23 | ||
|
|
dda1aadc67 | ||
|
|
d47cf15cdb | ||
|
|
d0c7968d66 | ||
|
|
a5c99971cc | ||
|
|
c422e7f02e | ||
|
|
02a993611c | ||
| aca2fff4ac | |||
| af1a386b13 | |||
|
|
f5018ce1df | ||
|
|
3529232f1f | ||
|
|
f4768bffd3 | ||
|
|
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 |
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
run: npm run lint-web
|
run: npm run lint-web
|
||||||
|
|
||||||
build-api:
|
build-api:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -56,4 +56,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
run: |
|
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": [
|
"cSpell.words": ["autoexec", "initialising"]
|
||||||
"autoexec"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
171
CHANGELOG.md
171
CHANGELOG.md
@@ -1,3 +1,174 @@
|
|||||||
|
## [0.39.3](https://github.com/sasjs/server/compare/v0.39.2...v0.39.3) (2025-11-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* (deps) bump @sasjs/core to 4.59.7 ([ab96653](https://github.com/sasjs/server/commit/ab966535642d08d4e8e984007b98c8fdffbe30f7))
|
||||||
|
* (deps) rerun npm i to sync ([225f381](https://github.com/sasjs/server/commit/225f381bdf8ad5aa2af8d75648df1dd5175e12e0))
|
||||||
|
|
||||||
|
## [0.39.2](https://github.com/sasjs/server/compare/v0.39.1...v0.39.2) (2025-09-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* addressing test fail ([e51b204](https://github.com/sasjs/server/commit/e51b20421adc1598ea267c79b1fb4dbc085f97b9))
|
||||||
|
* packages missmatch ([379ea60](https://github.com/sasjs/server/commit/379ea604bcb5686b5299fae6a32f759c45b275ea))
|
||||||
|
* type libs ([6d123c3](https://github.com/sasjs/server/commit/6d123c3e23628c1d703eaa13142c77f0da970a55))
|
||||||
|
* typescript errors ([631e956](https://github.com/sasjs/server/commit/631e95604b64b1a96f2abade659348618f3b00b2))
|
||||||
|
* typescript errors ([198cd79](https://github.com/sasjs/server/commit/198cd79354254511c21ac1acfbf7b6bcfdab2af7))
|
||||||
|
|
||||||
|
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* extra bit of sleep for file recognition ([f4768bf](https://github.com/sasjs/server/commit/f4768bffd3dbb2fe243966572ba74002024d96e1)), closes [#381](https://github.com/sasjs/server/issues/381)
|
||||||
|
|
||||||
|
# [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)
|
# [0.32.0](https://github.com/sasjs/server/compare/v0.31.0...v0.32.0) (2023-04-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -158,7 +158,7 @@ CORS=
|
|||||||
WHITELIST=
|
WHITELIST=
|
||||||
|
|
||||||
# HELMET Cross Origin Embedder Policy
|
# 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
|
# options: [true|false] default: true
|
||||||
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
|
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
|
||||||
HELMET_COEP=
|
HELMET_COEP=
|
||||||
@@ -184,10 +184,23 @@ MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
|
|||||||
|
|
||||||
|
|
||||||
# After this, access is blocked for an hour
|
# After this, access is blocked for an hour
|
||||||
# Store number for 90 days since first fail
|
# Store number for 24 days since first fail
|
||||||
# Once a successful login is attempted, it resets
|
# Once a successful login is attempted, it resets
|
||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
|
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`
|
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
|
||||||
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
||||||
LOG_FORMAT_MORGAN=
|
LOG_FORMAT_MORGAN=
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
|||||||
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||||
|
|
||||||
#default value is 100
|
#default value is 100
|
||||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
||||||
|
|
||||||
#default value is 10
|
#default value is 10
|
||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=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
|
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
|
||||||
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
||||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||||
|
|||||||
20538
api/package-lock.json
generated
20538
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -48,26 +48,25 @@
|
|||||||
},
|
},
|
||||||
"author": "4GL Ltd",
|
"author": "4GL Ltd",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/core": "^4.40.1",
|
"@sasjs/core": "^4.59.7",
|
||||||
"@sasjs/utils": "3.2.0",
|
"@sasjs/utils": "^3.5.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"connect-mongo": "^4.6.0",
|
"connect-mongo": "^5.1.0",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.17.1",
|
"express": "^4.21.2",
|
||||||
"express-session": "^1.17.2",
|
"express-session": "^1.18.2",
|
||||||
"helmet": "^5.0.2",
|
"helmet": "^5.0.2",
|
||||||
"joi": "^17.4.2",
|
"joi": "^17.4.2",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"ldapjs": "2.3.3",
|
"ldapjs": "2.3.3",
|
||||||
"mongoose": "^6.0.12",
|
"mongoose": "^6.13.8",
|
||||||
"mongoose-sequence": "^5.3.1",
|
"morgan": "^1.10.1",
|
||||||
"morgan": "^1.10.0",
|
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"rate-limiter-flexible": "2.4.1",
|
"rate-limiter-flexible": "2.4.1",
|
||||||
"rotating-file-stream": "^3.0.4",
|
"rotating-file-stream": "^3.0.4",
|
||||||
"swagger-ui-express": "4.3.0",
|
"swagger-ui-express": "4.3.0",
|
||||||
"unzipper": "^0.10.11",
|
"unzipper": "^0.12.3",
|
||||||
"url": "^0.10.3"
|
"url": "^0.10.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -77,33 +76,32 @@
|
|||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
"@types/express": "^4.17.12",
|
"@types/express": "^4.17.12",
|
||||||
"@types/express-session": "^1.17.4",
|
"@types/express-session": "^1.17.4",
|
||||||
"@types/jest": "^26.0.24",
|
"@types/jest": "^29.5.0",
|
||||||
"@types/jsonwebtoken": "^8.5.5",
|
"@types/jsonwebtoken": "^8.5.5",
|
||||||
"@types/ldapjs": "^2.2.4",
|
"@types/ldapjs": "^2.2.4",
|
||||||
"@types/mongoose-sequence": "^3.0.6",
|
|
||||||
"@types/morgan": "^1.9.3",
|
"@types/morgan": "^1.9.3",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^15.12.2",
|
"@types/node": "^20.0.0",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"@types/swagger-ui-express": "^4.1.3",
|
"@types/swagger-ui-express": "^4.1.3",
|
||||||
"@types/unzipper": "^0.10.5",
|
"@types/unzipper": "^0.10.5",
|
||||||
"adm-zip": "^0.5.9",
|
"adm-zip": "^0.5.9",
|
||||||
"axios": "0.27.2",
|
"axios": "^1.12.2",
|
||||||
"csrf": "^3.1.0",
|
"csrf": "^3.1.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^16.0.1",
|
||||||
"http-headers-validation": "^0.0.1",
|
"http-headers-validation": "^0.0.1",
|
||||||
"jest": "^27.0.6",
|
"jest": "^29.7.0",
|
||||||
"mongodb-memory-server": "8.11.4",
|
"mongodb-memory-server": "8.11.4",
|
||||||
"nodejs-file-downloader": "4.10.2",
|
"nodejs-file-downloader": "4.10.2",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^3.0.0",
|
||||||
"pkg": "5.6.0",
|
"pkg": "5.6.0",
|
||||||
"prettier": "^2.3.1",
|
"prettier": "^3.0.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"supertest": "^6.1.3",
|
"supertest": "^6.1.3",
|
||||||
"ts-jest": "^27.0.3",
|
"ts-jest": "^29.1.0",
|
||||||
"ts-node": "^10.0.0",
|
"ts-node": "^10.0.0",
|
||||||
"tsoa": "3.14.1",
|
"tsoa": "3.14.1",
|
||||||
"typescript": "^4.3.2"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"nodemonConfig": {
|
"nodemonConfig": {
|
||||||
"ignore": [
|
"ignore": [
|
||||||
|
|||||||
@@ -98,17 +98,47 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
code:
|
code:
|
||||||
type: string
|
type: string
|
||||||
description: 'Code of program'
|
description: 'The code to be executed'
|
||||||
example: '* Code HERE;'
|
example: '* Your Code HERE;'
|
||||||
runTime:
|
runTime:
|
||||||
$ref: '#/components/schemas/RunTimeType'
|
$ref: '#/components/schemas/RunTimeType'
|
||||||
description: 'runtime for program'
|
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
||||||
example: js
|
example: js
|
||||||
required:
|
required:
|
||||||
- code
|
- code
|
||||||
- runTime
|
- runTime
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
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:
|
MemberType.folder:
|
||||||
enum:
|
enum:
|
||||||
- folder
|
- folder
|
||||||
@@ -555,6 +585,14 @@ components:
|
|||||||
- needsToUpdatePassword
|
- needsToUpdatePassword
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
SessionState:
|
||||||
|
enum:
|
||||||
|
- initialising
|
||||||
|
- pending
|
||||||
|
- running
|
||||||
|
- completed
|
||||||
|
- failed
|
||||||
|
type: string
|
||||||
ExecutePostRequestPayload:
|
ExecutePostRequestPayload:
|
||||||
properties:
|
properties:
|
||||||
_program:
|
_program:
|
||||||
@@ -563,6 +601,16 @@ components:
|
|||||||
example: /Public/somefolder/some.file
|
example: /Public/somefolder/some.file
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
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:
|
LoginPayload:
|
||||||
properties:
|
properties:
|
||||||
username:
|
username:
|
||||||
@@ -772,7 +820,7 @@ paths:
|
|||||||
examples:
|
examples:
|
||||||
'Example 1':
|
'Example 1':
|
||||||
value: [{clientId: someClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}, {clientId: someOtherClientID, clientSecret: someOtherRandomCryptoString, accessTokenExpiration: 86400}]
|
value: [{clientId: someClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}, {clientId: someOtherClientID, clientSecret: someOtherRandomCryptoString, accessTokenExpiration: 86400}]
|
||||||
summary: 'Admin only task. Returns the list of all the clients *'
|
summary: 'Admin only task. Returns the list of all the clients'
|
||||||
tags:
|
tags:
|
||||||
- Client
|
- Client
|
||||||
security:
|
security:
|
||||||
@@ -792,7 +840,7 @@ paths:
|
|||||||
- {type: string}
|
- {type: string}
|
||||||
- {type: string, format: byte}
|
- {type: string, format: byte}
|
||||||
description: 'Execute Code on the Specified Runtime'
|
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:
|
tags:
|
||||||
- Code
|
- Code
|
||||||
security:
|
security:
|
||||||
@@ -805,6 +853,30 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ExecuteCodePayload'
|
$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:
|
/SASjsApi/drive/deploy:
|
||||||
post:
|
post:
|
||||||
operationId: Deploy
|
operationId: Deploy
|
||||||
@@ -1777,6 +1849,30 @@ paths:
|
|||||||
-
|
-
|
||||||
bearerAuth: []
|
bearerAuth: []
|
||||||
parameters: []
|
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:
|
/SASjsApi/stp/execute:
|
||||||
get:
|
get:
|
||||||
operationId: ExecuteGetRequest
|
operationId: ExecuteGetRequest
|
||||||
@@ -1789,7 +1885,7 @@ paths:
|
|||||||
anyOf:
|
anyOf:
|
||||||
- {type: string}
|
- {type: string}
|
||||||
- {type: string, format: byte}
|
- {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.'
|
summary: 'Execute a Stored Program, returns _webout and (optionally) log.'
|
||||||
tags:
|
tags:
|
||||||
- STP
|
- STP
|
||||||
@@ -1798,13 +1894,22 @@ paths:
|
|||||||
bearerAuth: []
|
bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
-
|
-
|
||||||
description: 'Location of code in SASjs Drive'
|
description: 'Location of Stored Program in SASjs Drive.'
|
||||||
in: query
|
in: query
|
||||||
name: _program
|
name: _program
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: /Projects/myApp/some/program
|
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:
|
post:
|
||||||
operationId: ExecutePostRequest
|
operationId: ExecutePostRequest
|
||||||
responses:
|
responses:
|
||||||
@@ -1838,6 +1943,50 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ExecutePostRequestPayload'
|
$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:
|
get:
|
||||||
operationId: Home
|
operationId: Home
|
||||||
|
|||||||
@@ -234,9 +234,10 @@ const verifyAuthCode = async (
|
|||||||
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
||||||
if (err) return resolve(undefined)
|
if (err) return resolve(undefined)
|
||||||
|
|
||||||
|
const payload = data as InfoJWT
|
||||||
const clientInfo: InfoJWT = {
|
const clientInfo: InfoJWT = {
|
||||||
clientId: data?.clientId,
|
clientId: payload?.clientId,
|
||||||
userId: data?.userId
|
userId: payload?.userId
|
||||||
}
|
}
|
||||||
if (clientInfo.clientId === clientId) {
|
if (clientInfo.clientId === clientId) {
|
||||||
return resolve(clientInfo)
|
return resolve(clientInfo)
|
||||||
|
|||||||
@@ -1,34 +1,71 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
||||||
import { ExecutionController } from './internal'
|
import { ExecutionController, getSessionController } from './internal'
|
||||||
import {
|
import {
|
||||||
getPreProgramVariables,
|
getPreProgramVariables,
|
||||||
getUserAutoExec,
|
getUserAutoExec,
|
||||||
ModeType,
|
ModeType,
|
||||||
parseLogToArray,
|
|
||||||
RunTimeType
|
RunTimeType
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
|
|
||||||
interface ExecuteCodePayload {
|
interface ExecuteCodePayload {
|
||||||
/**
|
/**
|
||||||
* Code of program
|
* The code to be executed
|
||||||
* @example "* Code HERE;"
|
* @example "* Your Code HERE;"
|
||||||
*/
|
*/
|
||||||
code: string
|
code: string
|
||||||
/**
|
/**
|
||||||
* runtime for program
|
* The runtime for the code - eg SAS, JS, PY or R
|
||||||
* @example "js"
|
* @example "js"
|
||||||
*/
|
*/
|
||||||
runTime: RunTimeType
|
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')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/code')
|
@Route('SASjsApi/code')
|
||||||
@Tags('Code')
|
@Tags('Code')
|
||||||
export class CodeController {
|
export class CodeController {
|
||||||
/**
|
/**
|
||||||
* Execute Code on the Specified Runtime
|
* 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')
|
@Post('/execute')
|
||||||
public async executeCode(
|
public async executeCode(
|
||||||
@@ -37,6 +74,18 @@ export class CodeController {
|
|||||||
): Promise<string | Buffer> {
|
): Promise<string | Buffer> {
|
||||||
return executeCode(request, body)
|
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 (
|
const executeCode = async (
|
||||||
@@ -55,7 +104,8 @@ const executeCode = async (
|
|||||||
preProgramVariables: getPreProgramVariables(req),
|
preProgramVariables: getPreProgramVariables(req),
|
||||||
vars: { ...req.query, _debug: 131 },
|
vars: { ...req.query, _debug: 131 },
|
||||||
otherArgs: { userAutoExec },
|
otherArgs: { userAutoExec },
|
||||||
runTime: runTime
|
runTime: runTime,
|
||||||
|
includePrintOutput: true
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
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 fs from 'fs'
|
||||||
import { getSessionController, processProgram } from './'
|
import { getSessionController, processProgram } from './'
|
||||||
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||||
import { PreProgramVars, Session, TreeNode } from '../../types'
|
import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
|
||||||
import {
|
import {
|
||||||
extractHeaders,
|
extractHeaders,
|
||||||
getFilesFolder,
|
getFilesFolder,
|
||||||
@@ -33,6 +33,7 @@ interface ExecuteFileParams {
|
|||||||
|
|
||||||
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
||||||
program: string
|
program: string
|
||||||
|
includePrintOutput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExecutionController {
|
export class ExecutionController {
|
||||||
@@ -67,18 +68,17 @@ export class ExecutionController {
|
|||||||
otherArgs,
|
otherArgs,
|
||||||
session: sessionByFileUpload,
|
session: sessionByFileUpload,
|
||||||
runTime,
|
runTime,
|
||||||
forceStringResult
|
forceStringResult,
|
||||||
|
includePrintOutput
|
||||||
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
|
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
|
||||||
const sessionController = getSessionController(runTime)
|
const sessionController = getSessionController(runTime)
|
||||||
|
|
||||||
const session =
|
const session =
|
||||||
sessionByFileUpload ?? (await sessionController.getSession())
|
sessionByFileUpload ?? (await sessionController.getSession())
|
||||||
session.inUse = true
|
session.state = SessionState.running
|
||||||
session.consumed = true
|
|
||||||
|
|
||||||
const logPath = path.join(session.path, 'log.log')
|
const logPath = path.join(session.path, 'log.log')
|
||||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
|
||||||
const weboutPath = path.join(session.path, 'webout.txt')
|
const weboutPath = path.join(session.path, 'webout.txt')
|
||||||
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||||
|
|
||||||
@@ -120,13 +120,32 @@ export class ExecutionController {
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
// it should be deleted by scheduleSessionDestroy
|
// 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 {
|
return {
|
||||||
httpHeaders,
|
httpHeaders,
|
||||||
result:
|
result:
|
||||||
isDebugOn(vars) || session.crashed
|
isDebugOn(vars) || session.failureReason
|
||||||
? `${webout}\n${process.logsUUID}\n${log}`
|
? resultParts.join(`\n`)
|
||||||
: webout
|
: webout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express'
|
|||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import { uuidv4 } from '@sasjs/utils'
|
import { uuidv4 } from '@sasjs/utils'
|
||||||
import { getSessionController } from '.'
|
import { getSessionController } from '.'
|
||||||
import {
|
import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
|
||||||
executeProgramRawValidation,
|
import { SessionState } from '../../types'
|
||||||
getRunTimeAndFilePath,
|
|
||||||
RunTimeType
|
|
||||||
} from '../../utils'
|
|
||||||
|
|
||||||
export class FileUploadController {
|
export class FileUploadController {
|
||||||
private storage = multer.diskStorage({
|
private storage = multer.diskStorage({
|
||||||
@@ -56,9 +53,8 @@ export class FileUploadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await sessionController.getSession()
|
const session = await sessionController.getSession()
|
||||||
// marking consumed true, so that it's not available
|
// change session state to 'running', so that it's not available for any other request
|
||||||
// as readySession for any other request
|
session.state = SessionState.running
|
||||||
session.consumed = true
|
|
||||||
|
|
||||||
req.sasjsSession = session
|
req.sasjsSession = session
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { Session } from '../../types'
|
import { Session, SessionState } from '../../types'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import {
|
import {
|
||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
createFile,
|
createFile,
|
||||||
fileExists,
|
fileExists,
|
||||||
generateTimestamp,
|
generateTimestamp,
|
||||||
readFile,
|
readFile
|
||||||
isWindows
|
|
||||||
} from '@sasjs/utils'
|
} from '@sasjs/utils'
|
||||||
|
|
||||||
const execFilePromise = promisify(execFile)
|
const execFilePromise = promisify(execFile)
|
||||||
@@ -24,7 +23,9 @@ export class SessionController {
|
|||||||
protected sessions: Session[] = []
|
protected sessions: Session[] = []
|
||||||
|
|
||||||
protected getReadySessions = (): 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> {
|
protected async createSession(): Promise<Session> {
|
||||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||||
@@ -40,19 +41,18 @@ export class SessionController {
|
|||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
ready: true,
|
state: SessionState.pending,
|
||||||
inUse: true,
|
|
||||||
consumed: false,
|
|
||||||
completed: false,
|
|
||||||
creationTimeStamp,
|
creationTimeStamp,
|
||||||
deathTimeStamp,
|
deathTimeStamp,
|
||||||
path: sessionFolder
|
path: sessionFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
|
||||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||||
|
|
||||||
this.sessions.push(session)
|
this.sessions.push(session)
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +67,10 @@ export class SessionController {
|
|||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getSessionById(id: string) {
|
||||||
|
return this.sessions.find((session) => session.id === id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SASSessionController extends SessionController {
|
export class SASSessionController extends SessionController {
|
||||||
@@ -84,10 +88,7 @@ export class SASSessionController extends SessionController {
|
|||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
ready: false,
|
state: SessionState.initialising,
|
||||||
inUse: false,
|
|
||||||
consumed: false,
|
|
||||||
completed: false,
|
|
||||||
creationTimeStamp,
|
creationTimeStamp,
|
||||||
deathTimeStamp,
|
deathTimeStamp,
|
||||||
path: sessionFolder
|
path: sessionFolder
|
||||||
@@ -134,7 +135,7 @@ ${autoExecContent}`
|
|||||||
session.path,
|
session.path,
|
||||||
'-AUTOEXEC',
|
'-AUTOEXEC',
|
||||||
autoExecPath,
|
autoExecPath,
|
||||||
isWindows() ? '-nologo' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nologo' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
||||||
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
||||||
@@ -145,13 +146,20 @@ ${autoExecContent}`
|
|||||||
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||||
])
|
])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
session.completed = true
|
session.state = SessionState.completed
|
||||||
|
|
||||||
process.logger.info('session completed', session)
|
process.logger.info('session completed', session)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
session.completed = true
|
session.state = SessionState.failed
|
||||||
session.crashed = err.toString()
|
|
||||||
process.logger.error('session crashed', session.id, session.crashed)
|
session.failureReason = err.toString()
|
||||||
|
|
||||||
|
process.logger.error(
|
||||||
|
'session crashed',
|
||||||
|
session.id,
|
||||||
|
session.failureReason
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// we have a triggered session - add to array
|
// we have a triggered session - add to array
|
||||||
@@ -168,15 +176,19 @@ ${autoExecContent}`
|
|||||||
const codeFilePath = path.join(session.path, 'code.sas')
|
const codeFilePath = path.join(session.path, 'code.sas')
|
||||||
|
|
||||||
// TODO: don't wait forever
|
// TODO: don't wait forever
|
||||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
while (
|
||||||
|
(await fileExists(codeFilePath)) &&
|
||||||
|
session.state !== SessionState.failed
|
||||||
|
) {}
|
||||||
|
|
||||||
if (session.crashed)
|
if (session.state === SessionState.failed) {
|
||||||
process.logger.error(
|
process.logger.error(
|
||||||
'session crashed! while waiting to be ready',
|
'session crashed! while waiting to be ready',
|
||||||
session.crashed
|
session.failureReason
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
session.ready = true
|
session.state = SessionState.pending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteSession(session: Session) {
|
private async deleteSession(session: Session) {
|
||||||
@@ -190,17 +202,37 @@ ${autoExecContent}`
|
|||||||
}
|
}
|
||||||
|
|
||||||
private scheduleSessionDestroy(session: Session) {
|
private scheduleSessionDestroy(session: Session) {
|
||||||
setTimeout(async () => {
|
setTimeout(
|
||||||
if (session.inUse) {
|
async () => {
|
||||||
// adding 10 more minutes
|
if (session.state === SessionState.running) {
|
||||||
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000
|
// adding 10 more minutes
|
||||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
const newDeathTimeStamp =
|
||||||
|
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
|
||||||
|
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||||
|
|
||||||
this.scheduleSessionDestroy(session)
|
this.scheduleSessionDestroy(session)
|
||||||
} else {
|
} else {
|
||||||
await this.deleteSession(session)
|
const { expiresAfterMins } = session
|
||||||
}
|
|
||||||
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100)
|
// 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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,9 +260,16 @@ data _null_;
|
|||||||
rc=filename(fname,getoption('SYSIN') );
|
rc=filename(fname,getoption('SYSIN') );
|
||||||
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
||||||
rc=filename(fname);
|
rc=filename(fname);
|
||||||
/* now wait for the real SYSIN */
|
/* now wait for the real SYSIN (location of code.sas) */
|
||||||
slept=0;
|
slept=0;fname='';
|
||||||
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
|
do until (slept>(60*15));
|
||||||
|
rc=filename(fname,getoption('SYSIN'));
|
||||||
|
if rc = 0 and fexist(fname) then do;
|
||||||
|
putlog fname=;
|
||||||
|
rc=filename(fname);
|
||||||
|
rc=sleep(0.01,1); /* wait just a little more */
|
||||||
|
stop;
|
||||||
|
end;
|
||||||
slept=slept+sleep(0.01,1);
|
slept=slept+sleep(0.01,1);
|
||||||
end;
|
end;
|
||||||
stop;
|
stop;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const createJSProgram = async (
|
|||||||
) => {
|
) => {
|
||||||
const varStatments = Object.keys(vars).reduce(
|
const varStatments = Object.keys(vars).reduce(
|
||||||
(computed: string, key: string) =>
|
(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;
|
%mend;
|
||||||
%_sasjs_server_init()
|
%_sasjs_server_init()
|
||||||
|
|
||||||
proc printto print="%sysfunc(getoption(log))";
|
|
||||||
run;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
program = `
|
program = `
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs'
|
|||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { once } from 'stream'
|
import { once } from 'stream'
|
||||||
import { createFile, moveFile } from '@sasjs/utils'
|
import { createFile, moveFile } from '@sasjs/utils'
|
||||||
import { PreProgramVars, Session } from '../../types'
|
import { PreProgramVars, Session, SessionState } from '../../types'
|
||||||
import { RunTimeType } from '../../utils'
|
import { RunTimeType } from '../../utils'
|
||||||
import {
|
import {
|
||||||
ExecutionVars,
|
ExecutionVars,
|
||||||
@@ -49,7 +49,7 @@ export const processProgram = async (
|
|||||||
await moveFile(codePath + '.bkp', codePath)
|
await moveFile(codePath + '.bkp', codePath)
|
||||||
|
|
||||||
// we now need to poll the session status
|
// we now need to poll the session status
|
||||||
while (!session.completed) {
|
while (session.state !== SessionState.completed) {
|
||||||
await delay(50)
|
await delay(50)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -114,13 +114,20 @@ export const processProgram = async (
|
|||||||
|
|
||||||
await execFilePromise(executablePath, [codePath], writeStream)
|
await execFilePromise(executablePath, [codePath], writeStream)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
session.completed = true
|
session.state = SessionState.completed
|
||||||
|
|
||||||
process.logger.info('session completed', session)
|
process.logger.info('session completed', session)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
session.completed = true
|
session.state = SessionState.failed
|
||||||
session.crashed = err.toString()
|
|
||||||
process.logger.error('session crashed', session.id, session.crashed)
|
session.failureReason = err.toString()
|
||||||
|
|
||||||
|
process.logger.error(
|
||||||
|
'session crashed',
|
||||||
|
session.id,
|
||||||
|
session.failureReason
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// copy the code file to log and end write stream
|
// copy the code file to log and end write stream
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||||
import { UserResponse } from './user'
|
import { UserResponse } from './user'
|
||||||
|
import { getSessionController } from './internal'
|
||||||
|
import { SessionState } from '../types'
|
||||||
|
|
||||||
interface SessionResponse extends UserResponse {
|
interface SessionResponse extends UserResponse {
|
||||||
needsToUpdatePassword: boolean
|
needsToUpdatePassword: boolean
|
||||||
@@ -26,6 +28,18 @@ export class SessionController {
|
|||||||
): Promise<SessionResponse> {
|
): Promise<SessionResponse> {
|
||||||
return session(request)
|
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) => ({
|
const session = (req: express.Request) => ({
|
||||||
@@ -35,3 +49,23 @@ const session = (req: express.Request) => ({
|
|||||||
isAdmin: req.user!.isAdmin,
|
isAdmin: req.user!.isAdmin,
|
||||||
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
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 express from 'express'
|
||||||
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
||||||
import { ExecutionController, ExecutionVars } from './internal'
|
import {
|
||||||
|
ExecutionController,
|
||||||
|
ExecutionVars,
|
||||||
|
getSessionController
|
||||||
|
} from './internal'
|
||||||
import {
|
import {
|
||||||
getPreProgramVariables,
|
getPreProgramVariables,
|
||||||
HTTPHeaders,
|
|
||||||
LogLine,
|
|
||||||
makeFilesNamesMap,
|
makeFilesNamesMap,
|
||||||
getRunTimeAndFilePath
|
getRunTimeAndFilePath
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
@@ -18,6 +20,36 @@ interface ExecutePostRequestPayload {
|
|||||||
_program?: string
|
_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')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/stp')
|
@Route('SASjsApi/stp')
|
||||||
@Tags('STP')
|
@Tags('STP')
|
||||||
@@ -25,20 +57,31 @@ export class STPController {
|
|||||||
/**
|
/**
|
||||||
* Trigger a Stored Program using the _program URL parameter.
|
* 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
|
* https://server.sasjs.io/storedprograms
|
||||||
*
|
*
|
||||||
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
* @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 _program "/Projects/myApp/some/program"
|
||||||
|
* @example _debug 131
|
||||||
*/
|
*/
|
||||||
@Get('/execute')
|
@Get('/execute')
|
||||||
public async executeGetRequest(
|
public async executeGetRequest(
|
||||||
@Request() request: express.Request,
|
@Request() request: express.Request,
|
||||||
@Query() _program: string
|
@Query() _program: string,
|
||||||
|
@Query() _debug?: number
|
||||||
): Promise<string | Buffer> {
|
): 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)
|
return execute(request, _program, vars)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +112,26 @@ export class STPController {
|
|||||||
|
|
||||||
return execute(request, program!, vars, otherArgs)
|
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 (
|
const execute = async (
|
||||||
@@ -107,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ const getUser = async (
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
isActive: user.isActive,
|
isActive: user.isActive,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
|
autoExec: getAutoExec ? (user.autoExec ?? '') : undefined,
|
||||||
groups: user.groups
|
groups: user.groups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,10 @@ const login = async (
|
|||||||
const rateLimiter = RateLimiter.getInstance()
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|
||||||
if (!validPass) {
|
if (!validPass) {
|
||||||
const retrySecs = await rateLimiter.consume(req.ip, user?.username)
|
const retrySecs = await rateLimiter.consume(
|
||||||
|
req.ip || 'unknown',
|
||||||
|
user?.username
|
||||||
|
)
|
||||||
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +117,7 @@ const login = async (
|
|||||||
if (!validPass) throw errors.invalidPassword
|
if (!validPass) throw errors.invalidPassword
|
||||||
|
|
||||||
// Reset on successful authorization
|
// Reset on successful authorization
|
||||||
rateLimiter.resetOnSuccess(req.ip, user.username)
|
rateLimiter.resetOnSuccess(req.ip || 'unknown', user.username)
|
||||||
|
|
||||||
req.session.loggedIn = true
|
req.session.loggedIn = true
|
||||||
req.session.user = {
|
req.session.user = {
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ export const authenticateAccessToken: RequestHandler = async (
|
|||||||
if (user.isActive) {
|
if (user.isActive) {
|
||||||
req.user = user
|
req.user = user
|
||||||
return csrfProtection(req, res, nextFunction)
|
return csrfProtection(req, res, nextFunction)
|
||||||
} else return res.sendStatus(401)
|
} else return res.status(401).send('Unauthorized')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res.sendStatus(401)
|
return res.status(401).send('Unauthorized')
|
||||||
}
|
}
|
||||||
|
|
||||||
await authenticateToken(
|
await authenticateToken(
|
||||||
@@ -118,6 +118,6 @@ const authenticateToken = async (
|
|||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
res.sendStatus(401)
|
res.status(401).send('Unauthorized')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { convertSecondsToHms } from '@sasjs/utils'
|
|||||||
import { RateLimiter } from '../utils'
|
import { RateLimiter } from '../utils'
|
||||||
|
|
||||||
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
||||||
const ip = req.ip
|
const ip = req.ip || 'unknown'
|
||||||
const username = req.body.username
|
const username = req.body.username
|
||||||
|
|
||||||
const rateLimiter = RateLimiter.getInstance()
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|||||||
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 { GroupDetailsResponse } from '../controllers'
|
||||||
import User, { IUser } from './User'
|
import User, { IUser } from './User'
|
||||||
import { AuthProviderType } from '../utils'
|
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
|
||||||
|
|
||||||
export const PUBLIC_GROUP_NAME = 'Public'
|
export const PUBLIC_GROUP_NAME = 'Public'
|
||||||
|
|
||||||
@@ -44,6 +43,10 @@ const groupSchema = new Schema<IGroupDocument>({
|
|||||||
required: true,
|
required: true,
|
||||||
unique: true
|
unique: true
|
||||||
},
|
},
|
||||||
|
groupId: {
|
||||||
|
type: Number,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Group description.'
|
default: 'Group description.'
|
||||||
@@ -59,9 +62,13 @@ const groupSchema = new Schema<IGroupDocument>({
|
|||||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
|
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
|
groupSchema.pre('save', async function () {
|
||||||
|
if (this.isNew) {
|
||||||
|
this.groupId = await getSequenceNextValue('groupId')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
groupSchema.post('save', function (group: IGroup, next: Function) {
|
groupSchema.post('save', function (group: IGroup, next: Function) {
|
||||||
group.populate('users', 'id username displayName -_id').then(function () {
|
group.populate('users', 'id username displayName -_id').then(function () {
|
||||||
next()
|
next()
|
||||||
@@ -69,7 +76,7 @@ groupSchema.post('save', function (group: IGroup, next: Function) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// pre remove hook to remove all references of group from users
|
// pre remove hook to remove all references of group from users
|
||||||
groupSchema.pre('remove', async function () {
|
groupSchema.pre('remove', async function (this: IGroupDocument) {
|
||||||
const userIds = this.users
|
const userIds = this.users
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
userIds.map(async (userId) => {
|
userIds.map(async (userId) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
import { Schema, model, Document, Model } from 'mongoose'
|
||||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
|
||||||
import { PermissionDetailsResponse } from '../controllers'
|
import { PermissionDetailsResponse } from '../controllers'
|
||||||
|
import { getSequenceNextValue } from '../utils'
|
||||||
|
|
||||||
interface GetPermissionBy {
|
interface GetPermissionBy {
|
||||||
user?: Schema.Types.ObjectId
|
user?: Schema.Types.ObjectId
|
||||||
@@ -23,6 +23,10 @@ interface IPermissionModel extends Model<IPermission> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const permissionSchema = new Schema<IPermissionDocument>({
|
const permissionSchema = new Schema<IPermissionDocument>({
|
||||||
|
permissionId: {
|
||||||
|
type: Number,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
path: {
|
path: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
@@ -39,7 +43,12 @@ const permissionSchema = new Schema<IPermissionDocument>({
|
|||||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
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
|
// Static Methods
|
||||||
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
import { Schema, model, Document, Model } from 'mongoose'
|
||||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { AuthProviderType } from '../utils'
|
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||||
|
|
||||||
export interface UserPayload {
|
export interface UserPayload {
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +65,10 @@ const userSchema = new Schema<IUserDocument>({
|
|||||||
required: true,
|
required: true,
|
||||||
unique: true
|
unique: true
|
||||||
},
|
},
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
password: {
|
password: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
@@ -107,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
|
// Static Methods
|
||||||
userSchema.static('hashPassword', (password: string): string => {
|
userSchema.static('hashPassword', (password: string): string => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { runCodeValidation } from '../../utils'
|
import { runCodeValidation, triggerCodeValidation } from '../../utils'
|
||||||
import { CodeController } from '../../controllers/'
|
import { CodeController } from '../../controllers/'
|
||||||
|
|
||||||
const runRouter = express.Router()
|
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
|
export default runRouter
|
||||||
|
|||||||
@@ -1,16 +1,37 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { SessionController } from '../../controllers'
|
import { SessionController } from '../../controllers'
|
||||||
|
import { sessionIdValidation } from '../../utils'
|
||||||
|
|
||||||
const sessionRouter = express.Router()
|
const sessionRouter = express.Router()
|
||||||
|
|
||||||
|
const controller = new SessionController()
|
||||||
|
|
||||||
sessionRouter.get('/', async (req, res) => {
|
sessionRouter.get('/', async (req, res) => {
|
||||||
const controller = new SessionController()
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.session(req)
|
const response = await controller.session(req)
|
||||||
|
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
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
|
export default sessionRouter
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
SASSessionController
|
SASSessionController
|
||||||
} from '../../../controllers/internal'
|
} from '../../../controllers/internal'
|
||||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||||
import { Session } from '../../../types'
|
import { Session, SessionState } from '../../../types'
|
||||||
|
|
||||||
const clientId = 'someclientID'
|
const clientId = 'someclientID'
|
||||||
|
|
||||||
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
|
|||||||
|
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
ready: true,
|
state: SessionState.pending,
|
||||||
inUse: true,
|
|
||||||
consumed: false,
|
|
||||||
completed: false,
|
|
||||||
creationTimeStamp,
|
creationTimeStamp,
|
||||||
deathTimeStamp,
|
deathTimeStamp,
|
||||||
path: sessionFolder
|
path: sessionFolder
|
||||||
|
|||||||
@@ -47,6 +47,77 @@ describe('web', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('SASLogon/authorize', () => {
|
||||||
|
let csrfToken: string
|
||||||
|
let authCookies: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
;({ csrfToken } = await getCSRF(app))
|
||||||
|
|
||||||
|
await userController.createUser(user)
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
username: user.username,
|
||||||
|
password: user.password
|
||||||
|
}
|
||||||
|
|
||||||
|
;({ authCookies } = await performLogin(app, credentials, csrfToken))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const collections = mongoose.connection.collections
|
||||||
|
const collection = collections['users']
|
||||||
|
await collection.deleteMany({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with authorization code', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({ clientId })
|
||||||
|
|
||||||
|
expect(res.body).toHaveProperty('code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Bad Request if CSRF Token is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.send({ clientId })
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Invalid CSRF token!')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Bad Request if clientId is missing', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({})
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(`"clientId" is required`)
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Forbidden if clientId is incorrect', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASLogon/authorize')
|
||||||
|
.set('Cookie', [authCookies].join('; '))
|
||||||
|
.set('x-xsrf-token', csrfToken)
|
||||||
|
.send({
|
||||||
|
clientId: 'WrongClientID'
|
||||||
|
})
|
||||||
|
.expect(403)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Error: Invalid clientId.')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('SASLogon/login', () => {
|
describe('SASLogon/login', () => {
|
||||||
let csrfToken: string
|
let csrfToken: string
|
||||||
|
|
||||||
@@ -187,78 +258,6 @@ describe('web', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('SASLogon/authorize', () => {
|
|
||||||
let csrfToken: string
|
|
||||||
let authCookies: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await deleteDocumentsFromLimitersCollections()
|
|
||||||
;({ csrfToken } = await getCSRF(app))
|
|
||||||
|
|
||||||
await userController.createUser(user)
|
|
||||||
|
|
||||||
const credentials = {
|
|
||||||
username: user.username,
|
|
||||||
password: user.password
|
|
||||||
}
|
|
||||||
|
|
||||||
;({ authCookies } = await performLogin(app, credentials, csrfToken))
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const collections = mongoose.connection.collections
|
|
||||||
const collection = collections['users']
|
|
||||||
await collection.deleteMany({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with authorization code', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.set('x-xsrf-token', csrfToken)
|
|
||||||
.send({ clientId })
|
|
||||||
|
|
||||||
expect(res.body).toHaveProperty('code')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Bad Request if CSRF Token is missing', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.send({ clientId })
|
|
||||||
.expect(400)
|
|
||||||
|
|
||||||
expect(res.text).toEqual('Invalid CSRF token!')
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Bad Request if clientId is missing', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.set('x-xsrf-token', csrfToken)
|
|
||||||
.send({})
|
|
||||||
.expect(400)
|
|
||||||
|
|
||||||
expect(res.text).toEqual(`"clientId" is required`)
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Forbidden if clientId is incorrect', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASLogon/authorize')
|
|
||||||
.set('Cookie', [authCookies].join('; '))
|
|
||||||
.set('x-xsrf-token', csrfToken)
|
|
||||||
.send({
|
|
||||||
clientId: 'WrongClientID'
|
|
||||||
})
|
|
||||||
.expect(403)
|
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: Invalid clientId.')
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const getCSRF = async (app: Express) => {
|
const getCSRF = async (app: Express) => {
|
||||||
@@ -278,19 +277,13 @@ const performLogin = async (
|
|||||||
.set('x-xsrf-token', csrfToken)
|
.set('x-xsrf-token', csrfToken)
|
||||||
.send(credentials)
|
.send(credentials)
|
||||||
|
|
||||||
return { authCookies: header['set-cookie'].join() }
|
return {
|
||||||
|
authCookies:
|
||||||
|
(header['set-cookie'] as unknown as string[] | undefined)?.join() || ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractCSRF = (text: string) =>
|
const extractCSRF = (text: string) =>
|
||||||
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
|
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
|
||||||
text
|
text
|
||||||
)![1]
|
)![1]
|
||||||
|
|
||||||
const deleteDocumentsFromLimitersCollections = async () => {
|
|
||||||
const { collections } = mongoose.connection
|
|
||||||
const login_fail_ip_per_day_collection = collections['login_fail_ip_per_day']
|
|
||||||
await login_fail_ip_per_day_collection.deleteMany({})
|
|
||||||
const login_fail_consecutive_username_and_ip_collection =
|
|
||||||
collections['login_fail_consecutive_username_and_ip']
|
|
||||||
await login_fail_consecutive_username_and_ip_collection.deleteMany({})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { executeProgramRawValidation } from '../../utils'
|
import {
|
||||||
|
executeProgramRawValidation,
|
||||||
|
triggerProgramValidation
|
||||||
|
} from '../../utils'
|
||||||
import { STPController } from '../../controllers/'
|
import { STPController } from '../../controllers/'
|
||||||
import { FileUploadController } from '../../controllers/internal'
|
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)
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.executeGetRequest(req, query._program)
|
const response = await controller.executeGetRequest(
|
||||||
|
req,
|
||||||
|
query._program,
|
||||||
|
query._debug
|
||||||
|
)
|
||||||
|
|
||||||
if (response instanceof Buffer) {
|
if (response instanceof Buffer) {
|
||||||
res.writeHead(200, (req as any).sasHeaders)
|
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
|
export default stpRouter
|
||||||
|
|||||||
@@ -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 {
|
export interface Session {
|
||||||
id: string
|
id: string
|
||||||
ready: boolean
|
state: SessionState
|
||||||
creationTimeStamp: string
|
creationTimeStamp: string
|
||||||
deathTimeStamp: string
|
deathTimeStamp: string
|
||||||
path: string
|
path: string
|
||||||
inUse: boolean
|
expiresAfterMins?: { mins: number; used: boolean }
|
||||||
consumed: boolean
|
failureReason?: string
|
||||||
completed: boolean
|
|
||||||
crashed?: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
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,5 +1,6 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import User from '../model/User'
|
import User from '../model/User'
|
||||||
|
import { InfoJWT } from '../types/InfoJWT'
|
||||||
|
|
||||||
const isValidToken = async (
|
const isValidToken = async (
|
||||||
token: string,
|
token: string,
|
||||||
@@ -11,7 +12,8 @@ const isValidToken = async (
|
|||||||
jwt.verify(token, key, (err, decoded) => {
|
jwt.verify(token, key, (err, decoded) => {
|
||||||
if (err) return reject(false)
|
if (err) return reject(false)
|
||||||
|
|
||||||
if (decoded?.userId === userId && decoded?.clientId === clientId) {
|
const payload = decoded as InfoJWT
|
||||||
|
if (payload?.userId === userId && payload?.clientId === clientId) {
|
||||||
return resolve(true)
|
return resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from './getCertificates'
|
|||||||
export * from './getDesktopFields'
|
export * from './getDesktopFields'
|
||||||
export * from './getPreProgramVariables'
|
export * from './getPreProgramVariables'
|
||||||
export * from './getRunTimeAndFilePath'
|
export * from './getRunTimeAndFilePath'
|
||||||
|
export * from './getSequenceNextValue'
|
||||||
export * from './getServerUrl'
|
export * from './getServerUrl'
|
||||||
export * from './getTokensFromDB'
|
export * from './getTokensFromDB'
|
||||||
export * from './instantiateLogger'
|
export * from './instantiateLogger'
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import mongoose from 'mongoose'
|
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||||
import { RateLimiterMongo } from 'rate-limiter-flexible'
|
|
||||||
|
|
||||||
export class RateLimiter {
|
export class RateLimiter {
|
||||||
private static instance: RateLimiter
|
private static instance: RateLimiter
|
||||||
private limiterSlowBruteByIP: RateLimiterMongo
|
private limiterSlowBruteByIP: RateLimiterMemory
|
||||||
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMongo
|
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMemory
|
||||||
private maxWrongAttemptsByIpPerDay: number
|
private maxWrongAttemptsByIpPerDay: number
|
||||||
private maxConsecutiveFailsByUsernameAndIp: number
|
private maxConsecutiveFailsByUsernameAndIp: number
|
||||||
|
|
||||||
@@ -19,19 +18,17 @@ export class RateLimiter {
|
|||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||||
)
|
)
|
||||||
|
|
||||||
this.limiterSlowBruteByIP = new RateLimiterMongo({
|
this.limiterSlowBruteByIP = new RateLimiterMemory({
|
||||||
storeClient: mongoose.connection,
|
|
||||||
keyPrefix: 'login_fail_ip_per_day',
|
keyPrefix: 'login_fail_ip_per_day',
|
||||||
points: this.maxWrongAttemptsByIpPerDay,
|
points: this.maxWrongAttemptsByIpPerDay,
|
||||||
duration: 60 * 60 * 24,
|
duration: 60 * 60 * 24,
|
||||||
blockDuration: 60 * 60 * 24 // Block for 1 day
|
blockDuration: 60 * 60 * 24 // Block for 1 day
|
||||||
})
|
})
|
||||||
|
|
||||||
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMongo({
|
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMemory({
|
||||||
storeClient: mongoose.connection,
|
|
||||||
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
||||||
points: this.maxConsecutiveFailsByUsernameAndIp,
|
points: this.maxConsecutiveFailsByUsernameAndIp,
|
||||||
duration: 60 * 60 * 24 * 90, // Store number for 90 days since first fail
|
duration: 60 * 60 * 24 * 24, // Store number for 24 days since first fail
|
||||||
blockDuration: 60 * 60 // Block for 1 hour
|
blockDuration: 60 * 60 // Block for 1 hour
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -60,8 +57,7 @@ export class RateLimiter {
|
|||||||
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
||||||
])
|
])
|
||||||
|
|
||||||
// NOTE: To make use of blockDuration option from RateLimiterMongo
|
// NOTE: To make use of blockDuration option, comparison in both following if statements should have greater than symbol
|
||||||
// comparison in both following if statements should have greater than symbol
|
|
||||||
// otherwise, blockDuration option will not work
|
// otherwise, blockDuration option will not work
|
||||||
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
|
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
|
||||||
|
|
||||||
@@ -103,10 +99,11 @@ export class RateLimiter {
|
|||||||
if (rlRejected instanceof Error) {
|
if (rlRejected instanceof Error) {
|
||||||
throw rlRejected
|
throw rlRejected
|
||||||
} else {
|
} else {
|
||||||
// based upon the implementation of consume method of RateLimiterMongo
|
// based upon the implementation of consume method of RateLimiterMemory
|
||||||
// we are sure that rlRejected will contain msBeforeNext
|
// we are sure that rlRejected will contain msBeforeNext
|
||||||
// for further reference,
|
// for further reference,
|
||||||
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
|
// 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 Math.ceil(rlRejected.msBeforeNext / 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import bcrypt from 'bcryptjs'
|
||||||
import Client from '../model/Client'
|
import Client from '../model/Client'
|
||||||
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
|
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 Configuration, { ConfigurationType } from '../model/Configuration'
|
||||||
|
import { ResetAdminPasswordType } from './verifyEnvVariables'
|
||||||
|
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
@@ -40,9 +42,13 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
process.logger.success(`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
|
// Checking if user is already in the database
|
||||||
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
|
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)
|
const user = new User(ADMIN_USER)
|
||||||
usernameExist = await user.save()
|
usernameExist = await user.save()
|
||||||
|
|
||||||
@@ -51,7 +57,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!groupExist.hasUser(usernameExist)) {
|
if (usernameExist.isAdmin && !groupExist.hasUser(usernameExist)) {
|
||||||
groupExist.addUser(usernameExist)
|
groupExist.addUser(usernameExist)
|
||||||
process.logger.success(
|
process.logger.success(
|
||||||
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
|
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
|
||||||
@@ -90,11 +96,52 @@ const CLIENT = {
|
|||||||
clientId: 'clientID1',
|
clientId: 'clientID1',
|
||||||
clientSecret: 'clientSecret'
|
clientSecret: 'clientSecret'
|
||||||
}
|
}
|
||||||
const ADMIN_USER = {
|
|
||||||
id: 1,
|
const getAdminUser = () => {
|
||||||
displayName: 'Super Admin',
|
const { ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL } = process.env
|
||||||
username: 'secretuser',
|
|
||||||
password: '$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO',
|
const salt = bcrypt.genSaltSync(10)
|
||||||
isAdmin: true,
|
const hashedPassword = bcrypt.hashSync(ADMIN_PASSWORD_INITIAL as string, salt)
|
||||||
isActive: true
|
|
||||||
|
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 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 '.'
|
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
|
||||||
|
|
||||||
export const setProcessVariables = async () => {
|
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
|
const { MODE, RUN_TIMES } = process.env
|
||||||
|
|
||||||
if (MODE === ModeType.Server) {
|
if (MODE === ModeType.Server) {
|
||||||
@@ -21,6 +43,7 @@ export const setProcessVariables = async () => {
|
|||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
||||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +64,9 @@ export const setProcessVariables = async () => {
|
|||||||
|
|
||||||
const { SASJS_ROOT } = process.env
|
const { SASJS_ROOT } = process.env
|
||||||
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
||||||
|
|
||||||
await createFolder(absPath)
|
await createFolder(absPath)
|
||||||
|
|
||||||
process.sasjsRoot = getRealPath(absPath)
|
process.sasjsRoot = getRealPath(absPath)
|
||||||
|
|
||||||
const { DRIVE_LOCATION } = process.env
|
const { DRIVE_LOCATION } = process.env
|
||||||
@@ -49,6 +74,7 @@ export const setProcessVariables = async () => {
|
|||||||
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
||||||
process.cwd()
|
process.cwd()
|
||||||
)
|
)
|
||||||
|
|
||||||
await createFolder(absDrivePath)
|
await createFolder(absDrivePath)
|
||||||
process.driveLoc = getRealPath(absDrivePath)
|
process.driveLoc = getRealPath(absDrivePath)
|
||||||
|
|
||||||
@@ -57,7 +83,9 @@ export const setProcessVariables = async () => {
|
|||||||
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
||||||
process.cwd()
|
process.cwd()
|
||||||
)
|
)
|
||||||
|
|
||||||
await createFolder(absLogsPath)
|
await createFolder(absLogsPath)
|
||||||
|
|
||||||
process.logsLoc = getRealPath(absLogsPath)
|
process.logsLoc = getRealPath(absLogsPath)
|
||||||
|
|
||||||
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ export const generateFileUploadSasCode = async (
|
|||||||
let fileCount = 0
|
let fileCount = 0
|
||||||
const uploadedFiles: UploadedFiles[] = []
|
const uploadedFiles: UploadedFiles[] = []
|
||||||
|
|
||||||
const sasSessionFolderList: string[] = await listFilesInFolder(
|
const sasSessionFolderList: string[] =
|
||||||
sasSessionFolder
|
await listFilesInFolder(sasSessionFolder)
|
||||||
)
|
|
||||||
sasSessionFolderList.forEach((fileName) => {
|
sasSessionFolderList.forEach((fileName) => {
|
||||||
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
|
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
|
||||||
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
|
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
|
||||||
|
|||||||
@@ -178,9 +178,31 @@ export const runCodeValidation = (data: any): Joi.ValidationResult =>
|
|||||||
runTime: Joi.string().valid(...process.runTimes)
|
runTime: Joi.string().valid(...process.runTimes)
|
||||||
}).validate(data)
|
}).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 =>
|
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||||
Joi.object({
|
Joi.object({
|
||||||
_program: Joi.string().required()
|
_program: Joi.string().required(),
|
||||||
|
_debug: Joi.number()
|
||||||
})
|
})
|
||||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||||
.validate(data)
|
.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)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export enum DatabaseType {
|
|||||||
COSMOS_MONGODB = 'cosmos_mongodb'
|
COSMOS_MONGODB = 'cosmos_mongodb'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ResetAdminPasswordType {
|
||||||
|
YES = 'YES',
|
||||||
|
NO = 'NO'
|
||||||
|
}
|
||||||
|
|
||||||
export const verifyEnvVariables = (): ReturnCode => {
|
export const verifyEnvVariables = (): ReturnCode => {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
|
|
||||||
@@ -79,6 +84,8 @@ export const verifyEnvVariables = (): ReturnCode => {
|
|||||||
|
|
||||||
errors.push(...verifyRateLimiter())
|
errors.push(...verifyRateLimiter())
|
||||||
|
|
||||||
|
errors.push(...verifyAdminUserConfig())
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
process.logger?.error(
|
process.logger?.error(
|
||||||
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
|
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
|
||||||
@@ -409,6 +416,38 @@ const verifyRateLimiter = () => {
|
|||||||
return errors
|
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 => {
|
const isNumeric = (val: string): boolean => {
|
||||||
return !isNaN(Number(val))
|
return !isNaN(Number(val))
|
||||||
}
|
}
|
||||||
@@ -422,5 +461,8 @@ const DEFAULTS = {
|
|||||||
RUN_TIMES: RunTimeType.SAS,
|
RUN_TIMES: RunTimeType.SAS,
|
||||||
DB_TYPE: DatabaseType.MONGO,
|
DB_TYPE: DatabaseType.MONGO,
|
||||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
|
||||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10'
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10',
|
||||||
|
ADMIN_USERNAME: 'secretuser',
|
||||||
|
ADMIN_PASSWORD_INITIAL: 'secretpassword',
|
||||||
|
ADMIN_PASSWORD_RESET: ResetAdminPasswordType.NO
|
||||||
}
|
}
|
||||||
|
|||||||
12864
package-lock.json
generated
12864
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
2194
web/package-lock.json
generated
2194
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,12 @@
|
|||||||
"@types/jest": "^26.0.24",
|
"@types/jest": "^26.0.24",
|
||||||
"@types/node": "^12.20.28",
|
"@types/node": "^12.20.28",
|
||||||
"@types/react": "^17.0.27",
|
"@types/react": "^17.0.27",
|
||||||
"axios": "^0.24.0",
|
"axios": "^1.12.2",
|
||||||
"monaco-editor": "^0.33.0",
|
"monaco-editor": "^0.33.0",
|
||||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-highlight": "^0.15.0",
|
||||||
"react-monaco-editor": "^0.48.0",
|
"react-monaco-editor": "^0.48.0",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-toastify": "^9.0.1"
|
"react-toastify": "^9.0.1"
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"@types/react": "^17.0.37",
|
"@types/react": "^17.0.37",
|
||||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@types/react-dom": "^17.0.11",
|
||||||
|
"@types/react-highlight": "^0.12.5",
|
||||||
"@types/react-router-dom": "^5.3.1",
|
"@types/react-router-dom": "^5.3.1",
|
||||||
"babel-loader": "^8.2.3",
|
"babel-loader": "^8.2.3",
|
||||||
"babel-plugin-prismjs": "^2.1.0",
|
"babel-plugin-prismjs": "^2.1.0",
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
"eslint-webpack-plugin": "^3.1.1",
|
"eslint-webpack-plugin": "^3.1.1",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"html-webpack-plugin": "5.5.0",
|
"html-webpack-plugin": "5.5.0",
|
||||||
|
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||||
"path": "0.12.7",
|
"path": "0.12.7",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.4.1",
|
||||||
"sass": "^1.44.0",
|
"sass": "^1.44.0",
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
"style-loader": "^3.3.1",
|
"style-loader": "^3.3.1",
|
||||||
"ts-loader": "^9.2.6",
|
"ts-loader": "^9.2.6",
|
||||||
"typescript": "^4.5.2",
|
"typescript": "^4.5.2",
|
||||||
|
"typescript-plugin-css-modules": "^5.0.1",
|
||||||
"webpack": "5.64.3",
|
"webpack": "5.64.3",
|
||||||
"webpack-cli": "^4.9.2",
|
"webpack-cli": "^4.9.2",
|
||||||
"webpack-dev-server": "4.7.4"
|
"webpack-dev-server": "4.7.4"
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import Snackbar from '@mui/material/Snackbar'
|
|||||||
import MuiAlert, { AlertProps } from '@mui/material/Alert'
|
import MuiAlert, { AlertProps } from '@mui/material/Alert'
|
||||||
import Slide, { SlideProps } from '@mui/material/Slide'
|
import Slide, { SlideProps } from '@mui/material/Slide'
|
||||||
|
|
||||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
|
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||||
props,
|
function Alert(props, ref) {
|
||||||
ref
|
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
||||||
) {
|
}
|
||||||
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const Transition = (props: SlideProps) => {
|
const Transition = (props: SlideProps) => {
|
||||||
return <Slide {...props} direction="up" />
|
return <Slide {...props} direction="up" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { PermissionsContext } from '../../../../context/permissionsContext'
|
|||||||
import {
|
import {
|
||||||
findExistingPermission,
|
findExistingPermission,
|
||||||
findUpdatingPermission
|
findUpdatingPermission
|
||||||
} from '../../../../utils/helper'
|
} from '../../../../utils'
|
||||||
|
|
||||||
const useAddPermission = () => {
|
const useAddPermission = () => {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Dispatch, SetStateAction } from 'react'
|
import { Dispatch, SetStateAction } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Backdrop,
|
Backdrop,
|
||||||
@@ -17,10 +17,14 @@ import { TabContext, TabList, TabPanel } from '@mui/lab'
|
|||||||
import FilePathInputModal from '../../components/filePathInputModal'
|
import FilePathInputModal from '../../components/filePathInputModal'
|
||||||
import FileMenu from './internal/components/fileMenu'
|
import FileMenu from './internal/components/fileMenu'
|
||||||
import RunMenu from './internal/components/runMenu'
|
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 { usePrompt } from '../../utils/hooks'
|
||||||
import { getLanguageFromExtension } from './internal/helper'
|
import { getLanguageFromExtension } from './internal/helper'
|
||||||
import useEditor from './internal/hooks/useEditor'
|
import useEditor from './internal/hooks/useEditor'
|
||||||
|
import { RunTimeType } from '../../context/appContext'
|
||||||
|
import { LogObject } from '../../utils'
|
||||||
|
|
||||||
const StyledTabPanel = styled(TabPanel)(() => ({
|
const StyledTabPanel = styled(TabPanel)(() => ({
|
||||||
padding: '10px'
|
padding: '10px'
|
||||||
@@ -58,6 +62,7 @@ const SASjsEditor = ({
|
|||||||
selectedRunTime,
|
selectedRunTime,
|
||||||
showDiff,
|
showDiff,
|
||||||
webout,
|
webout,
|
||||||
|
printOutput,
|
||||||
Dialog,
|
Dialog,
|
||||||
handleChangeRunTime,
|
handleChangeRunTime,
|
||||||
handleDiffEditorDidMount,
|
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 (
|
return (
|
||||||
<Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}>
|
<Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}>
|
||||||
<Backdrop
|
<Backdrop
|
||||||
@@ -145,15 +154,35 @@ const SASjsEditor = ({
|
|||||||
>
|
>
|
||||||
<TabList onChange={handleTabChange} centered>
|
<TabList onChange={handleTabChange} centered>
|
||||||
<StyledTab label="Code" value="code" />
|
<StyledTab label="Code" value="code" />
|
||||||
<StyledTab label="Log" value="log" />
|
{log && (
|
||||||
<StyledTab
|
<StyledTab
|
||||||
label={
|
label={logWithErrorsOrWarnings ? '' : 'log'}
|
||||||
<Tooltip title="Displays content from the _webout fileref">
|
value="log"
|
||||||
<Typography>Webout</Typography>
|
icon={
|
||||||
</Tooltip>
|
logWithErrorsOrWarnings ? (
|
||||||
}
|
<LogTabWithIcons log={log as LogObject} />
|
||||||
value="webout"
|
) : (
|
||||||
/>
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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>
|
</TabList>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -195,21 +224,24 @@ const SASjsEditor = ({
|
|||||||
</Paper>
|
</Paper>
|
||||||
</StyledTabPanel>
|
</StyledTabPanel>
|
||||||
<StyledTabPanel value="log">
|
<StyledTabPanel value="log">
|
||||||
<div>
|
{log && (
|
||||||
<h2>Log</h2>
|
<LogComponent log={log} selectedRunTime={selectedRunTime} />
|
||||||
<pre
|
)}
|
||||||
id="log"
|
|
||||||
style={{ overflow: 'auto', height: 'calc(100vh - 220px)' }}
|
|
||||||
>
|
|
||||||
{log}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</StyledTabPanel>
|
|
||||||
<StyledTabPanel value="webout">
|
|
||||||
<div>
|
|
||||||
<pre>{webout}</pre>
|
|
||||||
</div>
|
|
||||||
</StyledTabPanel>
|
</StyledTabPanel>
|
||||||
|
{webout && (
|
||||||
|
<StyledTabPanel value="webout">
|
||||||
|
<div>
|
||||||
|
<pre>{webout}</pre>
|
||||||
|
</div>
|
||||||
|
</StyledTabPanel>
|
||||||
|
)}
|
||||||
|
{printOutput && (
|
||||||
|
<StyledTabPanel value="printOutput">
|
||||||
|
<div>
|
||||||
|
<pre>{printOutput}</pre>
|
||||||
|
</div>
|
||||||
|
</StyledTabPanel>
|
||||||
|
)}
|
||||||
</TabContext>
|
</TabContext>
|
||||||
)}
|
)}
|
||||||
<Dialog />
|
<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
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useSnackbar,
|
useSnackbar,
|
||||||
useStateWithCallback
|
useStateWithCallback
|
||||||
} from '../../../../utils/hooks'
|
} from '../../../../utils/hooks'
|
||||||
|
import { parseErrorsAndWarnings, LogObject } from '../../../../utils'
|
||||||
|
|
||||||
const SASJS_LOGS_SEPARATOR =
|
const SASJS_LOGS_SEPARATOR =
|
||||||
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
@@ -38,13 +39,15 @@ const useEditor = ({
|
|||||||
const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } =
|
const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } =
|
||||||
useSnackbar()
|
useSnackbar()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
|
const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
|
||||||
const [fileContent, setFileContent] = useState('')
|
const [fileContent, setFileContent] = useState('')
|
||||||
const [log, setLog] = useState('')
|
const [log, setLog] = useState<LogObject | string>()
|
||||||
const [webout, setWebout] = useState('')
|
const [webout, setWebout] = useState<string>()
|
||||||
|
const [printOutput, setPrintOutput] = useState<string>()
|
||||||
const [runTimes, setRunTimes] = useState<string[]>([])
|
const [runTimes, setRunTimes] = useState<string[]>([])
|
||||||
const [selectedRunTime, setSelectedRunTime] = useState('')
|
const [selectedRunTime, setSelectedRunTime] = useState<RunTimeType>(
|
||||||
|
RunTimeType.SAS
|
||||||
|
)
|
||||||
const [selectedFileExtension, setSelectedFileExtension] = useState('')
|
const [selectedFileExtension, setSelectedFileExtension] = useState('')
|
||||||
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
|
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
|
||||||
const [showDiff, setShowDiff] = useState(false)
|
const [showDiff, setShowDiff] = useState(false)
|
||||||
@@ -150,6 +153,13 @@ const useEditor = ({
|
|||||||
const runCode = useCallback(
|
const runCode = useCallback(
|
||||||
(code: string) => {
|
(code: string) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
|
// Scroll to bottom of log
|
||||||
|
const logElement = document.getElementById('log')
|
||||||
|
if (logElement) logElement.scrollTop = logElement.scrollHeight
|
||||||
|
|
||||||
|
setIsLoading(false)
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(`/SASjsApi/code/execute`, {
|
.post(`/SASjsApi/code/execute`, {
|
||||||
code: programPathInjection(
|
code: programPathInjection(
|
||||||
@@ -159,9 +169,30 @@ const useEditor = ({
|
|||||||
),
|
),
|
||||||
runTime: selectedRunTime
|
runTime: selectedRunTime
|
||||||
})
|
})
|
||||||
.then((res: any) => {
|
.then((res: { data: string }) => {
|
||||||
setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '')
|
// INFO: the order of payload parts is set in @sasjs/server/api/src/controllers/internal/Execution.ts
|
||||||
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '')
|
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')
|
setTab('log')
|
||||||
|
|
||||||
// Scroll to bottom of log
|
// Scroll to bottom of log
|
||||||
@@ -249,7 +280,7 @@ const useEditor = ({
|
|||||||
}, [appContext.runTimes])
|
}, [appContext.runTimes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (runTimes.length) setSelectedRunTime(runTimes[0])
|
if (runTimes.length) setSelectedRunTime(runTimes[0] as RunTimeType)
|
||||||
}, [runTimes])
|
}, [runTimes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -280,7 +311,6 @@ const useEditor = ({
|
|||||||
const content = localStorage.getItem('fileContent') ?? ''
|
const content = localStorage.getItem('fileContent') ?? ''
|
||||||
setFileContent(content)
|
setFileContent(content)
|
||||||
}
|
}
|
||||||
setLog('')
|
|
||||||
setWebout('')
|
setWebout('')
|
||||||
setTab('code')
|
setTab('code')
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -294,7 +324,9 @@ const useEditor = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fileExtension = selectedFileExtension.toLowerCase()
|
const fileExtension = selectedFileExtension.toLowerCase()
|
||||||
if (runTimes.includes(fileExtension)) setSelectedRunTime(fileExtension)
|
|
||||||
|
if (runTimes.includes(fileExtension))
|
||||||
|
setSelectedRunTime(fileExtension as RunTimeType)
|
||||||
}, [selectedFileExtension, runTimes])
|
}, [selectedFileExtension, runTimes])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -308,6 +340,7 @@ const useEditor = ({
|
|||||||
selectedRunTime,
|
selectedRunTime,
|
||||||
showDiff,
|
showDiff,
|
||||||
webout,
|
webout,
|
||||||
|
printOutput,
|
||||||
Dialog,
|
Dialog,
|
||||||
handleChangeRunTime,
|
handleChangeRunTime,
|
||||||
handleDiffEditorDidMount,
|
handleDiffEditorDidMount,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family:
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||||
sans-serif;
|
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family:
|
||||||
monospace;
|
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
|||||||
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
|
isFolder: boolean
|
||||||
children: Array<TreeNode>
|
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,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx",
|
||||||
|
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,23 @@ const config: Configuration = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.css$/,
|
test: /\.css$/,
|
||||||
exclude: ['/node_modules/'],
|
exclude: ['/node_modules/', /\.module\.css$/],
|
||||||
use: ['style-loader', 'css-loader']
|
use: ['style-loader', 'css-loader']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.module\.css$/i,
|
||||||
|
use: [
|
||||||
|
'style-loader',
|
||||||
|
{
|
||||||
|
loader: 'css-loader',
|
||||||
|
options: {
|
||||||
|
modules: {
|
||||||
|
localIdentName: '[local]--[hash:base64:5]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
test: /\.scss$/,
|
test: /\.scss$/,
|
||||||
exclude: ['/node_modules/'],
|
exclude: ['/node_modules/'],
|
||||||
|
|||||||
Reference in New Issue
Block a user