mirror of
https://github.com/sasjs/server.git
synced 2026-07-23 21:25:29 +00:00
Compare commits
272 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 1fc1431442 | |||
| 3387efbb9a | |||
| e2996b495f | |||
| 41c627f93a | |||
| 49f5dc7555 | |||
| f6e77f99a4 | |||
| b57dfa429b | |||
| 9586dbb2d0 | |||
| a4f78ab48d | |||
| 2f47a2213b | |||
| 0f91395fbb | |||
| 167b14fed0 | |||
| 8940f4dc47 | |||
| 48c1ada1b6 | |||
| 0532488b55 | |||
| d458b5bb81 | |||
| 958ab9cad2 | |||
| 78ceed13e1 | |||
| a17814fc90 | |||
| 9aaffce820 | |||
| e78f87f5c0 | |||
| bd1b58086d | |||
| 9f521634d9 | |||
| a696168443 | |||
| 31df72ad88 | |||
| d2239f75c2 | |||
| 45428892cc | |||
| ac27a9b894 | |||
| dba53de646 | |||
| eb42683fff | |||
| d2de9dc13e | |||
| 6dd2f4f876 | |||
| c0f38ba7c9 | |||
| d2f011e8a9 | |||
| 5215633e96 | |||
| 64b156f762 | |||
| 9c5acd6de3 | |||
| 3e72384a63 | |||
| df5d40b445 | |||
| c44ec35b3d | |||
| 77fac663c5 | |||
| 3848bb0add | |||
| 56a522c07c | |||
| 87e9172cfc | |||
| 7df9588e66 | |||
| 6a520f5b26 | |||
| 777b3a55be | |||
| 70c3834022 | |||
| dbf6c7de08 | |||
| d49ea47bd7 | |||
| a38a9f9c3d | |||
| be4951d112 | |||
| c116b263d9 | |||
| b4436bad0d | |||
| 57b7f954a1 | |||
| 8254b78955 | |||
| 75f5a3c0b3 | |||
| c72ecc7e59 | |||
| e04300ad2a | |||
| c7a73991a7 | |||
| 02e2b060f9 | |||
| 3b1e4a128b | |||
| 7b12591595 | |||
| 3a887dec55 | |||
| 7c1c1e2410 | |||
| 15774eca34 | |||
| 5e325522f4 | |||
| e576fad8f4 | |||
| eda8e56bb0 | |||
| bee4f215d2 | |||
| 100f138f98 | |||
| 6ffaa7e9e2 | |||
| a433786011 | |||
| 1adff9a783 | |||
| 1435e380be | |||
| e099f2e678 | |||
| ddd155ba01 | |||
| 9936241815 | |||
| 570995e572 | |||
| 462829fd9a | |||
| c1c0554de2 | |||
| bd3aff9a7b | |||
| a1e255e0c7 | |||
| 0dae034f17 | |||
| 89048ce943 | |||
| a82cabb001 | |||
| c4066d32a0 | |||
| 6a44cd69d9 | |||
| e607115995 | |||
| edab51c519 | |||
| 081cc3102c | |||
| b19aa1eba4 | |||
| 2c31922f58 | |||
| 4d7a571a6e | |||
| a373a4eb5f | |||
| 5e3ce8a98f | |||
| 737b34567e | |||
| 6373442f83 | |||
| 3de59ac4f8 | |||
| 941988cd7c | |||
| 158f044363 | |||
| 02ae041a81 | |||
| c4c84b1537 | |||
| b3402ea80a | |||
| abe942e697 | |||
| faf2edb111 | |||
| 5bec453e89 | |||
| 7f2174dd2c | |||
| 2bae52e307 | |||
| b243e62ece | |||
| 88c3056e97 | |||
| 203303b659 | |||
| 835709bd36 | |||
| 69f2576ee6 | |||
| 305077f36e | |||
| 96eca3a35d | |||
| 0f5c815c25 | |||
| acccef1e99 | |||
| abc34ea047 | |||
| 71c429b093 | |||
| c126f2d5d9 | |||
| 34dd95d16e | |||
| 1192583843 | |||
| 518815acf1 | |||
| 80b7e14ed5 | |||
| 23c997b3be | |||
| 39ba995355 | |||
| 0e081e024b | |||
| 6a84bd0387 | |||
| 98d177a691 | |||
| 4dcee4b3c3 | |||
| 4ffc1ec6a9 | |||
| 5a1d168e83 | |||
| 515c976685 | |||
| 112431a1b7 | |||
| c26485afec | |||
| 1d48f8856b | |||
| 68758aa616 | |||
| 8b8c43c21b | |||
| 4581f32534 | |||
| b47e74a7e1 | |||
| b27d684145 | |||
| 6b666d5554 | |||
| b5f0911858 | |||
| b86ba5b8a3 | |||
| 200f6c596a | |||
| 1b7ccda6e9 | |||
| 532035d835 | |||
| 7ae862c5ce | |||
| ab5858b8af | |||
| a39f5dd9f1 | |||
| 3ea444756c | |||
| 96399ecbbe | |||
| bb054938c5 | |||
| fb6a556630 | |||
| 9dbd8e16bd | |||
| fe07c41f5f | |||
| acc25cbd68 | |||
| 4ca61feda6 | |||
| abd5c64b4a | |||
| 2413c05fea | |||
| 4c874c2c39 | |||
| d819d79bc9 | |||
| c51b50428f | |||
| e10a0554f0 | |||
| 337e2eb2a0 | |||
| 66f8e7840b | |||
| 1c9d167f86 | |||
| 7e684b54a6 | |||
| aafda2922b | |||
| 418bf41e38 | |||
| 81f0b03b09 | |||
| fe5ae44aab | |||
| 36be3a7d5e | |||
| 6434123401 | |||
| 0a6b972c65 | |||
| be11707042 | |||
| 2412622367 | |||
| de3a190a8d | |||
| d5daafc6ed | |||
| b1a2677b8c | |||
| 94072c3d24 | |||
| b64c0c12da | |||
| 79bc7b0e28 | |||
| fda0e0b57d | |||
| 14731e8824 | |||
| 258cc35f14 | |||
| 2295a518f0 | |||
| 1e5d621817 | |||
| 4d64420c45 |
@@ -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
|
||||
|
||||
@@ -5,8 +5,6 @@ node_modules/
|
||||
.env*
|
||||
sas/
|
||||
sasjs_root/
|
||||
api/mocks/custom/*
|
||||
!api/mocks/custom/.keep
|
||||
tmp/
|
||||
build/
|
||||
sasjsbuild/
|
||||
|
||||
Vendored
+1
-3
@@ -1,5 +1,3 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"autoexec"
|
||||
]
|
||||
"cSpell.words": ["autoexec", "initialising"]
|
||||
}
|
||||
|
||||
+406
@@ -1,3 +1,409 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add an api endpoint for admin to get list of client ids ([6ffaa7e](https://github.com/sasjs/server/commit/6ffaa7e9e2a62c083bb9fcc3398dcbed10cebdb1))
|
||||
|
||||
# [0.31.0](https://github.com/sasjs/server/compare/v0.30.3...v0.31.0) (2023-03-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* prevent brute force attack by rate limiting login endpoint ([a82cabb](https://github.com/sasjs/server/commit/a82cabb00134c79c5ee77afd1b1628a1f768e050))
|
||||
|
||||
## [0.30.3](https://github.com/sasjs/server/compare/v0.30.2...v0.30.3) (2023-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add location.pathname to location.origin conditionally ([edab51c](https://github.com/sasjs/server/commit/edab51c51997f17553e037dc7c2b5e5fa6ea8ffe))
|
||||
|
||||
## [0.30.2](https://github.com/sasjs/server/compare/v0.30.1...v0.30.2) (2023-03-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** add path to base in launch program url ([2c31922](https://github.com/sasjs/server/commit/2c31922f58a8aa20d7fa6bfc95b53a350f90c798))
|
||||
|
||||
## [0.30.1](https://github.com/sasjs/server/compare/v0.30.0...v0.30.1) (2023-03-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** add proper base url in axios.defaults ([5e3ce8a](https://github.com/sasjs/server/commit/5e3ce8a98f1825e14c1d26d8da0c9821beeff7b3))
|
||||
|
||||
# [0.30.0](https://github.com/sasjs/server/compare/v0.29.0...v0.30.0) (2023-02-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* lint + remove default settings ([3de59ac](https://github.com/sasjs/server/commit/3de59ac4f8e3d95cad31f09e6963bd04c4811f26))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add new env config DB_TYPE ([158f044](https://github.com/sasjs/server/commit/158f044363abf2576c8248f0ca9da4bc9cb7e9d8))
|
||||
|
||||
# [0.29.0](https://github.com/sasjs/server/compare/v0.28.7...v0.29.0) (2023-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add /SASjsApi endpoint in permissions ([b3402ea](https://github.com/sasjs/server/commit/b3402ea80afb8802eee8b8b6cbbbcc29903424bc))
|
||||
|
||||
## [0.28.7](https://github.com/sasjs/server/compare/v0.28.6...v0.28.7) (2023-02-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add user to all users group on user creation ([2bae52e](https://github.com/sasjs/server/commit/2bae52e307327d7ee4a94b19d843abdc0ccec9d1))
|
||||
|
||||
## [0.28.6](https://github.com/sasjs/server/compare/v0.28.5...v0.28.6) (2023-01-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show loading spinner on login screen while request is in process ([69f2576](https://github.com/sasjs/server/commit/69f2576ee6d3d7b7f3325922a88656d511e3ac88))
|
||||
|
||||
## [0.28.5](https://github.com/sasjs/server/compare/v0.28.4...v0.28.5) (2023-01-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* adding NOPRNGETLIST system option for faster startup ([96eca3a](https://github.com/sasjs/server/commit/96eca3a35dce4521150257ee019beb4488c8a08f))
|
||||
|
||||
## [0.28.4](https://github.com/sasjs/server/compare/v0.28.3...v0.28.4) (2022-12-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* replace main class with container class ([71c429b](https://github.com/sasjs/server/commit/71c429b093b91e2444ae75d946579dccc2e48636))
|
||||
|
||||
## [0.28.3](https://github.com/sasjs/server/compare/v0.28.2...v0.28.3) (2022-12-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* stringify json file ([1192583](https://github.com/sasjs/server/commit/1192583843d7efd1a6ab6943207f394c3ae966be))
|
||||
|
||||
## [0.28.2](https://github.com/sasjs/server/compare/v0.28.1...v0.28.2) (2022-12-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* execute child process asyncronously ([23c997b](https://github.com/sasjs/server/commit/23c997b3beabeb6b733ae893031d2f1a48f28ad2))
|
||||
* JS / Python / R session folders should be NEW folders, not existing SAS folders ([39ba995](https://github.com/sasjs/server/commit/39ba995355daa24bb7ab22720f8fc57d2dc85f40))
|
||||
|
||||
## [0.28.1](https://github.com/sasjs/server/compare/v0.28.0...v0.28.1) (2022-11-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update the content type header after the program has been executed ([4dcee4b](https://github.com/sasjs/server/commit/4dcee4b3c3950d402220b8f451c50ad98a317d83))
|
||||
|
||||
# [0.28.0](https://github.com/sasjs/server/compare/v0.27.0...v0.28.0) (2022-11-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update the response header of request to stp/execute routes ([112431a](https://github.com/sasjs/server/commit/112431a1b7461989c04100418d67d975a2a8f354))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add the api endpoint for updating user password ([4581f32](https://github.com/sasjs/server/commit/4581f325344eb68c5df5a28492f132312f15bb5c))
|
||||
* ask for updated password on first login ([1d48f88](https://github.com/sasjs/server/commit/1d48f8856b1fbbf3ef868914558333190e04981f))
|
||||
* **web:** add the UI for updating user password ([8b8c43c](https://github.com/sasjs/server/commit/8b8c43c21bde5379825c5ec44ecd81a92425f605))
|
||||
|
||||
# [0.27.0](https://github.com/sasjs/server/compare/v0.26.2...v0.27.0) (2022-11-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* on startup add webout.sas file in sasautos folder ([200f6c5](https://github.com/sasjs/server/commit/200f6c596a6e732d799ed408f1f0fd92f216ba58))
|
||||
|
||||
## [0.26.2](https://github.com/sasjs/server/compare/v0.26.1...v0.26.2) (2022-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* comments ([7ae862c](https://github.com/sasjs/server/commit/7ae862c5ce720e9483d4728f4295dede4f849436))
|
||||
|
||||
## [0.26.1](https://github.com/sasjs/server/compare/v0.26.0...v0.26.1) (2022-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* change the expiration of access/refresh tokens from days to seconds ([bb05493](https://github.com/sasjs/server/commit/bb054938c5bd0535ae6b9da93ba0b14f9b80ddcd))
|
||||
|
||||
# [0.26.0](https://github.com/sasjs/server/compare/v0.25.1...v0.26.0) (2022-11-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** dispose monaco editor actions in return of useEffect ([acc25cb](https://github.com/sasjs/server/commit/acc25cbd686952d3f1c65e57aefcebe1cb859cc7))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* make access token duration configurable when creating client/secret ([2413c05](https://github.com/sasjs/server/commit/2413c05fea3960f7e5c3c8b7b2f85d61314f08db))
|
||||
* make refresh token duration configurable ([abd5c64](https://github.com/sasjs/server/commit/abd5c64b4a726e3f17594a98111b6aa269b71fee))
|
||||
|
||||
## [0.25.1](https://github.com/sasjs/server/compare/v0.25.0...v0.25.1) (2022-11-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** use mui treeView instead of custom implementation ([c51b504](https://github.com/sasjs/server/commit/c51b50428f32608bc46438e9d7964429b2d595da))
|
||||
|
||||
# [0.25.0](https://github.com/sasjs/server/compare/v0.24.0...v0.25.0) (2022-11-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Enable DRIVE_LOCATION setting for deploying multiple instances of SASjs Server ([1c9d167](https://github.com/sasjs/server/commit/1c9d167f86bbbb108b96e9bc30efaf8de65d82ff))
|
||||
|
||||
# [0.24.0](https://github.com/sasjs/server/compare/v0.23.4...v0.24.0) (2022-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* cli mock testing ([6434123](https://github.com/sasjs/server/commit/643412340162e854f31fba2f162d83b7ab1751d8))
|
||||
* mocking sas9 responses with JS STP ([36be3a7](https://github.com/sasjs/server/commit/36be3a7d5e7df79f9a1f3f00c3661b925f462383))
|
||||
|
||||
## [0.23.4](https://github.com/sasjs/server/compare/v0.23.3...v0.23.4) (2022-10-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add action to editor ref for running code ([2412622](https://github.com/sasjs/server/commit/2412622367eb46c40f388e988ae4606a7ec239b2))
|
||||
|
||||
## [0.23.3](https://github.com/sasjs/server/compare/v0.23.2...v0.23.3) (2022-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* added domain for session cookies ([94072c3](https://github.com/sasjs/server/commit/94072c3d24a4d0d4c97900dc31bfbf1c9d2559b7))
|
||||
|
||||
## [0.23.2](https://github.com/sasjs/server/compare/v0.23.1...v0.23.2) (2022-10-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump in correct place ([14731e8](https://github.com/sasjs/server/commit/14731e8824fa9f3d1daf89fd62f9916d5e3fcae4))
|
||||
* bumping sasjs/score ([258cc35](https://github.com/sasjs/server/commit/258cc35f14cf50f2160f607000c60de27593fd79))
|
||||
* reverting commit ([fda0e0b](https://github.com/sasjs/server/commit/fda0e0b57d56e3b5231e626a8d933343ac0c5cdc))
|
||||
|
||||
## [0.23.1](https://github.com/sasjs/server/compare/v0.23.0...v0.23.1) (2022-10-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ldap issues ([4d64420](https://github.com/sasjs/server/commit/4d64420c45424134b4d2014a2d5dd6e846ed03b3))
|
||||
|
||||
# [0.23.0](https://github.com/sasjs/server/compare/v0.22.1...v0.23.0) (2022-10-03)
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ R_PATH=/usr/bin/Rscript
|
||||
SASJS_ROOT=./sasjs_root
|
||||
|
||||
|
||||
# This location is for files, sasjs packages and appStreamConfig.json
|
||||
DRIVE_LOCATION=./sasjs_root/drive
|
||||
|
||||
|
||||
# options: [http|https] default: http
|
||||
PROTOCOL=
|
||||
|
||||
@@ -103,6 +107,11 @@ PORT=
|
||||
# If not present, mocking function is disabled
|
||||
MOCK_SERVERTYPE=
|
||||
|
||||
# default: /api/mocks
|
||||
# Path to mocking folder, for generic responses, it's sub directories should be: sas9, viya, sasjs
|
||||
# Server will automatically use subdirectory accordingly
|
||||
STATIC_MOCK_LOCATION=
|
||||
|
||||
#
|
||||
## Additional SAS Options
|
||||
#
|
||||
@@ -128,6 +137,9 @@ CA_ROOT=fullchain.pem (optional)
|
||||
## ENV variables required for MODE: `server`
|
||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||
|
||||
# options: [mongodb|cosmos_mongodb] default: mongodb
|
||||
DB_TYPE=
|
||||
|
||||
# AUTH_PROVIDERS options: [ldap] default: ``
|
||||
AUTH_PROVIDERS=
|
||||
|
||||
@@ -146,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=
|
||||
@@ -163,6 +175,32 @@ HELMET_COEP=
|
||||
# }
|
||||
HELMET_CSP_CONFIG_PATH=./csp.config.json
|
||||
|
||||
# To prevent brute force attack on login route we have implemented rate limiter
|
||||
# Only valid for MODE: server
|
||||
# Following are configurable env variable rate limiter
|
||||
|
||||
# After this, access is blocked for 1 day
|
||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
|
||||
|
||||
|
||||
# After this, access is blocked for an hour
|
||||
# Store number for 24 days since first fail
|
||||
# Once a successful login is attempted, it resets
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
|
||||
|
||||
# Name of the admin user that will be created on startup if not exists already
|
||||
# Default is `secretuser`
|
||||
ADMIN_USERNAME=secretuser
|
||||
|
||||
# Temporary password for the ADMIN_USERNAME, which is in place until the first login
|
||||
# Default is `secretpassword`
|
||||
ADMIN_PASSWORD_INITIAL=secretpassword
|
||||
|
||||
# Specify whether app has to reset the ADMIN_USERNAME's password or not
|
||||
# Default is NO. Possible options are YES and NO
|
||||
# If ADMIN_PASSWORD_RESET is YES then the ADMIN_USERNAME will be prompted to change the password from ADMIN_PASSWORD_INITIAL on their next login. This will repeat on every server restart, unless the option is removed / set to NO.
|
||||
ADMIN_PASSWORD_RESET=NO
|
||||
|
||||
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
|
||||
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
||||
LOG_FORMAT_MORGAN=
|
||||
|
||||
+14
-1
@@ -1,5 +1,6 @@
|
||||
MODE=[desktop|server] default considered as desktop
|
||||
CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE
|
||||
ALLOWED_DOMAIN=<just domain e.g. example.com >
|
||||
WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
|
||||
|
||||
PROTOCOL=[http|https] default considered as http
|
||||
@@ -13,8 +14,9 @@ HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
|
||||
HELMET_COEP=[true|false] if omitted HELMET default will be used
|
||||
|
||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||
DB_TYPE=[mongodb|cosmos_mongodb] default considered as mongodb
|
||||
|
||||
AUTH_PROVIDERS=[ldap|internal] default considered as internal
|
||||
AUTH_PROVIDERS=[ldap]
|
||||
|
||||
LDAP_URL= <LDAP_SERVER_URL>
|
||||
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
|
||||
@@ -22,6 +24,16 @@ LDAP_BIND_PASSWORD = <password>
|
||||
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
||||
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||
|
||||
#default value is 100
|
||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
||||
|
||||
#default value is 10
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
||||
|
||||
ADMIN_USERNAME=secretuser
|
||||
ADMIN_PASSWORD_INITIAL=secretpassword
|
||||
ADMIN_PASSWORD_RESET=NO
|
||||
|
||||
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
|
||||
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
||||
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||
@@ -29,6 +41,7 @@ PYTHON_PATH=/usr/bin/python
|
||||
R_PATH=/usr/bin/Rscript
|
||||
|
||||
SASJS_ROOT=./sasjs_root
|
||||
DRIVE_LOCATION=./sasjs_root/drive
|
||||
|
||||
LOG_FORMAT_MORGAN=common
|
||||
LOG_LOCATION=./sasjs_root/logs
|
||||
@@ -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.
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="content">
|
||||
<form id="credentials" class="minimal" action="/SASLogon/login?service=http%3A%2F%2Flocalhost:5004%2FSASStoredProcess%2Fj_spring_cas_security_check" method="post">
|
||||
<!--form container-->
|
||||
<input type="hidden" name="lt" value="LT-8-WGkt9EXwICBihaVbxGc92opjufTK1D" aria-hidden="true" />
|
||||
<input type="hidden" name="lt" value="validtoken" aria-hidden="true" />
|
||||
<input type="hidden" name="execution" value="e2s1" aria-hidden="true" />
|
||||
<input type="hidden" name="_eventId" value="submit" aria-hidden="true" />
|
||||
|
||||
Generated
+6522
-12552
File diff suppressed because it is too large
Load Diff
+27
-29
@@ -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,25 +46,25 @@
|
||||
},
|
||||
"author": "4GL Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/core": "^4.31.3",
|
||||
"@sasjs/utils": "2.48.1",
|
||||
"@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": {
|
||||
@@ -76,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",
|
||||
"mongodb-memory-server": "^8.0.0",
|
||||
"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": [
|
||||
|
||||
+245
-10
@@ -47,6 +47,21 @@ components:
|
||||
- userId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
UpdatePasswordPayload:
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
description: 'Current Password'
|
||||
example: currentPasswordString
|
||||
newPassword:
|
||||
type: string
|
||||
description: 'New Password'
|
||||
example: newPassword
|
||||
required:
|
||||
- currentPassword
|
||||
- newPassword
|
||||
type: object
|
||||
additionalProperties: false
|
||||
ClientPayload:
|
||||
properties:
|
||||
clientId:
|
||||
@@ -57,6 +72,16 @@ components:
|
||||
type: string
|
||||
description: 'Client Secret'
|
||||
example: someRandomCryptoString
|
||||
accessTokenExpiration:
|
||||
type: number
|
||||
format: double
|
||||
description: 'Number of seconds after which access token will expire. Default is 86400 (1 day)'
|
||||
example: 86400
|
||||
refreshTokenExpiration:
|
||||
type: number
|
||||
format: double
|
||||
description: 'Number of seconds after which access token will expire. Default is 2592000 (30 days)'
|
||||
example: 2592000
|
||||
required:
|
||||
- clientId
|
||||
- clientSecret
|
||||
@@ -73,17 +98,47 @@ components:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'Code of program'
|
||||
example: '* Code HERE;'
|
||||
description: 'The code to be executed'
|
||||
example: '* Your Code HERE;'
|
||||
runTime:
|
||||
$ref: '#/components/schemas/RunTimeType'
|
||||
description: 'runtime for program'
|
||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
||||
example: js
|
||||
required:
|
||||
- code
|
||||
- runTime
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerCodeResponse:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerCodePayload:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: 'The code to be executed'
|
||||
example: '* Your Code HERE;'
|
||||
runTime:
|
||||
$ref: '#/components/schemas/RunTimeType'
|
||||
description: 'The runtime for the code - eg SAS, JS, PY or R'
|
||||
example: sas
|
||||
expiresAfterMins:
|
||||
type: number
|
||||
format: double
|
||||
description: "Amount of minutes after the completion of the job when the session must be\ndestroyed."
|
||||
example: 15
|
||||
required:
|
||||
- code
|
||||
- runTime
|
||||
type: object
|
||||
additionalProperties: false
|
||||
MemberType.folder:
|
||||
enum:
|
||||
- folder
|
||||
@@ -509,6 +564,35 @@ components:
|
||||
- setting
|
||||
type: object
|
||||
additionalProperties: false
|
||||
SessionResponse:
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
format: double
|
||||
username:
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
isAdmin:
|
||||
type: boolean
|
||||
needsToUpdatePassword:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- username
|
||||
- displayName
|
||||
- isAdmin
|
||||
- needsToUpdatePassword
|
||||
type: object
|
||||
additionalProperties: false
|
||||
SessionState:
|
||||
enum:
|
||||
- initialising
|
||||
- pending
|
||||
- running
|
||||
- completed
|
||||
- failed
|
||||
type: string
|
||||
ExecutePostRequestPayload:
|
||||
properties:
|
||||
_program:
|
||||
@@ -517,6 +601,16 @@ components:
|
||||
example: /Public/somefolder/some.file
|
||||
type: object
|
||||
additionalProperties: false
|
||||
TriggerProgramResponse:
|
||||
properties:
|
||||
sessionId:
|
||||
type: string
|
||||
description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
|
||||
example: 20241028074744-54132-1730101664824
|
||||
required:
|
||||
- sessionId
|
||||
type: object
|
||||
additionalProperties: false
|
||||
LoginPayload:
|
||||
properties:
|
||||
username:
|
||||
@@ -622,6 +716,25 @@ paths:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
/SASjsApi/auth/updatePassword:
|
||||
patch:
|
||||
operationId: UpdatePassword
|
||||
responses:
|
||||
'204':
|
||||
description: 'No content'
|
||||
summary: 'Update user''s password.'
|
||||
tags:
|
||||
- Auth
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdatePasswordPayload'
|
||||
/SASjsApi/authConfig:
|
||||
get:
|
||||
operationId: GetDetail
|
||||
@@ -679,8 +792,8 @@ paths:
|
||||
$ref: '#/components/schemas/ClientPayload'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString}
|
||||
summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.'
|
||||
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}
|
||||
summary: "Admin only task. Create client with the following attributes:\nClientId,\nClientSecret,\naccessTokenExpiration (optional),\nrefreshTokenExpiration (optional)"
|
||||
tags:
|
||||
- Client
|
||||
security:
|
||||
@@ -693,6 +806,27 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClientPayload'
|
||||
get:
|
||||
operationId: GetAllClients
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/components/schemas/ClientPayload'
|
||||
type: array
|
||||
examples:
|
||||
'Example 1':
|
||||
value: [{clientId: someClientID1234, clientSecret: someRandomCryptoString, accessTokenExpiration: 86400}, {clientId: someOtherClientID, clientSecret: someOtherRandomCryptoString, accessTokenExpiration: 86400}]
|
||||
summary: 'Admin only task. Returns the list of all the clients'
|
||||
tags:
|
||||
- Client
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
/SASjsApi/code/execute:
|
||||
post:
|
||||
operationId: ExecuteCode
|
||||
@@ -706,7 +840,7 @@ paths:
|
||||
- {type: string}
|
||||
- {type: string, format: byte}
|
||||
description: 'Execute Code on the Specified Runtime'
|
||||
summary: 'Run Code and Return Webout Content and Log'
|
||||
summary: "Run Code and Return Webout Content, Log and Print output\nThe order of returned parts of the payload is:\n1. Webout (if present)\n2. Logs UUID (used as separator)\n3. Log\n4. Logs UUID (used as separator)\n5. Print (if present and if the runtime is SAS)\nPlease see"
|
||||
tags:
|
||||
- Code
|
||||
security:
|
||||
@@ -719,6 +853,30 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecuteCodePayload'
|
||||
/SASjsApi/code/trigger:
|
||||
post:
|
||||
operationId: TriggerCode
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerCodeResponse'
|
||||
description: 'Trigger Code on the Specified Runtime'
|
||||
summary: 'Triggers code and returns SessionId immediately - does not wait for job completion'
|
||||
tags:
|
||||
- Code
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerCodePayload'
|
||||
/SASjsApi/drive/deploy:
|
||||
post:
|
||||
operationId: Deploy
|
||||
@@ -1680,7 +1838,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
$ref: '#/components/schemas/SessionResponse'
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {id: 123, username: johnusername, displayName: John, isAdmin: false}
|
||||
@@ -1691,6 +1849,30 @@ paths:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters: []
|
||||
'/SASjsApi/session/{sessionId}/state':
|
||||
get:
|
||||
operationId: SessionState
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionState'
|
||||
description: "The polling endpoint is currently implemented for single-server deployments only.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\nIf your site requires this, please reach out to SASjs Support."
|
||||
summary: 'Get session state (initialising, pending, running, completed, failed).'
|
||||
tags:
|
||||
- Session
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
in: path
|
||||
name: sessionId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
/SASjsApi/stp/execute:
|
||||
get:
|
||||
operationId: ExecuteGetRequest
|
||||
@@ -1703,7 +1885,7 @@ paths:
|
||||
anyOf:
|
||||
- {type: string}
|
||||
- {type: string, format: byte}
|
||||
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts URL parameters and file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
|
||||
description: "Trigger a Stored Program using the _program URL parameter.\n\nAccepts additional URL parameters (converted to session variables)\nand file uploads. For more details, see docs:\n\nhttps://server.sasjs.io/storedprograms"
|
||||
summary: 'Execute a Stored Program, returns _webout and (optionally) log.'
|
||||
tags:
|
||||
- STP
|
||||
@@ -1712,13 +1894,22 @@ paths:
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of code in SASjs Drive'
|
||||
description: 'Location of Stored Program in SASjs Drive.'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Projects/myApp/some/program
|
||||
-
|
||||
description: 'Optional query param for setting debug mode (returns the session log in the response body).'
|
||||
in: query
|
||||
name: _debug
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 131
|
||||
post:
|
||||
operationId: ExecutePostRequest
|
||||
responses:
|
||||
@@ -1752,6 +1943,50 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExecutePostRequestPayload'
|
||||
/SASjsApi/stp/trigger:
|
||||
post:
|
||||
operationId: TriggerProgram
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TriggerProgramResponse'
|
||||
description: 'Trigger Program on the Specified Runtime.'
|
||||
summary: 'Triggers program and returns SessionId immediately - does not wait for program completion.'
|
||||
tags:
|
||||
- STP
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of code in SASjs Drive.'
|
||||
in: query
|
||||
name: _program
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: /Projects/myApp/some/program
|
||||
-
|
||||
description: 'Optional query param for setting debug mode.'
|
||||
in: query
|
||||
name: _debug
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 131
|
||||
-
|
||||
description: 'Optional query param for setting amount of minutes after the completion of the program when the session must be destroyed.'
|
||||
in: query
|
||||
name: expiresAfterMins
|
||||
required: false
|
||||
schema:
|
||||
format: double
|
||||
type: number
|
||||
example: 15
|
||||
/:
|
||||
get:
|
||||
operationId: Home
|
||||
@@ -1777,7 +2012,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
user: {properties: {isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [isAdmin, displayName, username, id], type: object}
|
||||
user: {properties: {needsToUpdatePassword: {type: boolean}, isAdmin: {type: boolean}, displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [needsToUpdatePassword, isAdmin, displayName, username, id], type: object}
|
||||
loggedIn: {type: boolean}
|
||||
required:
|
||||
- user
|
||||
|
||||
@@ -15,7 +15,7 @@ export const configureCors = (app: Express) => {
|
||||
whiteList.push(url.replace(/\/$/, ''))
|
||||
})
|
||||
|
||||
console.log('All CORS Requests are enabled for:', whiteList)
|
||||
process.logger.info('All CORS Requests are enabled for:', whiteList)
|
||||
app.use(cors({ credentials: true, origin: whiteList }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { Express } from 'express'
|
||||
import { Express, CookieOptions } from 'express'
|
||||
import mongoose from 'mongoose'
|
||||
import session from 'express-session'
|
||||
import MongoStore from 'connect-mongo'
|
||||
|
||||
import { ModeType } from '../utils'
|
||||
import { cookieOptions } from '../app'
|
||||
import { DatabaseType, ModeType, ProtocolType } from '../utils'
|
||||
|
||||
export const configureExpressSession = (app: Express) => {
|
||||
const { MODE } = process.env
|
||||
const { MODE, DB_TYPE } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
let store: MongoStore | undefined
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
store = MongoStore.create({
|
||||
client: mongoose.connection!.getClient() as any,
|
||||
collectionName: 'sessions'
|
||||
})
|
||||
if (DB_TYPE === DatabaseType.COSMOS_MONGODB) {
|
||||
// COSMOS DB requires specific connection options (compatibility mode)
|
||||
// See: https://www.npmjs.com/package/connect-mongo#set-the-compatibility-mode
|
||||
store = MongoStore.create({
|
||||
client: mongoose.connection!.getClient() as any,
|
||||
autoRemove: 'interval'
|
||||
})
|
||||
} else {
|
||||
store = MongoStore.create({
|
||||
client: mongoose.connection!.getClient() as any
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { PROTOCOL, ALLOWED_DOMAIN } = process.env
|
||||
const cookieOptions: CookieOptions = {
|
||||
secure: PROTOCOL === ProtocolType.HTTPS,
|
||||
httpOnly: true,
|
||||
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
domain: ALLOWED_DOMAIN?.trim() || undefined
|
||||
}
|
||||
|
||||
app.use(
|
||||
|
||||
@@ -23,7 +23,7 @@ export const configureLogger = (app: Express) => {
|
||||
path: logsFolder
|
||||
})
|
||||
|
||||
console.log('Writing Logs to :', path.join(logsFolder, filename))
|
||||
process.logger.info('Writing Logs to :', path.join(logsFolder, filename))
|
||||
|
||||
options = { stream: accessLogStream }
|
||||
}
|
||||
|
||||
+24
-15
@@ -1,17 +1,21 @@
|
||||
import path from 'path'
|
||||
import express, { ErrorRequestHandler, CookieOptions } from 'express'
|
||||
import express, { ErrorRequestHandler } from 'express'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import {
|
||||
copySASjsCore,
|
||||
createWeboutSasFile,
|
||||
getFilesFolder,
|
||||
getPackagesFolder,
|
||||
getWebBuildFolder,
|
||||
instantiateLogger,
|
||||
loadAppStreamConfig,
|
||||
ProtocolType,
|
||||
ReturnCode,
|
||||
setProcessVariables,
|
||||
setupFolders,
|
||||
setupFilesFolder,
|
||||
setupPackagesFolder,
|
||||
setupUserAutoExec,
|
||||
verifyEnvVariables
|
||||
} from './utils'
|
||||
import {
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
configureLogger,
|
||||
configureSecurity
|
||||
} from './app-modules'
|
||||
import { folderExists } from '@sasjs/utils'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -29,17 +34,8 @@ if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
|
||||
|
||||
const app = express()
|
||||
|
||||
const { PROTOCOL } = process.env
|
||||
|
||||
export const cookieOptions: CookieOptions = {
|
||||
secure: PROTOCOL === ProtocolType.HTTPS,
|
||||
httpOnly: true,
|
||||
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
}
|
||||
|
||||
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
console.error(err.stack)
|
||||
process.logger.error(err.stack)
|
||||
res.status(500).send('Something broke!')
|
||||
}
|
||||
|
||||
@@ -72,8 +68,21 @@ export default setProcessVariables().then(async () => {
|
||||
// Currently only place we use it is SAS9 Mock - POST /SASLogon/login
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
await setupFolders()
|
||||
await copySASjsCore()
|
||||
await setupUserAutoExec()
|
||||
|
||||
if (!(await folderExists(getFilesFolder()))) await setupFilesFolder()
|
||||
|
||||
if (!(await folderExists(getPackagesFolder()))) await setupPackagesFolder()
|
||||
|
||||
const sasautosPath = path.join(process.driveLoc, 'sas', 'sasautos')
|
||||
if (await folderExists(sasautosPath)) {
|
||||
process.logger.warn(
|
||||
`SASAUTOS was not refreshed. To force a refresh, delete the ${sasautosPath} folder`
|
||||
)
|
||||
} else {
|
||||
await copySASjsCore()
|
||||
await createWeboutSasFile()
|
||||
}
|
||||
|
||||
// loading these modules after setting up variables due to
|
||||
// multer's usage of process var process.driveLoc
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
|
||||
import express from 'express'
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Example,
|
||||
Post,
|
||||
Patch,
|
||||
Request,
|
||||
Body,
|
||||
Query,
|
||||
Hidden
|
||||
} from 'tsoa'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
import {
|
||||
@@ -8,6 +20,8 @@ import {
|
||||
removeTokensInDB,
|
||||
saveTokensInDB
|
||||
} from '../utils'
|
||||
import Client from '../model/Client'
|
||||
import User from '../model/User'
|
||||
|
||||
@Route('SASjsApi/auth')
|
||||
@Tags('Auth')
|
||||
@@ -61,6 +75,18 @@ export class AuthController {
|
||||
public async logout(@Query() @Hidden() data?: InfoJWT) {
|
||||
return logout(data!)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Update user's password.
|
||||
*/
|
||||
@Security('bearerAuth')
|
||||
@Patch('updatePassword')
|
||||
public async updatePassword(
|
||||
@Request() req: express.Request,
|
||||
@Body() body: UpdatePasswordPayload
|
||||
) {
|
||||
return updatePassword(req, body)
|
||||
}
|
||||
}
|
||||
|
||||
const token = async (data: any): Promise<TokenResponse> => {
|
||||
@@ -83,8 +109,17 @@ const token = async (data: any): Promise<TokenResponse> => {
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(userInfo)
|
||||
const refreshToken = generateRefreshToken(userInfo)
|
||||
const client = await Client.findOne({ clientId })
|
||||
if (!client) throw new Error('Invalid clientId.')
|
||||
|
||||
const accessToken = generateAccessToken(
|
||||
userInfo,
|
||||
client.accessTokenExpiration
|
||||
)
|
||||
const refreshToken = generateRefreshToken(
|
||||
userInfo,
|
||||
client.refreshTokenExpiration
|
||||
)
|
||||
|
||||
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
||||
|
||||
@@ -92,8 +127,17 @@ const token = async (data: any): Promise<TokenResponse> => {
|
||||
}
|
||||
|
||||
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
||||
const accessToken = generateAccessToken(userInfo)
|
||||
const refreshToken = generateRefreshToken(userInfo)
|
||||
const client = await Client.findOne({ clientId: userInfo.clientId })
|
||||
if (!client) throw new Error('Invalid clientId.')
|
||||
|
||||
const accessToken = generateAccessToken(
|
||||
userInfo,
|
||||
client.accessTokenExpiration
|
||||
)
|
||||
const refreshToken = generateRefreshToken(
|
||||
userInfo,
|
||||
client.refreshTokenExpiration
|
||||
)
|
||||
|
||||
await saveTokensInDB(
|
||||
userInfo.userId,
|
||||
@@ -109,6 +153,40 @@ const logout = async (userInfo: InfoJWT) => {
|
||||
await removeTokensInDB(userInfo.userId, userInfo.clientId)
|
||||
}
|
||||
|
||||
const updatePassword = async (
|
||||
req: express.Request,
|
||||
data: UpdatePasswordPayload
|
||||
) => {
|
||||
const { currentPassword, newPassword } = data
|
||||
const userId = req.user?.userId
|
||||
const dbUser = await User.findOne({ id: userId })
|
||||
|
||||
if (!dbUser)
|
||||
throw {
|
||||
code: 404,
|
||||
message: `User not found!`
|
||||
}
|
||||
|
||||
if (dbUser?.authProvider) {
|
||||
throw {
|
||||
code: 405,
|
||||
message:
|
||||
'Can not update password of user that is created by an external auth provider.'
|
||||
}
|
||||
}
|
||||
|
||||
const validPass = dbUser.comparePassword(currentPassword)
|
||||
if (!validPass)
|
||||
throw {
|
||||
code: 403,
|
||||
message: `Invalid current password!`
|
||||
}
|
||||
|
||||
dbUser.password = User.hashPassword(newPassword)
|
||||
dbUser.needsToUpdatePassword = false
|
||||
await dbUser.save()
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
/**
|
||||
* Client ID
|
||||
@@ -135,6 +213,19 @@ interface TokenResponse {
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
interface UpdatePasswordPayload {
|
||||
/**
|
||||
* Current Password
|
||||
* @example "currentPasswordString"
|
||||
*/
|
||||
currentPassword: string
|
||||
/**
|
||||
* New Password
|
||||
* @example "newPassword"
|
||||
*/
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
const verifyAuthCode = async (
|
||||
clientId: string,
|
||||
code: string
|
||||
@@ -143,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)
|
||||
|
||||
@@ -74,7 +74,8 @@ const synchroniseWithLDAP = async () => {
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
password: hashPassword,
|
||||
authProvider: AuthProviderType.LDAP
|
||||
authProvider: AuthProviderType.LDAP,
|
||||
needsToUpdatePassword: false
|
||||
})
|
||||
|
||||
importedUsers.push(user)
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
|
||||
import { Security, Route, Tags, Example, Post, Body, Get } from 'tsoa'
|
||||
|
||||
import Client, { ClientPayload } from '../model/Client'
|
||||
import Client, {
|
||||
ClientPayload,
|
||||
NUMBER_OF_SECONDS_IN_A_DAY
|
||||
} from '../model/Client'
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/client')
|
||||
@Tags('Client')
|
||||
export class ClientController {
|
||||
/**
|
||||
* @summary Create client with the following attributes: ClientId, ClientSecret. Admin only task.
|
||||
* @summary Admin only task. Create client with the following attributes:
|
||||
* ClientId,
|
||||
* ClientSecret,
|
||||
* accessTokenExpiration (optional),
|
||||
* refreshTokenExpiration (optional)
|
||||
*
|
||||
*/
|
||||
@Example<ClientPayload>({
|
||||
clientId: 'someFormattedClientID1234',
|
||||
clientSecret: 'someRandomCryptoString'
|
||||
clientSecret: 'someRandomCryptoString',
|
||||
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
})
|
||||
@Post('/')
|
||||
public async createClient(
|
||||
@@ -20,10 +29,37 @@ export class ClientController {
|
||||
): Promise<ClientPayload> {
|
||||
return createClient(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Admin only task. Returns the list of all the clients
|
||||
*/
|
||||
@Example<ClientPayload[]>([
|
||||
{
|
||||
clientId: 'someClientID1234',
|
||||
clientSecret: 'someRandomCryptoString',
|
||||
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
},
|
||||
{
|
||||
clientId: 'someOtherClientID',
|
||||
clientSecret: 'someOtherRandomCryptoString',
|
||||
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
public async getAllClients(): Promise<ClientPayload[]> {
|
||||
return getAllClients()
|
||||
}
|
||||
}
|
||||
|
||||
const createClient = async (data: any): Promise<ClientPayload> => {
|
||||
const { clientId, clientSecret } = data
|
||||
const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenExpiration,
|
||||
refreshTokenExpiration
|
||||
} = data
|
||||
|
||||
// Checking if client is already in the database
|
||||
const clientExist = await Client.findOne({ clientId })
|
||||
@@ -32,13 +68,27 @@ const createClient = async (data: any): Promise<ClientPayload> => {
|
||||
// Create a new client
|
||||
const client = new Client({
|
||||
clientId,
|
||||
clientSecret
|
||||
clientSecret,
|
||||
accessTokenExpiration,
|
||||
refreshTokenExpiration
|
||||
})
|
||||
|
||||
const savedClient = await client.save()
|
||||
|
||||
return {
|
||||
clientId: savedClient.clientId,
|
||||
clientSecret: savedClient.clientSecret
|
||||
clientSecret: savedClient.clientSecret,
|
||||
accessTokenExpiration: savedClient.accessTokenExpiration,
|
||||
refreshTokenExpiration: savedClient.refreshTokenExpiration
|
||||
}
|
||||
}
|
||||
|
||||
const getAllClients = async (): Promise<ClientPayload[]> => {
|
||||
return Client.find({}).select({
|
||||
_id: 0,
|
||||
clientId: 1,
|
||||
clientSecret: 1,
|
||||
accessTokenExpiration: 1,
|
||||
refreshTokenExpiration: 1
|
||||
})
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -251,7 +251,7 @@ const updateUsersListInGroup = async (
|
||||
message: `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
|
||||
}
|
||||
|
||||
if (group.authProvider !== AuthProviderType.Internal)
|
||||
if (group.authProvider)
|
||||
throw {
|
||||
code: 405,
|
||||
status: 'Method Not Allowed',
|
||||
@@ -266,7 +266,7 @@ const updateUsersListInGroup = async (
|
||||
message: 'User not found.'
|
||||
}
|
||||
|
||||
if (user.authProvider !== AuthProviderType.Internal)
|
||||
if (user.authProvider)
|
||||
throw {
|
||||
code: 405,
|
||||
status: 'Method Not Allowed',
|
||||
|
||||
@@ -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
|
||||
@@ -28,10 +46,12 @@ interface ExecuteFileParams {
|
||||
returnJson?: boolean
|
||||
session?: Session
|
||||
runTime: RunTimeType
|
||||
forceStringResult?: boolean
|
||||
}
|
||||
|
||||
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
||||
program: string
|
||||
includePrintOutput?: boolean
|
||||
}
|
||||
|
||||
export class ExecutionController {
|
||||
@@ -42,7 +62,8 @@ export class ExecutionController {
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session,
|
||||
runTime
|
||||
runTime,
|
||||
forceStringResult
|
||||
}: ExecuteFileParams) {
|
||||
const program = await readFile(programPath)
|
||||
|
||||
@@ -53,7 +74,8 @@ export class ExecutionController {
|
||||
otherArgs,
|
||||
returnJson,
|
||||
session,
|
||||
runTime
|
||||
runTime,
|
||||
forceStringResult
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,14 +85,15 @@ export class ExecutionController {
|
||||
vars,
|
||||
otherArgs,
|
||||
session: sessionByFileUpload,
|
||||
runTime
|
||||
runTime,
|
||||
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')
|
||||
@@ -83,40 +106,79 @@ 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))
|
||||
? await readFile(headersPath)
|
||||
: ''
|
||||
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
|
||||
|
||||
if (isDebugOn(vars)) {
|
||||
httpHeaders['content-type'] = 'text/plain'
|
||||
}
|
||||
|
||||
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
|
||||
|
||||
const webout = (await fileExists(weboutPath))
|
||||
? fileResponse
|
||||
? fileResponse && !forceStringResult
|
||||
? await readFileBinary(weboutPath)
|
||||
: await readFile(weboutPath)
|
||||
: ''
|
||||
|
||||
// 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/plain')
|
||||
|
||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||
|
||||
this.sessions.push(session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ export class SessionController {
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
public getSessionById(id: string) {
|
||||
return this.sessions.find((session) => session.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export class SASSessionController extends SessionController {
|
||||
@@ -84,17 +88,14 @@ export class SASSessionController extends SessionController {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: false,
|
||||
inUse: false,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.initialising,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||
await createFile(headersPath, 'Content-type: text/plain')
|
||||
await createFile(headersPath, 'content-type: text/html; charset=utf-8\n')
|
||||
|
||||
// we do not want to leave sessions running forever
|
||||
// we clean them up after a predefined period, if unused
|
||||
@@ -134,23 +135,31 @@ ${autoExecContent}`
|
||||
session.path,
|
||||
'-AUTOEXEC',
|
||||
autoExecPath,
|
||||
isWindows() ? '-nologo' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nologo' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-NOPRNGETLIST' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '',
|
||||
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||
])
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
console.log('session completed', session)
|
||||
session.state = SessionState.completed
|
||||
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
console.log('session crashed', session.id, session.crashed)
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// we have a triggered session - add to array
|
||||
@@ -167,12 +176,19 @@ ${autoExecContent}`
|
||||
const codeFilePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// TODO: don't wait forever
|
||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||
while (
|
||||
(await fileExists(codeFilePath)) &&
|
||||
session.state !== SessionState.failed
|
||||
) {}
|
||||
|
||||
if (session.crashed)
|
||||
console.log('session crashed! while waiting to be ready', session.crashed)
|
||||
|
||||
session.ready = true
|
||||
if (session.state === SessionState.failed) {
|
||||
process.logger.error(
|
||||
'session crashed! while waiting to be ready',
|
||||
session.failureReason
|
||||
)
|
||||
} else {
|
||||
session.state = SessionState.pending
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSession(session: Session) {
|
||||
@@ -186,29 +202,52 @@ ${autoExecContent}`
|
||||
}
|
||||
|
||||
private scheduleSessionDestroy(session: Session) {
|
||||
setTimeout(async () => {
|
||||
if (session.inUse) {
|
||||
// adding 10 more minutes
|
||||
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
setTimeout(
|
||||
async () => {
|
||||
if (session.state === SessionState.running) {
|
||||
// adding 10 more minutes
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) + 10 * 60 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
await this.deleteSession(session)
|
||||
}
|
||||
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100)
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
const { expiresAfterMins } = session
|
||||
|
||||
// delay session destroy if expiresAfterMins present
|
||||
if (expiresAfterMins && session.state !== SessionState.completed) {
|
||||
// calculate session death time using expiresAfterMins
|
||||
const newDeathTimeStamp =
|
||||
parseInt(session.deathTimeStamp) +
|
||||
expiresAfterMins.mins * 60 * 1000
|
||||
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||
|
||||
// set expiresAfterMins to true to avoid using it again
|
||||
session.expiresAfterMins!.used = true
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
await this.deleteSession(session)
|
||||
}
|
||||
}
|
||||
},
|
||||
parseInt(session.deathTimeStamp) - new Date().getTime() - 100
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const getSessionController = (
|
||||
runTime: RunTimeType
|
||||
): SessionController => {
|
||||
if (process.sessionController) return process.sessionController
|
||||
if (runTime === RunTimeType.SAS) {
|
||||
process.sasSessionController =
|
||||
process.sasSessionController || new SASSessionController()
|
||||
|
||||
return process.sasSessionController
|
||||
}
|
||||
|
||||
process.sessionController =
|
||||
runTime === RunTimeType.SAS
|
||||
? new SASSessionController()
|
||||
: new SessionController()
|
||||
process.sessionController || new SessionController()
|
||||
|
||||
return process.sessionController
|
||||
}
|
||||
@@ -221,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 = `
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { WriteStream, createWriteStream } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { once } from 'stream'
|
||||
import { createFile, moveFile } from '@sasjs/utils'
|
||||
import { PreProgramVars, Session } from '../../types'
|
||||
import { PreProgramVars, Session, SessionState } from '../../types'
|
||||
import { RunTimeType } from '../../utils'
|
||||
import {
|
||||
ExecutionVars,
|
||||
@@ -49,7 +49,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 {
|
||||
@@ -105,30 +109,65 @@ export const processProgram = async (
|
||||
throw new Error('Invalid runtime!')
|
||||
}
|
||||
|
||||
try {
|
||||
await createFile(codePath, program)
|
||||
await createFile(codePath, program)
|
||||
|
||||
// create a stream that will write to console outputs to log file
|
||||
const writeStream = fs.createWriteStream(logPath)
|
||||
// create a stream that will write to console outputs to log file
|
||||
const writeStream = createWriteStream(logPath)
|
||||
// waiting for the open event so that we can have underlying file descriptor
|
||||
await once(writeStream, 'open')
|
||||
|
||||
// waiting for the open event so that we can have underlying file descriptor
|
||||
await once(writeStream, 'open')
|
||||
await execFilePromise(executablePath, [codePath], writeStream)
|
||||
.then(() => {
|
||||
session.state = SessionState.completed
|
||||
|
||||
execFileSync(executablePath, [codePath], {
|
||||
stdio: ['ignore', writeStream, writeStream]
|
||||
process.logger.info('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.state = SessionState.failed
|
||||
|
||||
session.failureReason = err.toString()
|
||||
|
||||
process.logger.error(
|
||||
'session crashed',
|
||||
session.id,
|
||||
session.failureReason
|
||||
)
|
||||
})
|
||||
|
||||
// copy the code file to log and end write stream
|
||||
writeStream.end(program)
|
||||
|
||||
session.completed = true
|
||||
console.log('session completed', session)
|
||||
} catch (err: any) {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
console.log('session crashed', session.id, session.crashed)
|
||||
}
|
||||
// copy the code file to log and end write stream
|
||||
writeStream.end(program)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promisified child_process.execFile
|
||||
*
|
||||
* @param file - The name or path of the executable file to run.
|
||||
* @param args - List of string arguments.
|
||||
* @param writeStream - Child process stdout and stderr will be piped to it.
|
||||
*
|
||||
* @returns {Promise<{ stdout: string, stderr: string }>}
|
||||
*/
|
||||
const execFilePromise = (
|
||||
file: string,
|
||||
args: string[],
|
||||
writeStream: WriteStream
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(file, args, (err, stdout, stderr) => {
|
||||
if (err) reject(err)
|
||||
|
||||
resolve({ stdout, stderr })
|
||||
})
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
writeStream.write(data)
|
||||
})
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
writeStream.write(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -2,6 +2,16 @@ import { readFile } from '@sasjs/utils'
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import { Request, Post, Get } from 'tsoa'
|
||||
import dotenv from 'dotenv'
|
||||
import { ExecutionController } from './internal'
|
||||
import {
|
||||
getPreProgramVariables,
|
||||
getRunTimeAndFilePath,
|
||||
makeFilesNamesMap
|
||||
} from '../utils'
|
||||
import { MulterFile } from '../types/Upload'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
export interface Sas9Response {
|
||||
content: string
|
||||
@@ -16,9 +26,17 @@ export interface MockFileRead {
|
||||
|
||||
export class MockSas9Controller {
|
||||
private loggedIn: string | undefined
|
||||
private mocksPath = process.env.STATIC_MOCK_LOCATION || 'mocks'
|
||||
|
||||
@Get('/SASStoredProcess')
|
||||
public async sasStoredProcess(): Promise<Sas9Response> {
|
||||
public async sasStoredProcess(
|
||||
@Request() req: express.Request
|
||||
): Promise<Sas9Response> {
|
||||
const username = req.query._username?.toString() || undefined
|
||||
const password = req.query._password?.toString() || undefined
|
||||
|
||||
if (username && password) this.loggedIn = req.body.username
|
||||
|
||||
if (!this.loggedIn) {
|
||||
return {
|
||||
content: '',
|
||||
@@ -26,17 +44,87 @@ export class MockSas9Controller {
|
||||
}
|
||||
}
|
||||
|
||||
let program = req.query._program?.toString() || undefined
|
||||
const filePath: string[] = program
|
||||
? program.replace('/', '').split('/')
|
||||
: ['generic', 'sas-stored-process']
|
||||
|
||||
if (program) {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
this.mocksPath,
|
||||
'sas9',
|
||||
...filePath
|
||||
])
|
||||
}
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'generic',
|
||||
'sas9',
|
||||
'sas-stored-process'
|
||||
...filePath
|
||||
])
|
||||
}
|
||||
|
||||
@Get('/SASStoredProcess/do')
|
||||
public async sasStoredProcessDoGet(
|
||||
@Request() req: express.Request
|
||||
): Promise<Sas9Response> {
|
||||
const username = req.query._username?.toString() || undefined
|
||||
const password = req.query._password?.toString() || undefined
|
||||
|
||||
if (username && password) this.loggedIn = username
|
||||
|
||||
if (!this.loggedIn) {
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/login'
|
||||
}
|
||||
}
|
||||
|
||||
const program = req.query._program ?? req.body?._program
|
||||
const filePath: string[] = ['generic', 'sas-stored-process']
|
||||
|
||||
if (program) {
|
||||
const vars = { ...req.query, ...req.body, _requestMethod: req.method }
|
||||
const otherArgs = {}
|
||||
|
||||
try {
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(
|
||||
program + '.js'
|
||||
)
|
||||
|
||||
const result = await new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: vars,
|
||||
otherArgs: otherArgs,
|
||||
runTime,
|
||||
forceStringResult: true
|
||||
})
|
||||
|
||||
return {
|
||||
content: result.result as string
|
||||
}
|
||||
} catch (err) {
|
||||
process.logger.error('err', err)
|
||||
}
|
||||
|
||||
return {
|
||||
content: 'No webout returned.'
|
||||
}
|
||||
}
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'sas9',
|
||||
...filePath
|
||||
])
|
||||
}
|
||||
|
||||
@Post('/SASStoredProcess/do/')
|
||||
public async sasStoredProcessDo(
|
||||
public async sasStoredProcessDoPost(
|
||||
@Request() req: express.Request
|
||||
): Promise<Sas9Response> {
|
||||
if (!this.loggedIn) {
|
||||
@@ -53,23 +141,38 @@ export class MockSas9Controller {
|
||||
}
|
||||
}
|
||||
|
||||
let program = req.query._program?.toString() || ''
|
||||
program = program.replace('/', '')
|
||||
const program = req.query._program ?? req.body?._program
|
||||
const vars = {
|
||||
...req.query,
|
||||
...req.body,
|
||||
_requestMethod: req.method,
|
||||
_driveLoc: process.driveLoc
|
||||
}
|
||||
const filesNamesMap = req.files?.length
|
||||
? makeFilesNamesMap(req.files as MulterFile[])
|
||||
: null
|
||||
const otherArgs = { filesNamesMap: filesNamesMap }
|
||||
const { codePath, runTime } = await getRunTimeAndFilePath(program + '.js')
|
||||
try {
|
||||
const result = await new ExecutionController().executeFile({
|
||||
programPath: codePath,
|
||||
preProgramVariables: getPreProgramVariables(req),
|
||||
vars: vars,
|
||||
otherArgs: otherArgs,
|
||||
runTime,
|
||||
session: req.sasjsSession,
|
||||
forceStringResult: true
|
||||
})
|
||||
|
||||
const content = await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
...program.split('/')
|
||||
])
|
||||
|
||||
if (content.error) {
|
||||
return content
|
||||
return {
|
||||
content: result.result as string
|
||||
}
|
||||
} catch (err) {
|
||||
process.logger.error('err', err)
|
||||
}
|
||||
|
||||
const parsedContent = parseJsonIfValid(content.content)
|
||||
|
||||
return {
|
||||
content: parsedContent
|
||||
content: 'No webout returned.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +188,8 @@ export class MockSas9Controller {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'generic',
|
||||
'sas9',
|
||||
'generic',
|
||||
'logged-in'
|
||||
])
|
||||
}
|
||||
@@ -95,21 +198,27 @@ export class MockSas9Controller {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'generic',
|
||||
'sas9',
|
||||
'generic',
|
||||
'login'
|
||||
])
|
||||
}
|
||||
|
||||
@Post('/SASLogon/login')
|
||||
public async loginPost(req: express.Request): Promise<Sas9Response> {
|
||||
if (req.body.lt && req.body.lt !== 'validtoken')
|
||||
return {
|
||||
content: '',
|
||||
redirect: '/SASLogon/login'
|
||||
}
|
||||
|
||||
this.loggedIn = req.body.username
|
||||
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'generic',
|
||||
'sas9',
|
||||
'generic',
|
||||
'logged-in'
|
||||
])
|
||||
}
|
||||
@@ -122,8 +231,8 @@ export class MockSas9Controller {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'generic',
|
||||
'sas9',
|
||||
'generic',
|
||||
'public-access-denied'
|
||||
])
|
||||
}
|
||||
@@ -131,8 +240,8 @@ export class MockSas9Controller {
|
||||
return await getMockResponseFromFile([
|
||||
process.cwd(),
|
||||
'mocks',
|
||||
'generic',
|
||||
'sas9',
|
||||
'generic',
|
||||
'logged-out'
|
||||
])
|
||||
}
|
||||
@@ -152,23 +261,6 @@ export class MockSas9Controller {
|
||||
private isPublicAccount = () => this.loggedIn?.toLowerCase() === 'public'
|
||||
}
|
||||
|
||||
/**
|
||||
* If JSON is valid it will be parsed otherwise will return text unaltered
|
||||
* @param content string to be parsed
|
||||
* @returns JSON or string
|
||||
*/
|
||||
const parseJsonIfValid = (content: string) => {
|
||||
let fileContent = ''
|
||||
|
||||
try {
|
||||
fileContent = JSON.parse(content)
|
||||
} catch (err: any) {
|
||||
fileContent = content
|
||||
}
|
||||
|
||||
return fileContent
|
||||
}
|
||||
|
||||
const getMockResponseFromFile = async (
|
||||
filePath: string[]
|
||||
): Promise<MockFileRead> => {
|
||||
@@ -177,7 +269,7 @@ const getMockResponseFromFile = async (
|
||||
|
||||
let file = await readFile(filePathParsed).catch((err: any) => {
|
||||
const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}`
|
||||
console.error(errMsg)
|
||||
process.logger.error(errMsg)
|
||||
|
||||
error = true
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import express from 'express'
|
||||
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||
import { UserResponse } from './user'
|
||||
import { getSessionController } from './internal'
|
||||
import { SessionState } from '../types'
|
||||
|
||||
interface SessionResponse extends UserResponse {
|
||||
needsToUpdatePassword: boolean
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/session')
|
||||
@@ -19,14 +25,47 @@ export class SessionController {
|
||||
@Get('/')
|
||||
public async session(
|
||||
@Request() request: express.Request
|
||||
): Promise<UserResponse> {
|
||||
): Promise<SessionResponse> {
|
||||
return session(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* The polling endpoint is currently implemented for single-server deployments only.<br>
|
||||
* Load balanced / grid topologies will be supported in a future release.<br>
|
||||
* If your site requires this, please reach out to SASjs Support.
|
||||
* @summary Get session state (initialising, pending, running, completed, failed).
|
||||
* @example completed
|
||||
*/
|
||||
@Get('/:sessionId/state')
|
||||
public async sessionState(sessionId: string): Promise<SessionState> {
|
||||
return sessionState(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
const session = (req: express.Request) => ({
|
||||
id: req.user!.userId,
|
||||
username: req.user!.username,
|
||||
displayName: req.user!.displayName,
|
||||
isAdmin: req.user!.isAdmin
|
||||
isAdmin: req.user!.isAdmin,
|
||||
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
||||
})
|
||||
|
||||
const sessionState = (sessionId: string): SessionState => {
|
||||
for (let runTime of process.runTimes) {
|
||||
// get session controller for each available runTime
|
||||
const sessionController = getSessionController(runTime)
|
||||
|
||||
// get session by sessionId
|
||||
const session = sessionController.getSessionById(sessionId)
|
||||
|
||||
// return session state if session was found
|
||||
if (session) {
|
||||
return session.state
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
code: 404,
|
||||
message: `Session with ID '${sessionId}' was not found.`
|
||||
}
|
||||
}
|
||||
|
||||
+122
-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 (
|
||||
@@ -91,11 +154,63 @@ const execute = async (
|
||||
}
|
||||
)
|
||||
|
||||
req.res?.header(httpHeaders)
|
||||
|
||||
if (result instanceof Buffer) {
|
||||
;(req as any).sasHeaders = httpHeaders
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -21,9 +21,9 @@ import {
|
||||
getUserAutoExec,
|
||||
updateUserAutoExec,
|
||||
ModeType,
|
||||
AuthProviderType
|
||||
ALL_USERS_GROUP
|
||||
} from '../utils'
|
||||
import { GroupResponse } from './group'
|
||||
import { GroupController, GroupResponse } from './group'
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
@@ -237,6 +237,15 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
|
||||
const savedUser = await user.save()
|
||||
|
||||
const groupController = new GroupController()
|
||||
const allUsersGroup = await groupController
|
||||
.getGroupByGroupName(ALL_USERS_GROUP.name)
|
||||
.catch(() => {})
|
||||
|
||||
if (allUsersGroup) {
|
||||
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
|
||||
}
|
||||
|
||||
return {
|
||||
id: savedUser.id,
|
||||
displayName: savedUser.displayName,
|
||||
@@ -276,7 +285,7 @@ const getUser = async (
|
||||
username: user.username,
|
||||
isActive: user.isActive,
|
||||
isAdmin: user.isAdmin,
|
||||
autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
|
||||
autoExec: getAutoExec ? (user.autoExec ?? '') : undefined,
|
||||
groups: user.groups
|
||||
}
|
||||
}
|
||||
@@ -299,14 +308,19 @@ const updateUser = async (
|
||||
|
||||
const user = await User.findOne(findBy)
|
||||
|
||||
if (
|
||||
user?.authProvider !== AuthProviderType.Internal &&
|
||||
(username !== user?.username || displayName !== user?.displayName)
|
||||
) {
|
||||
if (username && username !== user?.username && user?.authProvider) {
|
||||
throw {
|
||||
code: 405,
|
||||
message:
|
||||
'Can not update username and display name of user that is created by an external auth provider.'
|
||||
'Can not update username of user that is created by an external auth provider.'
|
||||
}
|
||||
}
|
||||
|
||||
if (displayName && displayName !== user?.displayName && user?.authProvider) {
|
||||
throw {
|
||||
code: 405,
|
||||
message:
|
||||
'Can not update display name of user that is created by an external auth provider.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+53
-13
@@ -1,13 +1,14 @@
|
||||
import path from 'path'
|
||||
import express from 'express'
|
||||
import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
|
||||
import { readFile } from '@sasjs/utils'
|
||||
import { readFile, convertSecondsToHms } from '@sasjs/utils'
|
||||
|
||||
import User from '../model/User'
|
||||
import Client from '../model/Client'
|
||||
import {
|
||||
getWebBuildFolder,
|
||||
generateAuthCode,
|
||||
RateLimiter,
|
||||
AuthProviderType,
|
||||
LDAPClient
|
||||
} from '../utils'
|
||||
@@ -83,19 +84,41 @@ const login = async (
|
||||
) => {
|
||||
// Authenticate User
|
||||
const user = await User.findOne({ username })
|
||||
if (!user) throw new Error('Username is not found.')
|
||||
|
||||
if (
|
||||
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
|
||||
user.authProvider === AuthProviderType.LDAP
|
||||
) {
|
||||
const ldapClient = await LDAPClient.init()
|
||||
await ldapClient.verifyUser(username, password)
|
||||
} else {
|
||||
const validPass = user.comparePassword(password)
|
||||
if (!validPass) throw new Error('Invalid password.')
|
||||
let validPass = false
|
||||
|
||||
if (user) {
|
||||
if (
|
||||
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
|
||||
user.authProvider === AuthProviderType.LDAP
|
||||
) {
|
||||
const ldapClient = await LDAPClient.init()
|
||||
validPass = await ldapClient
|
||||
.verifyUser(username, password)
|
||||
.catch(() => false)
|
||||
} else {
|
||||
validPass = user.comparePassword(password)
|
||||
}
|
||||
}
|
||||
|
||||
// code to prevent brute force attack
|
||||
|
||||
const rateLimiter = RateLimiter.getInstance()
|
||||
|
||||
if (!validPass) {
|
||||
const retrySecs = await rateLimiter.consume(
|
||||
req.ip || 'unknown',
|
||||
user?.username
|
||||
)
|
||||
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
||||
}
|
||||
|
||||
if (!user) throw errors.userNotFound
|
||||
if (!validPass) throw errors.invalidPassword
|
||||
|
||||
// Reset on successful authorization
|
||||
rateLimiter.resetOnSuccess(req.ip || 'unknown', user.username)
|
||||
|
||||
req.session.loggedIn = true
|
||||
req.session.user = {
|
||||
userId: user.id,
|
||||
@@ -104,7 +127,8 @@ const login = async (
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
isActive: user.isActive,
|
||||
autoExec: user.autoExec
|
||||
autoExec: user.autoExec,
|
||||
needsToUpdatePassword: user.needsToUpdatePassword
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -113,7 +137,8 @@ const login = async (
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
isAdmin: user.isAdmin,
|
||||
needsToUpdatePassword: user.needsToUpdatePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,3 +195,18 @@ interface AuthorizeResponse {
|
||||
*/
|
||||
code: string
|
||||
}
|
||||
|
||||
const errors = {
|
||||
invalidPassword: {
|
||||
code: 401,
|
||||
message: 'Invalid Password.'
|
||||
},
|
||||
userNotFound: {
|
||||
code: 401,
|
||||
message: 'Username is not found.'
|
||||
},
|
||||
tooManyRequests: (seconds: number) => ({
|
||||
code: 429,
|
||||
message: `Too Many Requests! Retry after ${convertSecondsToHms(seconds)}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -81,7 +81,8 @@ const authenticateToken = async (
|
||||
username: 'desktopModeUsername',
|
||||
displayName: 'desktopModeDisplayName',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
req.accessToken = 'desktopModeAccessToken'
|
||||
return next()
|
||||
@@ -117,6 +118,6 @@ const authenticateToken = async (
|
||||
return next()
|
||||
}
|
||||
|
||||
res.sendStatus(401)
|
||||
res.status(401).send('Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PermissionSettingForRoute,
|
||||
PermissionType
|
||||
} from '../controllers/permission'
|
||||
import { getPath, isPublicRoute } from '../utils'
|
||||
import { getPath, isPublicRoute, TopLevelRoutes } from '../utils'
|
||||
|
||||
export const authorize: RequestHandler = async (req, res, next) => {
|
||||
const { user } = req
|
||||
@@ -22,6 +22,9 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
||||
if (!dbUser) return res.sendStatus(401)
|
||||
|
||||
const path = getPath(req)
|
||||
const { baseUrl } = req
|
||||
const topLevelRoute =
|
||||
TopLevelRoutes.find((route) => baseUrl.startsWith(route)) || baseUrl
|
||||
|
||||
// find permission w.r.t user
|
||||
const permission = await Permission.findOne({
|
||||
@@ -35,6 +38,21 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
||||
else return res.sendStatus(401)
|
||||
}
|
||||
|
||||
// find permission w.r.t user on top level
|
||||
const topLevelPermission = await Permission.findOne({
|
||||
path: topLevelRoute,
|
||||
type: PermissionType.route,
|
||||
user: dbUser._id
|
||||
})
|
||||
|
||||
if (topLevelPermission) {
|
||||
if (topLevelPermission.setting === PermissionSettingForRoute.grant)
|
||||
return next()
|
||||
else return res.sendStatus(401)
|
||||
}
|
||||
|
||||
let isPermissionDenied = false
|
||||
|
||||
// find permission w.r.t user's groups
|
||||
for (const group of dbUser.groups) {
|
||||
const groupPermission = await Permission.findOne({
|
||||
@@ -42,8 +60,28 @@ export const authorize: RequestHandler = async (req, res, next) => {
|
||||
type: PermissionType.route,
|
||||
group
|
||||
})
|
||||
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
||||
return next()
|
||||
|
||||
if (groupPermission) {
|
||||
if (groupPermission.setting === PermissionSettingForRoute.grant) {
|
||||
return next()
|
||||
} else {
|
||||
isPermissionDenied = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPermissionDenied) {
|
||||
// find permission w.r.t user's groups on top level
|
||||
for (const group of dbUser.groups) {
|
||||
const groupPermission = await Permission.findOne({
|
||||
path: topLevelRoute,
|
||||
type: PermissionType.route,
|
||||
group
|
||||
})
|
||||
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RequestHandler } from 'express'
|
||||
import { convertSecondsToHms } from '@sasjs/utils'
|
||||
import { RateLimiter } from '../utils'
|
||||
|
||||
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
||||
const ip = req.ip || 'unknown'
|
||||
const username = req.body.username
|
||||
|
||||
const rateLimiter = RateLimiter.getInstance()
|
||||
|
||||
const retrySecs = await rateLimiter.check(ip, username)
|
||||
|
||||
if (retrySecs > 0) {
|
||||
res
|
||||
.status(429)
|
||||
.send(`Too Many Requests! Retry after ${convertSecondsToHms(retrySecs)}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
@@ -33,5 +33,6 @@ export const desktopUser: RequestUser = {
|
||||
username: userInfo().username,
|
||||
displayName: userInfo().username,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './csrfProtection'
|
||||
export * from './desktop'
|
||||
export * from './verifyAdmin'
|
||||
export * from './verifyAdminIfNeeded'
|
||||
export * from './bruteForceProtection'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
export const NUMBER_OF_SECONDS_IN_A_DAY = 86400
|
||||
export interface ClientPayload {
|
||||
/**
|
||||
* Client ID
|
||||
@@ -11,6 +12,16 @@ export interface ClientPayload {
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
clientSecret: string
|
||||
/**
|
||||
* Number of seconds after which access token will expire. Default is 86400 (1 day)
|
||||
* @example 86400
|
||||
*/
|
||||
accessTokenExpiration?: number
|
||||
/**
|
||||
* Number of seconds after which access token will expire. Default is 2592000 (30 days)
|
||||
* @example 2592000
|
||||
*/
|
||||
refreshTokenExpiration?: number
|
||||
}
|
||||
|
||||
const ClientSchema = new Schema<ClientPayload>({
|
||||
@@ -21,6 +32,14 @@ const ClientSchema = new Schema<ClientPayload>({
|
||||
clientSecret: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
accessTokenExpiration: {
|
||||
type: Number,
|
||||
default: NUMBER_OF_SECONDS_IN_A_DAY
|
||||
},
|
||||
refreshTokenExpiration: {
|
||||
type: Number,
|
||||
default: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
const CounterSchema = new Schema({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
seq: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
export default mongoose.model('Counter', CounterSchema)
|
||||
+14
-8
@@ -1,8 +1,7 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { GroupDetailsResponse } from '../controllers'
|
||||
import User, { IUser } from './User'
|
||||
import { AuthProviderType } from '../utils'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||
|
||||
export const PUBLIC_GROUP_NAME = 'Public'
|
||||
|
||||
@@ -44,14 +43,17 @@ const groupSchema = new Schema<IGroupDocument>({
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
groupId: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Group description.'
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType,
|
||||
default: 'internal'
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
@@ -60,9 +62,13 @@ const groupSchema = new Schema<IGroupDocument>({
|
||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||
})
|
||||
|
||||
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
|
||||
|
||||
// Hooks
|
||||
groupSchema.pre('save', async function () {
|
||||
if (this.isNew) {
|
||||
this.groupId = await getSequenceNextValue('groupId')
|
||||
}
|
||||
})
|
||||
|
||||
groupSchema.post('save', function (group: IGroup, next: Function) {
|
||||
group.populate('users', 'id username displayName -_id').then(function () {
|
||||
next()
|
||||
@@ -70,7 +76,7 @@ groupSchema.post('save', function (group: IGroup, next: Function) {
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import { PermissionDetailsResponse } from '../controllers'
|
||||
import { getSequenceNextValue } from '../utils'
|
||||
|
||||
interface GetPermissionBy {
|
||||
user?: Schema.Types.ObjectId
|
||||
@@ -23,6 +23,10 @@ interface IPermissionModel extends Model<IPermission> {
|
||||
}
|
||||
|
||||
const permissionSchema = new Schema<IPermissionDocument>({
|
||||
permissionId: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -39,7 +43,12 @@ const permissionSchema = new Schema<IPermissionDocument>({
|
||||
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||
})
|
||||
|
||||
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
|
||||
// Hooks
|
||||
permissionSchema.pre('save', async function () {
|
||||
if (this.isNew) {
|
||||
this.permissionId = await getSequenceNextValue('permissionId')
|
||||
}
|
||||
})
|
||||
|
||||
// Static Methods
|
||||
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
||||
|
||||
+21
-6
@@ -1,7 +1,6 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import { Schema, model, Document, Model } from 'mongoose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { AuthProviderType } from '../utils'
|
||||
import { AuthProviderType, getSequenceNextValue } from '../utils'
|
||||
|
||||
export interface UserPayload {
|
||||
/**
|
||||
@@ -40,6 +39,7 @@ interface IUserDocument extends UserPayload, Document {
|
||||
id: number
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
autoExec: string
|
||||
groups: Schema.Types.ObjectId[]
|
||||
tokens: [{ [key: string]: string }]
|
||||
@@ -65,14 +65,17 @@ const userSchema = new Schema<IUserDocument>({
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
authProvider: {
|
||||
type: String,
|
||||
enum: AuthProviderType,
|
||||
default: 'internal'
|
||||
enum: AuthProviderType
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
@@ -82,6 +85,10 @@ const userSchema = new Schema<IUserDocument>({
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
needsToUpdatePassword: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoExec: {
|
||||
type: String
|
||||
},
|
||||
@@ -103,7 +110,15 @@ const userSchema = new Schema<IUserDocument>({
|
||||
}
|
||||
]
|
||||
})
|
||||
userSchema.plugin(AutoIncrement, { inc_field: 'id' })
|
||||
|
||||
// Hooks
|
||||
userSchema.pre('save', async function (next) {
|
||||
if (this.isNew) {
|
||||
this.id = await getSequenceNextValue('id')
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// Static Methods
|
||||
userSchema.static('hashPassword', (password: string): string => {
|
||||
|
||||
@@ -7,12 +7,28 @@ import {
|
||||
authenticateRefreshToken
|
||||
} from '../../middlewares'
|
||||
|
||||
import { tokenValidation } from '../../utils'
|
||||
import { tokenValidation, updatePasswordValidation } from '../../utils'
|
||||
import { InfoJWT } from '../../types'
|
||||
|
||||
const authRouter = express.Router()
|
||||
const controller = new AuthController()
|
||||
|
||||
authRouter.patch(
|
||||
'/updatePassword',
|
||||
authenticateAccessToken,
|
||||
async (req, res) => {
|
||||
const { error, value: body } = updatePasswordValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
await controller.updatePassword(req, body)
|
||||
res.sendStatus(204)
|
||||
} catch (err: any) {
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
authRouter.post('/token', async (req, res) => {
|
||||
const { error, value: body } = tokenValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express'
|
||||
import { ClientController } from '../../controllers'
|
||||
import { registerClientValidation } from '../../utils'
|
||||
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||
|
||||
const clientRouter = express.Router()
|
||||
|
||||
@@ -17,4 +18,19 @@ clientRouter.post('/', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
clientRouter.get(
|
||||
'/',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const controller = new ClientController()
|
||||
try {
|
||||
const response = await controller.getAllClients()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default clientRouter
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express'
|
||||
import { runCodeValidation } from '../../utils'
|
||||
import { runCodeValidation, triggerCodeValidation } from '../../utils'
|
||||
import { CodeController } from '../../controllers/'
|
||||
|
||||
const runRouter = express.Router()
|
||||
@@ -28,4 +28,22 @@ runRouter.post('/execute', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
runRouter.post('/trigger', async (req, res) => {
|
||||
const { error, value: body } = triggerCodeValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.triggerCode(req, body)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default runRouter
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import express from 'express'
|
||||
import { SessionController } from '../../controllers'
|
||||
import { sessionIdValidation } from '../../utils'
|
||||
|
||||
const sessionRouter = express.Router()
|
||||
|
||||
const controller = new SessionController()
|
||||
|
||||
sessionRouter.get('/', async (req, res) => {
|
||||
const controller = new SessionController()
|
||||
try {
|
||||
const response = await controller.session(req)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
sessionRouter.get('/:sessionId/state', async (req, res) => {
|
||||
const { error, value: params } = sessionIdValidation(req.params)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.sessionState(params.sessionId)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default sessionRouter
|
||||
|
||||
@@ -5,6 +5,7 @@ import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import { UserController, ClientController } from '../../../controllers/'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../../../model/Client'
|
||||
|
||||
const client = {
|
||||
clientId: 'someclientID',
|
||||
@@ -26,6 +27,7 @@ describe('client', () => {
|
||||
let app: Express
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let adminAccessToken: string
|
||||
const userController = new UserController()
|
||||
const clientController = new ClientController()
|
||||
|
||||
@@ -34,6 +36,18 @@ describe('client', () => {
|
||||
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
const dbUser = await userController.createUser(adminUser)
|
||||
adminAccessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
client.clientId,
|
||||
adminAccessToken,
|
||||
'refreshToken'
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -43,22 +57,6 @@ describe('client', () => {
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
let adminAccessToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const dbUser = await userController.createUser(adminUser)
|
||||
adminAccessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
client.clientId,
|
||||
adminAccessToken,
|
||||
'refreshToken'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['clients']
|
||||
@@ -157,4 +155,80 @@ describe('client', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('get', () => {
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['clients']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with an array of all clients', async () => {
|
||||
await clientController.createClient(newClient)
|
||||
await clientController.createClient({
|
||||
clientId: 'clientID',
|
||||
clientSecret: 'clientSecret'
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/client')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
const expected = [
|
||||
{
|
||||
clientId: 'newClientID',
|
||||
clientSecret: 'newClientSecret',
|
||||
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
},
|
||||
{
|
||||
clientId: 'clientID',
|
||||
clientSecret: 'clientSecret',
|
||||
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||
}
|
||||
]
|
||||
|
||||
expect(res.body).toEqual(expected)
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app).get('/SASjsApi/client').send().expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbideen if access token is not of an admin account', async () => {
|
||||
const user = {
|
||||
displayName: 'User 2',
|
||||
username: 'username2',
|
||||
password: '12345678',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
const dbUser = await userController.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
client.clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/client')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.id)
|
||||
|
||||
await permissionController.createPermission({
|
||||
path: '/SASjsApi/code/execute',
|
||||
type: PermissionType.route,
|
||||
principalType: PrincipalType.user,
|
||||
principalId: dbUser.id,
|
||||
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: number) => {
|
||||
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,
|
||||
|
||||
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)
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SASSessionController
|
||||
} from '../../../controllers/internal'
|
||||
import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
|
||||
import { Session } from '../../../types'
|
||||
import { Session, SessionState } from '../../../types'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
|
||||
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: true,
|
||||
inUse: true,
|
||||
consumed: false,
|
||||
completed: false,
|
||||
state: SessionState.pending,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
|
||||
@@ -47,72 +47,6 @@ describe('web', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/login', () => {
|
||||
let csrfToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ csrfToken } = await getCSRF(app))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with successful login', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.loggedIn).toBeTruthy()
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is not present', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is invalid', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', 'INVALID_CSRF_TOKEN')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/authorize', () => {
|
||||
let csrfToken: string
|
||||
let authCookies: string
|
||||
@@ -183,6 +117,147 @@ describe('web', () => {
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SASLogon/login', () => {
|
||||
let csrfToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
;({ csrfToken } = await getCSRF(app))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with successful login', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.loggedIn).toBeTruthy()
|
||||
expect(res.body.user).toEqual({
|
||||
id: expect.any(Number),
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
isAdmin: user.isAdmin,
|
||||
needsToUpdatePassword: true
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with too many requests when attempting with invalid password for a same user too many times', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const promises: request.Test[] = []
|
||||
|
||||
const maxConsecutiveFailsByUsernameAndIp = Number(
|
||||
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||
)
|
||||
|
||||
Array(maxConsecutiveFailsByUsernameAndIp + 1)
|
||||
.fill(0)
|
||||
.map((_, i) => {
|
||||
promises.push(
|
||||
request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
password: 'invalid-password'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(429)
|
||||
|
||||
expect(res.text).toContain('Too Many Requests!')
|
||||
})
|
||||
|
||||
it('should respond with too many requests when attempting with invalid credentials for different users but with same ip too many times', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const promises: request.Test[] = []
|
||||
|
||||
const maxWrongAttemptsByIpPerDay = Number(
|
||||
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
|
||||
)
|
||||
|
||||
Array(maxWrongAttemptsByIpPerDay + 1)
|
||||
.fill(0)
|
||||
.map((_, i) => {
|
||||
promises.push(
|
||||
request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: `user${i}`,
|
||||
password: 'invalid-password'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', csrfToken)
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(429)
|
||||
|
||||
expect(res.text).toContain('Too Many Requests!')
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is not present', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if CSRF Token is invalid', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASLogon/login')
|
||||
.set('x-xsrf-token', 'INVALID_CSRF_TOKEN')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Invalid CSRF token!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const getCSRF = async (app: Express) => {
|
||||
@@ -202,7 +277,10 @@ 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) =>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import express from 'express'
|
||||
import { executeProgramRawValidation } from '../../utils'
|
||||
import {
|
||||
executeProgramRawValidation,
|
||||
triggerProgramValidation
|
||||
} from '../../utils'
|
||||
import { STPController } from '../../controllers/'
|
||||
import { FileUploadController } from '../../controllers/internal'
|
||||
|
||||
@@ -13,7 +16,11 @@ stpRouter.get('/execute', async (req, res) => {
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.executeGetRequest(req, query._program)
|
||||
const response = await controller.executeGetRequest(
|
||||
req,
|
||||
query._program,
|
||||
query._debug
|
||||
)
|
||||
|
||||
if (response instanceof Buffer) {
|
||||
res.writeHead(200, (req as any).sasHeaders)
|
||||
@@ -65,4 +72,28 @@ stpRouter.post(
|
||||
}
|
||||
)
|
||||
|
||||
stpRouter.post('/trigger', async (req, res) => {
|
||||
const { error, value: query } = triggerProgramValidation(req.query)
|
||||
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.triggerProgram(
|
||||
req,
|
||||
query._program,
|
||||
query._debug,
|
||||
query.expiresAfterMins
|
||||
)
|
||||
|
||||
res.status(200)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default stpRouter
|
||||
|
||||
@@ -58,7 +58,7 @@ export const publishAppStream = async (
|
||||
)
|
||||
|
||||
const sasJsPort = process.env.PORT || 5000
|
||||
console.log(
|
||||
process.logger.info(
|
||||
'Serving Stream App: ',
|
||||
`http://localhost:${sasJsPort}/AppStream/${streamServiceName}`
|
||||
)
|
||||
|
||||
@@ -15,5 +15,5 @@ export const setupRoutes = (app: Express) => {
|
||||
appStreamRouter(req, res, next)
|
||||
})
|
||||
|
||||
app.use('/', csrfProtection, webRouter)
|
||||
app.use('/', webRouter)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import sas9WebRouter from './sas9-web'
|
||||
import sasViyaWebRouter from './sasviya-web'
|
||||
import webRouter from './web'
|
||||
import { MOCK_SERVERTYPEType } from '../../utils'
|
||||
import { csrfProtection } from '../../middlewares'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
@@ -18,7 +19,7 @@ switch (MOCK_SERVERTYPE) {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
router.use('/', webRouter)
|
||||
router.use('/', csrfProtection, webRouter)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,25 @@ import express from 'express'
|
||||
import { generateCSRFToken } from '../../middlewares'
|
||||
import { WebController } from '../../controllers'
|
||||
import { MockSas9Controller } from '../../controllers/mock-sas9'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import dotenv from 'dotenv'
|
||||
import { FileUploadController } from '../../controllers/internal'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const sas9WebRouter = express.Router()
|
||||
const webController = new WebController()
|
||||
// Mock controller must be singleton because it keeps the states
|
||||
// for example `isLoggedIn` and potentially more in future mocks
|
||||
const controller = new MockSas9Controller()
|
||||
const fileUploadController = new FileUploadController()
|
||||
|
||||
const mockPath = process.env.STATIC_MOCK_LOCATION || 'mocks'
|
||||
|
||||
const upload = multer({
|
||||
dest: path.join(process.cwd(), mockPath, 'sas9', 'files-received')
|
||||
})
|
||||
|
||||
sas9WebRouter.get('/', async (req, res) => {
|
||||
let response
|
||||
@@ -27,7 +40,7 @@ sas9WebRouter.get('/', async (req, res) => {
|
||||
})
|
||||
|
||||
sas9WebRouter.get('/SASStoredProcess', async (req, res) => {
|
||||
const response = await controller.sasStoredProcess()
|
||||
const response = await controller.sasStoredProcess(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
@@ -41,8 +54,8 @@ sas9WebRouter.get('/SASStoredProcess', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.post('/SASStoredProcess/do/', async (req, res) => {
|
||||
const response = await controller.sasStoredProcessDo(req)
|
||||
sas9WebRouter.get('/SASStoredProcess/do/', async (req, res) => {
|
||||
const response = await controller.sasStoredProcessDoGet(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
@@ -56,6 +69,26 @@ sas9WebRouter.post('/SASStoredProcess/do/', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
sas9WebRouter.post(
|
||||
'/SASStoredProcess/do/',
|
||||
fileUploadController.preUploadMiddleware,
|
||||
fileUploadController.getMulterUploadObject().any(),
|
||||
async (req, res) => {
|
||||
const response = await controller.sasStoredProcessDoPost(req)
|
||||
|
||||
if (response.redirect) {
|
||||
res.redirect(response.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
res.send(response.content)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
sas9WebRouter.get('/SASLogon/login', async (req, res) => {
|
||||
const response = await controller.loginGet()
|
||||
|
||||
|
||||
+27
-11
@@ -1,7 +1,11 @@
|
||||
import express from 'express'
|
||||
import { generateCSRFToken } from '../../middlewares'
|
||||
import { WebController } from '../../controllers/web'
|
||||
import { authenticateAccessToken, desktopRestrict } from '../../middlewares'
|
||||
import {
|
||||
authenticateAccessToken,
|
||||
bruteForceProtection,
|
||||
desktopRestrict
|
||||
} from '../../middlewares'
|
||||
import { authorizeValidation, loginWebValidation } from '../../utils'
|
||||
|
||||
const webRouter = express.Router()
|
||||
@@ -14,7 +18,10 @@ webRouter.get('/', async (req, res) => {
|
||||
} catch (_) {
|
||||
response = '<html><head></head><body>Web Build is not present</body></html>'
|
||||
} finally {
|
||||
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${generateCSRFToken()}; Max-Age=86400; SameSite=Strict; Path=/;'</script>`
|
||||
const { ALLOWED_DOMAIN } = process.env
|
||||
const allowedDomain = ALLOWED_DOMAIN?.trim()
|
||||
const domain = allowedDomain ? ` Domain=${allowedDomain};` : ''
|
||||
const codeToInject = `<script>document.cookie = 'XSRF-TOKEN=${generateCSRFToken()};${domain} Max-Age=86400; SameSite=Strict; Path=/;'</script>`
|
||||
const injectedContent = response?.replace(
|
||||
'</head>',
|
||||
`${codeToInject}</head>`
|
||||
@@ -24,17 +31,26 @@ webRouter.get('/', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
webRouter.post('/SASLogon/login', desktopRestrict, async (req, res) => {
|
||||
const { error, value: body } = loginWebValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
webRouter.post(
|
||||
'/SASLogon/login',
|
||||
desktopRestrict,
|
||||
bruteForceProtection,
|
||||
async (req, res) => {
|
||||
const { error, value: body } = loginWebValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.login(req, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
try {
|
||||
const response = await controller.login(req, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
if (err instanceof Error) {
|
||||
res.status(500).send(err.toString())
|
||||
} else {
|
||||
res.status(err.code).send(err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/SASLogon/authorize',
|
||||
|
||||
+3
-3
@@ -7,11 +7,11 @@ appPromise.then(async (app) => {
|
||||
const protocol = process.env.PROTOCOL || 'http'
|
||||
const sasJsPort = process.env.PORT || 5000
|
||||
|
||||
console.log('PROTOCOL: ', protocol)
|
||||
process.logger.info('PROTOCOL: ', protocol)
|
||||
|
||||
if (protocol !== 'https') {
|
||||
app.listen(sasJsPort, () => {
|
||||
console.log(
|
||||
process.logger.info(
|
||||
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
|
||||
)
|
||||
})
|
||||
@@ -20,7 +20,7 @@ appPromise.then(async (app) => {
|
||||
|
||||
const httpsServer = createServer({ key, cert, ca }, app)
|
||||
httpsServer.listen(sasJsPort, () => {
|
||||
console.log(
|
||||
process.logger.info(
|
||||
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,5 +5,6 @@ export interface RequestUser {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
needsToUpdatePassword: boolean
|
||||
autoExec?: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export enum SessionState {
|
||||
initialising = 'initialising', // session is initialising and not ready to be used yet
|
||||
pending = 'pending', // session is ready to be used
|
||||
running = 'running', // session is in use
|
||||
completed = 'completed', // session is completed and can be destroyed
|
||||
failed = 'failed' // session failed
|
||||
}
|
||||
export interface Session {
|
||||
id: string
|
||||
ready: boolean
|
||||
state: SessionState
|
||||
creationTimeStamp: string
|
||||
deathTimeStamp: string
|
||||
path: string
|
||||
inUse: boolean
|
||||
consumed: boolean
|
||||
completed: boolean
|
||||
crashed?: string
|
||||
expiresAfterMins?: { mins: number; used: boolean }
|
||||
failureReason?: string
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -5,9 +5,11 @@ declare namespace NodeJS {
|
||||
pythonLoc?: string
|
||||
rLoc?: string
|
||||
driveLoc: string
|
||||
sasjsRoot: string
|
||||
logsLoc: string
|
||||
logsUUID: string
|
||||
sessionController?: import('../../controllers/internal').SessionController
|
||||
sasSessionController?: import('../../controllers/internal').SASSessionController
|
||||
appStreamConfig: import('../').AppStreamConfig
|
||||
logger: import('@sasjs/utils/logger').Logger
|
||||
runTimes: import('../../utils').RunTimeType[]
|
||||
|
||||
@@ -36,7 +36,7 @@ export const loadAppStreamConfig = async () => {
|
||||
)
|
||||
}
|
||||
|
||||
console.log('App Stream Config loaded!')
|
||||
process.logger.info('App Stream Config loaded!')
|
||||
}
|
||||
|
||||
export const addEntryToAppStreamConfig = (
|
||||
|
||||
@@ -8,6 +8,6 @@ export const connectDB = async () => {
|
||||
throw new Error('Unable to connect to DB!')
|
||||
}
|
||||
|
||||
console.log('Connected to DB!')
|
||||
process.logger.success('Connected to DB!')
|
||||
return seedDB()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
|
||||
export const copySASjsCore = async () => {
|
||||
if (process.env.NODE_ENV === 'test') return
|
||||
|
||||
console.log('Copying Macros from container to drive(tmp).')
|
||||
process.logger.log('Copying Macros from container to drive.')
|
||||
|
||||
const macrosDrivePath = getMacrosFolder()
|
||||
|
||||
@@ -30,5 +30,5 @@ export const copySASjsCore = async () => {
|
||||
await createFile(macroFileDestPath, macroContent)
|
||||
})
|
||||
|
||||
console.log('Macros Drive Path:', macrosDrivePath)
|
||||
process.logger.info('Macros Drive Path:', macrosDrivePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import path from 'path'
|
||||
import { createFile } from '@sasjs/utils'
|
||||
import { getMacrosFolder } from './file'
|
||||
|
||||
const fileContent = `%macro webout(action,ds,dslabel=,fmt=,missing=NULL,showmeta=NO,maxobs=MAX);
|
||||
%ms_webout(&action,ds=&ds,dslabel=&dslabel,fmt=&fmt
|
||||
,missing=&missing
|
||||
,showmeta=&showmeta
|
||||
,maxobs=&maxobs
|
||||
)
|
||||
%mend;`
|
||||
|
||||
export const createWeboutSasFile = async () => {
|
||||
const macrosDrivePath = getMacrosFolder()
|
||||
process.logger.log(`Creating webout.sas at ${macrosDrivePath}`)
|
||||
const filePath = path.join(macrosDrivePath, 'webout.sas')
|
||||
await createFile(filePath, fileContent)
|
||||
}
|
||||
@@ -20,22 +20,24 @@ export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server')
|
||||
export const getDesktopUserAutoExecPath = () =>
|
||||
path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
|
||||
|
||||
export const getSasjsRootFolder = () => process.driveLoc
|
||||
export const getSasjsRootFolder = () => process.sasjsRoot
|
||||
|
||||
export const getSasjsDriveFolder = () => process.driveLoc
|
||||
|
||||
export const getLogFolder = () => process.logsLoc
|
||||
|
||||
export const getAppStreamConfigPath = () =>
|
||||
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
|
||||
path.join(getSasjsDriveFolder(), 'appStreamConfig.json')
|
||||
|
||||
export const getMacrosFolder = () =>
|
||||
path.join(getSasjsRootFolder(), 'sas', 'sasautos')
|
||||
path.join(getSasjsDriveFolder(), 'sas', 'sasautos')
|
||||
|
||||
export const getPackagesFolder = () =>
|
||||
path.join(getSasjsRootFolder(), 'sas', 'sas_packages')
|
||||
path.join(getSasjsDriveFolder(), 'sas', 'sas_packages')
|
||||
|
||||
export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
|
||||
|
||||
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
|
||||
export const getFilesFolder = () => path.join(getSasjsDriveFolder(), 'files')
|
||||
|
||||
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
|
||||
|
||||
export const generateAccessToken = (data: InfoJWT) =>
|
||||
export const generateAccessToken = (data: InfoJWT, expiry?: number) =>
|
||||
jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, {
|
||||
expiresIn: '1day'
|
||||
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client'
|
||||
|
||||
export const generateRefreshToken = (data: InfoJWT) =>
|
||||
export const generateRefreshToken = (data: InfoJWT, expiry?: number) =>
|
||||
jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, {
|
||||
expiresIn: '30 days'
|
||||
expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Request } from 'express'
|
||||
|
||||
export const TopLevelRoutes = ['/AppStream', '/SASjsApi']
|
||||
|
||||
const StaticAuthorizedRoutes = [
|
||||
'/AppStream',
|
||||
'/SASjsApi/code/execute',
|
||||
'/SASjsApi/stp/execute',
|
||||
'/SASjsApi/drive/deploy',
|
||||
@@ -15,7 +16,7 @@ const StaticAuthorizedRoutes = [
|
||||
export const getAuthorizedRoutes = () => {
|
||||
const streamingApps = Object.keys(process.appStreamConfig)
|
||||
const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`)
|
||||
return [...StaticAuthorizedRoutes, ...streamingAppsRoutes]
|
||||
return [...TopLevelRoutes, ...StaticAuthorizedRoutes, ...streamingAppsRoutes]
|
||||
}
|
||||
|
||||
export const getPath = (req: Request) => {
|
||||
|
||||
@@ -10,9 +10,9 @@ export const getCertificates = async () => {
|
||||
const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)'))
|
||||
const caPath = CA_ROOT
|
||||
|
||||
console.log('keyPath: ', keyPath)
|
||||
console.log('certPath: ', certPath)
|
||||
if (caPath) console.log('caPath: ', caPath)
|
||||
process.logger.info('keyPath: ', keyPath)
|
||||
process.logger.info('certPath: ', certPath)
|
||||
if (caPath) process.logger.info('caPath: ', caPath)
|
||||
|
||||
const key = await readFile(keyPath)
|
||||
const cert = await readFile(certPath)
|
||||
|
||||
@@ -18,10 +18,12 @@ export const getPreProgramVariables = (req: Request): PreProgramVars => {
|
||||
|
||||
if (cookies.length) httpHeaders.push(`cookie: ${cookies.join('; ')}`)
|
||||
|
||||
//In desktop mode when mocking mode is enabled, user was undefined.
|
||||
//So this is workaround.
|
||||
return {
|
||||
username: user!.username,
|
||||
userId: user!.userId,
|
||||
displayName: user!.displayName,
|
||||
username: user ? user.username : 'demo',
|
||||
userId: user ? user.userId : 0,
|
||||
displayName: user ? user.displayName : 'demo',
|
||||
serverUrl: protocol + host,
|
||||
httpHeaders
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import Counter from '../model/Counter'
|
||||
|
||||
export const getSequenceNextValue = async (seqName: string) => {
|
||||
const seqDoc = await Counter.findOne({ id: seqName })
|
||||
if (!seqDoc) {
|
||||
await Counter.create({ id: seqName, seq: 1 })
|
||||
return 1
|
||||
}
|
||||
|
||||
seqDoc.seq += 1
|
||||
|
||||
await seqDoc.save()
|
||||
|
||||
return seqDoc.seq
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import User from '../model/User'
|
||||
import { InfoJWT } from '../types/InfoJWT'
|
||||
|
||||
const isValidToken = async (
|
||||
token: string,
|
||||
@@ -11,7 +12,8 @@ const isValidToken = async (
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './appStreamConfig'
|
||||
export * from './connectDB'
|
||||
export * from './copySASjsCore'
|
||||
export * from './createWeboutSasFile'
|
||||
export * from './desktopAutoExec'
|
||||
export * from './extractHeaders'
|
||||
export * from './extractName'
|
||||
@@ -13,20 +14,23 @@ export * from './getCertificates'
|
||||
export * from './getDesktopFields'
|
||||
export * from './getPreProgramVariables'
|
||||
export * from './getRunTimeAndFilePath'
|
||||
export * from './getSequenceNextValue'
|
||||
export * from './getServerUrl'
|
||||
export * from './getTokensFromDB'
|
||||
export * from './instantiateLogger'
|
||||
export * from './isDebugOn'
|
||||
export * from './isPublicRoute'
|
||||
export * from './ldapClient'
|
||||
export * from './zipped'
|
||||
export * from './parseLogToArray'
|
||||
export * from './rateLimiter'
|
||||
export * from './removeTokensInDB'
|
||||
export * from './saveTokensInDB'
|
||||
export * from './seedDB'
|
||||
export * from './setProcessVariables'
|
||||
export * from './setupFolders'
|
||||
export * from './setupUserAutoExec'
|
||||
export * from './upload'
|
||||
export * from './validation'
|
||||
export * from './verifyEnvVariables'
|
||||
export * from './verifyTokenInDB'
|
||||
export * from './zipped'
|
||||
|
||||
@@ -27,5 +27,6 @@ export const publicUser: RequestUser = {
|
||||
username: 'publicUser',
|
||||
displayName: 'Public User',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
needsToUpdatePassword: false
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ export const getEnvCSPDirectives = (
|
||||
try {
|
||||
cspConfigJson = JSON.parse(file)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
process.logger.error(
|
||||
'Parsing Content Security Policy JSON config failed. Make sure it is valid json'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error reading HELMET CSP config file', e)
|
||||
process.logger.error('Error reading HELMET CSP config file', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
export class RateLimiter {
|
||||
private static instance: RateLimiter
|
||||
private limiterSlowBruteByIP: RateLimiterMemory
|
||||
private limiterConsecutiveFailsByUsernameAndIP: RateLimiterMemory
|
||||
private maxWrongAttemptsByIpPerDay: number
|
||||
private maxConsecutiveFailsByUsernameAndIp: number
|
||||
|
||||
private constructor() {
|
||||
const {
|
||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||
} = process.env
|
||||
|
||||
this.maxWrongAttemptsByIpPerDay = Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY)
|
||||
this.maxConsecutiveFailsByUsernameAndIp = Number(
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||
)
|
||||
|
||||
this.limiterSlowBruteByIP = new RateLimiterMemory({
|
||||
keyPrefix: 'login_fail_ip_per_day',
|
||||
points: this.maxWrongAttemptsByIpPerDay,
|
||||
duration: 60 * 60 * 24,
|
||||
blockDuration: 60 * 60 * 24 // Block for 1 day
|
||||
})
|
||||
|
||||
this.limiterConsecutiveFailsByUsernameAndIP = new RateLimiterMemory({
|
||||
keyPrefix: 'login_fail_consecutive_username_and_ip',
|
||||
points: this.maxConsecutiveFailsByUsernameAndIp,
|
||||
duration: 60 * 60 * 24 * 24, // Store number for 24 days since first fail
|
||||
blockDuration: 60 * 60 // Block for 1 hour
|
||||
})
|
||||
}
|
||||
|
||||
public static getInstance() {
|
||||
if (!RateLimiter.instance) {
|
||||
RateLimiter.instance = new RateLimiter()
|
||||
}
|
||||
return RateLimiter.instance
|
||||
}
|
||||
|
||||
private getUsernameIPKey(ip: string, username: string) {
|
||||
return `${username}_${ip}`
|
||||
}
|
||||
|
||||
/**
|
||||
* This method checks for brute force attack
|
||||
* If attack is detected then returns the number of seconds after which user can make another request
|
||||
* Else returns 0
|
||||
*/
|
||||
public async check(ip: string, username: string) {
|
||||
const usernameIPkey = this.getUsernameIPKey(ip, username)
|
||||
|
||||
const [resSlowByIP, resUsernameAndIP] = await Promise.all([
|
||||
this.limiterSlowBruteByIP.get(ip),
|
||||
this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
||||
])
|
||||
|
||||
// NOTE: To make use of blockDuration option, comparison in both following if statements should have greater than symbol
|
||||
// otherwise, blockDuration option will not work
|
||||
// For more info see: https://github.com/animir/node-rate-limiter-flexible/wiki/Options#blockduration
|
||||
|
||||
// Check if IP or Username + IP is already blocked
|
||||
if (
|
||||
resSlowByIP !== null &&
|
||||
resSlowByIP.consumedPoints > this.maxWrongAttemptsByIpPerDay
|
||||
) {
|
||||
return Math.ceil(resSlowByIP.msBeforeNext / 1000)
|
||||
} else if (
|
||||
resUsernameAndIP !== null &&
|
||||
resUsernameAndIP.consumedPoints > this.maxConsecutiveFailsByUsernameAndIp
|
||||
) {
|
||||
return Math.ceil(resUsernameAndIP.msBeforeNext / 1000)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume 1 point from limiters on wrong attempt and block if limits reached
|
||||
* If limit is reached, return the number of seconds after which user can make another request
|
||||
* Else return 0
|
||||
*/
|
||||
public async consume(ip: string, username?: string) {
|
||||
try {
|
||||
const promises = [this.limiterSlowBruteByIP.consume(ip)]
|
||||
if (username) {
|
||||
const usernameIPkey = this.getUsernameIPKey(ip, username)
|
||||
|
||||
// Count failed attempts by Username + IP only for registered users
|
||||
promises.push(
|
||||
this.limiterConsecutiveFailsByUsernameAndIP.consume(usernameIPkey)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
} catch (rlRejected: any) {
|
||||
if (rlRejected instanceof Error) {
|
||||
throw rlRejected
|
||||
} else {
|
||||
// based upon the implementation of consume method of RateLimiterMemory
|
||||
// we are sure that rlRejected will contain msBeforeNext
|
||||
// for further reference,
|
||||
// see https://github.com/animir/node-rate-limiter-flexible/wiki/Overall-example#login-endpoint-protection
|
||||
// or see https://github.com/animir/node-rate-limiter-flexible#ratelimiterres-object
|
||||
return Math.ceil(rlRejected.msBeforeNext / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
public async resetOnSuccess(ip: string, username: string) {
|
||||
const usernameIPkey = this.getUsernameIPKey(ip, username)
|
||||
const resUsernameAndIP =
|
||||
await this.limiterConsecutiveFailsByUsernameAndIP.get(usernameIPkey)
|
||||
|
||||
if (resUsernameAndIP !== null && resUsernameAndIP.consumedPoints > 0) {
|
||||
await this.limiterConsecutiveFailsByUsernameAndIP.delete(usernameIPkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-20
@@ -1,7 +1,9 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import Client from '../model/Client'
|
||||
import Group, { PUBLIC_GROUP_NAME } from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import User, { IUser } from '../model/User'
|
||||
import Configuration, { ConfigurationType } from '../model/Configuration'
|
||||
import { ResetAdminPasswordType } from './verifyEnvVariables'
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
@@ -19,16 +21,16 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
const client = new Client(CLIENT)
|
||||
await client.save()
|
||||
|
||||
console.log(`DB Seed - client created: ${CLIENT.clientId}`)
|
||||
process.logger.success(`DB Seed - client created: ${CLIENT.clientId}`)
|
||||
}
|
||||
|
||||
// Checking if 'AllUsers' Group is already in the database
|
||||
let groupExist = await Group.findOne({ name: GROUP.name })
|
||||
let groupExist = await Group.findOne({ name: ALL_USERS_GROUP.name })
|
||||
if (!groupExist) {
|
||||
const group = new Group(GROUP)
|
||||
const group = new Group(ALL_USERS_GROUP)
|
||||
groupExist = await group.save()
|
||||
|
||||
console.log(`DB Seed - Group created: ${GROUP.name}`)
|
||||
process.logger.success(`DB Seed - Group created: ${ALL_USERS_GROUP.name}`)
|
||||
}
|
||||
|
||||
// Checking if 'Public' Group is already in the database
|
||||
@@ -37,22 +39,28 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
const group = new Group(PUBLIC_GROUP)
|
||||
await group.save()
|
||||
|
||||
console.log(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
|
||||
process.logger.success(`DB Seed - Group created: ${PUBLIC_GROUP.name}`)
|
||||
}
|
||||
|
||||
const ADMIN_USER = getAdminUser()
|
||||
|
||||
// Checking if user is already in the database
|
||||
let usernameExist = await User.findOne({ username: ADMIN_USER.username })
|
||||
if (!usernameExist) {
|
||||
if (usernameExist) {
|
||||
usernameExist = await resetAdminPassword(usernameExist, ADMIN_USER.password)
|
||||
} else {
|
||||
const user = new User(ADMIN_USER)
|
||||
usernameExist = await user.save()
|
||||
|
||||
console.log(`DB Seed - admin account created: ${ADMIN_USER.username}`)
|
||||
process.logger.success(
|
||||
`DB Seed - admin account created: ${ADMIN_USER.username}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!groupExist.hasUser(usernameExist)) {
|
||||
if (usernameExist.isAdmin && !groupExist.hasUser(usernameExist)) {
|
||||
groupExist.addUser(usernameExist)
|
||||
console.log(
|
||||
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${GROUP.name}'`
|
||||
process.logger.success(
|
||||
`DB Seed - admin account '${ADMIN_USER.username}' added to Group '${ALL_USERS_GROUP.name}'`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,7 +70,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
const configuration = new Configuration(SECRETS)
|
||||
configExist = await configuration.save()
|
||||
|
||||
console.log('DB Seed - configuration added')
|
||||
process.logger.success('DB Seed - configuration added')
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -73,7 +81,7 @@ export const seedDB = async (): Promise<ConfigurationType> => {
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP = {
|
||||
export const ALL_USERS_GROUP = {
|
||||
name: 'AllUsers',
|
||||
description: 'Group contains all users'
|
||||
}
|
||||
@@ -88,11 +96,52 @@ const CLIENT = {
|
||||
clientId: 'clientID1',
|
||||
clientSecret: 'clientSecret'
|
||||
}
|
||||
const ADMIN_USER = {
|
||||
id: 1,
|
||||
displayName: 'Super Admin',
|
||||
username: 'secretuser',
|
||||
password: '$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
|
||||
const getAdminUser = () => {
|
||||
const { ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL } = process.env
|
||||
|
||||
const salt = bcrypt.genSaltSync(10)
|
||||
const hashedPassword = bcrypt.hashSync(ADMIN_PASSWORD_INITIAL as string, salt)
|
||||
|
||||
return {
|
||||
displayName: 'Super Admin',
|
||||
username: ADMIN_USERNAME,
|
||||
password: hashedPassword,
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
|
||||
const resetAdminPassword = async (user: IUser, password: string) => {
|
||||
const { ADMIN_PASSWORD_RESET } = process.env
|
||||
|
||||
if (ADMIN_PASSWORD_RESET === ResetAdminPasswordType.YES) {
|
||||
if (!user.isAdmin) {
|
||||
process.logger.error(
|
||||
`Can not reset the password of non-admin user (${user.username}) on startup.`
|
||||
)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
if (user.authProvider) {
|
||||
process.logger.error(
|
||||
`Can not reset the password of admin (${user.username}) with ${user.authProvider} as authentication mechanism.`
|
||||
)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
process.logger.info(
|
||||
`DB Seed - resetting password for admin user: ${user.username}`
|
||||
)
|
||||
|
||||
user.password = password
|
||||
user.needsToUpdatePassword = true
|
||||
user = await user.save()
|
||||
|
||||
process.logger.success(`DB Seed - successfully reset the password`)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import path from 'path'
|
||||
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
|
||||
|
||||
import {
|
||||
createFolder,
|
||||
getAbsolutePath,
|
||||
getRealPath,
|
||||
fileExists
|
||||
} from '@sasjs/utils'
|
||||
import dotenv from 'dotenv'
|
||||
import { connectDB, getDesktopFields, ModeType, RunTimeType, SECRETS } from '.'
|
||||
|
||||
export const setProcessVariables = async () => {
|
||||
const { execPath } = process
|
||||
|
||||
// Check if execPath ends with 'api-macos' to determine executable for MacOS.
|
||||
// This is needed to fix picking .env file issue in MacOS executable.
|
||||
if (execPath) {
|
||||
const envPathSplitted = execPath.split(path.sep)
|
||||
|
||||
if (envPathSplitted.pop() === 'api-macos') {
|
||||
const envPath = path.join(envPathSplitted.join(path.sep), '.env')
|
||||
|
||||
// Override environment variables from envPath if file exists
|
||||
if (await fileExists(envPath)) {
|
||||
dotenv.config({ path: envPath, override: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { MODE, RUN_TIMES } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
@@ -19,7 +41,9 @@ export const setProcessVariables = async () => {
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root')
|
||||
process.sasjsRoot = path.join(process.cwd(), 'sasjs_root')
|
||||
process.driveLoc = path.join(process.cwd(), 'sasjs_root', 'drive')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,7 +56,6 @@ export const setProcessVariables = async () => {
|
||||
process.rLoc = process.env.R_PATH
|
||||
} else {
|
||||
const { sasLoc, nodeLoc, pythonLoc, rLoc } = await getDesktopFields()
|
||||
|
||||
process.sasLoc = sasLoc
|
||||
process.nodeLoc = nodeLoc
|
||||
process.pythonLoc = pythonLoc
|
||||
@@ -41,21 +64,34 @@ export const setProcessVariables = async () => {
|
||||
|
||||
const { SASJS_ROOT } = process.env
|
||||
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
|
||||
|
||||
await createFolder(absPath)
|
||||
process.driveLoc = getRealPath(absPath)
|
||||
|
||||
process.sasjsRoot = getRealPath(absPath)
|
||||
|
||||
const { DRIVE_LOCATION } = process.env
|
||||
const absDrivePath = getAbsolutePath(
|
||||
DRIVE_LOCATION ?? path.join(process.sasjsRoot, 'drive'),
|
||||
process.cwd()
|
||||
)
|
||||
|
||||
await createFolder(absDrivePath)
|
||||
process.driveLoc = getRealPath(absDrivePath)
|
||||
|
||||
const { LOG_LOCATION } = process.env
|
||||
const absLogsPath = getAbsolutePath(
|
||||
LOG_LOCATION ?? `sasjs_root${path.sep}logs`,
|
||||
LOG_LOCATION ?? path.join(process.sasjsRoot, 'logs'),
|
||||
process.cwd()
|
||||
)
|
||||
|
||||
await createFolder(absLogsPath)
|
||||
|
||||
process.logsLoc = getRealPath(absLogsPath)
|
||||
|
||||
process.logsUUID = 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||
|
||||
console.log('sasLoc: ', process.sasLoc)
|
||||
console.log('sasDrive: ', process.driveLoc)
|
||||
console.log('sasLogs: ', process.logsLoc)
|
||||
console.log('runTimes: ', process.runTimes)
|
||||
process.logger.info('sasLoc: ', process.sasLoc)
|
||||
process.logger.info('sasDrive: ', process.driveLoc)
|
||||
process.logger.info('sasLogs: ', process.logsLoc)
|
||||
process.logger.info('runTimes: ', process.runTimes)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { createFile, createFolder, fileExists } from '@sasjs/utils'
|
||||
import {
|
||||
getDesktopUserAutoExecPath,
|
||||
getFilesFolder,
|
||||
getPackagesFolder
|
||||
} from './file'
|
||||
import { ModeType } from './verifyEnvVariables'
|
||||
import { createFolder } from '@sasjs/utils'
|
||||
import { getFilesFolder, getPackagesFolder } from './file'
|
||||
|
||||
export const setupFolders = async () => {
|
||||
const drivePath = getFilesFolder()
|
||||
await createFolder(drivePath)
|
||||
export const setupFilesFolder = async () => await createFolder(getFilesFolder())
|
||||
|
||||
export const setupPackagesFolder = async () =>
|
||||
await createFolder(getPackagesFolder())
|
||||
|
||||
if (process.env.MODE === ModeType.Desktop) {
|
||||
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
|
||||
await createFile(getDesktopUserAutoExecPath(), '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createFile, fileExists } from '@sasjs/utils'
|
||||
import { getDesktopUserAutoExecPath } from './file'
|
||||
import { ModeType } from './verifyEnvVariables'
|
||||
|
||||
export const setupUserAutoExec = async () => {
|
||||
if (process.env.MODE === ModeType.Desktop) {
|
||||
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
|
||||
await createFile(getDesktopUserAutoExecPath(), '')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,8 @@ export const generateFileUploadSasCode = async (
|
||||
let fileCount = 0
|
||||
const uploadedFiles: UploadedFiles[] = []
|
||||
|
||||
const sasSessionFolderList: string[] = await listFilesInFolder(
|
||||
sasSessionFolder
|
||||
)
|
||||
const sasSessionFolderList: string[] =
|
||||
await listFilesInFolder(sasSessionFolder)
|
||||
sasSessionFolderList.forEach((fileName) => {
|
||||
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
|
||||
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
|
||||
|
||||
@@ -85,10 +85,18 @@ export const updateUserValidation = (
|
||||
return Joi.object(validationChecks).validate(data)
|
||||
}
|
||||
|
||||
export const updatePasswordValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
currentPassword: Joi.string().required(),
|
||||
newPassword: passwordSchema.required()
|
||||
}).validate(data)
|
||||
|
||||
export const registerClientValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
clientId: Joi.string().required(),
|
||||
clientSecret: Joi.string().required()
|
||||
clientSecret: Joi.string().required(),
|
||||
accessTokenExpiration: Joi.number(),
|
||||
refreshTokenExpiration: Joi.number()
|
||||
}).validate(data)
|
||||
|
||||
export const registerPermissionValidation = (data: any): Joi.ValidationResult =>
|
||||
@@ -170,9 +178,31 @@ export const runCodeValidation = (data: any): Joi.ValidationResult =>
|
||||
runTime: Joi.string().valid(...process.runTimes)
|
||||
}).validate(data)
|
||||
|
||||
export const triggerCodeValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
code: Joi.string().required(),
|
||||
runTime: Joi.string().valid(...process.runTimes),
|
||||
expiresAfterMins: Joi.number().greater(0)
|
||||
}).validate(data)
|
||||
|
||||
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required()
|
||||
_program: Joi.string().required(),
|
||||
_debug: Joi.number()
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required(),
|
||||
_debug: Joi.number(),
|
||||
expiresAfterMins: Joi.number().greater(0)
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
|
||||
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
sessionId: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
@@ -9,8 +9,7 @@ export enum ModeType {
|
||||
}
|
||||
|
||||
export enum AuthProviderType {
|
||||
LDAP = 'ldap',
|
||||
Internal = 'internal'
|
||||
LDAP = 'ldap'
|
||||
}
|
||||
|
||||
export enum ProtocolType {
|
||||
@@ -48,6 +47,16 @@ export enum ReturnCode {
|
||||
InvalidEnv
|
||||
}
|
||||
|
||||
export enum DatabaseType {
|
||||
MONGO = 'mongodb',
|
||||
COSMOS_MONGODB = 'cosmos_mongodb'
|
||||
}
|
||||
|
||||
export enum ResetAdminPasswordType {
|
||||
YES = 'YES',
|
||||
NO = 'NO'
|
||||
}
|
||||
|
||||
export const verifyEnvVariables = (): ReturnCode => {
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -71,6 +80,12 @@ export const verifyEnvVariables = (): ReturnCode => {
|
||||
|
||||
errors.push(...verifyLDAPVariables())
|
||||
|
||||
errors.push(...verifyDbType())
|
||||
|
||||
errors.push(...verifyRateLimiter())
|
||||
|
||||
errors.push(...verifyAdminUserConfig())
|
||||
|
||||
if (errors.length) {
|
||||
process.logger?.error(
|
||||
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
|
||||
@@ -111,7 +126,7 @@ const verifyMODE = (): string[] => {
|
||||
}
|
||||
|
||||
if (process.env.MODE === ModeType.Server) {
|
||||
const { DB_CONNECT, AUTH_MECHANISM } = process.env
|
||||
const { DB_CONNECT, AUTH_PROVIDERS } = process.env
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (!DB_CONNECT)
|
||||
@@ -119,14 +134,12 @@ const verifyMODE = (): string[] => {
|
||||
`- DB_CONNECT is required for PROTOCOL '${ModeType.Server}'`
|
||||
)
|
||||
|
||||
if (AUTH_MECHANISM) {
|
||||
const authMechanismTypes = Object.values(AuthProviderType)
|
||||
if (!authMechanismTypes.includes(AUTH_MECHANISM as AuthProviderType))
|
||||
if (AUTH_PROVIDERS) {
|
||||
const authProvidersType = Object.values(AuthProviderType)
|
||||
if (!authProvidersType.includes(AUTH_PROVIDERS as AuthProviderType))
|
||||
errors.push(
|
||||
`- AUTH_MECHANISM '${AUTH_MECHANISM}'\n - valid options ${authMechanismTypes}`
|
||||
`- AUTH_PROVIDERS '${AUTH_PROVIDERS}'\n - valid options ${authProvidersType}`
|
||||
)
|
||||
} else {
|
||||
process.env.AUTH_MECHANISM = DEFAULTS.AUTH_MECHANISM
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +283,7 @@ const verifyRUN_TIMES = (): string[] => {
|
||||
return errors
|
||||
}
|
||||
|
||||
const verifyExecutablePaths = () => {
|
||||
const verifyExecutablePaths = (): string[] => {
|
||||
const errors: string[] = []
|
||||
const { RUN_TIMES, SAS_PATH, NODE_PATH, PYTHON_PATH, R_PATH, MODE } =
|
||||
process.env
|
||||
@@ -307,37 +320,37 @@ const verifyLDAPVariables = () => {
|
||||
LDAP_USERS_BASE_DN,
|
||||
LDAP_GROUPS_BASE_DN,
|
||||
MODE,
|
||||
AUTH_MECHANISM
|
||||
AUTH_PROVIDERS
|
||||
} = process.env
|
||||
|
||||
if (MODE === ModeType.Server && AUTH_MECHANISM === AuthProviderType.LDAP) {
|
||||
if (MODE === ModeType.Server && AUTH_PROVIDERS === AuthProviderType.LDAP) {
|
||||
if (!LDAP_URL) {
|
||||
errors.push(
|
||||
`- LDAP_URL is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
|
||||
`- LDAP_URL is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
|
||||
)
|
||||
}
|
||||
|
||||
if (!LDAP_BIND_DN) {
|
||||
errors.push(
|
||||
`- LDAP_BIND_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
|
||||
`- LDAP_BIND_DN is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
|
||||
)
|
||||
}
|
||||
|
||||
if (!LDAP_BIND_PASSWORD) {
|
||||
errors.push(
|
||||
`- LDAP_BIND_PASSWORD is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
|
||||
`- LDAP_BIND_PASSWORD is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
|
||||
)
|
||||
}
|
||||
|
||||
if (!LDAP_USERS_BASE_DN) {
|
||||
errors.push(
|
||||
`- LDAP_USERS_BASE_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
|
||||
`- LDAP_USERS_BASE_DN is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
|
||||
)
|
||||
}
|
||||
|
||||
if (!LDAP_GROUPS_BASE_DN) {
|
||||
errors.push(
|
||||
`- LDAP_GROUPS_BASE_DN is required for AUTH_MECHANISM '${AuthProviderType.LDAP}'`
|
||||
`- LDAP_GROUPS_BASE_DN is required for AUTH_PROVIDER '${AuthProviderType.LDAP}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -345,12 +358,111 @@ const verifyLDAPVariables = () => {
|
||||
return errors
|
||||
}
|
||||
|
||||
const verifyDbType = () => {
|
||||
const errors: string[] = []
|
||||
|
||||
const { MODE, DB_TYPE } = process.env
|
||||
|
||||
if (MODE === ModeType.Server) {
|
||||
if (DB_TYPE) {
|
||||
const dbTypes = Object.values(DatabaseType)
|
||||
if (!dbTypes.includes(DB_TYPE as DatabaseType))
|
||||
errors.push(`- DB_TYPE '${DB_TYPE}'\n - valid options ${dbTypes}`)
|
||||
} else {
|
||||
process.env.DB_TYPE = DEFAULTS.DB_TYPE
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const verifyRateLimiter = () => {
|
||||
const errors: string[] = []
|
||||
const {
|
||||
MODE,
|
||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY,
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||
} = process.env
|
||||
if (MODE === ModeType.Server) {
|
||||
if (MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) {
|
||||
if (
|
||||
!isNumeric(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) ||
|
||||
Number(MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY) < 1
|
||||
) {
|
||||
errors.push(
|
||||
`- Invalid value for 'MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY' - Only positive number is acceptable`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
process.env.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY =
|
||||
DEFAULTS.MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY
|
||||
}
|
||||
|
||||
if (MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) {
|
||||
if (
|
||||
!isNumeric(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) ||
|
||||
Number(MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP) < 1
|
||||
) {
|
||||
errors.push(
|
||||
`- Invalid value for 'MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP' - Only positive number is acceptable`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
process.env.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP =
|
||||
DEFAULTS.MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const verifyAdminUserConfig = () => {
|
||||
const errors: string[] = []
|
||||
const { MODE, ADMIN_USERNAME, ADMIN_PASSWORD_INITIAL, ADMIN_PASSWORD_RESET } =
|
||||
process.env
|
||||
if (MODE === ModeType.Server) {
|
||||
if (ADMIN_USERNAME) {
|
||||
process.env.ADMIN_USERNAME = ADMIN_USERNAME.toLowerCase()
|
||||
} else {
|
||||
process.env.ADMIN_USERNAME = DEFAULTS.ADMIN_USERNAME
|
||||
}
|
||||
|
||||
if (!ADMIN_PASSWORD_INITIAL)
|
||||
process.env.ADMIN_PASSWORD_INITIAL = DEFAULTS.ADMIN_PASSWORD_INITIAL
|
||||
|
||||
if (ADMIN_PASSWORD_RESET) {
|
||||
const resetPasswordTypes = Object.values(ResetAdminPasswordType)
|
||||
if (
|
||||
!resetPasswordTypes.includes(
|
||||
ADMIN_PASSWORD_RESET as ResetAdminPasswordType
|
||||
)
|
||||
)
|
||||
errors.push(
|
||||
`- ADMIN_PASSWORD_RESET '${ADMIN_PASSWORD_RESET}'\n - valid options ${resetPasswordTypes}`
|
||||
)
|
||||
} else {
|
||||
process.env.ADMIN_PASSWORD_RESET = DEFAULTS.ADMIN_PASSWORD_RESET
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const isNumeric = (val: string): boolean => {
|
||||
return !isNaN(Number(val))
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
MODE: ModeType.Desktop,
|
||||
AUTH_MECHANISM: AuthProviderType.Internal,
|
||||
PROTOCOL: ProtocolType.HTTP,
|
||||
PORT: '5000',
|
||||
HELMET_COEP: HelmetCoepType.TRUE,
|
||||
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common,
|
||||
RUN_TIMES: RunTimeType.SAS
|
||||
RUN_TIMES: RunTimeType.SAS,
|
||||
DB_TYPE: DatabaseType.MONGO,
|
||||
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY: '100',
|
||||
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP: '10',
|
||||
ADMIN_USERNAME: 'secretuser',
|
||||
ADMIN_PASSWORD_INITIAL: 'secretpassword',
|
||||
ADMIN_PASSWORD_RESET: ResetAdminPasswordType.NO
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const fetchLatestAutoExec = async (
|
||||
displayName: dbUser.displayName,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isActive: dbUser.isActive,
|
||||
needsToUpdatePassword: dbUser.needsToUpdatePassword,
|
||||
autoExec: dbUser.autoExec
|
||||
}
|
||||
}
|
||||
@@ -41,6 +42,7 @@ export const verifyTokenInDB = async (
|
||||
displayName: dbUser.displayName,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isActive: dbUser.isActive,
|
||||
needsToUpdatePassword: dbUser.needsToUpdatePassword,
|
||||
autoExec: dbUser.autoExec
|
||||
}
|
||||
: undefined
|
||||
|
||||
Generated
+4233
-8631
File diff suppressed because it is too large
Load Diff
Generated
+2987
-1675
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user