mirror of
https://github.com/sasjs/server.git
synced 2026-07-24 05:32:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1007e7e75 | ||
|
|
45bff7b0d1 | ||
|
|
58c66c2ac4 | ||
|
|
9c3a1086b6 | ||
|
|
05768890a2 | ||
|
|
386185d1a0 | ||
|
|
681f0123ad | ||
|
|
daa5274fb0 | ||
|
|
50fa4320cd | ||
|
|
40b9fa8735 | ||
|
|
fd31fe94ea | ||
|
|
dec5191491 | ||
|
|
4858245372 | ||
|
|
2fe733d02f | ||
|
|
63086a8a0b | ||
|
|
0af6b63ffa | ||
|
|
67fce475a3 | ||
|
|
f84af4ac06 | ||
|
|
4f75fd290f | ||
|
|
8b2a6155a9 | ||
|
|
a56c0b0340 | ||
|
|
02fe79d4d7 | ||
|
|
00a107babd | ||
|
|
4f782025db | ||
|
|
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 | ||
|
|
66232aefd2 | ||
|
|
bf35791655 | ||
|
|
2dc11630e4 | ||
|
|
1473925896 | ||
|
|
b472f1bd61 | ||
|
|
c43afabe28 | ||
|
|
1531e9cd9c | ||
|
|
1fc1431442 | ||
|
|
3387efbb9a | ||
|
|
e2996b495f | ||
|
|
41c627f93a | ||
|
|
49f5dc7555 | ||
|
|
f6e77f99a4 | ||
|
|
b57dfa429b | ||
|
|
9586dbb2d0 | ||
|
|
a4f78ab48d | ||
|
|
2f47a2213b | ||
|
|
0f91395fbb | ||
|
|
167b14fed0 | ||
|
|
8940f4dc47 | ||
|
|
8cdf605006 | ||
|
|
3f815e9beb | ||
|
|
6c88eeabd2 | ||
|
|
093fe90589 | ||
|
|
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 |
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
run: npm run lint-web
|
||||
|
||||
build-api:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
CI: true
|
||||
|
||||
build-web:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -56,4 +56,4 @@ jobs:
|
||||
|
||||
- name: Release
|
||||
run: |
|
||||
GITHUB_TOKEN=${{ secrets.GH_TOKEN }} semantic-release
|
||||
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} semantic-release
|
||||
|
||||
Vendored
+1
-3
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"autoexec"
|
||||
]
|
||||
"cSpell.words": ["autoexec", "initialising"]
|
||||
}
|
||||
|
||||
+202
@@ -1,3 +1,205 @@
|
||||
## [0.39.7](https://github.com/sasjs/server/compare/v0.39.6...v0.39.7) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **jsonwebtoken:** bumped version to avoid vulnerability ([1473925](https://github.com/sasjs/server/commit/1473925896db0e4472c9ef5ba64955527a1be2de))
|
||||
|
||||
## [0.39.6](https://github.com/sasjs/server/compare/v0.39.5...v0.39.6) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** isolate drive.spec.ts's files folder from the real drive ([dec5191](https://github.com/sasjs/server/commit/dec51914911c10b66b43ebe95f77b0af63e2d03e))
|
||||
* **api:** stop overwriting a failed JS/PY/R session back to completed ([4858245](https://github.com/sasjs/server/commit/4858245372fa19929b6e59c28148f5d004239c9d))
|
||||
|
||||
## [0.39.5](https://github.com/sasjs/server/compare/v0.39.4...v0.39.5) (2026-07-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** harden mock SAS executable and timeouts for CI ([67fce47](https://github.com/sasjs/server/commit/67fce475a3c53b3c20919902edb45dce809dffb9))
|
||||
* **api:** return prompt error response instead of hanging when a SAS session fails ([f84af4a](https://github.com/sasjs/server/commit/f84af4ac0639625fee4508e764f0eed07d7b4e8a))
|
||||
* fix code.spec.ts failing on a fresh checkout (CI) ([0af6b63](https://github.com/sasjs/server/commit/0af6b63ffa770bfa2b77505d74778be517f8d631))
|
||||
|
||||
## [0.39.4](https://github.com/sasjs/server/compare/v0.39.3...v0.39.4) (2025-12-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** bump dependencies and resolve audit issues ([4f78202](https://github.com/sasjs/server/commit/4f782025dbcdfcbae6ca1fabb42ce1bc385e0162))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ CORS=
|
||||
WHITELIST=
|
||||
|
||||
# HELMET Cross Origin Embedder Policy
|
||||
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
|
||||
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
|
||||
# options: [true|false] default: true
|
||||
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
|
||||
HELMET_COEP=
|
||||
@@ -184,10 +184,23 @@ MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
|
||||
|
||||
|
||||
# After this, access is blocked for an hour
|
||||
# Store number for 90 days since first fail
|
||||
# Store number for 24 days since first fail
|
||||
# Once a successful login is attempted, it resets
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
|
||||
|
||||
# Name of the admin user that will be created on startup if not exists already
|
||||
# Default is `secretuser`
|
||||
ADMIN_USERNAME=secretuser
|
||||
|
||||
# Temporary password for the ADMIN_USERNAME, which is in place until the first login
|
||||
# Default is `secretpassword`
|
||||
ADMIN_PASSWORD_INITIAL=secretpassword
|
||||
|
||||
# Specify whether app has to reset the ADMIN_USERNAME's password or not
|
||||
# Default is NO. Possible options are YES and NO
|
||||
# If ADMIN_PASSWORD_RESET is YES then the ADMIN_USERNAME will be prompted to change the password from ADMIN_PASSWORD_INITIAL on their next login. This will repeat on every server restart, unless the option is removed / set to NO.
|
||||
ADMIN_PASSWORD_RESET=NO
|
||||
|
||||
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
|
||||
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
||||
LOG_FORMAT_MORGAN=
|
||||
|
||||
+5
-1
@@ -25,11 +25,15 @@ LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
||||
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||
|
||||
#default value is 100
|
||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
||||
|
||||
#default value is 10
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
||||
|
||||
ADMIN_USERNAME=secretuser
|
||||
ADMIN_PASSWORD_INITIAL=secretpassword
|
||||
ADMIN_PASSWORD_RESET=NO
|
||||
|
||||
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
|
||||
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Code execution & session diagrams
|
||||
|
||||
Mermaid diagrams describing how `@sasjs/server` executes submitted code
|
||||
(SAS/JS/PY/R) against pooled "sessions". Written for fast context-loading by
|
||||
an AI coding agent: each diagram is self-contained, node/edge labels carry
|
||||
the actual file:line references, and prose is kept to the minimum needed to
|
||||
disambiguate the diagram.
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| [session-lifecycle.md](session-lifecycle.md) | `SessionState` state machine; how it differs between the SAS runtime and the JS/PY/R runtimes |
|
||||
| [sas-execution-handshake.md](sas-execution-handshake.md) | The SYSIN/AUTOEXEC file-swap mechanism a SAS session uses to turn one long-lived `sas` process into a single-use execution slot |
|
||||
| [request-execution-flow.md](request-execution-flow.md) | End-to-end flowchart from HTTP request to response, covering session-pool acquisition and both runtime branches (SAS vs JS/PY/R) |
|
||||
|
||||
## Core concept
|
||||
|
||||
A "session" is not a request-scoped object. It is a pooled, reusable
|
||||
execution slot with a filesystem folder (`session.path`) and a state
|
||||
(`SessionState`). For the SAS runtime specifically, a session also owns one
|
||||
real OS process, spawned at session-*creation* time and consumed by exactly
|
||||
one code submission (see `sas-execution-handshake.md`). For JS/PY/R, a
|
||||
session is just an ID + folder; the actual interpreter process is spawned
|
||||
fresh per request inside `processProgram`.
|
||||
|
||||
## Key source files
|
||||
|
||||
- `api/src/controllers/internal/Session.ts` — session pool + lifecycle
|
||||
(`SessionController`, `SASSessionController`), `SessionState` transitions.
|
||||
- `api/src/controllers/internal/processProgram.ts` — writes the submitted
|
||||
code into the session and drives it to completion/failure, per runtime.
|
||||
- `api/src/controllers/internal/Execution.ts` — `ExecutionController`,
|
||||
the entry point controllers call; acquires a session, calls
|
||||
`processProgram`, reads back `log.log`/`webout.txt`/headers, builds the
|
||||
HTTP response (or a `SessionExecutionError` on failure).
|
||||
- `api/src/controllers/internal/create{SAS,JS,Python,R}Program.ts` —
|
||||
per-runtime code templating (wraps the user's submitted code with
|
||||
boilerplate: variable injection, `_webout` redirection, etc).
|
||||
- `api/src/types/Session.ts` — `SessionState` enum and `Session` interface.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Request execution flow (end-to-end)
|
||||
|
||||
Full path from an inbound HTTP request to the HTTP response, covering
|
||||
session-pool acquisition and the two structurally different runtime
|
||||
branches: SAS (reuses a parked long-lived process) vs JS/PY/R (spawns a
|
||||
fresh interpreter process per request).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["HTTP request<br/>POST /SASjsApi/code/execute<br/>or /SASjsApi/stp/execute"] --> B["Controller<br/>code.ts / stp.ts<br/>try/catch wrapper"]
|
||||
B --> C["ExecutionController.executeProgram<br/>or .executeFile<br/>Execution.ts:58-91"]
|
||||
C --> D{"session already provided?<br/>(e.g. file-upload flow)"}
|
||||
D -- no --> E["getSessionController(runTime).getSession()<br/>Session.ts:59-69"]
|
||||
D -- yes --> F["use provided session"]
|
||||
E --> G{"pending session<br/>available in pool?"}
|
||||
G -- yes --> H["reuse it"]
|
||||
G -- no --> I["createSession()<br/>SAS: spawns a real sas process, see<br/>sas-execution-handshake.md<br/>JS/PY/R: cheap - folder + id only"]
|
||||
H --> J["pre-warm: if pool has fewer<br/>than 3 pending, fire off more<br/>createSession() calls (not awaited)<br/>Session.ts:66"]
|
||||
I --> J
|
||||
F --> K
|
||||
J --> K["session.state = running<br/>Execution.ts:96"]
|
||||
K --> L["write webout.txt (empty),<br/>reqHeaders.txt<br/>Execution.ts:103-107"]
|
||||
L --> M["processProgram(...)<br/>processProgram.ts:16-27"]
|
||||
|
||||
M --> N{"runTime?"}
|
||||
|
||||
N -- SAS --> O["createSASProgram():<br/>wrap user code with _webout<br/>filename, macro vars, autoexec"]
|
||||
O --> P["write code.sas via .bkp + rename<br/>processProgram.ts:48-49"]
|
||||
P --> Q["poll: while session.state !== completed<br/>processProgram.ts:52-58"]
|
||||
Q -- "state becomes completed" --> R["resolve"]
|
||||
Q -- "state becomes failed" --> S["throw Error(session.failureReason)"]
|
||||
|
||||
N -- "JS / PY / R" --> T["createJSProgram / createPythonProgram /<br/>createRProgram: wrap user code similarly"]
|
||||
T --> U["write code file; spawn interpreter<br/>fresh via execFile; pipe stdout/stderr<br/>into a log.log write stream<br/>processProgram.ts:108-134"]
|
||||
U -- "exit 0" --> R
|
||||
U -- "exit non-zero" --> S
|
||||
|
||||
R --> V["read log.log, webout.txt,<br/>stpsrv_header.txt<br/>Execution.ts:128-131"]
|
||||
V --> W["build httpHeaders + result<br/>return ExecuteReturnRaw<br/>Execution.ts:128-151"]
|
||||
W --> X["Controller: res.send(result)<br/>HTTP 200"]
|
||||
|
||||
S --> Y["catch in Execution.ts:109-126:<br/>read whatever log exists,<br/>throw SessionExecutionError(message, log)"]
|
||||
Y --> Z["Controller catch block<br/>rethrow { code: 400, status: 'failure',<br/>message, error, log }"]
|
||||
Z --> AA["res.status(err.code).send(err)<br/>HTTP 400 with complete log"]
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Session pool is per-runtime.** SAS sessions and JS/PY/R sessions live in
|
||||
separate controllers/pools (`process.sasSessionController` vs
|
||||
`process.sessionController`, `Session.ts:239-253`) - a pending SAS session
|
||||
is never handed out for a JS request or vice versa.
|
||||
- **The SAS branch and the JS/PY/R branch converge** on the same
|
||||
success/failure signal shape: `session.state` becomes `completed` or
|
||||
`failed` either way, and both paths flow through the same log/webout
|
||||
reading logic in `Execution.ts` afterward. The mechanics of *how* that
|
||||
state gets set differ substantially (see `session-lifecycle.md`).
|
||||
- **`includePrintOutput`** (SAS only) additionally appends `output.lst`
|
||||
content to the result when debug mode is on - omitted above for brevity;
|
||||
see `Execution.ts:135-142`.
|
||||
- **`triggerProgram`/`triggerCode`** (fire-and-forget variants, not shown)
|
||||
call the same `ExecutionController` methods without awaiting them and
|
||||
immediately return `{ sessionId }`; the client polls
|
||||
`GET /SASjsApi/session/{sessionId}/state` separately.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAS session execution handshake
|
||||
|
||||
How one long-lived `sas` process is turned into a single-use execution slot
|
||||
via a SYSIN file-swap trick. This is the mechanism specific to
|
||||
`RunTimeType.SAS`; JS/PY/R sessions spawn a fresh interpreter process per
|
||||
request instead (see `request-execution-flow.md`).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as HTTP client
|
||||
participant Ctrl as Controller<br/>(code.ts / stp.ts)
|
||||
participant Exec as ExecutionController<br/>(Execution.ts)
|
||||
participant Pool as SASSessionController<br/>(Session.ts)
|
||||
participant FS as Filesystem<br/>(session.path)
|
||||
participant SAS as spawned "sas" process
|
||||
|
||||
Note over Pool,SAS: SESSION CREATION - happens ahead of any request,<br/>pool pre-warms up to 3 sessions (Session.ts:59-69)
|
||||
Pool->>FS: createFile(code.sas, "") - empty dummy SYSIN (Session.ts:117-118)
|
||||
Pool->>FS: readFile(sysInitCompiledPath) - compiled system-init<br/>macros, produced by `npm run compileSysInit` (Session.ts:105)
|
||||
Note over Pool: throws if this file doesn't exist yet -<br/>session creation never reaches execFile below
|
||||
Pool->>FS: createFile(autoexec.sas, autoExecContent) (Session.ts:108-114)
|
||||
Pool->>SAS: execFile(sasLoc, -SYSIN code.sas -AUTOEXEC autoexec.sas -LOG log.log ...) (Session.ts:127-147)
|
||||
activate SAS
|
||||
Note right of SAS: process stays active (this box)<br/>from spawn until it finally exits below
|
||||
SAS->>FS: autoexec step 1: delete code.sas, the dummy SYSIN (Session.ts:257-261)
|
||||
Pool->>FS: waitForSession() polls fileExists(code.sas) (Session.ts:175-192)
|
||||
FS-->>Pool: code.sas no longer exists
|
||||
Pool->>Pool: session.state = pending (Session.ts:190)
|
||||
SAS->>SAS: autoexec step 2: busy-wait for code.sas to reappear,<br/>up to 15 minutes (Session.ts:263-274)
|
||||
Note over SAS: process is now idle, parked mid-autoexec,<br/>waiting on the filesystem.<br/>Counts as "pending" in the pool.
|
||||
|
||||
Note over Client,SAS: REQUEST ARRIVES - reuses the parked process above
|
||||
Client->>Ctrl: POST .../execute { code, runTime: sas }
|
||||
Ctrl->>Exec: executeProgram({ program, runTime: SAS, ... })
|
||||
Exec->>Pool: getSession() returns the pending session (Session.ts:59-69)
|
||||
Exec->>Exec: session.state = running (Execution.ts:96)
|
||||
Exec->>FS: createFile(webout.txt, "") and createFile(reqHeaders.txt, ...) (Execution.ts:103-107)
|
||||
Exec->>Pool: processProgram(program, session, ...) (Execution.ts:110-121)
|
||||
Pool->>FS: createSASProgram() wraps user code with macro vars,<br/>_webout filename, autoexec injection (createSASProgram.ts)
|
||||
Pool->>FS: write code.sas.bkp, then rename to code.sas (processProgram.ts:48-49)
|
||||
Note over FS: write-then-rename, not a direct write,<br/>so SAS never reads a partial file
|
||||
|
||||
FS-->>SAS: code.sas exists again
|
||||
SAS->>SAS: autoexec step 2 loop exits, sleeps 0.01s, autoexec ends (Session.ts:267-271)
|
||||
Note over SAS: -SYSIN has pointed at code.sas from the start,<br/>so SAS now executes ITS CONTENT as the main job
|
||||
SAS->>FS: writes log.log, output.lst, webout.txt while running
|
||||
|
||||
Pool->>Pool: processProgram poll loop:<br/>while session.state !== completed (processProgram.ts:52-58)
|
||||
|
||||
alt program reaches EOF normally
|
||||
SAS->>SAS: exits with code 0
|
||||
SAS-->>Pool: execFilePromise resolves - .then() (Session.ts:148-152)
|
||||
Pool->>Pool: session.state = completed
|
||||
Pool-->>Exec: processProgram() resolves
|
||||
else program aborts (fatal error, license failure, hard STOP)
|
||||
SAS->>SAS: exits with non-zero code
|
||||
SAS-->>Pool: execFilePromise rejects - .catch() (Session.ts:153-163)
|
||||
Pool->>Pool: session.state = failed<br/>session.failureReason = err.toString()
|
||||
Pool-->>Exec: processProgram() throws (processProgram.ts:53-55)
|
||||
end
|
||||
deactivate SAS
|
||||
|
||||
Exec->>FS: read log.log, webout.txt, stpsrv_header.txt (Execution.ts:123, 128-131)
|
||||
Exec-->>Ctrl: { httpHeaders, result }<br/>or throws SessionExecutionError{ message, log } (Execution.ts:109-126)
|
||||
Ctrl-->>Client: 200 + result<br/>or 400 { status, message, error, log }
|
||||
```
|
||||
|
||||
## Why this design
|
||||
|
||||
- SAS has meaningful startup cost (loading the engine, running system init
|
||||
macros). Spawning a fresh `sas` process per request would pay that cost
|
||||
every time. Instead, a process is spawned once and parked, ready to run
|
||||
exactly one job the moment code shows up at a path it's already watching.
|
||||
- The pool (`SessionController.getSession`, `Session.ts:59-69`) keeps up to
|
||||
3 such parked processes ready, so most requests get an instant handoff
|
||||
instead of waiting through the ~15-minute autoexec spin-wait or a cold
|
||||
start.
|
||||
- A session is single-use: once its process consumes the real SYSIN content
|
||||
and exits (success or failure), that process is gone. The session object
|
||||
itself is deleted later by `scheduleSessionDestroy` (`Session.ts:204-236`),
|
||||
not reused for a second job.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Session lifecycle (`SessionState`)
|
||||
|
||||
Enum defined in `api/src/types/Session.ts`. A `Session` is `{ id, state,
|
||||
path, creationTimeStamp, deathTimeStamp, expiresAfterMins?, failureReason? }`.
|
||||
Sessions live in an in-memory array on a per-runtime singleton controller
|
||||
(`process.sasSessionController` for SAS, `process.sessionController` shared
|
||||
by JS/PY/R — see `getSessionController()` in `Session.ts:239-253`).
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> initialising: SAS runtime<br/>SASSessionController.createSession()<br/>Session.ts:77-95
|
||||
[*] --> pending: JS/PY/R runtime, no process yet<br/>SessionController.createSession()<br/>Session.ts:30-57
|
||||
|
||||
initialising --> pending: dummy SYSIN file deleted<br/>by SAS's autoexec<br/>waitForSession(), Session.ts:175-192
|
||||
initialising --> failed: spawned SAS process exits<br/>before handshake completes<br/>Session.ts:153-163, 184-188
|
||||
|
||||
pending --> running: session picked for a request<br/>executeProgram(), Execution.ts:94-96
|
||||
|
||||
running --> completed: process exits 0<br/>SAS: Session.ts:148-152 (original process)<br/>JS/PY/R: processProgram.ts:116-120 (fresh process)
|
||||
running --> failed: process exits non-zero<br/>SAS: Session.ts:153-163<br/>JS/PY/R: processProgram.ts:121-131<br/>failureReason = err.toString()
|
||||
|
||||
completed --> [*]: deleteSession()<br/>scheduleSessionDestroy(), Session.ts:194-236
|
||||
failed --> [*]: deleteSession()<br/>scheduleSessionDestroy()
|
||||
|
||||
note right of initialising
|
||||
SAS only. The real sas
|
||||
executable is already
|
||||
running, spin-waiting
|
||||
inside its AUTOEXEC for
|
||||
real code to arrive.
|
||||
See sas-execution-handshake.md
|
||||
end note
|
||||
|
||||
note right of pending
|
||||
Session sits in the pool,
|
||||
reusable. Pre-warmed up to
|
||||
3 pending sessions per
|
||||
runtime (getSession(),
|
||||
Session.ts:59-69)
|
||||
end note
|
||||
|
||||
note right of running
|
||||
SAS: no new process spawned
|
||||
here, request just feeds
|
||||
code to the already-running
|
||||
process.
|
||||
JS/PY/R: a brand new
|
||||
interpreter process is
|
||||
spawned right now.
|
||||
end note
|
||||
```
|
||||
|
||||
## Consumers of `state`
|
||||
|
||||
| Reader | Location | Watches for |
|
||||
|---|---|---|
|
||||
| `waitForSession` | `Session.ts:175-192` | `failed` (breaks early) or the dummy SYSIN file disappearing (implies session survived init) |
|
||||
| `processProgram` (SAS branch poll loop) | `processProgram.ts:52-58` | `completed` (success exit) or `failed` (throws, carrying `session.failureReason`) |
|
||||
| `scheduleSessionDestroy` | `Session.ts:204-236` | `running` (extends death timer instead of destroying) |
|
||||
|
||||
## Asymmetry between runtimes
|
||||
|
||||
- **SAS**: one OS process per session, spawned once at `createSession()`
|
||||
time and reused for exactly one job (see `sas-execution-handshake.md`).
|
||||
`running`/`completed`/`failed` are all driven by that *same* process's
|
||||
eventual exit.
|
||||
- **JS/PY/R**: a session is cheap (folder + id, no process). The interpreter
|
||||
process is spawned fresh, per request, inside `processProgram.ts:115` and
|
||||
its exit drives `completed`/`failed` directly in the same function - there
|
||||
is no separate poll loop for these runtimes.
|
||||
Generated
+6115
-14527
File diff suppressed because it is too large
Load Diff
+25
-28
@@ -6,11 +6,9 @@
|
||||
"scripts": {
|
||||
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
|
||||
"prestart": "npm run initial",
|
||||
"prebuild": "npm run initial",
|
||||
"start": "NODE_ENV=development nodemon ./src/server.ts",
|
||||
"start:prod": "node ./build/src/server.js",
|
||||
"build": "rimraf build && tsc",
|
||||
"postbuild": "npm run copy:files",
|
||||
"build": "npm run initial && rimraf build && tsc && npm run copy:files",
|
||||
"swagger": "tsoa spec",
|
||||
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
||||
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
|
||||
@@ -18,10 +16,10 @@
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"exe": "npm run build && pkg .",
|
||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
||||
"public:copy": "cp -r ./public/ ./build/public/",
|
||||
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
||||
"sas:copy": "cp -r ./sas/ ./build/sas/",
|
||||
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
||||
"public:copy": "cpr ./public/ ./build/public/",
|
||||
"sasjsbuild:copy": "cpr ./sasjsbuild/ ./build/sasjsbuild/",
|
||||
"sas:copy": "cpr ./sas/ ./build/sas/",
|
||||
"web:copy": "rimraf web && mkdir web && cpr ../web/build/ ./web/build/",
|
||||
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
||||
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
||||
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
||||
@@ -48,26 +46,25 @@
|
||||
},
|
||||
"author": "4GL Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/core": "^4.40.1",
|
||||
"@sasjs/utils": "3.2.0",
|
||||
"@sasjs/core": "^4.59.10",
|
||||
"@sasjs/utils": "^3.5.6",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-mongo": "^4.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"connect-mongo": "^5.1.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.2",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.2",
|
||||
"helmet": "^5.0.2",
|
||||
"joi": "^17.4.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"ldapjs": "2.3.3",
|
||||
"mongoose": "^6.0.12",
|
||||
"mongoose-sequence": "^5.3.1",
|
||||
"morgan": "^1.10.0",
|
||||
"mongoose": "^6.13.8",
|
||||
"morgan": "^1.10.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"rate-limiter-flexible": "2.4.1",
|
||||
"rotating-file-stream": "^3.0.4",
|
||||
"swagger-ui-express": "4.3.0",
|
||||
"unzipper": "^0.10.11",
|
||||
"unzipper": "^0.12.3",
|
||||
"url": "^0.10.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -77,33 +74,33 @@
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.12",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/jsonwebtoken": "^8.5.5",
|
||||
"@types/ldapjs": "^2.2.4",
|
||||
"@types/mongoose-sequence": "^3.0.6",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^15.12.2",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"@types/unzipper": "^0.10.5",
|
||||
"adm-zip": "^0.5.9",
|
||||
"axios": "0.27.2",
|
||||
"axios": "1.12.2",
|
||||
"cpr": "^3.0.1",
|
||||
"csrf": "^3.1.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"http-headers-validation": "^0.0.1",
|
||||
"jest": "^27.0.6",
|
||||
"jest": "^29.7.0",
|
||||
"mongodb-memory-server": "8.11.4",
|
||||
"nodejs-file-downloader": "4.10.2",
|
||||
"nodemon": "^2.0.7",
|
||||
"nodemon": "^3.0.0",
|
||||
"pkg": "5.6.0",
|
||||
"prettier": "^2.3.1",
|
||||
"prettier": "^3.0.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsoa": "3.14.1",
|
||||
"typescript": "^4.3.2"
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"ignore": [
|
||||
|
||||
+243
-107
@@ -40,8 +40,7 @@ components:
|
||||
clientId:
|
||||
type: string
|
||||
userId:
|
||||
type: number
|
||||
format: double
|
||||
type: string
|
||||
required:
|
||||
- clientId
|
||||
- userId
|
||||
@@ -98,17 +97,47 @@ components:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'Code of program'
|
||||
example: '* Code HERE;'
|
||||
description: 'The code to be executed'
|
||||
example: '* Your Code HERE;'
|
||||
runTime:
|
||||
$ref: '#/components/schemas/RunTimeType'
|
||||
description: 'runtime for program'
|
||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
||||
example: js
|
||||
required:
|
||||
- code
|
||||
- runTime
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerCodeResponse:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerCodePayload:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'The code to be executed'
|
||||
example: '* Your Code HERE;'
|
||||
runTime:
|
||||
$ref: '#/components/schemas/RunTimeType'
|
||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
||||
example: sas
|
||||
expiresAfterMins:
|
||||
type: number
|
||||
format: double
|
||||
description: "Amount of minutes after the completion of the job when the session must be\ndestroyed."
|
||||
example: 15
|
||||
required:
|
||||
- code
|
||||
- runTime
|
||||
type: object
|
||||
additionalProperties: false
|
||||
MemberType.folder:
|
||||
enum:
|
||||
- folder
|
||||
@@ -285,9 +314,8 @@ components:
|
||||
additionalProperties: false
|
||||
UserResponse:
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
@@ -295,7 +323,7 @@ components:
|
||||
isAdmin:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- uid
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
@@ -303,32 +331,30 @@ components:
|
||||
additionalProperties: false
|
||||
GroupResponse:
|
||||
properties:
|
||||
groupId:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
required:
|
||||
- groupId
|
||||
- uid
|
||||
- name
|
||||
- description
|
||||
type: object
|
||||
additionalProperties: false
|
||||
UserDetailsResponse:
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
format: double
|
||||
displayName:
|
||||
uid:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
isActive:
|
||||
type: boolean
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
isActive:
|
||||
type: boolean
|
||||
autoExec:
|
||||
type: string
|
||||
groups:
|
||||
@@ -336,11 +362,11 @@ components:
|
||||
$ref: '#/components/schemas/GroupResponse'
|
||||
type: array
|
||||
required:
|
||||
- id
|
||||
- displayName
|
||||
- uid
|
||||
- username
|
||||
- isActive
|
||||
- displayName
|
||||
- isAdmin
|
||||
- isActive
|
||||
type: object
|
||||
additionalProperties: false
|
||||
UserPayload:
|
||||
@@ -376,9 +402,8 @@ components:
|
||||
additionalProperties: false
|
||||
GroupDetailsResponse:
|
||||
properties:
|
||||
groupId:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
@@ -390,7 +415,7 @@ components:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
type: array
|
||||
required:
|
||||
- groupId
|
||||
- uid
|
||||
- name
|
||||
- description
|
||||
- isActive
|
||||
@@ -459,9 +484,8 @@ components:
|
||||
additionalProperties: false
|
||||
PermissionDetailsResponse:
|
||||
properties:
|
||||
permissionId:
|
||||
type: number
|
||||
format: double
|
||||
uid:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
type:
|
||||
@@ -473,7 +497,7 @@ components:
|
||||
group:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
required:
|
||||
- permissionId
|
||||
- uid
|
||||
- path
|
||||
- type
|
||||
- setting
|
||||
@@ -512,10 +536,8 @@ components:
|
||||
description: 'Indicates the type of principal'
|
||||
example: user
|
||||
principalId:
|
||||
type: number
|
||||
format: double
|
||||
type: string
|
||||
description: 'The id of user or group to which a rule is assigned.'
|
||||
example: 123
|
||||
required:
|
||||
- path
|
||||
- type
|
||||
@@ -534,27 +556,47 @@ components:
|
||||
- setting
|
||||
type: object
|
||||
additionalProperties: false
|
||||
SessionResponse:
|
||||
Pick_UserResponse.Exclude_keyofUserResponse.uid__:
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
format: double
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
needsToUpdatePassword:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
- needsToUpdatePassword
|
||||
type: object
|
||||
description: 'From T, pick a set of properties whose keys are in the union K'
|
||||
SessionResponse:
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
needsToUpdatePassword:
|
||||
type: boolean
|
||||
required:
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
- id
|
||||
type: object
|
||||
additionalProperties: false
|
||||
SessionState:
|
||||
enum:
|
||||
- initialising
|
||||
- pending
|
||||
- running
|
||||
- completed
|
||||
- failed
|
||||
type: string
|
||||
ExecutePostRequestPayload:
|
||||
properties:
|
||||
_program:
|
||||
@@ -563,6 +605,16 @@ components:
|
||||
example: /Public/somefolder/some.file
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerProgramResponse:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
LoginPayload:
|
||||
properties:
|
||||
username:
|
||||
@@ -772,7 +824,7 @@ paths:
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{clientId: someClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}, {clientId: someOtherClientID, clientSecret: someOtherRandomCryptoString, accessTokenExpiration: 86400}]
|
||||
summary: 'Admin only task. Returns the list of all the clients *'
|
||||
summary: 'Admin only task. Returns the list of all the clients'
|
||||
tags:
|
||||
- Client
|
||||
security:
|
||||
@@ -792,7 +844,7 @@ paths:
|
||||
- {type: string}
|
||||
- {type: string, format: byte}
|
||||
description: 'Execute Code on the Specified Runtime'
|
||||
summary: 'Run Code and Return Webout Content and Log'
|
||||
summary: "Run Code and Return Webout Content, Log and Print output\nThe order of returned parts of the payload is:\n1. Webout (if present)\n2. Logs UUID (used as separator)\n3. Log\n4. Logs UUID (used as separator)\n5. Print (if present and if the runtime is SAS)\nPlease see"
|
||||
tags:
|
||||
- Code
|
||||
security:
|
||||
@@ -805,6 +857,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecuteCodePayload'
|
||||
/SASjsApi/code/trigger:
|
||||
post:
|
||||
operationId: TriggerCode
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerCodeResponse'
|
||||
description: 'Trigger Code on the Specified Runtime'
|
||||
summary: 'Triggers code and returns SessionId immediately - does not wait for job completion'
|
||||
tags:
|
||||
- Code
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerCodePayload'
|
||||
/SASjsApi/drive/deploy:
|
||||
post:
|
||||
operationId: Deploy
|
||||
@@ -1206,7 +1282,7 @@ paths:
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{id: 123, username: johnusername, displayName: John, isAdmin: false}, {id: 456, username: starkusername, displayName: Stark, isAdmin: true}]
|
||||
value: [{uid: userIdString, username: johnusername, displayName: John, isAdmin: false}, {uid: anotherUserIdString, username: starkusername, displayName: Stark, isAdmin: true}]
|
||||
summary: 'Get list of all users (username, displayname). All users can request this.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1225,7 +1301,7 @@ paths:
|
||||
$ref: '#/components/schemas/UserDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
summary: 'Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1276,7 +1352,7 @@ paths:
|
||||
$ref: '#/components/schemas/UserDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1327,7 +1403,7 @@ paths:
|
||||
password:
|
||||
type: string
|
||||
type: object
|
||||
'/SASjsApi/user/{userId}':
|
||||
'/SASjsApi/user/{uid}':
|
||||
get:
|
||||
operationId: GetUser
|
||||
responses:
|
||||
@@ -1346,14 +1422,12 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: userId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
'/SASjsApi/user/{userId}':
|
||||
patch:
|
||||
operationId: UpdateUser
|
||||
responses:
|
||||
@@ -1365,7 +1439,7 @@ paths:
|
||||
$ref: '#/components/schemas/UserDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
value: {uid: userIdString, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
|
||||
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
|
||||
tags:
|
||||
- User
|
||||
@@ -1379,8 +1453,7 @@ paths:
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
type: string
|
||||
example: '1234'
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1406,8 +1479,7 @@ paths:
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
type: string
|
||||
example: 1234
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1432,7 +1504,7 @@ paths:
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users'}]
|
||||
value: [{uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users'}]
|
||||
summary: 'Get list of all groups (groupName and groupDescription). All users can request this.'
|
||||
tags:
|
||||
- Group
|
||||
@@ -1451,7 +1523,7 @@ paths:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Create a new group. Admin only.'
|
||||
tags:
|
||||
- Group
|
||||
@@ -1467,7 +1539,7 @@ paths:
|
||||
$ref: '#/components/schemas/GroupPayload'
|
||||
'/SASjsApi/group/by/groupname/{name}':
|
||||
get:
|
||||
operationId: GetGroupByGroupName
|
||||
operationId: GetGroupByName
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
@@ -1489,7 +1561,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
'/SASjsApi/group/{groupId}':
|
||||
'/SASjsApi/group/{uid}':
|
||||
get:
|
||||
operationId: GetGroup
|
||||
responses:
|
||||
@@ -1509,12 +1581,11 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
example: 12ByteString
|
||||
delete:
|
||||
operationId: DeleteGroup
|
||||
responses:
|
||||
@@ -1536,13 +1607,12 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
'/SASjsApi/group/{groupId}/{userId}':
|
||||
type: string
|
||||
example: 12ByteString
|
||||
'/SASjsApi/group/{groupUid}/{userUid}':
|
||||
post:
|
||||
operationId: AddUserToGroup
|
||||
responses:
|
||||
@@ -1554,7 +1624,7 @@ paths:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Add a user to a group. Admin task only.'
|
||||
tags:
|
||||
- Group
|
||||
@@ -1565,21 +1635,18 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: groupUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '1234'
|
||||
type: string
|
||||
example: 12ByteString
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: userId
|
||||
name: userUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '6789'
|
||||
type: string
|
||||
delete:
|
||||
operationId: RemoveUserFromGroup
|
||||
responses:
|
||||
@@ -1591,8 +1658,8 @@ paths:
|
||||
$ref: '#/components/schemas/GroupDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Remove a user to a group. Admin task only.'
|
||||
value: {uid: groupIdString, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}
|
||||
summary: 'Remove a user from a group. Admin task only.'
|
||||
tags:
|
||||
- Group
|
||||
security:
|
||||
@@ -1602,21 +1669,19 @@ paths:
|
||||
-
|
||||
description: 'The group''s identifier'
|
||||
in: path
|
||||
name: groupId
|
||||
name: groupUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '1234'
|
||||
type: string
|
||||
example: 12ByteString
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: userId
|
||||
name: userUid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: '6789'
|
||||
type: string
|
||||
example: 12ByteString
|
||||
/SASjsApi/info:
|
||||
get:
|
||||
operationId: Info
|
||||
@@ -1667,7 +1732,7 @@ paths:
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {permissionId: 124, path: /SASjsApi/code/execute, type: Route, setting: Grant, group: {groupId: 1, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
|
||||
value: [{uid: permissionId1String, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: user1-id, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}, {uid: permissionId2String, path: /SASjsApi/code/execute, type: Route, setting: Grant, group: {uid: group1-id, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []}}]
|
||||
description: "Get the list of permission rules applicable the authenticated user.\nIf the user is an admin, all rules are returned."
|
||||
summary: 'Get the list of permission rules. If the user is admin, all rules are returned.'
|
||||
tags:
|
||||
@@ -1687,7 +1752,7 @@ paths:
|
||||
$ref: '#/components/schemas/PermissionDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
summary: 'Create a new permission. Admin only.'
|
||||
tags:
|
||||
- Permission
|
||||
@@ -1701,7 +1766,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterPermissionPayload'
|
||||
'/SASjsApi/permission/{permissionId}':
|
||||
'/SASjsApi/permission/{uid}':
|
||||
patch:
|
||||
operationId: UpdatePermission
|
||||
responses:
|
||||
@@ -1713,7 +1778,7 @@ paths:
|
||||
$ref: '#/components/schemas/PermissionDetailsResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {permissionId: 123, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {id: 1, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
value: {uid: permissionIdString, path: /SASjsApi/code/execute, type: Route, setting: Grant, user: {uid: userIdString, username: johnSnow01, displayName: 'John Snow', isAdmin: false}}
|
||||
summary: 'Update permission setting. Admin only'
|
||||
tags:
|
||||
- Permission
|
||||
@@ -1722,14 +1787,11 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The permission''s identifier'
|
||||
in: path
|
||||
name: permissionId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1749,14 +1811,11 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'The user''s identifier'
|
||||
in: path
|
||||
name: permissionId
|
||||
name: uid
|
||||
required: true
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 1234
|
||||
type: string
|
||||
/SASjsApi/session:
|
||||
get:
|
||||
operationId: Session
|
||||
@@ -1769,7 +1828,7 @@ paths:
|
||||
$ref: '#/components/schemas/SessionResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
|
||||
value: {id: userIdString, username: johnusername, displayName: John, isAdmin: false, needsToUpdatePassword: false}
|
||||
summary: 'Get session info (username).'
|
||||
tags:
|
||||
- Session
|
||||
@@ -1777,6 +1836,30 @@ paths:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
'/SASjsApi/session/{sessionId}/state':
|
||||
get:
|
||||
operationId: SessionState
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionState'
|
||||
description: "The polling endpoint is currently implemented for single-server deployments only.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\nIf your site requires this, please reach out to SASjs Support."
|
||||
summary: 'Get session state (initialising, pending, running, completed, failed).'
|
||||
tags:
|
||||
- Session
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
in: path
|
||||
name: sessionId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/SASjsApi/stp/execute:
|
||||
get:
|
||||
operationId: ExecuteGetRequest
|
||||
@@ -1789,7 +1872,7 @@ paths:
|
||||
anyOf:
|
||||
- {type: string}
|
||||
- {type: string, format: byte}
|
||||
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
|
||||
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts additional URL parameters (converted to session variables)\nand file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
|
||||
summary: 'Execute a Stored Program, returns _webout and (optionally) log.'
|
||||
tags:
|
||||
- STP
|
||||
@@ -1798,13 +1881,22 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of code in SASjs Drive'
|
||||
description: 'Location of Stored Program in SASjs Drive.'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Projects/myApp/some/program
|
||||
-
|
||||
description: 'Optional query param for setting debug mode (returns the session log in the response body).'
|
||||
in: query
|
||||
name: _debug
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 131
|
||||
post:
|
||||
operationId: ExecutePostRequest
|
||||
responses:
|
||||
@@ -1838,6 +1930,50 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecutePostRequestPayload'
|
||||
/SASjsApi/stp/trigger:
|
||||
post:
|
||||
operationId: TriggerProgram
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerProgramResponse'
|
||||
description: 'Trigger Program on the Specified Runtime.'
|
||||
summary: 'Triggers program and returns SessionId immediately - does not wait for program completion.'
|
||||
tags:
|
||||
- STP
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of code in SASjs Drive.'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Projects/myApp/some/program
|
||||
-
|
||||
description: 'Optional query param for setting debug mode.'
|
||||
in: query
|
||||
name: _debug
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 131
|
||||
-
|
||||
description: 'Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.'
|
||||
in: query
|
||||
name: expiresAfterMins
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 15
|
||||
/:
|
||||
get:
|
||||
operationId: Home
|
||||
@@ -1863,7 +1999,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
|
||||
user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
|
||||
loggedIn: {type: boolean}
|
||||
required:
|
||||
- user
|
||||
|
||||
@@ -27,14 +27,14 @@ import User from '../model/User'
|
||||
@Tags('Auth')
|
||||
export class AuthController {
|
||||
static authCodes: { [key: string]: { [key: string]: string } } = {}
|
||||
static saveCode = (userId: number, clientId: string, code: string) => {
|
||||
static saveCode = (userId: string, clientId: string, code: string) => {
|
||||
if (AuthController.authCodes[userId])
|
||||
return (AuthController.authCodes[userId][clientId] = code)
|
||||
|
||||
AuthController.authCodes[userId] = { [clientId]: code }
|
||||
return AuthController.authCodes[userId][clientId]
|
||||
}
|
||||
static deleteCode = (userId: number, clientId: string) =>
|
||||
static deleteCode = (userId: string, clientId: string) =>
|
||||
delete AuthController.authCodes[userId][clientId]
|
||||
|
||||
/**
|
||||
@@ -159,7 +159,7 @@ const updatePassword = async (
|
||||
) => {
|
||||
const { currentPassword, newPassword } = data
|
||||
const userId = req.user?.userId
|
||||
const dbUser = await User.findOne({ id: userId })
|
||||
const dbUser = await User.findOne({ _id: userId })
|
||||
|
||||
if (!dbUser)
|
||||
throw {
|
||||
@@ -234,9 +234,10 @@ const verifyAuthCode = async (
|
||||
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
||||
if (err) return resolve(undefined)
|
||||
|
||||
const payload = data as InfoJWT
|
||||
const clientInfo: InfoJWT = {
|
||||
clientId: data?.clientId,
|
||||
userId: data?.userId
|
||||
clientId: payload?.clientId,
|
||||
userId: payload?.userId
|
||||
}
|
||||
if (clientInfo.clientId === clientId) {
|
||||
return resolve(clientInfo)
|
||||
|
||||
+104
-7
@@ -1,34 +1,71 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
||||
import { ExecutionController } from './internal'
|
||||
import { ExecutionController, getSessionController } from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
getUserAutoExec,
|
||||
ModeType,
|
||||
parseLogToArray,
|
||||
RunTimeType
|
||||
} from '../utils'
|
||||
|
||||
interface ExecuteCodePayload {
|
||||
/**
|
||||
* Code of program
|
||||
* @example "* Code HERE;"
|
||||
* The code to be executed
|
||||
* @example "* Your Code HERE;"
|
||||
*/
|
||||
code: string
|
||||
/**
|
||||
* runtime for program
|
||||
* The runtime for the code - eg SAS, JS, PY or R
|
||||
* @example "js"
|
||||
*/
|
||||
runTime: RunTimeType
|
||||
}
|
||||
|
||||
interface TriggerCodePayload {
|
||||
/**
|
||||
* The code to be executed
|
||||
* @example "* Your Code HERE;"
|
||||
*/
|
||||
code: string
|
||||
/**
|
||||
* The runtime for the code - eg SAS, JS, PY or R
|
||||
* @example "sas"
|
||||
*/
|
||||
runTime: RunTimeType
|
||||
/**
|
||||
* Amount of minutes after the completion of the job when the session must be
|
||||
* destroyed.
|
||||
* @example 15
|
||||
*/
|
||||
expiresAfterMins?: number
|
||||
}
|
||||
|
||||
interface TriggerCodeResponse {
|
||||
/**
|
||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
||||
* used to store code outputs.<br><br>
|
||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
||||
* `sessionId` can be used to poll session state using the
|
||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
||||
* @example "20241028074744-54132-1730101664824"
|
||||
*/
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/code')
|
||||
@Tags('Code')
|
||||
export class CodeController {
|
||||
/**
|
||||
* Execute Code on the Specified Runtime
|
||||
* @summary Run Code and Return Webout Content and Log
|
||||
* @summary Run Code and Return Webout Content, Log and Print output
|
||||
* The order of returned parts of the payload is:
|
||||
* 1. Webout (if present)
|
||||
* 2. Logs UUID (used as separator)
|
||||
* 3. Log
|
||||
* 4. Logs UUID (used as separator)
|
||||
* 5. Print (if present and if the runtime is SAS)
|
||||
* Please see @sasjs/server/api/src/controllers/internal/Execution.ts for more information
|
||||
*/
|
||||
@Post('/execute')
|
||||
public async executeCode(
|
||||
@@ -37,6 +74,18 @@ export class CodeController {
|
||||
): Promise<string | Buffer> {
|
||||
return executeCode(request, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Code on the Specified Runtime
|
||||
* @summary Triggers code and returns SessionId immediately - does not wait for job completion
|
||||
*/
|
||||
@Post('/trigger')
|
||||
public async triggerCode(
|
||||
@Request() request: express.Request,
|
||||
@Body() body: TriggerCodePayload
|
||||
): Promise<TriggerCodeResponse> {
|
||||
return triggerCode(request, body)
|
||||
}
|
||||
}
|
||||
|
||||
const executeCode = async (
|
||||
@@ -55,10 +104,58 @@ const executeCode = async (
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: { ...req.query, _debug: 131 },
|
||||
otherArgs: { userAutoExec },
|
||||
runTime: runTime
|
||||
runTime: runTime,
|
||||
includePrintOutput: true
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err,
|
||||
log: err?.log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -12,28 +12,29 @@ import {
|
||||
|
||||
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import { AuthProviderType } from '../utils'
|
||||
import { UserResponse } from './user'
|
||||
import { GetUserBy, UserResponse } from './user'
|
||||
|
||||
export interface GroupResponse {
|
||||
groupId: number
|
||||
uid: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface GroupDetailsResponse {
|
||||
groupId: number
|
||||
name: string
|
||||
description: string
|
||||
export interface GroupDetailsResponse extends GroupResponse {
|
||||
isActive: boolean
|
||||
users: UserResponse[]
|
||||
}
|
||||
|
||||
interface GetGroupBy {
|
||||
groupId?: number
|
||||
_id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
enum GroupAction {
|
||||
AddUser = 'addUser',
|
||||
RemoveUser = 'removeUser'
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/group')
|
||||
@Tags('Group')
|
||||
@@ -44,7 +45,7 @@ export class GroupController {
|
||||
*/
|
||||
@Example<GroupResponse[]>([
|
||||
{
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users'
|
||||
}
|
||||
@@ -59,7 +60,7 @@ export class GroupController {
|
||||
*
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
@@ -78,7 +79,7 @@ export class GroupController {
|
||||
* @example dcgroup
|
||||
*/
|
||||
@Get('by/groupname/{name}')
|
||||
public async getGroupByGroupName(
|
||||
public async getGroupByName(
|
||||
@Path() name: string
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return getGroup({ name })
|
||||
@@ -86,68 +87,66 @@ export class GroupController {
|
||||
|
||||
/**
|
||||
* @summary Get list of members of a group (userName). All users can request this.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId 1234
|
||||
* @param uid The group's identifier
|
||||
* @example uid "12ByteString"
|
||||
*/
|
||||
@Get('{groupId}')
|
||||
public async getGroup(
|
||||
@Path() groupId: number
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return getGroup({ groupId })
|
||||
@Get('{uid}')
|
||||
public async getGroup(@Path() uid: string): Promise<GroupDetailsResponse> {
|
||||
return getGroup({ _id: uid })
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Add a user to a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId "1234"
|
||||
* @param userId The user's identifier
|
||||
* @example userId "6789"
|
||||
* @param groupUid The group's identifier
|
||||
* @example groupUid "12ByteString"
|
||||
* @param userUid The user's identifier
|
||||
* @example userId "12ByteString"
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
})
|
||||
@Post('{groupId}/{userId}')
|
||||
@Post('{groupUid}/{userUid}')
|
||||
public async addUserToGroup(
|
||||
@Path() groupId: number,
|
||||
@Path() userId: number
|
||||
@Path() groupUid: string,
|
||||
@Path() userUid: string
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return addUserToGroup(groupId, userId)
|
||||
return addUserToGroup(groupUid, userUid)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Remove a user to a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId "1234"
|
||||
* @param userId The user's identifier
|
||||
* @example userId "6789"
|
||||
* @summary Remove a user from a group. Admin task only.
|
||||
* @param groupUid The group's identifier
|
||||
* @example groupUid "12ByteString"
|
||||
* @param userUid The user's identifier
|
||||
* @example userUid "12ByteString"
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
uid: 'groupIdString',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
})
|
||||
@Delete('{groupId}/{userId}')
|
||||
@Delete('{groupUid}/{userUid}')
|
||||
public async removeUserFromGroup(
|
||||
@Path() groupId: number,
|
||||
@Path() userId: number
|
||||
@Path() groupUid: string,
|
||||
@Path() userUid: string
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return removeUserFromGroup(groupId, userId)
|
||||
return removeUserFromGroup(groupUid, userUid)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Delete a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId 1234
|
||||
* @param uid The group's identifier
|
||||
* @example uid "12ByteString"
|
||||
*/
|
||||
@Delete('{groupId}')
|
||||
public async deleteGroup(@Path() groupId: number) {
|
||||
const group = await Group.findOne({ groupId })
|
||||
@Delete('{uid}')
|
||||
public async deleteGroup(@Path() uid: string) {
|
||||
const group = await Group.findOne({ _id: uid })
|
||||
if (!group)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -160,9 +159,7 @@ export class GroupController {
|
||||
}
|
||||
|
||||
const getAllGroups = async (): Promise<GroupResponse[]> =>
|
||||
await Group.find({})
|
||||
.select({ _id: 0, groupId: 1, name: 1, description: 1 })
|
||||
.exec()
|
||||
await Group.find({}).select('uid name description').exec()
|
||||
|
||||
const createGroup = async ({
|
||||
name,
|
||||
@@ -187,7 +184,7 @@ const createGroup = async ({
|
||||
const savedGroup = await group.save()
|
||||
|
||||
return {
|
||||
groupId: savedGroup.groupId,
|
||||
uid: savedGroup.uid,
|
||||
name: savedGroup.name,
|
||||
description: savedGroup.description,
|
||||
isActive: savedGroup.isActive,
|
||||
@@ -198,11 +195,12 @@ const createGroup = async ({
|
||||
const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
const group = (await Group.findOne(
|
||||
findBy,
|
||||
'groupId name description isActive users -_id'
|
||||
'uid name description isActive users'
|
||||
).populate(
|
||||
'users',
|
||||
'id username displayName isAdmin -_id'
|
||||
'uid username displayName isAdmin'
|
||||
)) as unknown as GroupDetailsResponse
|
||||
|
||||
if (!group)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -211,7 +209,7 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
}
|
||||
|
||||
return {
|
||||
groupId: group.groupId,
|
||||
uid: group.uid,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
isActive: group.isActive,
|
||||
@@ -220,23 +218,23 @@ const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||
}
|
||||
|
||||
const addUserToGroup = async (
|
||||
groupId: number,
|
||||
userId: number
|
||||
groupUid: string,
|
||||
userUid: string
|
||||
): Promise<GroupDetailsResponse> =>
|
||||
updateUsersListInGroup(groupId, userId, 'addUser')
|
||||
updateUsersListInGroup(groupUid, userUid, GroupAction.AddUser)
|
||||
|
||||
const removeUserFromGroup = async (
|
||||
groupId: number,
|
||||
userId: number
|
||||
groupUid: string,
|
||||
userUid: string
|
||||
): Promise<GroupDetailsResponse> =>
|
||||
updateUsersListInGroup(groupId, userId, 'removeUser')
|
||||
updateUsersListInGroup(groupUid, userUid, GroupAction.RemoveUser)
|
||||
|
||||
const updateUsersListInGroup = async (
|
||||
groupId: number,
|
||||
userId: number,
|
||||
action: 'addUser' | 'removeUser'
|
||||
groupUid: string,
|
||||
userUid: string,
|
||||
action: GroupAction
|
||||
): Promise<GroupDetailsResponse> => {
|
||||
const group = await Group.findOne({ groupId })
|
||||
const group = await Group.findOne({ _id: groupUid })
|
||||
if (!group)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -258,7 +256,7 @@ const updateUsersListInGroup = async (
|
||||
message: `Can't add/remove user to group created by external auth provider.`
|
||||
}
|
||||
|
||||
const user = await User.findOne({ id: userId })
|
||||
const user = await User.findOne({ _id: userUid })
|
||||
if (!user)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -274,7 +272,7 @@ const updateUsersListInGroup = async (
|
||||
}
|
||||
|
||||
const updatedGroup =
|
||||
action === 'addUser'
|
||||
action === GroupAction.AddUser
|
||||
? await group.addUser(user)
|
||||
: await group.removeUser(user)
|
||||
|
||||
@@ -286,7 +284,7 @@ const updateUsersListInGroup = async (
|
||||
}
|
||||
|
||||
return {
|
||||
groupId: updatedGroup.groupId,
|
||||
uid: updatedGroup.uid,
|
||||
name: updatedGroup.name,
|
||||
description: updatedGroup.description,
|
||||
isActive: updatedGroup.isActive,
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getSessionController, processProgram } from './'
|
||||
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session, TreeNode } from '../../types'
|
||||
import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
|
||||
import {
|
||||
extractHeaders,
|
||||
getFilesFolder,
|
||||
@@ -15,6 +15,24 @@ export interface ExecutionVars {
|
||||
[key: string]: string | number | undefined
|
||||
}
|
||||
|
||||
// Thrown when the session itself fails (e.g. SAS exits abnormally via
|
||||
// %abort;). Carries the complete log - by the time this is thrown, the
|
||||
// session's process has already exited, so the log file it wrote is final,
|
||||
// not a partial/truncated snapshot.
|
||||
export class SessionExecutionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public log?: string
|
||||
) {
|
||||
super(message)
|
||||
|
||||
// required for `instanceof` to work when compiling to ES5, since the
|
||||
// default __extends helper does not preserve the prototype chain for
|
||||
// classes extending built-ins like Error
|
||||
Object.setPrototypeOf(this, SessionExecutionError.prototype)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteReturnRaw {
|
||||
httpHeaders: HTTPHeaders
|
||||
result: string | Buffer
|
||||
@@ -33,6 +51,7 @@ interface ExecuteFileParams {
|
||||
|
||||
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
||||
program: string
|
||||
includePrintOutput?: boolean
|
||||
}
|
||||
|
||||
export class ExecutionController {
|
||||
@@ -67,18 +86,17 @@ export class ExecutionController {
|
||||
otherArgs,
|
||||
session: sessionByFileUpload,
|
||||
runTime,
|
||||
forceStringResult
|
||||
forceStringResult,
|
||||
includePrintOutput
|
||||
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
const session =
|
||||
sessionByFileUpload ?? (await sessionController.getSession())
|
||||
session.inUse = true
|
||||
session.consumed = true
|
||||
session.state = SessionState.running
|
||||
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
|
||||
const weboutPath = path.join(session.path, 'webout.txt')
|
||||
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||
|
||||
@@ -88,18 +106,24 @@ export class ExecutionController {
|
||||
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
||||
)
|
||||
|
||||
await processProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
runTime,
|
||||
logPath,
|
||||
otherArgs
|
||||
)
|
||||
try {
|
||||
await processProgram(
|
||||
program,
|
||||
preProgramVariables,
|
||||
vars,
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
runTime,
|
||||
logPath,
|
||||
otherArgs
|
||||
)
|
||||
} catch (err: any) {
|
||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||
|
||||
throw new SessionExecutionError(err.message, log)
|
||||
}
|
||||
|
||||
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||
const headersContent = (await fileExists(headersPath))
|
||||
@@ -120,13 +144,41 @@ export class ExecutionController {
|
||||
: ''
|
||||
|
||||
// it should be deleted by scheduleSessionDestroy
|
||||
session.inUse = false
|
||||
//
|
||||
// Guarded: for JS/PY/R, processProgram sets state to `failed` itself
|
||||
// (without throwing) when the interpreter process exits non-zero - if
|
||||
// we unconditionally set `completed` here we'd silently overwrite that,
|
||||
// and anything downstream inspecting session.state (e.g.
|
||||
// scheduleSessionDestroy's expiresAfterMins branch in Session.ts) would
|
||||
// see a crashed session mis-reported as successful.
|
||||
if ((session.state as SessionState) !== SessionState.failed) {
|
||||
session.state = SessionState.completed
|
||||
}
|
||||
|
||||
const resultParts = []
|
||||
|
||||
// INFO: webout can be a Buffer, that is why it's length should be checked to determine if it is empty
|
||||
if (webout && webout.length !== 0) resultParts.push(webout)
|
||||
|
||||
// INFO: log separator wraps the log from the beginning and the end
|
||||
resultParts.push(process.logsUUID)
|
||||
resultParts.push(log)
|
||||
resultParts.push(process.logsUUID)
|
||||
|
||||
if (includePrintOutput && runTime === RunTimeType.SAS) {
|
||||
const printOutputPath = path.join(session.path, 'output.lst')
|
||||
const printOutput = (await fileExists(printOutputPath))
|
||||
? await readFile(printOutputPath)
|
||||
: ''
|
||||
|
||||
if (printOutput) resultParts.push(printOutput)
|
||||
}
|
||||
|
||||
return {
|
||||
httpHeaders,
|
||||
result:
|
||||
isDebugOn(vars) || session.crashed
|
||||
? `${webout}\n${process.logsUUID}\n${log}`
|
||||
isDebugOn(vars) || session.failureReason
|
||||
? resultParts.join(`\n`)
|
||||
: webout
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express'
|
||||
import multer from 'multer'
|
||||
import { uuidv4 } from '@sasjs/utils'
|
||||
import { getSessionController } from '.'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
getRunTimeAndFilePath,
|
||||
RunTimeType
|
||||
} from '../../utils'
|
||||
import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
|
||||
import { SessionState } from '../../types'
|
||||
|
||||
export class FileUploadController {
|
||||
private storage = multer.diskStorage({
|
||||
@@ -56,9 +53,8 @@ export class FileUploadController {
|
||||
}
|
||||
|
||||
const session = await sessionController.getSession()
|
||||
// marking consumed true, so that it's not available
|
||||
// as readySession for any other request
|
||||
session.consumed = true
|
||||
// change session state to 'running', so that it's not available for any other request
|
||||
session.state = SessionState.running
|
||||
|
||||
req.sasjsSession = session
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import { Session } from '../../types'
|
||||
import { Session, SessionState } from '../../types'
|
||||
import { promisify } from 'util'
|
||||
import { execFile } from 'child_process'
|
||||
import {
|
||||
@@ -14,8 +14,7 @@ import {
|
||||
createFile,
|
||||
fileExists,
|
||||
generateTimestamp,
|
||||
readFile,
|
||||
isWindows
|
||||
readFile
|
||||
} from '@sasjs/utils'
|
||||
|
||||
const execFilePromise = promisify(execFile)
|
||||
@@ -24,7 +23,9 @@ export class SessionController {
|
||||
protected sessions: Session[] = []
|
||||
|
||||
protected getReadySessions = (): Session[] =>
|
||||
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
|
||||
this.sessions.filter(
|
||||
(session: Session) => session.state === SessionState.pending
|
||||
)
|
||||
|
||||
protected async createSession(): Promise<Session> {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
@@ -40,19 +41,18 @@ export class SessionController {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
|
||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||
|
||||
this.sessions.push(session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ export class SessionController {
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
public getSessionById(id: string) {
|
||||
return this.sessions.find((session) => session.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export class SASSessionController extends SessionController {
|
||||
@@ -84,10 +88,7 @@ export class SASSessionController extends SessionController {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: false,
|
||||
inUse: false,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.initialising,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
@@ -134,7 +135,7 @@ ${autoExecContent}`
|
||||
session.path,
|
||||
'-AUTOEXEC',
|
||||
autoExecPath,
|
||||
isWindows() ? '-nologo' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nologo' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
||||
@@ -145,13 +146,20 @@ ${autoExecContent}`
|
||||
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||
])
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
session.state = SessionState.completed
|
||||
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
process.logger.error('session crashed', session.id, session.crashed)
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// we have a triggered session - add to array
|
||||
@@ -168,15 +176,19 @@ ${autoExecContent}`
|
||||
const codeFilePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// TODO: don't wait forever
|
||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||
while (
|
||||
(await fileExists(codeFilePath)) &&
|
||||
session.state !== SessionState.failed
|
||||
) {}
|
||||
|
||||
if (session.crashed)
|
||||
if (session.state === SessionState.failed) {
|
||||
process.logger.error(
|
||||
'session crashed! while waiting to be ready',
|
||||
session.crashed
|
||||
session.failureReason
|
||||
)
|
||||
|
||||
session.ready = true
|
||||
} else {
|
||||
session.state = SessionState.pending
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSession(session: Session) {
|
||||
@@ -190,17 +202,37 @@ ${autoExecContent}`
|
||||
}
|
||||
|
||||
private scheduleSessionDestroy(session: Session) {
|
||||
setTimeout(async () => {
|
||||
if (session.inUse) {
|
||||
// adding 10 more minutes
|
||||
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
setTimeout(
|
||||
async () => {
|
||||
if (session.state === SessionState.running) {
|
||||
// adding 10 more minutes
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
await this.deleteSession(session)
|
||||
}
|
||||
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100)
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
const { expiresAfterMins } = session
|
||||
|
||||
// delay session destroy if expiresAfterMins present
|
||||
if (expiresAfterMins && session.state !== SessionState.completed) {
|
||||
// calculate session death time using expiresAfterMins
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) +
|
||||
expiresAfterMins.mins * 60 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
|
||||
// set expiresAfterMins to true to avoid using it again
|
||||
session.expiresAfterMins!.used = true
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
await this.deleteSession(session)
|
||||
}
|
||||
}
|
||||
},
|
||||
parseInt(session.deathTimeStamp) - new Date().getTime() - 100
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,9 +260,16 @@ data _null_;
|
||||
rc=filename(fname,getoption('SYSIN') );
|
||||
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
||||
rc=filename(fname);
|
||||
/* now wait for the real SYSIN */
|
||||
slept=0;
|
||||
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
|
||||
/* now wait for the real SYSIN (location of code.sas) */
|
||||
slept=0;fname='';
|
||||
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);
|
||||
end;
|
||||
stop;
|
||||
|
||||
@@ -15,7 +15,7 @@ export const createJSProgram = async (
|
||||
) => {
|
||||
const varStatments = Object.keys(vars).reduce(
|
||||
(computed: string, key: string) =>
|
||||
`${computed}const ${key} = '${vars[key]}';\n`,
|
||||
`${computed}const ${key} = \`${vars[key]}\`;\n`,
|
||||
''
|
||||
)
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ export const createSASProgram = async (
|
||||
%mend;
|
||||
%_sasjs_server_init()
|
||||
|
||||
proc printto print="%sysfunc(getoption(log))";
|
||||
run;
|
||||
`
|
||||
|
||||
program = `
|
||||
|
||||
@@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { once } from 'stream'
|
||||
import { createFile, moveFile } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { PreProgramVars, Session, SessionState } from '../../types'
|
||||
import { RunTimeType } from '../../utils'
|
||||
import {
|
||||
ExecutionVars,
|
||||
@@ -49,7 +49,11 @@ export const processProgram = async (
|
||||
await moveFile(codePath + '.bkp', codePath)
|
||||
|
||||
// we now need to poll the session status
|
||||
while (!session.completed) {
|
||||
while (session.state !== SessionState.completed) {
|
||||
if (session.state === SessionState.failed) {
|
||||
throw new Error(session.failureReason || 'SAS session failed')
|
||||
}
|
||||
|
||||
await delay(50)
|
||||
}
|
||||
} else {
|
||||
@@ -114,13 +118,20 @@ export const processProgram = async (
|
||||
|
||||
await execFilePromise(executablePath, [codePath], writeStream)
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
session.state = SessionState.completed
|
||||
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
process.logger.error('session crashed', session.id, session.crashed)
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// copy the code file to log and end write stream
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
||||
import * as ProcessProgramModule from '../processProgram'
|
||||
import { ExecutionController, SessionExecutionError } from '../Execution'
|
||||
import { Session, SessionState, PreProgramVars } from '../../../types'
|
||||
import { RunTimeType } from '../../../utils'
|
||||
|
||||
const preProgramVariables: PreProgramVars = {
|
||||
username: 'testUser',
|
||||
userId: '1',
|
||||
displayName: 'Test User',
|
||||
serverUrl: 'http://localhost:5000',
|
||||
httpHeaders: []
|
||||
}
|
||||
|
||||
describe('ExecutionController.executeProgram', () => {
|
||||
let session: Session
|
||||
|
||||
beforeEach(() => {
|
||||
const sessionId = `test-session-${generateTimestamp()}`
|
||||
session = {
|
||||
id: sessionId,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp: '0',
|
||||
deathTimeStamp: '0',
|
||||
path: path.join(os.tmpdir(), sessionId)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks()
|
||||
await deleteFolder(session.path)
|
||||
})
|
||||
|
||||
// Regression coverage: for JS/PY/R, processProgram sets session.state to
|
||||
// `failed` itself (without throwing) on a non-zero interpreter exit.
|
||||
// ExecutionController.executeProgram used to unconditionally overwrite
|
||||
// that back to `completed` right after processProgram returned, silently
|
||||
// losing the failure for anything downstream that inspects session.state.
|
||||
describe('JS/PY/R failure path', () => {
|
||||
it('does not overwrite a failed session state back to completed', async () => {
|
||||
// mirrors processProgram's real JS/PY/R branch on a non-zero exit:
|
||||
// it sets state/failureReason itself and resolves - it does not throw
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(async () => {
|
||||
session.state = SessionState.failed
|
||||
session.failureReason = 'Error: process exited with code 1'
|
||||
})
|
||||
|
||||
const controller = new ExecutionController()
|
||||
|
||||
await controller.executeProgram({
|
||||
program: 'throw new Error("boom")',
|
||||
preProgramVariables,
|
||||
vars: {},
|
||||
session,
|
||||
runTime: RunTimeType.JS
|
||||
})
|
||||
|
||||
expect(session.state).toBe(SessionState.failed)
|
||||
})
|
||||
|
||||
it('still marks a genuinely successful session as completed', async () => {
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(async () => {
|
||||
session.state = SessionState.completed
|
||||
})
|
||||
|
||||
const controller = new ExecutionController()
|
||||
|
||||
await controller.executeProgram({
|
||||
program: 'console.log("hello")',
|
||||
preProgramVariables,
|
||||
vars: {},
|
||||
session,
|
||||
runTime: RunTimeType.JS
|
||||
})
|
||||
|
||||
expect(session.state).toBe(SessionState.completed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SAS failure path', () => {
|
||||
it('throws a SessionExecutionError carrying the complete log when the session fails', async () => {
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
const logContent =
|
||||
'NOTE: SAS session\nERROR: SAS session terminated. See log for details.\n'
|
||||
|
||||
await createFile(logPath, logContent)
|
||||
|
||||
jest
|
||||
.spyOn(ProcessProgramModule, 'processProgram')
|
||||
.mockImplementation(async () => {
|
||||
throw new Error('ERROR: SAS session terminated. See log for details.')
|
||||
})
|
||||
|
||||
const controller = new ExecutionController()
|
||||
|
||||
const resultPromise = controller.executeProgram({
|
||||
program: '%abort;',
|
||||
preProgramVariables,
|
||||
vars: {},
|
||||
session,
|
||||
runTime: RunTimeType.SAS
|
||||
})
|
||||
|
||||
await expect(resultPromise).rejects.toBeInstanceOf(SessionExecutionError)
|
||||
await expect(resultPromise).rejects.toMatchObject({
|
||||
log: logContent,
|
||||
message: expect.stringContaining('SAS session terminated')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { createFile, deleteFolder, generateTimestamp } from '@sasjs/utils'
|
||||
import { processProgram } from '../processProgram'
|
||||
import { Session, SessionState, PreProgramVars } from '../../../types'
|
||||
import {
|
||||
RunTimeType,
|
||||
getSessionsFolder,
|
||||
generateUniqueFileName
|
||||
} from '../../../utils'
|
||||
|
||||
const preProgramVariables: PreProgramVars = {
|
||||
username: 'testUser',
|
||||
userId: '1',
|
||||
displayName: 'Test User',
|
||||
serverUrl: 'http://localhost:5000',
|
||||
httpHeaders: []
|
||||
}
|
||||
|
||||
const makeSession = (): Session => {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
const deathTimeStamp = (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString()
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
state: SessionState.running,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
}
|
||||
|
||||
describe('processProgram (SAS runtime)', () => {
|
||||
let session: Session
|
||||
let logPath: string
|
||||
let weboutPath: string
|
||||
let headersPath: string
|
||||
let tokenFile: string
|
||||
|
||||
beforeAll(() => {
|
||||
const root = path.join(
|
||||
os.tmpdir(),
|
||||
`sasjs-processProgram-spec-${generateTimestamp()}`
|
||||
)
|
||||
process.sasjsRoot = root
|
||||
process.driveLoc = path.join(root, 'drive')
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
session = makeSession()
|
||||
logPath = path.join(session.path, 'log.log')
|
||||
weboutPath = path.join(session.path, 'webout.txt')
|
||||
headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||
await createFile(weboutPath, '')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteFolder(session.path)
|
||||
})
|
||||
|
||||
it('rejects instead of hanging when the session fails (e.g. %abort;)', async () => {
|
||||
setTimeout(() => {
|
||||
session.state = SessionState.failed
|
||||
session.failureReason =
|
||||
'ERROR: SAS session terminated. See log for details.'
|
||||
}, 100)
|
||||
|
||||
await expect(
|
||||
processProgram(
|
||||
'%abort;',
|
||||
preProgramVariables,
|
||||
{},
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
RunTimeType.SAS,
|
||||
logPath
|
||||
)
|
||||
).rejects.toThrow(/SAS session terminated/)
|
||||
}, 3000)
|
||||
|
||||
it('resolves without throwing when the session completes normally', async () => {
|
||||
setTimeout(() => {
|
||||
session.state = SessionState.completed
|
||||
}, 100)
|
||||
|
||||
await expect(
|
||||
processProgram(
|
||||
'%put hello world;',
|
||||
preProgramVariables,
|
||||
{},
|
||||
session,
|
||||
weboutPath,
|
||||
headersPath,
|
||||
tokenFile,
|
||||
RunTimeType.SAS,
|
||||
logPath
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
}, 3000)
|
||||
})
|
||||
@@ -56,9 +56,9 @@ interface RegisterPermissionPayload {
|
||||
principalType: PrincipalType
|
||||
/**
|
||||
* The id of user or group to which a rule is assigned.
|
||||
* @example 123
|
||||
* @example 'groupIdString'
|
||||
*/
|
||||
principalId: number
|
||||
principalId: string
|
||||
}
|
||||
|
||||
interface UpdatePermissionPayload {
|
||||
@@ -70,7 +70,7 @@ interface UpdatePermissionPayload {
|
||||
}
|
||||
|
||||
export interface PermissionDetailsResponse {
|
||||
permissionId: number
|
||||
uid: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
@@ -91,24 +91,24 @@ export class PermissionController {
|
||||
*/
|
||||
@Example<PermissionDetailsResponse[]>([
|
||||
{
|
||||
permissionId: 123,
|
||||
uid: 'permissionId1String',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
uid: 'user1-id',
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
},
|
||||
{
|
||||
permissionId: 124,
|
||||
uid: 'permissionId2String',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
group: {
|
||||
groupId: 1,
|
||||
uid: 'group1-id',
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
@@ -128,12 +128,12 @@ export class PermissionController {
|
||||
*
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uid: 'permissionIdString',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
uid: 'userIdString',
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
@@ -149,36 +149,36 @@ export class PermissionController {
|
||||
/**
|
||||
* @summary Update permission setting. Admin only
|
||||
* @param permissionId The permission's identifier
|
||||
* @example permissionId 1234
|
||||
* @example permissionId "permissionIdString"
|
||||
*/
|
||||
@Example<PermissionDetailsResponse>({
|
||||
permissionId: 123,
|
||||
uid: 'permissionIdString',
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: 'Route',
|
||||
setting: 'Grant',
|
||||
user: {
|
||||
id: 1,
|
||||
uid: 'userIdString',
|
||||
username: 'johnSnow01',
|
||||
displayName: 'John Snow',
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
@Patch('{permissionId}')
|
||||
@Patch('{uid}')
|
||||
public async updatePermission(
|
||||
@Path() permissionId: number,
|
||||
@Path() uid: string,
|
||||
@Body() body: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> {
|
||||
return updatePermission(permissionId, body)
|
||||
return updatePermission(uid, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Delete a permission. Admin only.
|
||||
* @param permissionId The user's identifier
|
||||
* @example permissionId 1234
|
||||
* @example permissionId "permissionIdString"
|
||||
*/
|
||||
@Delete('{permissionId}')
|
||||
public async deletePermission(@Path() permissionId: number) {
|
||||
return deletePermission(permissionId)
|
||||
@Delete('{uid}')
|
||||
public async deletePermission(@Path() uid: string) {
|
||||
return deletePermission(uid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ const getAllPermissions = async (
|
||||
else {
|
||||
const permissions: PermissionDetailsResponse[] = []
|
||||
|
||||
const dbUser = await User.findOne({ id: user?.userId })
|
||||
const dbUser = await User.findOne({ _id: user?.userId })
|
||||
if (!dbUser)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -227,7 +227,7 @@ const createPermission = async ({
|
||||
|
||||
switch (principalType) {
|
||||
case PrincipalType.user: {
|
||||
const userInDB = await User.findOne({ id: principalId })
|
||||
const userInDB = await User.findOne({ _id: principalId })
|
||||
if (!userInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -259,7 +259,7 @@ const createPermission = async ({
|
||||
permission.user = userInDB._id
|
||||
|
||||
user = {
|
||||
id: userInDB.id,
|
||||
uid: userInDB.uid,
|
||||
username: userInDB.username,
|
||||
displayName: userInDB.displayName,
|
||||
isAdmin: userInDB.isAdmin
|
||||
@@ -267,7 +267,7 @@ const createPermission = async ({
|
||||
break
|
||||
}
|
||||
case PrincipalType.group: {
|
||||
const groupInDB = await Group.findOne({ groupId: principalId })
|
||||
const groupInDB = await Group.findOne({ _id: principalId })
|
||||
if (!groupInDB)
|
||||
throw {
|
||||
code: 404,
|
||||
@@ -291,13 +291,13 @@ const createPermission = async ({
|
||||
permission.group = groupInDB._id
|
||||
|
||||
group = {
|
||||
groupId: groupInDB.groupId,
|
||||
uid: groupInDB.uid,
|
||||
name: groupInDB.name,
|
||||
description: groupInDB.description,
|
||||
isActive: groupInDB.isActive,
|
||||
users: groupInDB.populate({
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
select: 'uid username displayName isAdmin -_id',
|
||||
options: { limit: 15 }
|
||||
}) as unknown as UserResponse[]
|
||||
}
|
||||
@@ -314,7 +314,7 @@ const createPermission = async ({
|
||||
const savedPermission = await permission.save()
|
||||
|
||||
return {
|
||||
permissionId: savedPermission.permissionId,
|
||||
uid: savedPermission.uid,
|
||||
path: savedPermission.path,
|
||||
type: savedPermission.type,
|
||||
setting: savedPermission.setting,
|
||||
@@ -324,27 +324,21 @@ const createPermission = async ({
|
||||
}
|
||||
|
||||
const updatePermission = async (
|
||||
id: number,
|
||||
uid: string,
|
||||
data: UpdatePermissionPayload
|
||||
): Promise<PermissionDetailsResponse> => {
|
||||
const { setting } = data
|
||||
|
||||
const updatedPermission = (await Permission.findOneAndUpdate(
|
||||
{ permissionId: id },
|
||||
{ _id: uid },
|
||||
{ setting },
|
||||
{ new: true }
|
||||
)
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
path: 1,
|
||||
type: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.select('uid path type setting')
|
||||
.populate({ path: 'user', select: 'uid username displayName isAdmin' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id'
|
||||
select: 'groupId name description'
|
||||
})) as unknown as PermissionDetailsResponse
|
||||
if (!updatedPermission)
|
||||
throw {
|
||||
@@ -356,13 +350,13 @@ const updatePermission = async (
|
||||
return updatedPermission
|
||||
}
|
||||
|
||||
const deletePermission = async (id: number) => {
|
||||
const permission = await Permission.findOne({ permissionId: id })
|
||||
const deletePermission = async (uid: string) => {
|
||||
const permission = await Permission.findOne({ _id: uid })
|
||||
if (!permission)
|
||||
throw {
|
||||
code: 404,
|
||||
status: 'Not Found',
|
||||
message: 'Permission not found.'
|
||||
}
|
||||
await Permission.deleteOne({ permissionId: id })
|
||||
await Permission.deleteOne({ _id: uid })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||
import { UserResponse } from './user'
|
||||
import { getSessionController } from './internal'
|
||||
import { SessionState } from '../types'
|
||||
|
||||
interface SessionResponse extends UserResponse {
|
||||
needsToUpdatePassword: boolean
|
||||
needsToUpdatePassword?: boolean
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@@ -14,11 +16,12 @@ export class SessionController {
|
||||
* @summary Get session info (username).
|
||||
*
|
||||
*/
|
||||
@Example<UserResponse>({
|
||||
id: 123,
|
||||
@Example<SessionResponse>({
|
||||
uid: 'userIdString',
|
||||
username: 'johnusername',
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
isAdmin: false,
|
||||
needsToUpdatePassword: false
|
||||
})
|
||||
@Get('/')
|
||||
public async session(
|
||||
@@ -26,12 +29,44 @@ export class SessionController {
|
||||
): Promise<SessionResponse> {
|
||||
return session(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* The polling endpoint is currently implemented for single-server deployments only.<br>
|
||||
* Load balanced / grid topologies will be supported in a future release.<br>
|
||||
* If your site requires this, please reach out to SASjs Support.
|
||||
* @summary Get session state (initialising, pending, running, completed, failed).
|
||||
* @example completed
|
||||
*/
|
||||
@Get('/:sessionId/state')
|
||||
public async sessionState(sessionId: string): Promise<SessionState> {
|
||||
return sessionState(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const session = (req: express.Request) => ({
|
||||
id: req.user!.userId,
|
||||
uid: req.user!.userId,
|
||||
username: req.user!.username,
|
||||
displayName: req.user!.displayName,
|
||||
isAdmin: req.user!.isAdmin,
|
||||
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
||||
})
|
||||
|
||||
const sessionState = (sessionId: string): SessionState => {
|
||||
for (let runTime of process.runTimes) {
|
||||
// get session controller for each available runTime
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
// get session by sessionId
|
||||
const session = sessionController.getSessionById(sessionId)
|
||||
|
||||
// return session state if session was found
|
||||
if (session) {
|
||||
return session.state
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
code: 404,
|
||||
message: `Session with ID '${sessionId}' was not found.`
|
||||
}
|
||||
}
|
||||
|
||||
+120
-7
@@ -1,10 +1,12 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
||||
import { ExecutionController, ExecutionVars } from './internal'
|
||||
import {
|
||||
ExecutionController,
|
||||
ExecutionVars,
|
||||
getSessionController
|
||||
} from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
HTTPHeaders,
|
||||
LogLine,
|
||||
makeFilesNamesMap,
|
||||
getRunTimeAndFilePath
|
||||
} from '../utils'
|
||||
@@ -18,6 +20,36 @@ interface ExecutePostRequestPayload {
|
||||
_program?: string
|
||||
}
|
||||
|
||||
interface TriggerProgramPayload {
|
||||
/**
|
||||
* Location of SAS program.
|
||||
* @example "/Public/somefolder/some.file"
|
||||
*/
|
||||
_program: string
|
||||
/**
|
||||
* Amount of minutes after the completion of the program when the session must be
|
||||
* destroyed.
|
||||
* @example 15
|
||||
*/
|
||||
expiresAfterMins?: number
|
||||
/**
|
||||
* Query param for setting debug mode.
|
||||
*/
|
||||
_debug?: number
|
||||
}
|
||||
|
||||
interface TriggerProgramResponse {
|
||||
/**
|
||||
* `sessionId` is the ID of the session and the name of the temporary folder
|
||||
* used to store program outputs.<br><br>
|
||||
* For SAS, this would be the location of the SASWORK folder.<br><br>
|
||||
* `sessionId` can be used to poll session state using the
|
||||
* GET /SASjsApi/session/{sessionId}/state endpoint.
|
||||
* @example "20241028074744-54132-1730101664824"
|
||||
*/
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/stp')
|
||||
@Tags('STP')
|
||||
@@ -25,20 +57,31 @@ export class STPController {
|
||||
/**
|
||||
* Trigger a Stored Program using the _program URL parameter.
|
||||
*
|
||||
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||
* Accepts additional URL parameters (converted to session variables)
|
||||
* and file uploads. For more details, see docs:
|
||||
*
|
||||
* https://server.sasjs.io/storedprograms
|
||||
*
|
||||
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
||||
* @param _program Location of code in SASjs Drive
|
||||
* @param _program Location of Stored Program in SASjs Drive.
|
||||
* @param _debug Optional query param for setting debug mode (returns the session log in the response body).
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
* @example _debug 131
|
||||
*/
|
||||
@Get('/execute')
|
||||
public async executeGetRequest(
|
||||
@Request() request: express.Request,
|
||||
@Query() _program: string
|
||||
@Query() _program: string,
|
||||
@Query() _debug?: number
|
||||
): Promise<string | Buffer> {
|
||||
const vars = request.query as ExecutionVars
|
||||
let vars = request.query as ExecutionVars
|
||||
if (_debug) {
|
||||
vars = {
|
||||
...vars,
|
||||
_debug
|
||||
}
|
||||
}
|
||||
|
||||
return execute(request, _program, vars)
|
||||
}
|
||||
|
||||
@@ -69,6 +112,26 @@ export class STPController {
|
||||
|
||||
return execute(request, program!, vars, otherArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Program on the Specified Runtime.
|
||||
* @summary Triggers program and returns SessionId immediately - does not wait for program completion.
|
||||
* @param _program Location of code in SASjs Drive.
|
||||
* @param expiresAfterMins Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.
|
||||
* @param _debug Optional query param for setting debug mode.
|
||||
* @example _program "/Projects/myApp/some/program"
|
||||
* @example _debug 131
|
||||
* @example expiresAfterMins 15
|
||||
*/
|
||||
@Post('/trigger')
|
||||
public async triggerProgram(
|
||||
@Request() request: express.Request,
|
||||
@Query() _program: string,
|
||||
@Query() _debug?: number,
|
||||
@Query() expiresAfterMins?: number
|
||||
): Promise<TriggerProgramResponse> {
|
||||
return triggerProgram(request, { _program, _debug, expiresAfterMins })
|
||||
}
|
||||
}
|
||||
|
||||
const execute = async (
|
||||
@@ -98,6 +161,56 @@ const execute = async (
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err,
|
||||
log: err?.log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+32
-38
@@ -26,18 +26,14 @@ import {
|
||||
import { GroupController, GroupResponse } from './group'
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
uid: string
|
||||
username: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface UserDetailsResponse {
|
||||
id: number
|
||||
displayName: string
|
||||
username: string
|
||||
export interface UserDetailsResponse extends UserResponse {
|
||||
isActive: boolean
|
||||
isAdmin: boolean
|
||||
autoExec?: string
|
||||
groups?: GroupResponse[]
|
||||
}
|
||||
@@ -52,13 +48,13 @@ export class UserController {
|
||||
*/
|
||||
@Example<UserResponse[]>([
|
||||
{
|
||||
id: 123,
|
||||
uid: 'userIdString',
|
||||
username: 'johnusername',
|
||||
displayName: 'John',
|
||||
isAdmin: false
|
||||
},
|
||||
{
|
||||
id: 456,
|
||||
uid: 'anotherUserIdString',
|
||||
username: 'starkusername',
|
||||
displayName: 'Stark',
|
||||
isAdmin: true
|
||||
@@ -74,7 +70,7 @@ export class UserController {
|
||||
*
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
uid: 'userIdString',
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
@@ -111,20 +107,20 @@ export class UserController {
|
||||
* Only Admin or user itself will get user autoExec code.
|
||||
* @summary Get user properties - such as group memberships, userName, displayName.
|
||||
* @param userId The user's identifier
|
||||
* @example userId 1234
|
||||
* @example userId "userIdString"
|
||||
*/
|
||||
@Get('{userId}')
|
||||
@Get('{uid}')
|
||||
public async getUser(
|
||||
@Request() req: express.Request,
|
||||
@Path() userId: number
|
||||
@Path() uid: string
|
||||
): Promise<UserDetailsResponse> {
|
||||
const { MODE } = process.env
|
||||
|
||||
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
|
||||
|
||||
const { user } = req
|
||||
const getAutoExec = user!.isAdmin || user!.userId == userId
|
||||
return getUser({ id: userId }, getAutoExec)
|
||||
const getAutoExec = user!.isAdmin || user!.userId === uid
|
||||
return getUser({ _id: uid }, getAutoExec)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +129,7 @@ export class UserController {
|
||||
* @example username "johnSnow01"
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
uid: 'userIdString',
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
@@ -158,7 +154,7 @@ export class UserController {
|
||||
* @example userId "1234"
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
uid: 'userIdString',
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
@@ -166,7 +162,7 @@ export class UserController {
|
||||
})
|
||||
@Patch('{userId}')
|
||||
public async updateUser(
|
||||
@Path() userId: number,
|
||||
@Path() userId: string,
|
||||
@Body() body: UserPayload
|
||||
): Promise<UserDetailsResponse> {
|
||||
const { MODE } = process.env
|
||||
@@ -174,7 +170,7 @@ export class UserController {
|
||||
if (MODE === ModeType.Desktop)
|
||||
return updateDesktopAutoExec(body.autoExec ?? '')
|
||||
|
||||
return updateUser({ id: userId }, body)
|
||||
return updateUser({ _id: userId }, body)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,18 +194,16 @@ export class UserController {
|
||||
*/
|
||||
@Delete('{userId}')
|
||||
public async deleteUser(
|
||||
@Path() userId: number,
|
||||
@Path() userId: string,
|
||||
@Body() body: { password?: string },
|
||||
@Query() @Hidden() isAdmin: boolean = false
|
||||
) {
|
||||
return deleteUser({ id: userId }, isAdmin, body)
|
||||
return deleteUser({ _id: userId }, isAdmin, body)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllUsers = async (): Promise<UserResponse[]> =>
|
||||
await User.find({})
|
||||
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
|
||||
.exec()
|
||||
await User.find({}).select('uid username displayName isAdmin').exec()
|
||||
|
||||
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
const { displayName, username, password, isAdmin, isActive, autoExec } = data
|
||||
@@ -239,15 +233,15 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
|
||||
const groupController = new GroupController()
|
||||
const allUsersGroup = await groupController
|
||||
.getGroupByGroupName(ALL_USERS_GROUP.name)
|
||||
.getGroupByName(ALL_USERS_GROUP.name)
|
||||
.catch(() => {})
|
||||
|
||||
if (allUsersGroup) {
|
||||
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
|
||||
await groupController.addUserToGroup(allUsersGroup.uid, savedUser.uid)
|
||||
}
|
||||
|
||||
return {
|
||||
id: savedUser.id,
|
||||
uid: savedUser.uid,
|
||||
displayName: savedUser.displayName,
|
||||
username: savedUser.username,
|
||||
isActive: savedUser.isActive,
|
||||
@@ -256,8 +250,8 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
}
|
||||
}
|
||||
|
||||
interface GetUserBy {
|
||||
id?: number
|
||||
export interface GetUserBy {
|
||||
_id?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
@@ -267,10 +261,10 @@ const getUser = async (
|
||||
): Promise<UserDetailsResponse> => {
|
||||
const user = (await User.findOne(
|
||||
findBy,
|
||||
`id displayName username isActive isAdmin autoExec -_id`
|
||||
`uid displayName username isActive isAdmin autoExec`
|
||||
).populate(
|
||||
'groups',
|
||||
'groupId name description -_id'
|
||||
'uid name description'
|
||||
)) as unknown as UserDetailsResponse
|
||||
|
||||
if (!user)
|
||||
@@ -280,12 +274,12 @@ const getUser = async (
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
uid: user.uid,
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
isActive: user.isActive,
|
||||
isAdmin: user.isAdmin,
|
||||
autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
|
||||
autoExec: getAutoExec ? (user.autoExec ?? '') : undefined,
|
||||
groups: user.groups
|
||||
}
|
||||
}
|
||||
@@ -293,7 +287,7 @@ const getUser = async (
|
||||
const getDesktopAutoExec = async () => {
|
||||
return {
|
||||
...desktopUser,
|
||||
id: desktopUser.userId,
|
||||
uid: desktopUser.userId,
|
||||
autoExec: await getUserAutoExec()
|
||||
}
|
||||
}
|
||||
@@ -329,8 +323,8 @@ const updateUser = async (
|
||||
const usernameExist = await User.findOne({ username })
|
||||
if (usernameExist) {
|
||||
if (
|
||||
(findBy.id && usernameExist.id != findBy.id) ||
|
||||
(findBy.username && usernameExist.username != findBy.username)
|
||||
(findBy._id && usernameExist.uid !== findBy._id) ||
|
||||
(findBy.username && usernameExist.username !== findBy.username)
|
||||
)
|
||||
throw {
|
||||
code: 409,
|
||||
@@ -350,11 +344,11 @@ const updateUser = async (
|
||||
if (!updatedUser)
|
||||
throw {
|
||||
code: 404,
|
||||
message: `Unable to find user with ${findBy.id || findBy.username}`
|
||||
message: `Unable to find user with ${findBy._id || findBy.username}`
|
||||
}
|
||||
|
||||
return {
|
||||
id: updatedUser.id,
|
||||
uid: updatedUser.uid,
|
||||
username: updatedUser.username,
|
||||
displayName: updatedUser.displayName,
|
||||
isAdmin: updatedUser.isAdmin,
|
||||
@@ -367,7 +361,7 @@ const updateDesktopAutoExec = async (autoExec: string) => {
|
||||
await updateUserAutoExec(autoExec)
|
||||
return {
|
||||
...desktopUser,
|
||||
id: desktopUser.userId,
|
||||
uid: desktopUser.userId,
|
||||
autoExec
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,10 @@ const login = async (
|
||||
const rateLimiter = RateLimiter.getInstance()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -114,11 +117,11 @@ const login = async (
|
||||
if (!validPass) throw errors.invalidPassword
|
||||
|
||||
// Reset on successful authorization
|
||||
rateLimiter.resetOnSuccess(req.ip, user.username)
|
||||
rateLimiter.resetOnSuccess(req.ip || 'unknown', user.username)
|
||||
|
||||
req.session.loggedIn = true
|
||||
req.session.user = {
|
||||
userId: user.id,
|
||||
userId: user.uid,
|
||||
clientId: 'web_app',
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
@@ -131,7 +134,7 @@ const login = async (
|
||||
return {
|
||||
loggedIn: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
uid: user.uid,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
|
||||
@@ -37,10 +37,10 @@ export const authenticateAccessToken: RequestHandler = async (
|
||||
if (user.isActive) {
|
||||
req.user = user
|
||||
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(
|
||||
@@ -76,7 +76,7 @@ const authenticateToken = async (
|
||||
const { MODE } = process.env
|
||||
if (MODE === ModeType.Desktop) {
|
||||
req.user = {
|
||||
userId: 1234,
|
||||
userId: '1234',
|
||||
clientId: 'desktopModeClientId',
|
||||
username: 'desktopModeUsername',
|
||||
displayName: 'desktopModeDisplayName',
|
||||
@@ -118,6 +118,6 @@ const authenticateToken = async (
|
||||
return next()
|
||||
}
|
||||
|
||||
res.sendStatus(401)
|
||||
res.status(401).send('Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
||||
// no need to check for permissions when route is Public
|
||||
if (await isPublicRoute(req)) return next()
|
||||
|
||||
const dbUser = await User.findOne({ id: user.userId })
|
||||
const dbUser = await User.findOne({ _id: user.userId })
|
||||
if (!dbUser) return res.sendStatus(401)
|
||||
|
||||
const path = getPath(req)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { convertSecondsToHms } from '@sasjs/utils'
|
||||
import { RateLimiter } from '../utils'
|
||||
|
||||
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
||||
const ip = req.ip
|
||||
const ip = req.ip || 'unknown'
|
||||
const username = req.body.username
|
||||
|
||||
const rateLimiter = RateLimiter.getInstance()
|
||||
|
||||
@@ -28,7 +28,7 @@ export const desktopRestrict: RequestHandler = (req, res, next) => {
|
||||
}
|
||||
|
||||
export const desktopUser: RequestUser = {
|
||||
userId: 12345,
|
||||
userId: '12345',
|
||||
clientId: 'desktop_app',
|
||||
username: userInfo().username,
|
||||
displayName: userInfo().username,
|
||||
|
||||
@@ -8,8 +8,8 @@ export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => {
|
||||
if (!user?.isAdmin) {
|
||||
let adminAccountRequired: boolean = true
|
||||
|
||||
if (req.params.userId) {
|
||||
adminAccountRequired = user?.userId !== parseInt(req.params.userId)
|
||||
if (req.params.uid) {
|
||||
adminAccountRequired = user?.userId !== req.params.uid
|
||||
} else if (req.params.username) {
|
||||
adminAccountRequired = user?.username !== req.params.username
|
||||
}
|
||||
|
||||
+42
-27
@@ -1,10 +1,9 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { GroupDetailsResponse } from '../controllers'
|
||||
import User, { IUser } from './User'
|
||||
import { AuthProviderType } from '../utils'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
|
||||
export const PUBLIC_GROUP_NAME = 'Public'
|
||||
export const PUBLIC_GROUP_NAME = 'public'
|
||||
|
||||
export interface GroupPayload {
|
||||
/**
|
||||
@@ -25,10 +24,12 @@ export interface GroupPayload {
|
||||
}
|
||||
|
||||
interface IGroupDocument extends GroupPayload, Document {
|
||||
groupId: number
|
||||
isActive: boolean
|
||||
users: Schema.Types.ObjectId[]
|
||||
authProvider?: AuthProviderType
|
||||
|
||||
// Declare virtual properties as read-only properties
|
||||
readonly uid: string
|
||||
}
|
||||
|
||||
interface IGroup extends IGroupDocument {
|
||||
@@ -38,38 +39,52 @@ interface IGroup extends IGroupDocument {
|
||||
}
|
||||
interface IGroupModel extends Model<IGroup> {}
|
||||
|
||||
const groupSchema = new Schema<IGroupDocument>({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
const opts = {
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function (doc: any, ret: any, options: any) {
|
||||
delete ret._id
|
||||
delete ret.id
|
||||
return ret
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupSchema = new Schema<IGroupDocument>(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Group description.'
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Group description.'
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||
opts
|
||||
)
|
||||
|
||||
groupSchema.virtual('uid').get(function () {
|
||||
return this._id.toString()
|
||||
})
|
||||
|
||||
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
|
||||
|
||||
// Hooks
|
||||
groupSchema.post('save', function (group: IGroup, next: Function) {
|
||||
group.populate('users', 'id username displayName -_id').then(function () {
|
||||
group.populate('users', 'uid username displayName').then(function () {
|
||||
next()
|
||||
})
|
||||
})
|
||||
|
||||
// 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
|
||||
await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
|
||||
+41
-30
@@ -1,5 +1,4 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { PermissionDetailsResponse } from '../controllers'
|
||||
|
||||
interface GetPermissionBy {
|
||||
@@ -11,9 +10,11 @@ interface IPermissionDocument extends Document {
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
permissionId: number
|
||||
user: Schema.Types.ObjectId
|
||||
group: Schema.Types.ObjectId
|
||||
|
||||
// Declare virtual properties as read-only properties
|
||||
readonly uid: string
|
||||
}
|
||||
|
||||
interface IPermission extends IPermissionDocument {}
|
||||
@@ -22,44 +23,54 @@ interface IPermissionModel extends Model<IPermission> {
|
||||
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
|
||||
}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>({
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
setting: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||
})
|
||||
const opts = {
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function (doc: any, ret: any, options: any) {
|
||||
delete ret._id
|
||||
delete ret.id
|
||||
return ret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
|
||||
const permissionSchema = new Schema<IPermissionDocument>(
|
||||
{
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
setting: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
user: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||
},
|
||||
opts
|
||||
)
|
||||
|
||||
permissionSchema.virtual('uid').get(function () {
|
||||
return this._id.toString()
|
||||
})
|
||||
|
||||
// Static Methods
|
||||
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
||||
PermissionDetailsResponse[]
|
||||
> {
|
||||
return (await this.find(getBy)
|
||||
.select({
|
||||
_id: 0,
|
||||
permissionId: 1,
|
||||
path: 1,
|
||||
type: 1,
|
||||
setting: 1
|
||||
})
|
||||
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||
.select('uid path type setting')
|
||||
.populate({ path: 'user', select: 'uid username displayName isAdmin' })
|
||||
.populate({
|
||||
path: 'group',
|
||||
select: 'groupId name description -_id',
|
||||
select: 'uid name description',
|
||||
populate: {
|
||||
path: 'users',
|
||||
select: 'id username displayName isAdmin -_id',
|
||||
select: 'uid username displayName isAdmin',
|
||||
options: { limit: 15 }
|
||||
}
|
||||
})) as unknown as PermissionDetailsResponse[]
|
||||
|
||||
+71
-54
@@ -1,5 +1,4 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { Schema, model, Document, Model, ObjectId } from 'mongoose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AuthProviderType } from '../utils'
|
||||
|
||||
@@ -37,7 +36,6 @@ export interface UserPayload {
|
||||
|
||||
interface IUserDocument extends UserPayload, Document {
|
||||
_id: Schema.Types.ObjectId
|
||||
id: number
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
@@ -45,6 +43,9 @@ interface IUserDocument extends UserPayload, Document {
|
||||
groups: Schema.Types.ObjectId[]
|
||||
tokens: [{ [key: string]: string }]
|
||||
authProvider?: AuthProviderType
|
||||
|
||||
// Declare virtual properties as read-only properties
|
||||
readonly uid: string
|
||||
}
|
||||
|
||||
export interface IUser extends IUserDocument {
|
||||
@@ -55,59 +56,75 @@ export interface IUser extends IUserDocument {
|
||||
interface IUserModel extends Model<IUser> {
|
||||
hashPassword(password: string): string
|
||||
}
|
||||
|
||||
const userSchema = new Schema<IUserDocument>({
|
||||
displayName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
needsToUpdatePassword: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoExec: {
|
||||
type: String
|
||||
},
|
||||
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
|
||||
tokens: [
|
||||
{
|
||||
clientId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
accessToken: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
refreshToken: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
const opts = {
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
transform: function (doc: any, ret: any, options: any) {
|
||||
delete ret._id
|
||||
delete ret.id
|
||||
return ret
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const userSchema = new Schema<IUserDocument>(
|
||||
{
|
||||
displayName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
needsToUpdatePassword: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoExec: {
|
||||
type: String
|
||||
},
|
||||
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
|
||||
tokens: [
|
||||
{
|
||||
clientId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
accessToken: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
refreshToken: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
opts
|
||||
)
|
||||
|
||||
userSchema.virtual('uid').get(function () {
|
||||
return this._id.toString()
|
||||
})
|
||||
userSchema.plugin(AutoIncrement, { inc_field: 'id' })
|
||||
|
||||
// Static Methods
|
||||
userSchema.static('hashPassword', (password: string): string => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express'
|
||||
import { runCodeValidation } from '../../utils'
|
||||
import { runCodeValidation, triggerCodeValidation } from '../../utils'
|
||||
import { CodeController } from '../../controllers/'
|
||||
|
||||
const runRouter = express.Router()
|
||||
@@ -28,4 +28,22 @@ runRouter.post('/execute', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
runRouter.post('/trigger', async (req, res) => {
|
||||
const { error, value: body } = triggerCodeValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.triggerCode(req, body)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default runRouter
|
||||
|
||||
+24
-20
@@ -1,7 +1,11 @@
|
||||
import express from 'express'
|
||||
import { GroupController } from '../../controllers/'
|
||||
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||
import { getGroupValidation, registerGroupValidation } from '../../utils'
|
||||
import {
|
||||
getGroupValidation,
|
||||
registerGroupValidation,
|
||||
uidValidation
|
||||
} from '../../utils'
|
||||
|
||||
const groupRouter = express.Router()
|
||||
|
||||
@@ -33,12 +37,15 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
|
||||
const { groupId } = req.params
|
||||
groupRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.getGroup(parseInt(groupId))
|
||||
const response = await controller.getGroup(uid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -56,7 +63,7 @@ groupRouter.get(
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.getGroupByGroupName(name)
|
||||
const response = await controller.getGroupByName(name)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -65,18 +72,15 @@ groupRouter.get(
|
||||
)
|
||||
|
||||
groupRouter.post(
|
||||
'/:groupId/:userId',
|
||||
'/:groupUid/:userUid',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const { groupId, userId } = req.params
|
||||
const { groupUid, userUid } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.addUserToGroup(
|
||||
parseInt(groupId),
|
||||
parseInt(userId)
|
||||
)
|
||||
const response = await controller.addUserToGroup(groupUid, userUid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -85,18 +89,15 @@ groupRouter.post(
|
||||
)
|
||||
|
||||
groupRouter.delete(
|
||||
'/:groupId/:userId',
|
||||
'/:groupUid/:userUid',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const { groupId, userId } = req.params
|
||||
const { groupUid, userUid } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.removeUserFromGroup(
|
||||
parseInt(groupId),
|
||||
parseInt(userId)
|
||||
)
|
||||
const response = await controller.removeUserFromGroup(groupUid, userUid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -105,15 +106,18 @@ groupRouter.delete(
|
||||
)
|
||||
|
||||
groupRouter.delete(
|
||||
'/:groupId',
|
||||
'/:uid',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const { groupId } = req.params
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
await controller.deleteGroup(parseInt(groupId))
|
||||
await controller.deleteGroup(uid)
|
||||
res.status(200).send('Group Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PermissionController } from '../../controllers/'
|
||||
import { verifyAdmin } from '../../middlewares'
|
||||
import {
|
||||
registerPermissionValidation,
|
||||
uidValidation,
|
||||
updatePermissionValidation
|
||||
} from '../../utils'
|
||||
|
||||
@@ -34,14 +35,17 @@ permissionRouter.post('/', verifyAdmin, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
permissionRouter.patch('/:uid', verifyAdmin, async (req: any, res) => {
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const { error, value: body } = updatePermissionValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.updatePermission(permissionId, body)
|
||||
const response = await controller.updatePermission(uid, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
@@ -50,20 +54,18 @@ permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
permissionRouter.delete(
|
||||
'/:permissionId',
|
||||
verifyAdmin,
|
||||
async (req: any, res) => {
|
||||
const { permissionId } = req.params
|
||||
permissionRouter.delete('/:uid', verifyAdmin, async (req: any, res) => {
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
try {
|
||||
await controller.deletePermission(permissionId)
|
||||
res.status(200).send('Permission Deleted!')
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
const { uid } = params
|
||||
try {
|
||||
await controller.deletePermission(uid)
|
||||
res.status(200).send('Permission Deleted!')
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
delete err.code
|
||||
res.status(statusCode).send(err.message)
|
||||
}
|
||||
)
|
||||
})
|
||||
export default permissionRouter
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import express from 'express'
|
||||
import { SessionController } from '../../controllers'
|
||||
import { sessionIdValidation } from '../../utils'
|
||||
|
||||
const sessionRouter = express.Router()
|
||||
|
||||
const controller = new SessionController()
|
||||
|
||||
sessionRouter.get('/', async (req, res) => {
|
||||
const controller = new SessionController()
|
||||
try {
|
||||
const response = await controller.session(req)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sessionRouter.get('/:sessionId/state', async (req, res) => {
|
||||
const { error, value: params } = sessionIdValidation(req.params)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.sessionState(params.sessionId)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default sessionRouter
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
generateAccessToken,
|
||||
generateAuthCode,
|
||||
generateRefreshToken,
|
||||
randomBytesHexString,
|
||||
saveTokensInDB,
|
||||
verifyTokenInDB
|
||||
} from '../../../utils'
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
const clientId = 'someclientID'
|
||||
const clientSecret = 'someclientSecret'
|
||||
const user = {
|
||||
id: 1234,
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
@@ -52,7 +52,7 @@ describe('auth', () => {
|
||||
describe('token', () => {
|
||||
const userInfo: InfoJWT = {
|
||||
clientId,
|
||||
userId: user.id
|
||||
userId: randomBytesHexString(12)
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await userController.createUser(user)
|
||||
@@ -151,10 +151,10 @@ describe('auth', () => {
|
||||
currentUser = await userController.createUser(user)
|
||||
refreshToken = generateRefreshToken({
|
||||
clientId,
|
||||
userId: currentUser.id
|
||||
userId: currentUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
currentUser.id,
|
||||
currentUser.uid,
|
||||
clientId,
|
||||
'accessToken',
|
||||
refreshToken
|
||||
@@ -202,11 +202,11 @@ describe('auth', () => {
|
||||
currentUser = await userController.createUser(user)
|
||||
accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: currentUser.id
|
||||
userId: currentUser.uid
|
||||
})
|
||||
|
||||
await saveTokensInDB(
|
||||
currentUser.id,
|
||||
currentUser.uid,
|
||||
clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
|
||||
@@ -40,10 +40,10 @@ describe('client', () => {
|
||||
const dbUser = await userController.createUser(adminUser)
|
||||
adminAccessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
dbUser.uid,
|
||||
client.clientId,
|
||||
adminAccessToken,
|
||||
'refreshToken'
|
||||
@@ -95,10 +95,10 @@ describe('client', () => {
|
||||
const dbUser = await userController.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
dbUser.uid,
|
||||
client.clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
@@ -212,10 +212,10 @@ describe('client', () => {
|
||||
const dbUser = await userController.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
dbUser.uid,
|
||||
client.clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import path from 'path'
|
||||
import { createFile, fileExists } from '@sasjs/utils'
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import {
|
||||
UserController,
|
||||
PermissionController,
|
||||
PermissionType,
|
||||
PermissionSettingForRoute,
|
||||
PrincipalType
|
||||
} from '../../../controllers/'
|
||||
import {
|
||||
generateAccessToken,
|
||||
saveTokensInDB,
|
||||
RunTimeType,
|
||||
sysInitCompiledPath
|
||||
} from '../../../utils'
|
||||
|
||||
// Real, unmocked end-to-end test of the SAS execution pipeline (session
|
||||
// spawn -> autoexec handshake -> exit code -> HTTP response), using a fake
|
||||
// "SAS executable" (mockSas.js) in place of a real SAS install. This is the
|
||||
// regression test for issue #388: SAS code that aborts (%abort;) used to
|
||||
// hang the request forever instead of returning an error response.
|
||||
const mockSasPath = path.join(__dirname, 'files', 'mockSas.js')
|
||||
|
||||
const clientId = 'codeSpecClientID'
|
||||
|
||||
const user = {
|
||||
displayName: 'Code Spec User',
|
||||
username: 'codeSpecUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
let app: Express
|
||||
let accessToken: string
|
||||
|
||||
describe('code', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let userController: UserController
|
||||
let permissionController: PermissionController
|
||||
|
||||
beforeAll(async () => {
|
||||
// SASSessionController.createSession() unconditionally reads this file
|
||||
// (Session.ts:105) before it ever spawns the SAS process. It's normally
|
||||
// produced by `npm run compileSysInit` (part of the `initial`/`build`
|
||||
// scripts), which `npm test` does not run - so on a fresh checkout
|
||||
// (e.g. CI, where "Run Unit Tests" happens before "Build Package") it
|
||||
// doesn't exist yet. Content is irrelevant here since mockSas.js never
|
||||
// interprets it; it just needs to exist and be readable.
|
||||
if (!(await fileExists(sysInitCompiledPath))) {
|
||||
await createFile(sysInitCompiledPath, '')
|
||||
}
|
||||
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
process.env.DB_CONNECT = mongoServer.getUri()
|
||||
process.env.MODE = 'server'
|
||||
process.env.RUN_TIMES = 'sas'
|
||||
process.env.SAS_PATH = mockSasPath
|
||||
|
||||
const appPromise = (await import('../../../app')).default
|
||||
app = await appPromise
|
||||
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
userController = new UserController()
|
||||
permissionController = new PermissionController()
|
||||
|
||||
const dbUser = await userController.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
await permissionController.createPermission({
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.uid,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
|
||||
process.runTimes = [RunTimeType.SAS]
|
||||
process.sasLoc = mockSasPath
|
||||
}, 30000)
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns 200 with the mock log when the SAS program completes normally', async () => {
|
||||
const response = await request(app)
|
||||
.post('/SASjsApi/code/execute')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ code: '%put hello world;', runTime: 'sas' })
|
||||
.expect(200)
|
||||
|
||||
expect(response.text).toEqual(
|
||||
expect.stringContaining('mock SAS execution')
|
||||
)
|
||||
}, 30000)
|
||||
|
||||
it('returns a prompt 400 (not a hang) with the complete log when the SAS session fails (%abort;)', async () => {
|
||||
const response = await request(app)
|
||||
.post('/SASjsApi/code/execute')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ code: '%abort;', runTime: 'sas' })
|
||||
.expect(400)
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.'
|
||||
})
|
||||
expect(response.body.log).toEqual(
|
||||
expect.stringContaining('mock SAS execution')
|
||||
)
|
||||
}, 30000)
|
||||
})
|
||||
})
|
||||
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, accessToken, 'refreshToken')
|
||||
return accessToken
|
||||
}
|
||||
@@ -28,6 +28,15 @@ jest
|
||||
.spyOn(fileUtilModules, 'getUploadsFolder')
|
||||
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
|
||||
|
||||
// getFilesFolder() resolves via getSasjsDriveFolder()/process.driveLoc, a
|
||||
// separate root from getSasjsRootFolder()/process.sasjsRoot above - without
|
||||
// this, every test in this file that creates content under the drive (e.g.
|
||||
// 'level1', 'my/path/...') writes into the real, shared sasjs_root/drive
|
||||
// instead of this run's isolated tmpFolder, leaking state into later runs.
|
||||
jest
|
||||
.spyOn(fileUtilModules, 'getFilesFolder')
|
||||
.mockImplementation(() => path.join(tmpFolder, 'drive', 'files'))
|
||||
|
||||
import appPromise from '../../../app'
|
||||
import {
|
||||
UserController,
|
||||
@@ -71,31 +80,31 @@ describe('drive', () => {
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
const dbUser = await controller.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy/upload',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/file',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/folder',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/rename',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1197,7 +1206,7 @@ const getExampleService = (): ServiceMember =>
|
||||
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
|
||||
.members[0] as ServiceMember
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Minimal fake "SAS executable" used by tests in place of a real SAS
|
||||
// install. It only fulfils the CLI/filesystem handshake contract that
|
||||
// Session.ts / processProgram.ts rely on:
|
||||
//
|
||||
// -SYSIN <path> the file used as a signal channel: it starts out
|
||||
// containing a dummy (empty) placeholder, we delete it
|
||||
// to signal "session ready", then wait for it to be
|
||||
// rewritten with the real submitted program.
|
||||
// -LOG <path> where we write a fake log so downstream fileExists()/
|
||||
// readFile() calls have something to find.
|
||||
//
|
||||
// It does NOT interpret real SAS syntax. Exit code is the only thing that
|
||||
// matters to the Node side: 0 mimics a normal SAS termination, non-zero
|
||||
// mimics an abnormal one (e.g. %abort;).
|
||||
|
||||
const fs = require('fs')
|
||||
|
||||
const arg = (flag) => {
|
||||
const idx = process.argv.indexOf(flag)
|
||||
|
||||
return idx === -1 ? undefined : process.argv[idx + 1]
|
||||
}
|
||||
|
||||
const sysin = arg('-SYSIN')
|
||||
const logPath = arg('-LOG')
|
||||
|
||||
// Give up waiting for the real program after this long, so an unused
|
||||
// pre-warmed session (sasjs pools up to 3 ready sessions) doesn't linger
|
||||
// forever and keep a test process alive. Generous rather than tight: CI
|
||||
// runners are frequently slower/more contended than a local dev machine
|
||||
// (shared CPU, coverage instrumentation on the Node side slowing down the
|
||||
// round trip this process is waiting on), and this cost is only ever paid
|
||||
// by sessions nothing is actually waiting on - the session actually used
|
||||
// by a request gets its real code written far sooner than this in practice.
|
||||
const GIVE_UP_AFTER_MS = 8000
|
||||
|
||||
const sleepSync = (ms) => {
|
||||
const until = Date.now() + ms
|
||||
|
||||
while (Date.now() < until) {
|
||||
/* busy-wait, mirroring the real autoexec's SAS-side sleep() loop */
|
||||
}
|
||||
}
|
||||
|
||||
// Any of the filesystem calls below can legitimately race against
|
||||
// processProgram's own write-then-rename (e.g. existsSync sees the file,
|
||||
// then a rename mid-flight makes the following read miss). Treat that as
|
||||
// "not ready yet" and retry a few times, rather than letting an uncaught
|
||||
// exception crash this process with a non-zero exit - which would be
|
||||
// indistinguishable, to the Node side, from a genuine SAS failure.
|
||||
const retry = (fn, attempts = 5, delayMs = 20) => {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return { ok: true, value: fn() }
|
||||
} catch (err) {
|
||||
if (i === attempts - 1) return { ok: false, error: err }
|
||||
|
||||
sleepSync(delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. remove the dummy SYSIN, signalling "session ready" to waitForSession()
|
||||
if (sysin) {
|
||||
retry(() => {
|
||||
if (fs.existsSync(sysin)) fs.unlinkSync(sysin)
|
||||
})
|
||||
}
|
||||
|
||||
// 2. wait for the real program to be written back to the same path
|
||||
const deadline = Date.now() + GIVE_UP_AFTER_MS
|
||||
|
||||
while (!sysin || !fs.existsSync(sysin)) {
|
||||
if (Date.now() > deadline) process.exit(0)
|
||||
|
||||
sleepSync(10)
|
||||
}
|
||||
|
||||
// small settle delay, mirroring the real autoexec's sleep(0.01,1) after
|
||||
// detecting the file, so a fast-moving rename isn't read mid-write
|
||||
sleepSync(50)
|
||||
|
||||
const readResult = retry(() => fs.readFileSync(sysin, 'utf-8'))
|
||||
|
||||
if (!readResult.ok) {
|
||||
process.stderr.write(
|
||||
`mockSas.js: failed to read ${sysin}: ${readResult.error}\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const code = readResult.value
|
||||
|
||||
if (logPath) {
|
||||
retry(() => fs.writeFileSync(logPath, `NOTE: mock SAS execution\n${code}\n`))
|
||||
}
|
||||
|
||||
if (code.includes('%abort;')) {
|
||||
process.stderr.write('ERROR: SAS session terminated. See log for details.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../../utils'
|
||||
import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group'
|
||||
import User from '../../../model/User'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const adminUser = {
|
||||
@@ -75,7 +76,7 @@ describe('group', () => {
|
||||
.send(group)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -155,7 +156,7 @@ describe('group', () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -174,17 +175,17 @@ describe('group', () => {
|
||||
username: 'deletegroup2'
|
||||
})
|
||||
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id)
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser1.uid)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser2.uid)
|
||||
|
||||
await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
const res1 = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -192,7 +193,7 @@ describe('group', () => {
|
||||
expect(res1.body.groups).toEqual([])
|
||||
|
||||
const res2 = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser2.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser2.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -201,8 +202,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/1234`)
|
||||
.delete(`/SASjsApi/group/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -229,7 +232,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
@@ -245,15 +248,15 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with group', async () => {
|
||||
const { groupId } = await groupController.createGroup(group)
|
||||
const { uid } = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/group/${groupId}`)
|
||||
.get(`/SASjsApi/group/${uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -266,15 +269,15 @@ describe('group', () => {
|
||||
username: 'get' + user.username
|
||||
})
|
||||
|
||||
const { groupId } = await groupController.createGroup(group)
|
||||
const { uid } = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/group/${groupId}`)
|
||||
.get(`/SASjsApi/group/${uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -292,8 +295,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/group/1234')
|
||||
.get(`/SASjsApi/group/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -312,7 +317,7 @@ describe('group', () => {
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -333,7 +338,7 @@ describe('group', () => {
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -379,7 +384,7 @@ describe('group', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
name: group.name,
|
||||
description: group.description
|
||||
}
|
||||
@@ -401,7 +406,7 @@ describe('group', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
name: group.name,
|
||||
description: group.description
|
||||
}
|
||||
@@ -426,18 +431,18 @@ describe('group', () => {
|
||||
const dbUser = await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
}
|
||||
@@ -452,20 +457,20 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groups).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
name: group.name,
|
||||
description: group.description
|
||||
}
|
||||
@@ -478,21 +483,21 @@ describe('group', () => {
|
||||
...user,
|
||||
username: 'addUserRandomUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: 'addUserRandomUser',
|
||||
displayName: user.displayName
|
||||
}
|
||||
@@ -526,8 +531,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group/123/123')
|
||||
.post(`/SASjsApi/group/${hexValue}/123`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -538,8 +545,10 @@ describe('group', () => {
|
||||
|
||||
it('should respond with Not Found if userId is incorrect', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -556,7 +565,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
@@ -577,7 +586,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -596,7 +605,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.post(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -618,15 +627,15 @@ describe('group', () => {
|
||||
...user,
|
||||
username: 'removeUserRandomUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
@@ -639,16 +648,16 @@ describe('group', () => {
|
||||
...user,
|
||||
username: 'removeGroupFromUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${dbUser.id}`)
|
||||
.get(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -667,7 +676,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -686,7 +695,7 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(405)
|
||||
@@ -723,8 +732,10 @@ describe('group', () => {
|
||||
})
|
||||
|
||||
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/group/123/123')
|
||||
.delete(`/SASjsApi/group/${hexValue}/123`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -735,8 +746,10 @@ describe('group', () => {
|
||||
|
||||
it('should respond with Not Found if userId is incorrect', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||
.delete(`/SASjsApi/group/${dbGroup.uid}/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -752,10 +765,10 @@ const generateSaveTokenAndCreateUser = async (
|
||||
): Promise<string> => {
|
||||
const dbUser = await userController.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
return generateAndSaveToken(dbUser.uid)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PermissionDetailsResponse
|
||||
} from '../../../controllers'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const deployPayload = {
|
||||
appLoc: 'string',
|
||||
@@ -103,10 +104,10 @@ describe('permission', () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.send({ ...permission, principalId: dbUser.uid })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.path).toEqual(permission.path)
|
||||
expect(res.body.type).toEqual(permission.type)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
@@ -122,11 +123,11 @@ describe('permission', () => {
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group',
|
||||
principalId: dbGroup.groupId
|
||||
principalId: dbGroup.uid
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.permissionId).toBeTruthy()
|
||||
expect(res.body.uid).toBeTruthy()
|
||||
expect(res.body.path).toEqual(permission.path)
|
||||
expect(res.body.type).toEqual(permission.type)
|
||||
expect(res.body.setting).toEqual(permission.setting)
|
||||
@@ -144,7 +145,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
@@ -281,17 +282,19 @@ describe('permission', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if principalId is not a number', async () => {
|
||||
it('should respond with Bad Request if principalId is not a string of 24 hex characters', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 'someCharacters'
|
||||
principalId: randomBytes(10).toString('hex')
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('"principalId" must be a number')
|
||||
expect(res.text).toEqual(
|
||||
'"principalId" length must be 24 characters long'
|
||||
)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -307,7 +310,7 @@ describe('permission', () => {
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: adminUser.id
|
||||
principalId: adminUser.uid
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
@@ -321,7 +324,7 @@ describe('permission', () => {
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...permission,
|
||||
principalId: 123
|
||||
principalId: randomBytes(12).toString('hex')
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
@@ -336,7 +339,7 @@ describe('permission', () => {
|
||||
.send({
|
||||
...permission,
|
||||
principalType: 'group',
|
||||
principalId: 123
|
||||
principalId: randomBytes(12).toString('hex')
|
||||
})
|
||||
.expect(404)
|
||||
|
||||
@@ -347,13 +350,13 @@ describe('permission', () => {
|
||||
it('should respond with Conflict (409) if permission already exists', async () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/permission')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...permission, principalId: dbUser.id })
|
||||
.send({ ...permission, principalId: dbUser.uid })
|
||||
.expect(409)
|
||||
|
||||
expect(res.text).toEqual(
|
||||
@@ -368,7 +371,7 @@ describe('permission', () => {
|
||||
beforeAll(async () => {
|
||||
dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -378,7 +381,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with updated permission', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ setting: PermissionSettingForRoute.deny })
|
||||
.expect(200)
|
||||
@@ -388,7 +391,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
@@ -403,7 +406,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
@@ -414,7 +417,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with Bad Request if setting is missing', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
@@ -425,7 +428,7 @@ describe('permission', () => {
|
||||
|
||||
it('should respond with Bad Request if setting is invalid', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.patch(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
setting: 'invalid'
|
||||
@@ -437,8 +440,9 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exist', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
const res = await request(app)
|
||||
.patch('/SASjsApi/permission/123')
|
||||
.patch(`/SASjsApi/permission/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
setting: PermissionSettingForRoute.deny
|
||||
@@ -454,10 +458,10 @@ describe('permission', () => {
|
||||
it('should delete permission', async () => {
|
||||
const dbPermission = await permissionController.createPermission({
|
||||
...permission,
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/permission/${dbPermission?.permissionId}`)
|
||||
.delete(`/SASjsApi/permission/${dbPermission?.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -466,8 +470,10 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond with not found (404) if permission with provided id does not exists', async () => {
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/permission/123')
|
||||
.delete(`/SASjsApi/permission/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -481,12 +487,12 @@ describe('permission', () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/test-1',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/test-2',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -505,12 +511,12 @@ describe('permission', () => {
|
||||
...user,
|
||||
username: 'get' + user.username
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(nonAdminUser.id)
|
||||
const accessToken = await generateAndSaveToken(nonAdminUser.uid)
|
||||
await permissionController.createPermission({
|
||||
path: '/test-1',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: nonAdminUser.id,
|
||||
principalId: nonAdminUser.uid,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
|
||||
@@ -531,7 +537,7 @@ describe('permission', () => {
|
||||
await permissionController.createPermission({
|
||||
...permission,
|
||||
path: '/SASjsApi/drive/deploy',
|
||||
principalId: dbUser.id
|
||||
principalId: dbUser.uid
|
||||
})
|
||||
})
|
||||
|
||||
@@ -551,7 +557,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should create files in SASJS drive', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy')
|
||||
@@ -561,7 +567,7 @@ describe('permission', () => {
|
||||
})
|
||||
|
||||
it('should respond unauthorized', async () => {
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
await request(app)
|
||||
.get('/SASjsApi/drive/deploy/upload')
|
||||
@@ -577,10 +583,10 @@ const generateSaveTokenAndCreateUser = async (
|
||||
): Promise<string> => {
|
||||
const dbUser = await userController.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
return generateAndSaveToken(dbUser.uid)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SASSessionController
|
||||
} from '../../../controllers/internal'
|
||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||
import { Session } from '../../../types'
|
||||
import { Session, SessionState } from '../../../types'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
|
||||
@@ -58,12 +58,12 @@ describe('stp', () => {
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
const dbUser = await userController.createUser(user)
|
||||
accessToken = await generateAndSaveToken(dbUser.id)
|
||||
accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
await permissionController.createPermission({
|
||||
path: '/SASjsApi/stp/execute',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
principalId: dbUser.uid,
|
||||
setting: PermissionSettingForRoute.grant
|
||||
})
|
||||
})
|
||||
@@ -456,7 +456,7 @@ const makeRequestAndAssert = async (
|
||||
)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
@@ -101,9 +102,9 @@ describe('user', () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: dbUser.id
|
||||
userId: dbUser.uid
|
||||
})
|
||||
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
|
||||
await saveTokensInDB(dbUser.uid, clientId, accessToken, 'refreshToken')
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
@@ -187,7 +188,7 @@ describe('user', () => {
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...user, displayName: newDisplayName })
|
||||
.expect(200)
|
||||
@@ -200,11 +201,11 @@ describe('user', () => {
|
||||
|
||||
it('should respond with updated user when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({
|
||||
displayName: newDisplayName,
|
||||
@@ -221,11 +222,11 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ ...user, displayName: newDisplayName })
|
||||
.expect(400)
|
||||
@@ -277,10 +278,10 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
@@ -297,7 +298,7 @@ describe('user', () => {
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ username: dbUser2.username })
|
||||
.expect(409)
|
||||
@@ -325,7 +326,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with updated user when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
@@ -346,7 +347,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
await request(app)
|
||||
@@ -372,10 +373,10 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.patch(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
@@ -418,7 +419,7 @@ describe('user', () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
@@ -428,10 +429,10 @@ describe('user', () => {
|
||||
|
||||
it('should respond with OK when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: user.password })
|
||||
.expect(200)
|
||||
@@ -441,10 +442,10 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request when user himself requests and password is missing', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
@@ -469,10 +470,10 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser1.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
@@ -483,10 +484,10 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.delete(`/SASjsApi/user/${dbUser.uid}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: 'incorrectpassword' })
|
||||
.expect(401)
|
||||
@@ -510,7 +511,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with OK when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
|
||||
@@ -523,7 +524,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Bad Request when user himself requests and password is missing', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
|
||||
@@ -551,7 +552,7 @@ describe('user', () => {
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser2.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser1.username}`)
|
||||
@@ -565,7 +566,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with Unauthorized when user himself requests and password is incorrect', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const accessToken = await generateAndSaveToken(dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
|
||||
@@ -592,7 +593,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with user autoExec when same user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
const accessToken = await generateAndSaveToken(userId)
|
||||
|
||||
const res = await request(app)
|
||||
@@ -611,7 +612,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with user autoExec when admin user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
@@ -634,7 +635,7 @@ describe('user', () => {
|
||||
})
|
||||
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
@@ -652,7 +653,7 @@ describe('user', () => {
|
||||
|
||||
it('should respond with user along with associated groups', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
const accessToken = await generateAndSaveToken(userId)
|
||||
|
||||
const group = {
|
||||
@@ -661,7 +662,7 @@ describe('user', () => {
|
||||
}
|
||||
const groupController = new GroupController()
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
await groupController.addUserToGroup(dbGroup.uid, dbUser.uid)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
@@ -690,8 +691,10 @@ describe('user', () => {
|
||||
it('should respond with Not Found if userId is incorrect', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const hexValue = randomBytes(12).toString('hex')
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user/1234')
|
||||
.get(`/SASjsApi/user/${hexValue}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(404)
|
||||
@@ -703,7 +706,7 @@ describe('user', () => {
|
||||
describe('by username', () => {
|
||||
it('should respond with user autoExec when same user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
const userId = dbUser.uid
|
||||
const accessToken = await generateAndSaveToken(userId)
|
||||
|
||||
const res = await request(app)
|
||||
@@ -803,13 +806,13 @@ describe('user', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
@@ -831,13 +834,13 @@ describe('user', () => {
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName,
|
||||
isAdmin: adminUser.isAdmin
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
username: 'randomUser',
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
@@ -859,10 +862,10 @@ const generateSaveTokenAndCreateUser = async (
|
||||
): Promise<string> => {
|
||||
const dbUser = await controller.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
return generateAndSaveToken(dbUser.uid)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const generateAndSaveToken = async (userId: string) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
|
||||
@@ -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', () => {
|
||||
let csrfToken: string
|
||||
|
||||
@@ -74,7 +145,7 @@ describe('web', () => {
|
||||
|
||||
expect(res.body.loggedIn).toBeTruthy()
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
uid: expect.any(String),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
@@ -187,78 +258,6 @@ describe('web', () => {
|
||||
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) => {
|
||||
@@ -278,19 +277,13 @@ const performLogin = async (
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send(credentials)
|
||||
|
||||
return { authCookies: header['set-cookie'].join() }
|
||||
return {
|
||||
authCookies:
|
||||
(header['set-cookie'] as unknown as string[] | undefined)?.join() || ''
|
||||
}
|
||||
}
|
||||
|
||||
const extractCSRF = (text: string) =>
|
||||
/<script>document.cookie = 'XSRF-TOKEN=(.*); Max-Age=86400; SameSite=Strict; Path=\/;'<\/script>/.exec(
|
||||
text
|
||||
)![1]
|
||||
|
||||
const deleteDocumentsFromLimitersCollections = async () => {
|
||||
const { collections } = mongoose.connection
|
||||
const login_fail_ip_per_day_collection = collections['login_fail_ip_per_day']
|
||||
await login_fail_ip_per_day_collection.deleteMany({})
|
||||
const login_fail_consecutive_username_and_ip_collection =
|
||||
collections['login_fail_consecutive_username_and_ip']
|
||||
await login_fail_consecutive_username_and_ip_collection.deleteMany({})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import express from 'express'
|
||||
import { executeProgramRawValidation } from '../../utils'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
triggerProgramValidation
|
||||
} from '../../utils'
|
||||
import { STPController } from '../../controllers/'
|
||||
import { FileUploadController } from '../../controllers/internal'
|
||||
|
||||
@@ -13,7 +16,11 @@ stpRouter.get('/execute', async (req, res) => {
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.executeGetRequest(req, query._program)
|
||||
const response = await controller.executeGetRequest(
|
||||
req,
|
||||
query._program,
|
||||
query._debug
|
||||
)
|
||||
|
||||
if (response instanceof Buffer) {
|
||||
res.writeHead(200, (req as any).sasHeaders)
|
||||
@@ -65,4 +72,28 @@ stpRouter.post(
|
||||
}
|
||||
)
|
||||
|
||||
stpRouter.post('/trigger', async (req, res) => {
|
||||
const { error, value: query } = triggerProgramValidation(req.query)
|
||||
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.triggerProgram(
|
||||
req,
|
||||
query._program,
|
||||
query._debug,
|
||||
query.expiresAfterMins
|
||||
)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default stpRouter
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
deleteUserValidation,
|
||||
getUserValidation,
|
||||
registerUserValidation,
|
||||
uidValidation,
|
||||
updateUserValidation
|
||||
} from '../../utils'
|
||||
|
||||
@@ -56,12 +57,15 @@ userRouter.get(
|
||||
}
|
||||
)
|
||||
|
||||
userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
|
||||
const { userId } = req.params
|
||||
userRouter.get('/:uid', authenticateAccessToken, async (req, res) => {
|
||||
const { error, value: params } = uidValidation(req.params)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.getUser(req, parseInt(userId))
|
||||
const response = await controller.getUser(req, uid)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -97,12 +101,16 @@ userRouter.patch(
|
||||
)
|
||||
|
||||
userRouter.patch(
|
||||
'/:userId',
|
||||
'/:uid',
|
||||
authenticateAccessToken,
|
||||
verifyAdminIfNeeded,
|
||||
async (req, res) => {
|
||||
const { user } = req
|
||||
const { userId } = req.params
|
||||
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
// only an admin can update `isActive` and `isAdmin` fields
|
||||
const { error, value: body } = updateUserValidation(req.body, user!.isAdmin)
|
||||
@@ -110,7 +118,7 @@ userRouter.patch(
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.updateUser(parseInt(userId), body)
|
||||
const response = await controller.updateUser(uid, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
@@ -147,12 +155,16 @@ userRouter.delete(
|
||||
)
|
||||
|
||||
userRouter.delete(
|
||||
'/:userId',
|
||||
'/:uid',
|
||||
authenticateAccessToken,
|
||||
verifyAdminIfNeeded,
|
||||
async (req, res) => {
|
||||
const { user } = req
|
||||
const { userId } = req.params
|
||||
|
||||
const { error: uidError, value: params } = uidValidation(req.params)
|
||||
if (uidError) return res.status(400).send(uidError.details[0].message)
|
||||
|
||||
const { uid } = params
|
||||
|
||||
// only an admin can delete user without providing password
|
||||
const { error, value: data } = deleteUserValidation(req.body, user!.isAdmin)
|
||||
@@ -160,7 +172,7 @@ userRouter.delete(
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
await controller.deleteUser(parseInt(userId), data, user!.isAdmin)
|
||||
await controller.deleteUser(uid, data, user!.isAdmin)
|
||||
res.status(200).send('Account Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface InfoJWT {
|
||||
clientId: string
|
||||
userId: number
|
||||
userId: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface PreProgramVars {
|
||||
username: string
|
||||
userId: number
|
||||
userId: string
|
||||
displayName: string
|
||||
serverUrl: string
|
||||
httpHeaders: string[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface RequestUser {
|
||||
userId: number
|
||||
userId: string
|
||||
clientId: string
|
||||
username: string
|
||||
displayName: string
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export enum SessionState {
|
||||
initialising = 'initialising', // session is initialising and not ready to be used yet
|
||||
pending = 'pending', // session is ready to be used
|
||||
running = 'running', // session is in use
|
||||
completed = 'completed', // session is completed and can be destroyed
|
||||
failed = 'failed' // session failed
|
||||
}
|
||||
export interface Session {
|
||||
id: string
|
||||
ready: boolean
|
||||
state: SessionState
|
||||
creationTimeStamp: string
|
||||
deathTimeStamp: string
|
||||
path: string
|
||||
inUse: boolean
|
||||
consumed: boolean
|
||||
completed: boolean
|
||||
crashed?: string
|
||||
expiresAfterMins?: { mins: number; used: boolean }
|
||||
failureReason?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export const randomBytesHexString = (bytesCount: number) =>
|
||||
randomBytes(bytesCount).toString('hex')
|
||||
@@ -22,7 +22,7 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
|
||||
//So this is workaround.
|
||||
return {
|
||||
username: user ? user.username : 'demo',
|
||||
userId: user ? user.userId : 0,
|
||||
userId: user ? user.userId : 'demoId',
|
||||
displayName: user ? user.displayName : 'demo',
|
||||
serverUrl: protocol + host,
|
||||
httpHeaders
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import User from '../model/User'
|
||||
import { InfoJWT } from '../types/InfoJWT'
|
||||
|
||||
const isValidToken = async (
|
||||
token: string,
|
||||
key: string,
|
||||
userId: number,
|
||||
userId: string,
|
||||
clientId: string
|
||||
) => {
|
||||
const promise = new Promise<boolean>((resolve, reject) =>
|
||||
jwt.verify(token, key, (err, decoded) => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -22,8 +24,8 @@ const isValidToken = async (
|
||||
return await promise.then(() => true).catch(() => false)
|
||||
}
|
||||
|
||||
export const getTokensFromDB = async (userId: number, clientId: string) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
export const getTokensFromDB = async (userId: string, clientId: string) => {
|
||||
const user = await User.findOne({ _id: userId })
|
||||
if (!user) return
|
||||
|
||||
const currentTokenObj = user.tokens.find(
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './appStreamConfig'
|
||||
export * from './connectDB'
|
||||
export * from './copySASjsCore'
|
||||
export * from './createWeboutSasFile'
|
||||
export * from './crypto'
|
||||
export * from './desktopAutoExec'
|
||||
export * from './extractHeaders'
|
||||
export * from './extractName'
|
||||
|
||||
@@ -22,7 +22,7 @@ export const isPublicRoute = async (req: Request): Promise<boolean> => {
|
||||
}
|
||||
|
||||
export const publicUser: RequestUser = {
|
||||
userId: 0,
|
||||
userId: 'public_user_id',
|
||||
clientId: 'public_app',
|
||||
username: 'publicUser',
|
||||
displayName: 'Public User',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import mongoose from 'mongoose'
|
||||
import { RateLimiterMongo } from 'rate-limiter-flexible'
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
export class RateLimiter {
|
||||
private static instance: RateLimiter
|
||||
private limiterSlowBruteByIP: RateLimiterMongo
|
||||
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMongo
|
||||
private limiterSlowBruteByIP: RateLimiterMemory
|
||||
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMemory
|
||||
private maxWrongAttemptsByIpPerDay: number
|
||||
private maxConsecutiveFailsByUsernameAndIp: number
|
||||
|
||||
@@ -19,19 +18,17 @@ export class RateLimiter {
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||
)
|
||||
|
||||
this.limiterSlowBruteByIP = new RateLimiterMongo({
|
||||
storeClient: mongoose.connection,
|
||||
this.limiterSlowBruteByIP = new RateLimiterMemory({
|
||||
keyPrefix: 'login_fail_ip_per_day',
|
||||
points: this.maxWrongAttemptsByIpPerDay,
|
||||
duration: 60 * 60 * 24,
|
||||
blockDuration: 60 * 60 * 24 // Block for 1 day
|
||||
})
|
||||
|
||||
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMongo({
|
||||
storeClient: mongoose.connection,
|
||||
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMemory({
|
||||
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -60,8 +57,7 @@ export class RateLimiter {
|
||||
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
||||
])
|
||||
|
||||
// NOTE: To make use of blockDuration option from RateLimiterMongo
|
||||
// comparison in both following if statements should have greater than symbol
|
||||
// NOTE: To make use of blockDuration option, comparison in both following if statements should have greater than symbol
|
||||
// otherwise, blockDuration option will not work
|
||||
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
|
||||
|
||||
@@ -103,10 +99,11 @@ export class RateLimiter {
|
||||
if (rlRejected instanceof Error) {
|
||||
throw rlRejected
|
||||
} 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
|
||||
// for further reference,
|
||||
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
|
||||
// or see https://github.com/animir/node-rate-limiter-flexible#ratelimiterres-object
|
||||
return Math.ceil(rlRejected.msBeforeNext / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import User from '../model/User'
|
||||
|
||||
export const removeTokensInDB = async (userId: number, clientId: string) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
export const removeTokensInDB = async (userId: string, clientId: string) => {
|
||||
const user = await User.findOne({ _id: userId })
|
||||
if (!user) return
|
||||
|
||||
const tokenObjIndex = user.tokens.findIndex(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import User from '../model/User'
|
||||
|
||||
export const saveTokensInDB = async (
|
||||
userId: number,
|
||||
userId: string,
|
||||
clientId: string,
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
const user = await User.findOne({ _id: userId })
|
||||
if (!user) return
|
||||
|
||||
const currentTokenObj = user.tokens.find(
|
||||
|
||||
+58
-11
@@ -1,7 +1,9 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import Client from '../model/Client'
|
||||
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import User, { IUser } from '../model/User'
|
||||
import Configuration, { ConfigurationType } from '../model/Configuration'
|
||||
import { ResetAdminPasswordType } from './verifyEnvVariables'
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
@@ -40,9 +42,13 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
process.logger.success(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
|
||||
}
|
||||
|
||||
const ADMIN_USER = getAdminUser()
|
||||
|
||||
// Checking if user is already in the database
|
||||
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
|
||||
if (!usernameExist) {
|
||||
if (usernameExist) {
|
||||
usernameExist = await resetAdminPassword(usernameExist, ADMIN_USER.password)
|
||||
} else {
|
||||
const user = new User(ADMIN_USER)
|
||||
usernameExist = await user.save()
|
||||
|
||||
@@ -51,7 +57,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
)
|
||||
}
|
||||
|
||||
if (!groupExist.hasUser(usernameExist)) {
|
||||
if (usernameExist.isAdmin && !groupExist.hasUser(usernameExist)) {
|
||||
groupExist.addUser(usernameExist)
|
||||
process.logger.success(
|
||||
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
|
||||
@@ -76,7 +82,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
}
|
||||
|
||||
export const ALL_USERS_GROUP = {
|
||||
name: 'AllUsers',
|
||||
name: 'all-users',
|
||||
description: 'Group contains all users'
|
||||
}
|
||||
|
||||
@@ -90,11 +96,52 @@ const CLIENT = {
|
||||
clientId: 'clientID1',
|
||||
clientSecret: 'clientSecret'
|
||||
}
|
||||
const ADMIN_USER = {
|
||||
id: 1,
|
||||
displayName: 'Super Admin',
|
||||
username: 'secretuser',
|
||||
password: '$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
|
||||
const getAdminUser = () => {
|
||||
const { ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL } = process.env
|
||||
|
||||
const salt = bcrypt.genSaltSync(10)
|
||||
const hashedPassword = bcrypt.hashSync(ADMIN_PASSWORD_INITIAL as string, salt)
|
||||
|
||||
return {
|
||||
displayName: 'Super Admin',
|
||||
username: ADMIN_USERNAME,
|
||||
password: hashedPassword,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
|
||||
const resetAdminPassword = async (user: IUser, password: string) => {
|
||||
const { ADMIN_PASSWORD_RESET } = process.env
|
||||
|
||||
if (ADMIN_PASSWORD_RESET === ResetAdminPasswordType.YES) {
|
||||
if (!user.isAdmin) {
|
||||
process.logger.error(
|
||||
`Can not reset the password of non-admin user (${user.username}) on startup.`
|
||||
)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
if (user.authProvider) {
|
||||
process.logger.error(
|
||||
`Can not reset the password of admin (${user.username}) with ${user.authProvider} as authentication mechanism.`
|
||||
)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
process.logger.info(
|
||||
`DB Seed - resetting password for admin user: ${user.username}`
|
||||
)
|
||||
|
||||
user.password = password
|
||||
user.needsToUpdatePassword = true
|
||||
user = await user.save()
|
||||
|
||||
process.logger.success(`DB Seed - successfully reset the password`)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import path from 'path'
|
||||
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
|
||||
|
||||
import {
|
||||
createFolder,
|
||||
getAbsolutePath,
|
||||
getRealPath,
|
||||
fileExists
|
||||
} from '@sasjs/utils'
|
||||
import dotenv from 'dotenv'
|
||||
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
|
||||
|
||||
export const setProcessVariables = async () => {
|
||||
const { execPath } = process
|
||||
|
||||
// Check if execPath ends with 'api-macos' to determine executable for MacOS.
|
||||
// This is needed to fix picking .env file issue in MacOS executable.
|
||||
if (execPath) {
|
||||
const envPathSplitted = execPath.split(path.sep)
|
||||
|
||||
if (envPathSplitted.pop() === 'api-macos') {
|
||||
const envPath = path.join(envPathSplitted.join(path.sep), '.env')
|
||||
|
||||
// Override environment variables from envPath if file exists
|
||||
if (await fileExists(envPath)) {
|
||||
dotenv.config({ path: envPath, override: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { MODE, RUN_TIMES } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
@@ -21,6 +43,7 @@ export const setProcessVariables = async () => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,7 +64,9 @@ export const setProcessVariables = async () => {
|
||||
|
||||
const { SASJS_ROOT } = process.env
|
||||
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
||||
|
||||
await createFolder(absPath)
|
||||
|
||||
process.sasjsRoot = getRealPath(absPath)
|
||||
|
||||
const { DRIVE_LOCATION } = process.env
|
||||
@@ -49,6 +74,7 @@ export const setProcessVariables = async () => {
|
||||
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
||||
process.cwd()
|
||||
)
|
||||
|
||||
await createFolder(absDrivePath)
|
||||
process.driveLoc = getRealPath(absDrivePath)
|
||||
|
||||
@@ -57,7 +83,9 @@ export const setProcessVariables = async () => {
|
||||
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
||||
process.cwd()
|
||||
)
|
||||
|
||||
await createFolder(absLogsPath)
|
||||
|
||||
process.logsLoc = getRealPath(absLogsPath)
|
||||
|
||||
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||
|
||||
@@ -51,9 +51,8 @@ export const generateFileUploadSasCode = async (
|
||||
let fileCount = 0
|
||||
const uploadedFiles: UploadedFiles[] = []
|
||||
|
||||
const sasSessionFolderList: string[] = await listFilesInFolder(
|
||||
sasSessionFolder
|
||||
)
|
||||
const sasSessionFolderList: string[] =
|
||||
await listFilesInFolder(sasSessionFolder)
|
||||
sasSessionFolderList.forEach((fileName) => {
|
||||
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
|
||||
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
|
||||
|
||||
@@ -12,6 +12,11 @@ const groupnameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
|
||||
|
||||
export const blockFileRegex = /\.(exe|sh|htaccess)$/i
|
||||
|
||||
export const uidValidation = (data: any) =>
|
||||
Joi.object({
|
||||
uid: Joi.string().length(24).hex().required()
|
||||
}).validate(data)
|
||||
|
||||
export const getUserValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
username: usernameSchema.required()
|
||||
@@ -113,7 +118,7 @@ export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
principalType: Joi.string()
|
||||
.required()
|
||||
.valid(...Object.values(PrincipalType)),
|
||||
principalId: Joi.number().required()
|
||||
principalId: Joi.string().length(24).hex().required()
|
||||
}).validate(data)
|
||||
|
||||
export const updatePermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
@@ -178,9 +183,31 @@ export const runCodeValidation = (data: any): Joi.ValidationResult =>
|
||||
runTime: Joi.string().valid(...process.runTimes)
|
||||
}).validate(data)
|
||||
|
||||
export const triggerCodeValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
code: Joi.string().required(),
|
||||
runTime: Joi.string().valid(...process.runTimes),
|
||||
expiresAfterMins: Joi.number().greater(0)
|
||||
}).validate(data)
|
||||
|
||||
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required()
|
||||
_program: Joi.string().required(),
|
||||
_debug: Joi.number()
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required(),
|
||||
_debug: Joi.number(),
|
||||
expiresAfterMins: Joi.number().greater(0)
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
sessionId: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
@@ -52,6 +52,11 @@ export enum DatabaseType {
|
||||
COSMOS_MONGODB = 'cosmos_mongodb'
|
||||
}
|
||||
|
||||
export enum ResetAdminPasswordType {
|
||||
YES = 'YES',
|
||||
NO = 'NO'
|
||||
}
|
||||
|
||||
export const verifyEnvVariables = (): ReturnCode => {
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -79,6 +84,8 @@ export const verifyEnvVariables = (): ReturnCode => {
|
||||
|
||||
errors.push(...verifyRateLimiter())
|
||||
|
||||
errors.push(...verifyAdminUserConfig())
|
||||
|
||||
if (errors.length) {
|
||||
process.logger?.error(
|
||||
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
|
||||
@@ -409,6 +416,38 @@ const verifyRateLimiter = () => {
|
||||
return errors
|
||||
}
|
||||
|
||||
const verifyAdminUserConfig = () => {
|
||||
const errors: string[] = []
|
||||
const { MODE, ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL, ADMIN_PASSWORD_RESET } =
|
||||
process.env
|
||||
if (MODE === ModeType.Server) {
|
||||
if (ADMIN_USERNAME) {
|
||||
process.env.ADMIN_USERNAME = ADMIN_USERNAME.toLowerCase()
|
||||
} else {
|
||||
process.env.ADMIN_USERNAME = DEFAULTS.ADMIN_USERNAME
|
||||
}
|
||||
|
||||
if (!ADMIN_PASSWORD_INITIAL)
|
||||
process.env.ADMIN_PASSWORD_INITIAL = DEFAULTS.ADMIN_PASSWORD_INITIAL
|
||||
|
||||
if (ADMIN_PASSWORD_RESET) {
|
||||
const resetPasswordTypes = Object.values(ResetAdminPasswordType)
|
||||
if (
|
||||
!resetPasswordTypes.includes(
|
||||
ADMIN_PASSWORD_RESET as ResetAdminPasswordType
|
||||
)
|
||||
)
|
||||
errors.push(
|
||||
`- ADMIN_PASSWORD_RESET '${ADMIN_PASSWORD_RESET}'\n - valid options ${resetPasswordTypes}`
|
||||
)
|
||||
} else {
|
||||
process.env.ADMIN_PASSWORD_RESET = DEFAULTS.ADMIN_PASSWORD_RESET
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const isNumeric = (val: string): boolean => {
|
||||
return !isNaN(Number(val))
|
||||
}
|
||||
@@ -422,5 +461,8 @@ const DEFAULTS = {
|
||||
RUN_TIMES: RunTimeType.SAS,
|
||||
DB_TYPE: DatabaseType.MONGO,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RequestUser } from '../types'
|
||||
export const fetchLatestAutoExec = async (
|
||||
reqUser: RequestUser
|
||||
): Promise<RequestUser | undefined> => {
|
||||
const dbUser = await User.findOne({ id: reqUser.userId })
|
||||
const dbUser = await User.findOne({ _id: reqUser.userId })
|
||||
|
||||
if (!dbUser) return undefined
|
||||
|
||||
@@ -21,12 +21,12 @@ export const fetchLatestAutoExec = async (
|
||||
}
|
||||
|
||||
export const verifyTokenInDB = async (
|
||||
userId: number,
|
||||
userId: string,
|
||||
clientId: string,
|
||||
token: string,
|
||||
tokenType: 'accessToken' | 'refreshToken'
|
||||
): Promise<RequestUser | undefined> => {
|
||||
const dbUser = await User.findOne({ id: userId })
|
||||
const dbUser = await User.findOne({ _id: userId })
|
||||
|
||||
if (!dbUser) return undefined
|
||||
|
||||
@@ -36,7 +36,7 @@ export const verifyTokenInDB = async (
|
||||
|
||||
return currentTokenObj?.[tokenType] === token
|
||||
? {
|
||||
userId: dbUser.id,
|
||||
userId: dbUser.uid,
|
||||
clientId,
|
||||
username: dbUser.username,
|
||||
displayName: dbUser.displayName,
|
||||
|
||||
Generated
+4233
-8631
File diff suppressed because it is too large
Load Diff
Generated
+2706
-1576
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -4,7 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "webpack-dev-server --config webpack.dev.ts --hot",
|
||||
"build": "webpack --config webpack.prod.ts"
|
||||
"build": "webpack --config webpack.prod.ts",
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.4.1",
|
||||
@@ -19,12 +21,12 @@
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/node": "^12.20.28",
|
||||
"@types/react": "^17.0.27",
|
||||
"axios": "^0.24.0",
|
||||
"axios": "1.12.2",
|
||||
"monaco-editor": "^0.33.0",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"react": "^17.0.2",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-monaco-editor": "^0.48.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-toastify": "^9.0.1"
|
||||
@@ -41,6 +43,7 @@
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-highlight": "^0.12.5",
|
||||
"@types/react-router-dom": "^5.3.1",
|
||||
"babel-loader": "^8.2.3",
|
||||
"babel-plugin-prismjs": "^2.1.0",
|
||||
@@ -52,13 +55,15 @@
|
||||
"eslint-webpack-plugin": "^3.1.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "5.5.0",
|
||||
"monaco-editor-webpack-plugin": "^7.0.1",
|
||||
"path": "0.12.7",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.44.0",
|
||||
"sass-loader": "^12.3.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^4.5.2",
|
||||
"typescript-plugin-css-modules": "^5.0.1",
|
||||
"webpack": "5.64.3",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-dev-server": "4.7.4"
|
||||
|
||||
@@ -40,7 +40,7 @@ const Login = () => {
|
||||
})
|
||||
|
||||
if (loggedIn) {
|
||||
appContext.setUserId?.(user.id)
|
||||
appContext.setUserId?.(user.uid)
|
||||
appContext.setUsername?.(user.username)
|
||||
appContext.setDisplayName?.(user.displayName)
|
||||
appContext.setIsAdmin?.(user.isAdmin)
|
||||
|
||||
@@ -3,12 +3,11 @@ import Snackbar from '@mui/material/Snackbar'
|
||||
import MuiAlert, { AlertProps } from '@mui/material/Alert'
|
||||
import Slide, { SlideProps } from '@mui/material/Slide'
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
||||
})
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
function Alert(props, ref) {
|
||||
return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
|
||||
}
|
||||
)
|
||||
|
||||
const Transition = (props: SlideProps) => {
|
||||
return <Slide {...props} direction="up" />
|
||||
|
||||
@@ -99,8 +99,8 @@ const AddPermissionModal = ({
|
||||
principalType: principalType.toLowerCase(),
|
||||
principalId:
|
||||
principalType.toLowerCase() === 'user'
|
||||
? userPrincipal?.id
|
||||
: groupPrincipal?.groupId
|
||||
? userPrincipal?.uid
|
||||
: groupPrincipal?.uid
|
||||
}
|
||||
|
||||
permissions.push(addPermissionPayload)
|
||||
|
||||
@@ -61,7 +61,7 @@ const PermissionTable = ({
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{permissions.map((permission) => (
|
||||
<TableRow key={permission.permissionId}>
|
||||
<TableRow key={permission.uid}>
|
||||
<BootstrapTableCell>{permission.path}</BootstrapTableCell>
|
||||
<BootstrapTableCell>{permission.type}</BootstrapTableCell>
|
||||
<BootstrapTableCell>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PermissionsContext } from '../../../../context/permissionsContext'
|
||||
import {
|
||||
findExistingPermission,
|
||||
findUpdatingPermission
|
||||
} from '../../../../utils/helper'
|
||||
} from '../../../../utils'
|
||||
|
||||
const useAddPermission = () => {
|
||||
const {
|
||||
@@ -69,7 +69,7 @@ const useAddPermission = () => {
|
||||
|
||||
for (const permission of updatingPermissions) {
|
||||
await axios
|
||||
.patch(`/SASjsApi/permission/${permission.permissionId}`, {
|
||||
.patch(`/SASjsApi/permission/${permission.uid}`, {
|
||||
setting: permission.setting === 'Grant' ? 'Deny' : 'Grant'
|
||||
})
|
||||
.then((res) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ const useDeletePermissionModal = () => {
|
||||
setDeleteConfirmationModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`)
|
||||
.delete(`/SASjsApi/permission/${selectedPermission?.uid}`)
|
||||
.then((res: any) => {
|
||||
fetchPermissions()
|
||||
setSnackbarMessage('Permission deleted!')
|
||||
|
||||
@@ -62,21 +62,17 @@ const useFilterPermissions = () => {
|
||||
: permissions
|
||||
|
||||
let filteredArray = uriFilteredPermissions.filter((permission) =>
|
||||
principalFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
)
|
||||
principalFilteredPermissions.some((item) => item.uid === permission.uid)
|
||||
)
|
||||
|
||||
filteredArray = filteredArray.filter((permission) =>
|
||||
principalTypeFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
(item) => item.uid === permission.uid
|
||||
)
|
||||
)
|
||||
|
||||
filteredArray = filteredArray.filter((permission) =>
|
||||
settingFilteredPermissions.some(
|
||||
(item) => item.permissionId === permission.permissionId
|
||||
)
|
||||
settingFilteredPermissions.some((item) => item.uid === permission.uid)
|
||||
)
|
||||
|
||||
setFilteredPermissions(filteredArray)
|
||||
|
||||
@@ -24,7 +24,7 @@ const useUpdatePermissionModal = () => {
|
||||
setUpdatePermissionModalOpen(false)
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, {
|
||||
.patch(`/SASjsApi/permission/${selectedPermission?.uid}`, {
|
||||
setting
|
||||
})
|
||||
.then((res: any) => {
|
||||
|
||||
@@ -26,18 +26,20 @@ const Profile = () => {
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.get(`/SASjsApi/user/${appContext.userId}`)
|
||||
.then((res: any) => {
|
||||
setUser(res.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
if (appContext.userId) {
|
||||
setIsLoading(true)
|
||||
axios
|
||||
.get(`/SASjsApi/user/${appContext.userId}`)
|
||||
.then((res: any) => {
|
||||
setUser(res.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [appContext.userId])
|
||||
|
||||
const handleChange = (event: any) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Dispatch, SetStateAction } from 'react'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import {
|
||||
Backdrop,
|
||||
@@ -17,10 +17,14 @@ import { TabContext, TabList, TabPanel } from '@mui/lab'
|
||||
import FilePathInputModal from '../../components/filePathInputModal'
|
||||
import FileMenu from './internal/components/fileMenu'
|
||||
import RunMenu from './internal/components/runMenu'
|
||||
import LogComponent from './internal/components/log/logComponent'
|
||||
import LogTabWithIcons from './internal/components/log/logTabWithIcons'
|
||||
|
||||
import { usePrompt } from '../../utils/hooks'
|
||||
import { getLanguageFromExtension } from './internal/helper'
|
||||
import useEditor from './internal/hooks/useEditor'
|
||||
import { RunTimeType } from '../../context/appContext'
|
||||
import { LogObject } from '../../utils'
|
||||
|
||||
const StyledTabPanel = styled(TabPanel)(() => ({
|
||||
padding: '10px'
|
||||
@@ -58,6 +62,7 @@ const SASjsEditor = ({
|
||||
selectedRunTime,
|
||||
showDiff,
|
||||
webout,
|
||||
printOutput,
|
||||
Dialog,
|
||||
handleChangeRunTime,
|
||||
handleDiffEditorDidMount,
|
||||
@@ -108,6 +113,10 @@ const SASjsEditor = ({
|
||||
/>
|
||||
)
|
||||
|
||||
// INFO: variable indicating if selected run type is SAS if there are any errors or warnings in the log
|
||||
const logWithErrorsOrWarnings =
|
||||
selectedRunTime === RunTimeType.SAS && log && typeof log === 'object'
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}>
|
||||
<Backdrop
|
||||
@@ -145,15 +154,35 @@ const SASjsEditor = ({
|
||||
>
|
||||
<TabList onChange={handleTabChange} centered>
|
||||
<StyledTab label="Code" value="code" />
|
||||
<StyledTab label="Log" value="log" />
|
||||
<StyledTab
|
||||
label={
|
||||
<Tooltip title="Displays content from the _webout fileref">
|
||||
<Typography>Webout</Typography>
|
||||
</Tooltip>
|
||||
}
|
||||
value="webout"
|
||||
/>
|
||||
{log && (
|
||||
<StyledTab
|
||||
label={logWithErrorsOrWarnings ? '' : 'log'}
|
||||
value="log"
|
||||
icon={
|
||||
logWithErrorsOrWarnings ? (
|
||||
<LogTabWithIcons log={log as LogObject} />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
const logWrapper = document.querySelector(`#logWrapper`)
|
||||
|
||||
if (logWrapper) logWrapper.scrollTop = 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{webout && (
|
||||
<StyledTab
|
||||
label={
|
||||
<Tooltip title="Displays content from the _webout fileref">
|
||||
<Typography>Webout</Typography>
|
||||
</Tooltip>
|
||||
}
|
||||
value="webout"
|
||||
/>
|
||||
)}
|
||||
{printOutput && <StyledTab label="print" value="printOutput" />}
|
||||
</TabList>
|
||||
</Box>
|
||||
|
||||
@@ -195,21 +224,24 @@ const SASjsEditor = ({
|
||||
</Paper>
|
||||
</StyledTabPanel>
|
||||
<StyledTabPanel value="log">
|
||||
<div>
|
||||
<h2>Log</h2>
|
||||
<pre
|
||||
id="log"
|
||||
style={{ overflow: 'auto', height: 'calc(100vh - 220px)' }}
|
||||
>
|
||||
{log}
|
||||
</pre>
|
||||
</div>
|
||||
</StyledTabPanel>
|
||||
<StyledTabPanel value="webout">
|
||||
<div>
|
||||
<pre>{webout}</pre>
|
||||
</div>
|
||||
{log && (
|
||||
<LogComponent log={log} selectedRunTime={selectedRunTime} />
|
||||
)}
|
||||
</StyledTabPanel>
|
||||
{webout && (
|
||||
<StyledTabPanel value="webout">
|
||||
<div>
|
||||
<pre>{webout}</pre>
|
||||
</div>
|
||||
</StyledTabPanel>
|
||||
)}
|
||||
{printOutput && (
|
||||
<StyledTabPanel value="printOutput">
|
||||
<div>
|
||||
<pre>{printOutput}</pre>
|
||||
</div>
|
||||
</StyledTabPanel>
|
||||
)}
|
||||
</TabContext>
|
||||
)}
|
||||
<Dialog />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
.ChunkHeader {
|
||||
color: #444;
|
||||
cursor: pointer;
|
||||
padding: 18px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: 0.4s;
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.2) 0px 2px 1px -1px,
|
||||
rgba(0, 0, 0, 0.14) 0px 1px 1px 0px,
|
||||
rgba(0, 0, 0, 0.12) 0px 1px 3px 0px;
|
||||
}
|
||||
|
||||
.ChunkDetails {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ChunkExpandIcon {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ChunkBody {
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ChunksContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.LogContainer {
|
||||
background-color: #fbfbfb;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 3px;
|
||||
min-height: 50px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
white-space: pre-wrap;
|
||||
font-family: Monaco, Courier, monospace;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.LogWrapper {
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 130px);
|
||||
}
|
||||
|
||||
.LogBody {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.TreeContainer {
|
||||
background-color: white;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.TabContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.TabDownloadIcon {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.HighlightedLine {
|
||||
background-color: #f6e30599;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.GreenIcon {
|
||||
color: green;
|
||||
}
|
||||
@@ -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,
|
||||
useStateWithCallback
|
||||
} from '../../../../utils/hooks'
|
||||
import { parseErrorsAndWarnings, LogObject } from '../../../../utils'
|
||||
|
||||
const SASJS_LOGS_SEPARATOR =
|
||||
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||
@@ -38,13 +39,15 @@ const useEditor = ({
|
||||
const { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } =
|
||||
useSnackbar()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [prevFileContent, setPrevFileContent] = useStateWithCallback('')
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [log, setLog] = useState('')
|
||||
const [webout, setWebout] = useState('')
|
||||
const [log, setLog] = useState<LogObject | string>()
|
||||
const [webout, setWebout] = useState<string>()
|
||||
const [printOutput, setPrintOutput] = useState<string>()
|
||||
const [runTimes, setRunTimes] = useState<string[]>([])
|
||||
const [selectedRunTime, setSelectedRunTime] = useState('')
|
||||
const [selectedRunTime, setSelectedRunTime] = useState<RunTimeType>(
|
||||
RunTimeType.SAS
|
||||
)
|
||||
const [selectedFileExtension, setSelectedFileExtension] = useState('')
|
||||
const [openFilePathInputModal, setOpenFilePathInputModal] = useState(false)
|
||||
const [showDiff, setShowDiff] = useState(false)
|
||||
@@ -150,6 +153,13 @@ const useEditor = ({
|
||||
const runCode = useCallback(
|
||||
(code: string) => {
|
||||
setIsLoading(true)
|
||||
|
||||
// Scroll to bottom of log
|
||||
const logElement = document.getElementById('log')
|
||||
if (logElement) logElement.scrollTop = logElement.scrollHeight
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
axios
|
||||
.post(`/SASjsApi/code/execute`, {
|
||||
code: programPathInjection(
|
||||
@@ -159,9 +169,30 @@ const useEditor = ({
|
||||
),
|
||||
runTime: selectedRunTime
|
||||
})
|
||||
.then((res: any) => {
|
||||
setWebout(res.data.split(SASJS_LOGS_SEPARATOR)[0] ?? '')
|
||||
setLog(res.data.split(SASJS_LOGS_SEPARATOR)[1] ?? '')
|
||||
.then((res: { data: string }) => {
|
||||
// INFO: the order of payload parts is set in @sasjs/server/api/src/controllers/internal/Execution.ts
|
||||
const resDataSplitted = res.data.split(SASJS_LOGS_SEPARATOR)
|
||||
const webout = resDataSplitted[0]
|
||||
const log = resDataSplitted[1]
|
||||
const printOutput = resDataSplitted[2]
|
||||
|
||||
if (selectedRunTime === RunTimeType.SAS) {
|
||||
const { errors, warnings, logLines } = parseErrorsAndWarnings(log)
|
||||
|
||||
const logObject: LogObject = {
|
||||
body: logLines.join(`\n`),
|
||||
errors,
|
||||
warnings,
|
||||
linesCount: logLines.length
|
||||
}
|
||||
|
||||
setLog(logObject)
|
||||
} else {
|
||||
setLog(log)
|
||||
}
|
||||
|
||||
setWebout(webout)
|
||||
setPrintOutput(printOutput)
|
||||
setTab('log')
|
||||
|
||||
// Scroll to bottom of log
|
||||
@@ -249,7 +280,7 @@ const useEditor = ({
|
||||
}, [appContext.runTimes])
|
||||
|
||||
useEffect(() => {
|
||||
if (runTimes.length) setSelectedRunTime(runTimes[0])
|
||||
if (runTimes.length) setSelectedRunTime(runTimes[0] as RunTimeType)
|
||||
}, [runTimes])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -280,7 +311,6 @@ const useEditor = ({
|
||||
const content = localStorage.getItem('fileContent') ?? ''
|
||||
setFileContent(content)
|
||||
}
|
||||
setLog('')
|
||||
setWebout('')
|
||||
setTab('code')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -294,7 +324,9 @@ const useEditor = ({
|
||||
|
||||
useEffect(() => {
|
||||
const fileExtension = selectedFileExtension.toLowerCase()
|
||||
if (runTimes.includes(fileExtension)) setSelectedRunTime(fileExtension)
|
||||
|
||||
if (runTimes.includes(fileExtension))
|
||||
setSelectedRunTime(fileExtension as RunTimeType)
|
||||
}, [selectedFileExtension, runTimes])
|
||||
|
||||
return {
|
||||
@@ -308,6 +340,7 @@ const useEditor = ({
|
||||
selectedRunTime,
|
||||
showDiff,
|
||||
webout,
|
||||
printOutput,
|
||||
Dialog,
|
||||
handleChangeRunTime,
|
||||
handleDiffEditorDidMount,
|
||||
|
||||
@@ -24,39 +24,32 @@ export enum RunTimeType {
|
||||
interface AppContextProps {
|
||||
checkingSession: boolean
|
||||
loggedIn: boolean
|
||||
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
|
||||
setLoggedIn?: Dispatch<SetStateAction<boolean>>
|
||||
needsToUpdatePassword: boolean
|
||||
setNeedsToUpdatePassword: Dispatch<SetStateAction<boolean>> | null
|
||||
userId: number
|
||||
setUserId: Dispatch<SetStateAction<number>> | null
|
||||
setNeedsToUpdatePassword?: Dispatch<SetStateAction<boolean>>
|
||||
userId?: string
|
||||
setUserId?: Dispatch<SetStateAction<string | undefined>>
|
||||
username: string
|
||||
setUsername: Dispatch<SetStateAction<string>> | null
|
||||
setUsername?: Dispatch<SetStateAction<string>>
|
||||
displayName: string
|
||||
setDisplayName: Dispatch<SetStateAction<string>> | null
|
||||
setDisplayName?: Dispatch<SetStateAction<string>>
|
||||
isAdmin: boolean
|
||||
setIsAdmin: Dispatch<SetStateAction<boolean>> | null
|
||||
setIsAdmin?: Dispatch<SetStateAction<boolean>>
|
||||
mode: ModeType
|
||||
runTimes: RunTimeType[]
|
||||
logout: (() => void) | null
|
||||
logout?: () => void
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextProps>({
|
||||
checkingSession: false,
|
||||
loggedIn: false,
|
||||
setLoggedIn: null,
|
||||
needsToUpdatePassword: false,
|
||||
setNeedsToUpdatePassword: null,
|
||||
userId: 0,
|
||||
setUserId: null,
|
||||
userId: '',
|
||||
username: '',
|
||||
setUsername: null,
|
||||
displayName: '',
|
||||
setDisplayName: null,
|
||||
isAdmin: false,
|
||||
setIsAdmin: null,
|
||||
mode: ModeType.Server,
|
||||
runTimes: [],
|
||||
logout: null
|
||||
runTimes: []
|
||||
})
|
||||
|
||||
const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
@@ -64,7 +57,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
const [checkingSession, setCheckingSession] = useState(false)
|
||||
const [loggedIn, setLoggedIn] = useState(false)
|
||||
const [needsToUpdatePassword, setNeedsToUpdatePassword] = useState(false)
|
||||
const [userId, setUserId] = useState(0)
|
||||
const [userId, setUserId] = useState<string>()
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
@@ -79,7 +72,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
|
||||
.then((res) => res.data)
|
||||
.then((data: any) => {
|
||||
setCheckingSession(false)
|
||||
setUserId(data.id)
|
||||
setUserId(data.uid)
|
||||
setUsername(data.username)
|
||||
setDisplayName(data.displayName)
|
||||
setIsAdmin(data.isAdmin)
|
||||
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: { [key: string]: string }
|
||||
export default classes
|
||||
}
|
||||
@@ -6,13 +6,13 @@ export const findExistingPermission = (
|
||||
) => {
|
||||
for (const permission of existingPermissions) {
|
||||
if (
|
||||
permission.user?.id === newPermission.principalId &&
|
||||
permission.user?.uid === newPermission.principalId &&
|
||||
hasSameCombination(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
|
||||
if (
|
||||
permission.group?.groupId === newPermission.principalId &&
|
||||
permission.group?.uid === newPermission.principalId &&
|
||||
hasSameCombination(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
@@ -27,13 +27,13 @@ export const findUpdatingPermission = (
|
||||
) => {
|
||||
for (const permission of existingPermissions) {
|
||||
if (
|
||||
permission.user?.id === newPermission.principalId &&
|
||||
permission.user?.uid === newPermission.principalId &&
|
||||
hasDifferentSetting(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
|
||||
if (
|
||||
permission.group?.groupId === newPermission.principalId &&
|
||||
permission.group?.uid === newPermission.principalId &&
|
||||
hasDifferentSetting(permission, newPermission)
|
||||
)
|
||||
return permission
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './log'
|
||||
export * from './types'
|
||||
export * from './helper'
|
||||
@@ -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)
|
||||
}
|
||||
+19
-4
@@ -1,12 +1,12 @@
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
uid: string
|
||||
username: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface GroupResponse {
|
||||
groupId: number
|
||||
uid: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export interface GroupDetailsResponse extends GroupResponse {
|
||||
}
|
||||
|
||||
export interface PermissionResponse {
|
||||
permissionId: number
|
||||
uid: string
|
||||
path: string
|
||||
type: string
|
||||
setting: string
|
||||
@@ -30,7 +30,7 @@ export interface RegisterPermissionPayload {
|
||||
type: string
|
||||
setting: string
|
||||
principalType: string
|
||||
principalId: number
|
||||
principalId: string
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -39,3 +39,18 @@ export interface TreeNode {
|
||||
isFolder: boolean
|
||||
children: Array<TreeNode>
|
||||
}
|
||||
|
||||
export interface LogInstance {
|
||||
body: string
|
||||
line: number
|
||||
type: 'error' | 'warning'
|
||||
id: number
|
||||
ref?: any
|
||||
}
|
||||
|
||||
export interface LogObject {
|
||||
body: string
|
||||
errors?: LogInstance[]
|
||||
warnings?: LogInstance[]
|
||||
linesCount: number
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,7 +14,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"plugins": [{ "name": "typescript-plugin-css-modules" }]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user