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

Compare commits

...

183 Commits

Author SHA1 Message Date
Saad Jutt
0a9d734e09 chore(release): 0.0.9 2021-12-07 10:35:56 +05:00
Muhammad Saad
a0822e6b61 fix: release with files (#35) 2021-12-07 10:33:11 +05:00
Saad Jutt
a80e5c8ead chore(release): 0.0.3 2021-11-30 16:32:52 +05:00
Saad Jutt
c8634953ca chore: trigger release 2021-11-30 16:29:30 +05:00
Saad Jutt
129ad71a15 chore: trigger release 2021-11-30 16:16:17 +05:00
Allan Bowe
4ff4d39e95 fix: removing renegade dash 2021-11-30 10:52:46 +00:00
Allan Bowe
6ad67b6dcf Merge pull request #34 from sasjs/issue-9
fix: Issue 9
2021-11-30 10:50:26 +00:00
Saad Jutt
e6e4cd901d chore: README.md 2021-11-30 07:24:35 +05:00
Saad Jutt
70b60af10c chore: release.yml syntax fix 2021-11-30 07:22:58 +05:00
Saad Jutt
5e92864f2d chore: sasjs/utils version bumped 2021-11-30 06:16:08 +05:00
Saad Jutt
511a68732b chore: sasjs/version bumped 2021-11-30 04:14:54 +05:00
Saad Jutt
19c3298a02 chore: Merge branch 'issue-9' of github.com:sasjs/server into issue-9 2021-11-20 05:18:23 +05:00
Saad Jutt
77f49386d8 chore: publish releases on github 2021-11-19 08:18:15 +05:00
Allan Bowe
b97523e555 fix: sending _webout as result object in response JSON 2021-11-18 20:57:56 +00:00
Saad Jutt
d2955645f0 chore: fix for returning execute json 2021-11-18 04:32:31 +05:00
Saad Jutt
b75139dda5 feat: compile systemInit and inject to autoExec 2021-11-18 03:12:05 +05:00
Allan Bowe
319743a23e Merge pull request #30 from sasjs/authentication-with-jwt
Authentication with jwt
2021-11-17 11:46:14 +00:00
Saad Jutt
3c328dbab2 chore: docker bind mount + sasjs/cli in container 2021-11-17 07:49:42 +05:00
Saad Jutt
455367f10a chore: reverted 4 commits related to copying exe scripts 2021-11-17 07:49:02 +05:00
Allan Bowe
b3147ec680 feat: adding _metaperson and _metauser to Stored Programs 2021-11-16 12:51:02 +00:00
Saad Jutt
c55ba5620e chore: scripts updated 2021-11-16 06:13:05 +05:00
Saad Jutt
b20d6ec59c chore: copy sas exe script extracted common 2021-11-16 06:12:22 +05:00
Saad Jutt
44fe149ed8 chore: docker start script for development 2021-11-16 05:29:55 +05:00
Saad Jutt
357bccce01 chore: provide sas executable to docker 2021-11-16 05:07:22 +05:00
Saad Jutt
f030aa1516 chore: bug fixes for session crash details + return code 2021-11-15 21:30:12 +05:00
Saad Jutt
9ee7951816 chore: docker server certificates for development 2021-11-15 13:00:06 +05:00
Saad Jutt
ca49aad153 chore: readme updated 2021-11-15 05:39:30 +05:00
Saad Jutt
8a6e8f54f1 chore: readme updated 2021-11-15 04:49:29 +05:00
Saad Jutt
4a363c5b97 fix(docker): docker-compose for prod+development 2021-11-15 00:52:29 +05:00
Saad Jutt
5e7cecf3ea chore: fix for development docker-compose 2021-11-14 21:12:10 +05:00
Saad Jutt
4792f15c40 chore: docker configured for development api+web+mongodb 2021-11-14 09:03:38 +05:00
Saad Jutt
d5024012c4 "feat: added docker containers""
This reverts commit 9e2123cfe9.
2021-11-14 00:55:30 +05:00
Muhammad Saad
64656c2919 Merge pull request #33 from sasjs/executables
Executables
2021-11-14 00:51:07 +05:00
Saad Jutt
f2e50eb4cc refactor: specs + cleanup 2021-11-14 00:48:22 +05:00
Saad Jutt
2bb10c7166 fix: cors enabled for desktop mode 2021-11-13 23:29:27 +05:00
Allan Bowe
cbe07b4abb fix: session refactoring with Saad & Allan 2021-11-13 14:31:09 +00:00
Saad Jutt
a4ac5dc280 chore(drive): post new file route added 2021-11-13 16:39:20 +05:00
Saad Jutt
04a8626570 chore: additional param for stp/execute + desktop user in req 2021-11-13 16:20:51 +05:00
Saad Jutt
cfdb67a049 chore: docs bug fixed 2021-11-13 00:41:02 +05:00
Saad Jutt
cd738aa4b8 chore: default is desktop mode with prompts 2021-11-12 23:59:55 +05:00
Saad Jutt
46f2648a95 chore: windows env variables needs to be trimmed 2021-11-12 21:27:29 +05:00
Saad Jutt
2eb42408d1 chore: added desktop mode + drive tmp folder fix 2021-11-12 19:43:56 +05:00
Saad Jutt
514a262340 feat: generate executables for sasjs/server with web component 2021-11-12 06:40:10 +05:00
Saad Jutt
7b8cd0892c chore: build fix 2021-11-11 03:51:01 +05:00
Saad Jutt
1814c3a1f4 chore: restructured controllers 2021-11-11 03:40:57 +05:00
Saad Jutt
7a8123eb52 chore: docs finalized for stp and others 2021-11-11 03:20:19 +05:00
Saad Jutt
45fbf2df46 chore: drive all endpoints docs generated 2021-11-10 21:59:46 +05:00
Saad Jutt
31959455c3 chore: embedded clientId for web component 2021-11-10 20:00:27 +05:00
Saad Jutt
98ef40ffd6 chore: server issue fix 2021-11-10 08:33:44 +05:00
Saad Jutt
652af82dac chore(web): package files added 2021-11-10 08:15:05 +05:00
Saad Jutt
13d2e15d72 chore(api): added missing type 2021-11-10 08:13:12 +05:00
Saad Jutt
42dde3eded chore(web): commented local endpoints 2021-11-10 08:10:57 +05:00
Saad Jutt
c47782eed2 chore(web): added login component + use access token 2021-11-10 08:05:47 +05:00
Saad Jutt
d9057bc33b chore: added preProgramVariables 2021-11-10 08:04:45 +05:00
Saad Jutt
f07f269839 test(group): specs added for group api 2021-11-10 04:13:24 +05:00
Saad Jutt
cc111675cc chore: filename corrected for git 2021-11-09 19:26:40 +05:00
Saad Jutt
d2dbbce7ad chore: github flow fix setup-node v2 2021-11-09 19:07:19 +05:00
Saad Jutt
3fad8e94f5 chore: updated to lts node version 2021-11-09 19:05:43 +05:00
Saad Jutt
9a86419d7a chore: lint fix + yaml + .env.example moved 2021-11-09 18:53:23 +05:00
Saad Jutt
61738f6a0d chore(merge): Merge branch 'master' into authentication-with-jwt 2021-11-09 18:24:35 +05:00
Saad Jutt
ab62dad237 test(user): added more specs to user api 2021-11-09 17:46:01 +05:00
Saad Jutt
9e2123cfe9 Revert "chore: added docker containers"
This reverts commit d7cbd315a6.
2021-11-09 17:42:55 +05:00
Allan Bowe
a056eed6b4 Merge pull request #24 from sasjs/homepage-sasjs-executor
Homepage sasjs executor
2021-11-09 11:55:49 +00:00
Saad Jutt
d7cbd315a6 chore: added docker containers 2021-11-09 03:51:49 +05:00
Saad Jutt
d3fef0f973 chore: refactored, added instance + static methods to models 2021-11-09 03:46:39 +05:00
Saad Jutt
2fe9d5ca9c feat: Groups are added + docs 2021-11-07 05:14:37 +05:00
Saad Jutt
14c2def459 chore: drive auto-generated swagger 2021-11-06 00:36:51 +05:00
Saad Jutt
dbbdaa83f0 chore: auth docs also added 2021-11-05 15:57:36 +05:00
Saad Jutt
52c3823f20 chore: added client docs + tags 2021-11-05 04:37:43 +05:00
Saad Jutt
ae34aa52f0 chore: swagger authentication added 2021-11-05 03:54:07 +05:00
Saad Jutt
2b7dfeb2ea chore: added incremental field 'id' in user collection 2021-11-05 03:07:20 +05:00
Saad Jutt
882f36d30e chore: swagger docs generated 2021-11-04 18:47:40 +05:00
Saad Jutt
728f277f5c feat: user operation apis added 2021-11-04 05:51:29 +05:00
Saad Jutt
9f17b17e31 fix: DB names updates + refresh api is added 2021-11-03 15:56:58 +05:00
Saad Jutt
46c5a75ac4 feat: JWT saved in DB + logout api added 2021-11-03 14:56:04 +05:00
Saad Jutt
d6aeb378de test(client): new route /client + specs added 2021-11-03 01:35:52 +05:00
Saad Jutt
d7337ce456 chore: updated build.yml 2021-11-02 20:50:12 +05:00
Saad Jutt
60f2b34567 test(user): added specs for admin action to create user 2021-11-02 20:44:16 +05:00
4048c1a4aa chore: lint fix 2021-11-02 14:08:30 +00:00
d9814441bb fix: update SASjsApi/stp/execute post api endpoints to capture url params 2021-11-02 14:07:26 +00:00
Saad Jutt
b48e674468 test(auth): added for authorize + token 2021-11-02 18:42:06 +05:00
d93673f2a5 fix: change api endpoint SASjsExecutor/do -> SASjsApi/stp/execute 2021-11-02 09:54:56 +00:00
Saad Jutt
f7e0849148 chore: added .env.example 2021-11-02 03:18:25 +05:00
Saad Jutt
22dfcfddb9 feat: authentication with jwt 2021-11-02 03:13:16 +05:00
d52c2ed18c chore: lint fix 2021-11-01 15:28:07 +00:00
031e492d44 fix: update api calls from client side 2021-11-01 15:25:43 +00:00
0c6ccddafd Merge branch 'master' into homepage-sasjs-executor
Conflicts:
	api/src/app.ts
	api/src/routes/index.ts
	api/src/routes/spec/routes.spec.ts
2021-11-01 13:12:52 +00:00
Allan Bowe
f1e464d4a4 Merge pull request #29 from sasjs/routes-fix
fix: fix web route
2021-10-28 16:54:37 +01:00
Yury Shkoda
6c7a6b6c6a fix: fix web route 2021-10-28 15:04:34 +00:00
Yury Shkoda
f5e6b56abb Merge pull request #27 from sasjs/routes
feat(routes): separate routes into web and api
2021-10-28 15:30:50 +03:00
Yury Shkoda
48fe7994ec chore(routes): change files route with drive 2021-10-28 12:09:19 +00:00
Yury Shkoda
49c152a398 fix(routes): fix routes imports 2021-10-28 08:43:28 +00:00
Yury Shkoda
dabef59728 feat(routes): separate routes into web and api 2021-10-28 08:25:36 +00:00
bdbf7573be chore(lint): lint fix 2021-10-27 16:41:46 +00:00
56cb2d1d51 fix: remove .sas extension from _program parameter at the end of string 2021-10-27 16:39:34 +00:00
8dd201ecbe Merge branch 'master' into homepage-sasjs-executor
Conflicts:
	package.json
2021-10-26 09:37:38 +00:00
ac745c8f5c fix(web): infinite call to api end point fixed 2021-10-26 09:31:30 +00:00
Yury Shkoda
c6c24da0e2 Merge pull request #25 from sasjs/deploy-fix
Fix payload processing within deploy end-point
2021-10-26 11:28:00 +03:00
Yury Shkoda
8f815b7874 test(deploy): fix payload 2021-10-26 08:22:44 +00:00
Yury Shkoda
361b539271 fix(deploy): fix payload processing 2021-10-26 08:18:02 +00:00
a3f47708a8 chore(web-build): fix web build warnings 2021-10-25 06:47:50 +00:00
ec6333f6aa fix(root-package.json): lint:fix command fixed in root package json 2021-10-25 06:31:04 +00:00
0fb4301966 fix(web): remove unnecessary packages and files 2021-10-25 06:20:46 +00:00
552a3584ec fix(weeb): add catch block with each axios request 2021-10-25 06:10:35 +00:00
03d1d60660 fix(api-cdrive-oller): throw erow error when file not found 2021-10-25 05:51:46 +00:00
70a8aaf600 chore(build): mcreate web/build folder before running tests in github CI steps 2021-10-23 13:34:51 +00:00
e2b12b74f5 fix: norefferer issue in home page external links fix 2021-10-23 13:28:55 +00:00
9648c51b54 feat(api-utility): create getWebBuildFolderPath utility 2021-10-22 19:17:27 +00:00
5aeefb7955 chore(refactor-api): remove sasjsExecutor file and move code to ExecutionController 2021-10-22 19:01:14 +00:00
20eb64a994 chore: add lint script to run linter for both web and api 2021-10-22 18:41:35 +00:00
f8a79ff451 chore(web): open hyper links on home page in new tab or window 2021-10-22 18:27:20 +00:00
1d66079e11 chore(web): remove baseUrl from axios requests 2021-10-22 18:22:55 +00:00
9db65c9fa9 chore(refactor): remove sasjs from components name, just use Studio, Drive etc 2021-10-22 18:00:56 +00:00
6284e72e9b chore: change _debug param type from boolean to number i ExecutionQuery interface 2021-10-22 17:46:29 +00:00
Yury Shkoda
4297285b26 chore(web): fix build warnings 2021-10-20 16:09:16 +03:00
Yury Shkoda
77cb7ef50f chore(web): cleaned package.json 2021-10-20 16:08:55 +03:00
Yury Shkoda
c0ec406b2a chore(workflow): disable 'Run Unit Tests' step for web 2021-10-20 15:56:40 +03:00
Yury Shkoda
65a57acff6 chore(workflow): remove code style check step from build jobs 2021-10-20 15:52:53 +03:00
Yury Shkoda
6071b6c054 chore(web): fix linting 2021-10-20 15:51:11 +03:00
Yury Shkoda
d97fd9b4d7 chore(npm): add package-lock 2021-10-20 15:49:28 +03:00
Yury Shkoda
ba2c209fce chore(workflow): add lint step 2021-10-20 15:48:05 +03:00
Yury Shkoda
19c60c4c98 chore(workflow): remove 'package:lib' script 2021-10-20 15:32:35 +03:00
Yury Shkoda
fd2092ecf3 chore(workflow): add working-directory for lint step 2021-10-20 15:25:28 +03:00
Yury Shkoda
fda5680b0e chore(workflow): add working-directory 2021-10-20 15:23:45 +03:00
Yury Shkoda
6b23452d6a chore(workflow): check pwd 2021-10-20 15:18:31 +03:00
Yury Shkoda
174d94a23c fix(workflow): fix 'SASjs Server Build' 2021-10-20 15:15:24 +03:00
829c88c4a0 chore: remove tsconfig file from root directory 2021-10-20 11:49:44 +00:00
d530e0801e chore: restructure repo into sub folders 2021-10-20 11:43:10 +00:00
99d55775aa fix: load file when url contains filePath 2021-10-20 11:13:36 +00:00
ee053d1a52 chore: remove app.test.tsx from react app 2021-10-20 09:29:14 +00:00
c72867d5a7 fix: use hash router instead of browser router in react app 2021-10-19 18:39:06 +00:00
a5bfdf9503 chore: update package-lock.json 2021-10-19 17:11:14 +00:00
49d3a02e85 chore: Merge branch 'master into homepage-sasjs-executor 2021-10-19 17:08:41 +00:00
02f5371f57 fix: on clicking execute button open new tab for response 2021-10-19 16:49:33 +00:00
cff5ba460d chore: add content in home page 2021-10-19 16:33:36 +00:00
cb8b34afac chore: rename tabs SAS Drive -> Drive and SAS Studio -> Studio 2021-10-19 15:32:23 +00:00
c760e7bf30 chore: rename component from SASStudio -> SASjsStudio 2021-10-19 15:29:46 +00:00
a1ae0e3ed0 chore: rename Web folder to web 2021-10-19 15:26:26 +00:00
3fe475d477 fix: update sasjs drive controller from function base to class base 2021-10-19 15:11:15 +00:00
299319e2db fix: immplementation of files api fixed 2021-10-19 10:54:27 +00:00
d713d04b20 chore: fix the position of circular progress icon 2021-10-19 10:53:09 +00:00
Yury Shkoda
a1de0ae888 Merge pull request #23 from sasjs/issue16-session
feat: uploading files in sas request
2021-10-19 13:45:13 +03:00
Mihajlo Medjedovic
38db48d59f style: lint 2021-10-19 10:37:41 +00:00
Mihajlo Medjedovic
a09e90b010 chore: always adding sas extension 2021-10-19 10:37:23 +00:00
Mihajlo Medjedovic
f374acae6e chore: extension util fix 2021-10-19 10:10:00 +00:00
Mihajlo Medjedovic
91c7c60dc9 style: lint 2021-10-19 09:19:03 +00:00
a42a1693c2 chore: modify the implementation files and executor api 2021-10-18 19:50:30 +00:00
91e2e2bc4a fix: modify the directory tree algorithm to include relative path with each node 2021-10-18 19:48:25 +00:00
bc3cb7bb20 feat: add new type TreeNode 2021-10-18 19:45:00 +00:00
Mihajlo Medjedovic
94fc242afe chore: addExtension function fix and testing 2021-10-18 16:24:10 +00:00
Mihajlo Medjedovic
6efb2d0b51 style: lint 2021-10-18 12:07:42 +00:00
a506bc9dd9 feat: add top app bar with tab navigation 2021-10-18 11:42:35 +00:00
936a205e66 fix: update api endpoints 2021-10-18 11:39:14 +00:00
7396f4c952 chore: add base container for home page 2021-10-18 11:36:28 +00:00
24d3290366 chore: add base container for sas studio 2021-10-18 11:35:53 +00:00
da3feb85e3 chore: override defaulyt theme for mui tab 2021-10-18 11:34:55 +00:00
Mihajlo Medjedovic
125cf35722 chore: file removed 2021-10-18 11:25:02 +00:00
Mihajlo Medjedovic
d9555e151b fix: debug not passed 2021-10-18 10:42:21 +00:00
Mihajlo Medjedovic
38ab27c1ed style: lint 2021-10-15 12:57:26 +00:00
Mihajlo Medjedovic
8bcb1c4b7b Merge branch 'issue16-session' of github.com:sasjs/server into issue16-session
Conflicts:
	src/routes/index.ts
	src/utils/upload.ts
2021-10-15 12:57:20 +00:00
Mihajlo Medjedovic
c0a4e1aa14 chore: comments addressing 2021-10-15 12:43:22 +00:00
Allan Bowe
716ae81d92 fix: prettier 2021-10-15 06:24:01 +00:00
f1a19be07c chore: Merge branch 'master' into homepage-sasjs-executor 2021-10-14 18:38:03 +00:00
db8eb8dd71 feat: frontend app for sasjs server 2021-10-14 18:22:50 +00:00
96b5fef302 feat: add api endpoint for sasjs drive 2021-10-14 18:21:20 +00:00
f90dcff7cf chore: remove public folder 2021-10-14 18:19:08 +00:00
41d6e53548 chore: remove unnecessary express middleware 2021-10-14 18:17:08 +00:00
Mihajlo Medjedovic
17a7a26fc3 chore: merging with sessions - sas request with file upload 2021-10-14 15:44:28 +00:00
Allan Bowe
3f39cab357 Merge pull request #22 from sasjs/session
Add session controller and execution controller
2021-10-14 11:44:07 +01:00
Yury Shkoda
00c7d2150c chore(git): add blank line to the file end 2021-10-14 07:40:01 +00:00
Yury Shkoda
ba0722b98c chore(utils): add sleep export 2021-10-14 07:35:10 +00:00
Yury Shkoda
129cb7c128 chore(utils): add sleep util 2021-10-14 07:34:37 +00:00
Yury Shkoda
9cf02b25d0 refactor: improve types and imports 2021-10-14 07:34:08 +00:00
Yury Shkoda
6a34fa1b1d feat(session): add SessionController 2021-10-14 07:32:16 +00:00
Yury Shkoda
8b2564120d feat(execution): add ExecutionController working with session 2021-10-14 07:31:46 +00:00
22e1378ecd chore: remove views folder 2021-10-12 21:33:43 +00:00
2e32f4e6dc chore: remove pug dependency 2021-10-12 21:19:36 +00:00
Yury Shkoda
37b6936cca fix(ts): enable files 2021-10-11 14:35:47 +03:00
Yury Shkoda
6e0b04a6e5 feat(session): add SessionController and ExecutionController 2021-10-11 14:16:22 +03:00
279fbf2a9a feat: add sasjsExecutor controller 2021-10-07 17:22:44 +00:00
a446f5c4f7 feat: add views and styles for rendering html 2021-10-07 17:22:00 +00:00
bd92b8b983 chore: add configuration for static resources and rendering views 2021-10-07 17:18:51 +00:00
Sabir Hassan
3ffa168c8b feat: add pug and directory tree dependencies 2021-10-07 17:14:55 +00:00
132 changed files with 87794 additions and 8976 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
**/.classpath
**/.dockerignore
**/.env
!.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
api/build
api/coverage
api/build
api/node_modules
api/public
api/web
web/build
web/node_modules
README.md

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
SAS_EXEC=<path to folder containing SAS executable 'sas'>
PORT_API=<port for sasjs server (api)>
PORT_WEB=<port for sasjs web component(react)>
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>

View File

@@ -1,30 +1,91 @@
name: SASjs Server Build
on:
push:
pull_request:
jobs:
build:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
node-version: [lts/*]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
run: npm ci
- name: Check Code Style
run: npm run lint
- name: Check Api Code Style
run: npm run lint-api
- name: Check Web Code Style
run: npm run lint-web
build-api:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
working-directory: ./api
run: npm ci
- name: Run Unit Tests
working-directory: ./api
run: npm test
- name: Build Package
run: npm run package:lib
env:
CI: true
MODE: 'server'
ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}}
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}
- name: Build Package
working-directory: ./api
run: npm run build
env:
CI: true
build-web:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install Dependencies
working-directory: ./web
run: npm ci
# TODO: Uncomment next step when unit tests provided
# - name: Run Unit Tests
# working-directory: ./web
# run: npm test
- name: Build Package
working-directory: ./web
run: npm run build
env:
CI: true

41
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: SASjs Server Executable Release
on:
push:
tags:
- 'v*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install Dependencies WEB
working-directory: ./web
run: npm ci
- name: Build WEB
working-directory: ./web
run: npm run build
env:
CI: true
- name: Install Dependencies API
working-directory: ./api
run: npm ci
- name: Build Executables
working-directory: ./api
run: npm run exe
env:
CI: true
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./executables/api-linux
./executables/api-macos
./executables/api-win.exe

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@ node_modules/
sas/
tmp/
build/
sasjsbuild/
certificates/
executables/
.env

11
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"configurations": [
{
"name": "Docker Node.js Launch",
"type": "docker",
"request": "launch",
"preLaunchTask": "docker-run: debug",
"platform": "node"
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"cSpell.words": [
"autoexec"
]
}

35
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "docker-build",
"label": "docker-build",
"platform": "node",
"dockerBuild": {
"dockerfile": "${workspaceFolder}/Dockerfile",
"context": "${workspaceFolder}",
"pull": true
}
},
{
"type": "docker-run",
"label": "docker-run: release",
"dependsOn": ["docker-build"],
"platform": "node"
},
{
"type": "docker-run",
"label": "docker-run: debug",
"dependsOn": ["docker-build"],
"dockerRun": {
"env": {
"DEBUG": "*",
"NODE_ENV": "development"
}
},
"node": {
"enableDebugging": true
}
}
]
}

170
CHANGELOG.md Normal file
View File

@@ -0,0 +1,170 @@
# Changelog
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.0.9](https://github.com/sasjs/server/compare/v0.0.3...v0.0.9) (2021-12-07)
### Bug Fixes
* release with files ([#35](https://github.com/sasjs/server/issues/35)) ([a0822e6](https://github.com/sasjs/server/commit/a0822e6b61905257475121ffd907fd1f79ed146b))
### [0.0.8](https://github.com/saadjutt01/server/compare/v0.0.7...v0.0.8) (2021-12-07)
### [0.0.7](https://github.com/saadjutt01/server/compare/v0.0.6...v0.0.7) (2021-12-07)
### [0.0.6](https://github.com/saadjutt01/server/compare/v0.0.5...v0.0.6) (2021-12-07)
### [0.0.5](https://github.com/saadjutt01/server/compare/v0.0.4...v0.0.5) (2021-12-07)
### 0.0.4 (2021-12-07)
### Features
* add api endpoint for sasjs drive ([96b5fef](https://github.com/saadjutt01/server/commit/96b5fef3021f67f66e5e3b854319230618421852))
* add new type TreeNode ([bc3cb7b](https://github.com/saadjutt01/server/commit/bc3cb7bb20a1202d17aaf8bbcddd1feef4fff724))
* add pug and directory tree dependencies ([3ffa168](https://github.com/saadjutt01/server/commit/3ffa168c8bafc989caf1a744cebc20d36c6aa11b))
* add sasjsExecutor controller ([279fbf2](https://github.com/saadjutt01/server/commit/279fbf2a9a0bd6bc0938f9a66e9685fb93d86089))
* add top app bar with tab navigation ([a506bc9](https://github.com/saadjutt01/server/commit/a506bc9dd9d201b89fc9ffd1a552c16bd170f058))
* add views and styles for rendering html ([a446f5c](https://github.com/saadjutt01/server/commit/a446f5c4f73a4e829a2c5eec041e3adffeddff52))
* adding _metaperson and _metauser to Stored Programs ([b3147ec](https://github.com/saadjutt01/server/commit/b3147ec680646b3d9c7e89152e472dddc8a36075))
* **api-utility:** create getWebBuildFolderPath utility ([9648c51](https://github.com/saadjutt01/server/commit/9648c51b5491d8b6bbe5497273efa2d11e2486d2))
* **api:** set up endpoint for sas code execution ([f6046b1](https://github.com/saadjutt01/server/commit/f6046b15ae30cd8ace685cf283339871de658b7d))
* authentication with jwt ([22dfcfd](https://github.com/saadjutt01/server/commit/22dfcfddb9abd355a63d1ee5acd925c759e86d69))
* compile systemInit and inject to autoExec ([b75139d](https://github.com/saadjutt01/server/commit/b75139dda5cacc7e10a4d635eb2a222f7dfa3fec))
* **deploy:** add appLoc ([f0f1e1d](https://github.com/saadjutt01/server/commit/f0f1e1d57ea1e961fc3b1cfcbd4cb259a77a90d0))
* **deploy:** add route to deploy a file tree to @sasjs/server ([b4bf72f](https://github.com/saadjutt01/server/commit/b4bf72f70401a81b6d5d0104332a1fbc5f71562b))
* **execute:** add macroVars to job execution ([39e486b](https://github.com/saadjutt01/server/commit/39e486b8cb5efbadc86eb7029b60c7073744eb2b))
* **execute:** add sas controller ([bf1db4d](https://github.com/saadjutt01/server/commit/bf1db4dd47d2488bac073cd468db920ff9fd533d))
* **execution:** add ExecutionController working with session ([8b25641](https://github.com/saadjutt01/server/commit/8b2564120def137f80647064e28062b880d58efe))
* **executor:** improved api response ([707b503](https://github.com/saadjutt01/server/commit/707b50394267217e717aa72f74dbeba3852a93e6))
* **executor:** response with webout ([52275ba](https://github.com/saadjutt01/server/commit/52275ba67d97d5cbdf6c5511c9bd789bd6ca6b4e))
* **express:** increase payload max size ([7b403c1](https://github.com/saadjutt01/server/commit/7b403c151e889cae975944546bb4bb53eff1dd26))
* frontend app for sasjs server ([db8eb8d](https://github.com/saadjutt01/server/commit/db8eb8dd7197bbe36f2d10cabbb58b3eb7ce7c33))
* generate executables for sasjs/server with web component ([514a262](https://github.com/saadjutt01/server/commit/514a262340dc34007de75caf08ad03969e7110c1))
* Groups are added + docs ([2fe9d5c](https://github.com/saadjutt01/server/commit/2fe9d5ca9ce1fb376f03534f8685d65efb2f68a6))
* improved deploy and execute endpoints ([5b4e562](https://github.com/saadjutt01/server/commit/5b4e5626fc7ae3e020819e3ebd334cc3712ae8e7))
* JWT saved in DB + logout api added ([46c5a75](https://github.com/saadjutt01/server/commit/46c5a75ac4fb26ebec219118eb204f1b5049ae90))
* **routes:** separate routes into web and api ([dabef59](https://github.com/saadjutt01/server/commit/dabef597287a59f3bfaff54a18de465f820aa514))
* **session:** add SessionController ([6a34fa1](https://github.com/saadjutt01/server/commit/6a34fa1b1dae07fe032352bea0644ab7a6f9c3f9))
* **session:** add SessionController and ExecutionController ([6e0b04a](https://github.com/saadjutt01/server/commit/6e0b04a6e548ac31baee726c9249b7e25f50f0bf))
* user operation apis added ([728f277](https://github.com/saadjutt01/server/commit/728f277f5ce136d62951071833cd6db478b07e4a))
### Bug Fixes
* **api-cdrive-oller:** throw erow error when file not found ([03d1d60](https://github.com/saadjutt01/server/commit/03d1d60660fc46421ef6ad9cee8493dd884e309a))
* change api endpoint SASjsExecutor/do -> SASjsApi/stp/execute ([d93673f](https://github.com/saadjutt01/server/commit/d93673f2a51098c6af8abc4a793081d4591e27de))
* cors enabled for desktop mode ([2bb10c7](https://github.com/saadjutt01/server/commit/2bb10c71661b5de7ed515c82e5b1967b88449972))
* DB names updates + refresh api is added ([9f17b17](https://github.com/saadjutt01/server/commit/9f17b17e3138ce49f24447cd5ae457e3e90ad4da))
* debug not passed ([d9555e1](https://github.com/saadjutt01/server/commit/d9555e151b0e1d1a4068efdf8ee9ed53b25b9b89))
* **deploy:** fix payload processing ([361b539](https://github.com/saadjutt01/server/commit/361b539271cf95bbe570cca9e44635ab563d3f9e))
* **deps:** removed malicious dependency ([c4b9402](https://github.com/saadjutt01/server/commit/c4b9402f017b76dc412a17a10313f1fd5a3891ef))
* **docker:** docker-compose for prod+development ([4a363c5](https://github.com/saadjutt01/server/commit/4a363c5b9796283199debcc8afa810c6f561f8e6))
* **executor:** create tmp files before execution ([cdbc3fd](https://github.com/saadjutt01/server/commit/cdbc3fd298e2a581773448bdddcad93de3b3544d))
* **executor:** fix nosplash argument and api response ([715b1de](https://github.com/saadjutt01/server/commit/715b1dec68377eefe03aa8203a73debe77842436))
* fix web route ([6c7a6b6](https://github.com/saadjutt01/server/commit/6c7a6b6c6af28c29b391162e4e332da6524b1c61))
* **github:** fixed github flow ([8dab288](https://github.com/saadjutt01/server/commit/8dab28861dfa7c4c7fefc7fe038df50f58d04547))
* **github:** removed npm token ([bbb94d6](https://github.com/saadjutt01/server/commit/bbb94d61ce39c84a6c0c44186e89787ab0e76a8c))
* immplementation of files api fixed ([299319e](https://github.com/saadjutt01/server/commit/299319e2dbe06c7ca99e403fcbdec2ad1db8b7e4))
* load file when url contains filePath ([99d5577](https://github.com/saadjutt01/server/commit/99d55775aaac3b2caaa4b10d4ed698f6cd7fcb2a))
* modify the directory tree algorithm to include relative path with each node ([91e2e2b](https://github.com/saadjutt01/server/commit/91e2e2bc4a46da0d149578593559efdb87681bd4))
* norefferer issue in home page external links fix ([e2b12b7](https://github.com/saadjutt01/server/commit/e2b12b74f52c3ce4541fde9af6af0093b56b157b))
* on clicking execute button open new tab for response ([02f5371](https://github.com/saadjutt01/server/commit/02f5371f57b311ff700ba8108f9d5168da8c22a4))
* prettier ([716ae81](https://github.com/saadjutt01/server/commit/716ae81d9293b42dd2a7047ac52d75401b3b8798))
* **prod-server:** use port from configuration ([4d8efbb](https://github.com/saadjutt01/server/commit/4d8efbb88d32154d84e80b79780e2e3de2f519e4))
* readme overview| ([b3342f0](https://github.com/saadjutt01/server/commit/b3342f00031d19080fb72e3460f023c5f44bac95))
* remove .sas extension from _program parameter at the end of string ([56cb2d1](https://github.com/saadjutt01/server/commit/56cb2d1d512beadb5cfdc4ab4034ac917311ff23))
* removing renegade dash ([4ff4d39](https://github.com/saadjutt01/server/commit/4ff4d39e954e895b46ddc3e2919f7f2c4e1ce01d))
* **root-package.json:** lint:fix command fixed in root package json ([ec6333f](https://github.com/saadjutt01/server/commit/ec6333f6aa67c1b94f54b017ed27eb3b21b4207f))
* **routes:** fix routes imports ([49c152a](https://github.com/saadjutt01/server/commit/49c152a398b60f6b0a0c25a68eb4c1c291984872))
* **semantic-release:** fixed package.json ([ef45787](https://github.com/saadjutt01/server/commit/ef45787019f1e61d0e4e2acee334236e8aca23cc))
* sending _webout as result object in response JSON ([b97523e](https://github.com/saadjutt01/server/commit/b97523e55584cc7d9d682cfeaab8f5b70a10b899))
* session refactoring with Saad & Allan ([cbe07b4](https://github.com/saadjutt01/server/commit/cbe07b4abb2e936037874af1a088cd038e0fc731))
* **ts:** enable files ([37b6936](https://github.com/saadjutt01/server/commit/37b6936cca3cff9c1ca26ec7b4b938a357c448df))
* update api calls from client side ([031e492](https://github.com/saadjutt01/server/commit/031e492d44674dec4f2b3bc1f5bf7affac5716bd))
* update api endpoints ([936a205](https://github.com/saadjutt01/server/commit/936a205e66073b9178089c6ab10d6ac3bf323c54))
* update sasjs drive controller from function base to class base ([3fe475d](https://github.com/saadjutt01/server/commit/3fe475d477c466556659b48c70eeac5153ff5b0e))
* update SASjsApi/stp/execute post api endpoints to capture url params ([d981444](https://github.com/saadjutt01/server/commit/d9814441bb1d269ec2404e50f51124f998c65c40))
* use hash router instead of browser router in react app ([c72867d](https://github.com/saadjutt01/server/commit/c72867d5a70550660c8c37220aa33693716a93f1))
* **web:** infinite call to api end point fixed ([ac745c8](https://github.com/saadjutt01/server/commit/ac745c8f5c3e4aa2ac8d6ca23bb1276452d4018b))
* **web:** remove unnecessary packages and files ([0fb4301](https://github.com/saadjutt01/server/commit/0fb43019668f5a13f6e77fdb4b3e543006b509c0))
* **weeb:** add catch block with each axios request ([552a358](https://github.com/saadjutt01/server/commit/552a3584ec9345bc1dec0ff5377bf773a7928d62))
* **workflow:** fix 'SASjs Server Build' ([174d94a](https://github.com/saadjutt01/server/commit/174d94a23c5036d61a4f2e11296283f128d4dafa))
### 0.0.3 (2021-11-30)
### Features
* add api endpoint for sasjs drive ([96b5fef](https://github.com/sasjs/server/commit/96b5fef3021f67f66e5e3b854319230618421852))
* add new type TreeNode ([bc3cb7b](https://github.com/sasjs/server/commit/bc3cb7bb20a1202d17aaf8bbcddd1feef4fff724))
* add pug and directory tree dependencies ([3ffa168](https://github.com/sasjs/server/commit/3ffa168c8bafc989caf1a744cebc20d36c6aa11b))
* add sasjsExecutor controller ([279fbf2](https://github.com/sasjs/server/commit/279fbf2a9a0bd6bc0938f9a66e9685fb93d86089))
* add top app bar with tab navigation ([a506bc9](https://github.com/sasjs/server/commit/a506bc9dd9d201b89fc9ffd1a552c16bd170f058))
* add views and styles for rendering html ([a446f5c](https://github.com/sasjs/server/commit/a446f5c4f73a4e829a2c5eec041e3adffeddff52))
* adding _metaperson and _metauser to Stored Programs ([b3147ec](https://github.com/sasjs/server/commit/b3147ec680646b3d9c7e89152e472dddc8a36075))
* **api-utility:** create getWebBuildFolderPath utility ([9648c51](https://github.com/sasjs/server/commit/9648c51b5491d8b6bbe5497273efa2d11e2486d2))
* **api:** set up endpoint for sas code execution ([f6046b1](https://github.com/sasjs/server/commit/f6046b15ae30cd8ace685cf283339871de658b7d))
* authentication with jwt ([22dfcfd](https://github.com/sasjs/server/commit/22dfcfddb9abd355a63d1ee5acd925c759e86d69))
* compile systemInit and inject to autoExec ([b75139d](https://github.com/sasjs/server/commit/b75139dda5cacc7e10a4d635eb2a222f7dfa3fec))
* **deploy:** add appLoc ([f0f1e1d](https://github.com/sasjs/server/commit/f0f1e1d57ea1e961fc3b1cfcbd4cb259a77a90d0))
* **deploy:** add route to deploy a file tree to @sasjs/server ([b4bf72f](https://github.com/sasjs/server/commit/b4bf72f70401a81b6d5d0104332a1fbc5f71562b))
* **execute:** add macroVars to job execution ([39e486b](https://github.com/sasjs/server/commit/39e486b8cb5efbadc86eb7029b60c7073744eb2b))
* **execute:** add sas controller ([bf1db4d](https://github.com/sasjs/server/commit/bf1db4dd47d2488bac073cd468db920ff9fd533d))
* **execution:** add ExecutionController working with session ([8b25641](https://github.com/sasjs/server/commit/8b2564120def137f80647064e28062b880d58efe))
* **executor:** improved api response ([707b503](https://github.com/sasjs/server/commit/707b50394267217e717aa72f74dbeba3852a93e6))
* **executor:** response with webout ([52275ba](https://github.com/sasjs/server/commit/52275ba67d97d5cbdf6c5511c9bd789bd6ca6b4e))
* **express:** increase payload max size ([7b403c1](https://github.com/sasjs/server/commit/7b403c151e889cae975944546bb4bb53eff1dd26))
* frontend app for sasjs server ([db8eb8d](https://github.com/sasjs/server/commit/db8eb8dd7197bbe36f2d10cabbb58b3eb7ce7c33))
* generate executables for sasjs/server with web component ([514a262](https://github.com/sasjs/server/commit/514a262340dc34007de75caf08ad03969e7110c1))
* Groups are added + docs ([2fe9d5c](https://github.com/sasjs/server/commit/2fe9d5ca9ce1fb376f03534f8685d65efb2f68a6))
* improved deploy and execute endpoints ([5b4e562](https://github.com/sasjs/server/commit/5b4e5626fc7ae3e020819e3ebd334cc3712ae8e7))
* JWT saved in DB + logout api added ([46c5a75](https://github.com/sasjs/server/commit/46c5a75ac4fb26ebec219118eb204f1b5049ae90))
* **routes:** separate routes into web and api ([dabef59](https://github.com/sasjs/server/commit/dabef597287a59f3bfaff54a18de465f820aa514))
* **session:** add SessionController ([6a34fa1](https://github.com/sasjs/server/commit/6a34fa1b1dae07fe032352bea0644ab7a6f9c3f9))
* **session:** add SessionController and ExecutionController ([6e0b04a](https://github.com/sasjs/server/commit/6e0b04a6e548ac31baee726c9249b7e25f50f0bf))
* user operation apis added ([728f277](https://github.com/sasjs/server/commit/728f277f5ce136d62951071833cd6db478b07e4a))
### Bug Fixes
* **api-cdrive-oller:** throw erow error when file not found ([03d1d60](https://github.com/sasjs/server/commit/03d1d60660fc46421ef6ad9cee8493dd884e309a))
* change api endpoint SASjsExecutor/do -> SASjsApi/stp/execute ([d93673f](https://github.com/sasjs/server/commit/d93673f2a51098c6af8abc4a793081d4591e27de))
* cors enabled for desktop mode ([2bb10c7](https://github.com/sasjs/server/commit/2bb10c71661b5de7ed515c82e5b1967b88449972))
* DB names updates + refresh api is added ([9f17b17](https://github.com/sasjs/server/commit/9f17b17e3138ce49f24447cd5ae457e3e90ad4da))
* debug not passed ([d9555e1](https://github.com/sasjs/server/commit/d9555e151b0e1d1a4068efdf8ee9ed53b25b9b89))
* **deploy:** fix payload processing ([361b539](https://github.com/sasjs/server/commit/361b539271cf95bbe570cca9e44635ab563d3f9e))
* **deps:** removed malicious dependency ([c4b9402](https://github.com/sasjs/server/commit/c4b9402f017b76dc412a17a10313f1fd5a3891ef))
* **docker:** docker-compose for prod+development ([4a363c5](https://github.com/sasjs/server/commit/4a363c5b9796283199debcc8afa810c6f561f8e6))
* **executor:** create tmp files before execution ([cdbc3fd](https://github.com/sasjs/server/commit/cdbc3fd298e2a581773448bdddcad93de3b3544d))
* **executor:** fix nosplash argument and api response ([715b1de](https://github.com/sasjs/server/commit/715b1dec68377eefe03aa8203a73debe77842436))
* fix web route ([6c7a6b6](https://github.com/sasjs/server/commit/6c7a6b6c6af28c29b391162e4e332da6524b1c61))
* **github:** fixed github flow ([8dab288](https://github.com/sasjs/server/commit/8dab28861dfa7c4c7fefc7fe038df50f58d04547))
* **github:** removed npm token ([bbb94d6](https://github.com/sasjs/server/commit/bbb94d61ce39c84a6c0c44186e89787ab0e76a8c))
* immplementation of files api fixed ([299319e](https://github.com/sasjs/server/commit/299319e2dbe06c7ca99e403fcbdec2ad1db8b7e4))
* load file when url contains filePath ([99d5577](https://github.com/sasjs/server/commit/99d55775aaac3b2caaa4b10d4ed698f6cd7fcb2a))
* modify the directory tree algorithm to include relative path with each node ([91e2e2b](https://github.com/sasjs/server/commit/91e2e2bc4a46da0d149578593559efdb87681bd4))
* norefferer issue in home page external links fix ([e2b12b7](https://github.com/sasjs/server/commit/e2b12b74f52c3ce4541fde9af6af0093b56b157b))
* on clicking execute button open new tab for response ([02f5371](https://github.com/sasjs/server/commit/02f5371f57b311ff700ba8108f9d5168da8c22a4))
* prettier ([716ae81](https://github.com/sasjs/server/commit/716ae81d9293b42dd2a7047ac52d75401b3b8798))
* **prod-server:** use port from configuration ([4d8efbb](https://github.com/sasjs/server/commit/4d8efbb88d32154d84e80b79780e2e3de2f519e4))
* readme overview| ([b3342f0](https://github.com/sasjs/server/commit/b3342f00031d19080fb72e3460f023c5f44bac95))
* remove .sas extension from _program parameter at the end of string ([56cb2d1](https://github.com/sasjs/server/commit/56cb2d1d512beadb5cfdc4ab4034ac917311ff23))
* removing renegade dash ([4ff4d39](https://github.com/sasjs/server/commit/4ff4d39e954e895b46ddc3e2919f7f2c4e1ce01d))
* **root-package.json:** lint:fix command fixed in root package json ([ec6333f](https://github.com/sasjs/server/commit/ec6333f6aa67c1b94f54b017ed27eb3b21b4207f))
* **routes:** fix routes imports ([49c152a](https://github.com/sasjs/server/commit/49c152a398b60f6b0a0c25a68eb4c1c291984872))
* **semantic-release:** fixed package.json ([ef45787](https://github.com/sasjs/server/commit/ef45787019f1e61d0e4e2acee334236e8aca23cc))
* sending _webout as result object in response JSON ([b97523e](https://github.com/sasjs/server/commit/b97523e55584cc7d9d682cfeaab8f5b70a10b899))
* session refactoring with Saad & Allan ([cbe07b4](https://github.com/sasjs/server/commit/cbe07b4abb2e936037874af1a088cd038e0fc731))
* **ts:** enable files ([37b6936](https://github.com/sasjs/server/commit/37b6936cca3cff9c1ca26ec7b4b938a357c448df))
* update api calls from client side ([031e492](https://github.com/sasjs/server/commit/031e492d44674dec4f2b3bc1f5bf7affac5716bd))
* update api endpoints ([936a205](https://github.com/sasjs/server/commit/936a205e66073b9178089c6ab10d6ac3bf323c54))
* update sasjs drive controller from function base to class base ([3fe475d](https://github.com/sasjs/server/commit/3fe475d477c466556659b48c70eeac5153ff5b0e))
* update SASjsApi/stp/execute post api endpoints to capture url params ([d981444](https://github.com/sasjs/server/commit/d9814441bb1d269ec2404e50f51124f998c65c40))
* use hash router instead of browser router in react app ([c72867d](https://github.com/sasjs/server/commit/c72867d5a70550660c8c37220aa33693716a93f1))
* **web:** infinite call to api end point fixed ([ac745c8](https://github.com/sasjs/server/commit/ac745c8f5c3e4aa2ac8d6ca23bb1276452d4018b))
* **web:** remove unnecessary packages and files ([0fb4301](https://github.com/sasjs/server/commit/0fb43019668f5a13f6e77fdb4b3e543006b509c0))
* **weeb:** add catch block with each axios request ([552a358](https://github.com/sasjs/server/commit/552a3584ec9345bc1dec0ff5377bf773a7928d62))
* **workflow:** fix 'SASjs Server Build' ([174d94a](https://github.com/sasjs/server/commit/174d94a23c5036d61a4f2e11296283f128d4dafa))

10
DockerfileApi Normal file
View File

@@ -0,0 +1,10 @@
FROM node:lts-alpine
RUN npm install -g @sasjs/cli
WORKDIR /usr/server/api
COPY ["package.json","package-lock.json", "./"]
RUN npm ci
COPY ./api .
COPY ./certificates ../certificates
# RUN chown -R node /usr/server/api
# USER node
CMD ["npm","start"]

11
DockerfileProd Normal file
View File

@@ -0,0 +1,11 @@
FROM node:lts-alpine
RUN npm install -g @sasjs/cli
WORKDIR /usr/server/
COPY . .
RUN cd web && npm ci --silent
RUN cd web && REACT_APP_CLIENT_ID=clientID1 npm run build
RUN cd api && npm ci --silent
# RUN chown -R node /usr/server/api
# USER node
WORKDIR /usr/server/api
CMD ["npm","run","start:prod"]

111
README.md
View File

@@ -1,10 +1,10 @@
# SASjs Server
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or it could even run locally on your desktop. It provides the following functionality:
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or it could even run locally on your desktop. It provides the following functionality:
* Virtual filesystem for storing SAS programs and other content
* Ability to execute Stored Programs from a URL
* Ability to create web apps using simple Desktop SAS
- Virtual filesystem for storing SAS programs and other content
- Ability to execute Stored Programs from a URL
- Ability to create web apps using simple Desktop SAS
One major benefit of using SASjs Server (alongside other components of the SASjs framework such as the [CLI](https://cli.sasjs.io), [Adapter](https://adapter.sasjs.io) and [Core](https://core.sasjs.io) library) is that the projects you create can be very easily ported to SAS 9 (Stored Process server) or Viya (Job Execution server).
@@ -13,4 +13,105 @@ One major benefit of using SASjs Server (alongside other components of the SASjs
Configuration is made in the `configuration` section of `package.json`:
- Provide path to SAS9 executable.
- Provide `SASjsServer` hostname and port (eg `localhost:5000`).
### Using dockers:
There is `.env.example` file present at root of the project. [for Production]
There is `.env.example` file present at `./api` of the project. [for Development]
There is `.env.example` file present at `./web` of the project. [for Development]
Remember to provide enviornment variables.
#### Development
Command to run docker for development:
```
docker-compose up -d
```
It uses default docker compose file i.e. `docker-compose.yml` present at root.
It will build following images if running first time:
- `sasjs_server_api` - image for sasjs api server app based on _ExpressJS_
- `sasjs_server_web` - image for sasjs web component app based on _ReactJS_
- `mongodb` - image for mongo database
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
#### Production
Command to run docker for production:
```
docker-compose -f docker-compose.prod.yml up -d
```
It uses specified docker compose file i.e. `docker-compose.prod.yml` present at root.
It will build following images if running first time:
- `sasjs_server_prod` - image for sasjs server app containing api and web component's build served at route `/`
- `mongodb` - image for mongo database
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
### Using node:
#### Development (running api and web seperately):
##### API
Navigate to `./api`
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
npm install
npm start
```
##### Web
Navigate to `./web`
There is `.env.example` file present at `./web` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
npm install
npm start
```
#### Development (running only api server and have web build served):
##### API server also serving Web build files
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
cd ./web && npm i && npm build && cd ../
cd ./api && npm i && npm start
```
#### Production
##### API & WEB
```
npm run server
```
This will install/build `web` and install `api`, then start prod server.
## Executables
Command to generate executables
```
cd ./web && npm i && npm build && cd ../
cd ./api && npm i && npm run exe
```
This will install/build web app and install/create executables of sasjs server at root `./executables`

6
api/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
build
coverage
node_modules
public
web
Dockerfile

8
api/.env.example Normal file
View File

@@ -0,0 +1,8 @@
MODE=[desktop|server] default considered as desktop
CORS=[disable|enable] default considered as disable
PORT=[5000] default value is 5000
PORT_WEB=[port for sasjs web component(react)] default value is 3000
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority

26278
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

91
api/package.json Normal file
View File

@@ -0,0 +1,91 @@
{
"name": "api",
"version": "0.0.1",
"description": "Api of SASjs server",
"main": "./src/server.ts",
"scripts": {
"initial": "npm run swagger && npm run compileSysInit",
"prestart": "npm run initial",
"prestart:prod": "npm run initial",
"prebuild": "npm run initial",
"start": "nodemon ./src/server.ts",
"start:prod": "nodemon ./src/prod-server.ts",
"build": "rimraf build && tsc",
"swagger": "tsoa spec",
"semantic-release": "semantic-release -d",
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
"exe": "npm run build && npm run exe:copy && pkg .",
"exe:copy": "npm run public:copy && npm run sasjsbuild:copy && npm run web:copy",
"public:copy": "cp -r ./public/ ./build/public/",
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
"compileSysInit": "ts-node ./scripts/compileSysInit.ts"
},
"bin": "./build/src/server.js",
"pkg": {
"assets": [
"./build/public/**/*",
"./build/sasjsbuild/**/*",
"./web/build/**/*"
],
"targets": [
"node16-linux-x64",
"node16-macos-x64",
"node16-win-x64"
],
"outputPath": "../executables"
},
"release": {
"branches": [
"master"
]
},
"author": "Analytium Ltd",
"dependencies": {
"@sasjs/core": "^2.48.6",
"@sasjs/utils": "2.34.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.17.1",
"joi": "^17.4.2",
"jsonwebtoken": "^8.5.1",
"mongoose": "^6.0.12",
"mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0",
"multer": "^1.4.3",
"swagger-ui-express": "^4.1.6",
"tsoa": "^3.14.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.12",
"@types/jest": "^26.0.24",
"@types/jsonwebtoken": "^8.5.5",
"@types/mongoose-sequence": "^3.0.6",
"@types/morgan": "^1.9.3",
"@types/multer": "^1.4.7",
"@types/node": "^15.12.2",
"@types/supertest": "^2.0.11",
"@types/swagger-ui-express": "^4.1.3",
"dotenv": "^10.0.0",
"jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0",
"nodemon": "^2.0.7",
"pkg": "^5.4.1",
"prettier": "^2.3.1",
"rimraf": "^3.0.2",
"semantic-release": "^17.4.3",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",
"ts-node": "^10.0.0",
"typescript": "^4.3.2"
},
"configuration": {
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4"
}
}

File diff suppressed because it is too large Load Diff

1050
api/public/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import path from 'path'
import {
createFile,
loadDependenciesFile,
readFile,
SASJsFileType
} from '@sasjs/utils'
import { apiRoot, sysInitCompiledPath } from '../src/utils'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
const compiledSystemInit = async (systemInit: string) =>
'options ps=max;\n' +
(await loadDependenciesFile({
fileContent: systemInit,
type: SASJsFileType.job,
programFolders: [],
macroFolders: [],
buildSourceFolder: '',
macroCorePath
}))
const createSysInitFile = async () => {
console.log('macroCorePath', macroCorePath)
const systemInitContent = await readFile(
path.join(__dirname, 'systemInit.sas')
)
await createFile(
path.join(sysInitCompiledPath),
await compiledSystemInit(systemInitContent)
)
}
createSysInitFile()

View File

@@ -0,0 +1,13 @@
/**
@file
@brief The systemInit program
@details This program is inserted into every sasjs/server program invocation,
_before_ any user-provided content.
<h4> SAS Macros </h4>
@li mcf_stpsrv_header.sas
**/
%mcf_stpsrv_header(wrap=YES, insert_cmplib=YES)

33
api/src/app.ts Normal file
View File

@@ -0,0 +1,33 @@
import path from 'path'
import express from 'express'
import morgan from 'morgan'
import dotenv from 'dotenv'
import cors from 'cors'
import webRouter from './routes/web'
import apiRouter from './routes/api'
import { connectDB, getWebBuildFolderPath } from './utils'
dotenv.config()
const app = express()
const { MODE, CORS, PORT_WEB } = process.env
if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') {
console.log('All CORS Requests are enabled')
app.use(
cors({ credentials: true, origin: `http://localhost:${PORT_WEB ?? 3000}` })
)
}
app.use(express.json({ limit: '50mb' }))
app.use(morgan('tiny'))
app.use(express.static(path.join(__dirname, '../public')))
app.use('/', webRouter)
app.use('/SASjsApi', apiRouter)
app.use(express.json({ limit: '50mb' }))
app.use(express.static(getWebBuildFolderPath()))
export default connectDB().then(() => app)

212
api/src/controllers/auth.ts Normal file
View File

@@ -0,0 +1,212 @@
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
import jwt from 'jsonwebtoken'
import User from '../model/User'
import { InfoJWT } from '../types'
import {
generateAccessToken,
generateAuthCode,
generateRefreshToken,
removeTokensInDB,
saveTokensInDB
} from '../utils'
@Route('SASjsApi/auth')
@Tags('Auth')
export class AuthController {
static authCodes: { [key: string]: { [key: string]: string } } = {}
static saveCode = (userId: number, clientId: string, code: string) => {
if (AuthController.authCodes[userId])
return (AuthController.authCodes[userId][clientId] = code)
AuthController.authCodes[userId] = { [clientId]: code }
return AuthController.authCodes[userId][clientId]
}
static deleteCode = (userId: number, clientId: string) =>
delete AuthController.authCodes[userId][clientId]
/**
* @summary Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE
*
*/
@Example<AuthorizeResponse>({
code: 'someRandomCryptoString'
})
@Post('/authorize')
public async authorize(
@Body() body: AuthorizePayload
): Promise<AuthorizeResponse> {
return authorize(body)
}
/**
* @summary Accepts client/auth code and returns access/refresh tokens
*
*/
@Example<TokenResponse>({
accessToken: 'someRandomCryptoString',
refreshToken: 'someRandomCryptoString'
})
@Post('/token')
public async token(@Body() body: TokenPayload): Promise<TokenResponse> {
return token(body)
}
/**
* @summary Returns new access/refresh tokens
*
*/
@Example<TokenResponse>({
accessToken: 'someRandomCryptoString',
refreshToken: 'someRandomCryptoString'
})
@Security('bearerAuth')
@Post('/refresh')
public async refresh(
@Query() @Hidden() data?: InfoJWT
): Promise<TokenResponse> {
return refresh(data!)
}
/**
* @summary Logout terminate access/refresh tokens and returns nothing
*
*/
@Security('bearerAuth')
@Post('/logout')
public async logout(@Query() @Hidden() data?: InfoJWT) {
return logout(data!)
}
}
const authorize = async (data: any): Promise<AuthorizeResponse> => {
const { username, password, clientId } = data
// Authenticate User
const user = await User.findOne({ username })
if (!user) throw new Error('Username is not found.')
const validPass = user.comparePassword(password)
if (!validPass) throw new Error('Invalid password.')
// generate authorization code against clientId
const userInfo: InfoJWT = {
clientId,
userId: user.id
}
const code = AuthController.saveCode(
user.id,
clientId,
generateAuthCode(userInfo)
)
return { code }
}
const token = async (data: any): Promise<TokenResponse> => {
const { clientId, code } = data
const userInfo = await verifyAuthCode(clientId, code)
if (!userInfo) throw new Error('Invalid Auth Code')
if (AuthController.authCodes[userInfo.userId][clientId] !== code)
throw new Error('Invalid Auth Code')
AuthController.deleteCode(userInfo.userId, clientId)
const accessToken = generateAccessToken(userInfo)
const refreshToken = generateRefreshToken(userInfo)
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
return { accessToken, refreshToken }
}
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
const accessToken = generateAccessToken(userInfo)
const refreshToken = generateRefreshToken(userInfo)
await saveTokensInDB(
userInfo.userId,
userInfo.clientId,
accessToken,
refreshToken
)
return { accessToken, refreshToken }
}
const logout = async (userInfo: InfoJWT) => {
await removeTokensInDB(userInfo.userId, userInfo.clientId)
}
interface AuthorizePayload {
/**
* Username for user
* @example "secretuser"
*/
username: string
/**
* Password for user
* @example "secretpassword"
*/
password: string
/**
* Client ID
* @example "clientID1"
*/
clientId: string
}
interface AuthorizeResponse {
/**
* Authorization code
* @example "someRandomCryptoString"
*/
code: string
}
interface TokenPayload {
/**
* Client ID
* @example "clientID1"
*/
clientId: string
/**
* Authorization code
* @example "someRandomCryptoString"
*/
code: string
}
interface TokenResponse {
/**
* Access Token
* @example "someRandomCryptoString"
*/
accessToken: string
/**
* Refresh Token
* @example "someRandomCryptoString"
*/
refreshToken: string
}
const verifyAuthCode = async (
clientId: string,
code: string
): Promise<InfoJWT | undefined> => {
return new Promise((resolve, reject) => {
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
if (err) return resolve(undefined)
const clientInfo: InfoJWT = {
clientId: data?.clientId,
userId: data?.userId
}
if (clientInfo.clientId === clientId) {
return resolve(clientInfo)
}
return resolve(undefined)
})
})
}

View File

@@ -0,0 +1,44 @@
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
import Client, { ClientPayload } 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.
*
*/
@Example<ClientPayload>({
clientId: 'someFormattedClientID1234',
clientSecret: 'someRandomCryptoString'
})
@Post('/')
public async createClient(
@Body() body: ClientPayload
): Promise<ClientPayload> {
return createClient(body)
}
}
const createClient = async (data: any): Promise<ClientPayload> => {
const { clientId, clientSecret } = data
// Checking if client is already in the database
const clientExist = await Client.findOne({ clientId })
if (clientExist) throw new Error('Client ID already exists.')
// Create a new client
const client = new Client({
clientId,
clientSecret
})
const savedClient = await client.save()
return {
clientId: savedClient.clientId,
clientSecret: savedClient.clientSecret
}
}

View File

@@ -0,0 +1,243 @@
import {
Security,
Route,
Tags,
Example,
Post,
Body,
Response,
Query,
Get,
Patch
} from 'tsoa'
import { fileExists, readFile, createFile } from '@sasjs/utils'
import { createFileTree, ExecutionController, getTreeExample } from './internal'
import { FileTree, isFileTree, TreeNode } from '../types'
import path from 'path'
import { getTmpFilesFolderPath } from '../utils'
interface DeployPayload {
appLoc?: string
fileTree: FileTree
}
interface FilePayload {
/**
* Path of the file
* @example "/Public/somefolder/some.file"
*/
filePath: string
/**
* Contents of the file
* @example "Contents of the File"
*/
fileContent: string
}
interface DeployResponse {
status: string
message: string
example?: FileTree
}
interface GetFileResponse {
status: string
fileContent?: string
message?: string
}
interface GetFileTreeResponse {
status: string
tree: TreeNode
}
interface UpdateFileResponse {
status: string
message?: string
}
const fileTreeExample = getTreeExample()
const successDeployResponse: DeployResponse = {
status: 'success',
message: 'Files deployed successfully to @sasjs/server.'
}
const invalidDeployFormatResponse: DeployResponse = {
status: 'failure',
message: 'Provided not supported data format.',
example: fileTreeExample
}
const execDeployErrorResponse: DeployResponse = {
status: 'failure',
message: 'Deployment failed!'
}
@Security('bearerAuth')
@Route('SASjsApi/drive')
@Tags('Drive')
export class DriveController {
/**
* @summary Creates/updates files within SASjs Drive using provided payload.
*
*/
@Example<DeployResponse>(successDeployResponse)
@Response<DeployResponse>(400, 'Invalid Format', invalidDeployFormatResponse)
@Response<DeployResponse>(500, 'Execution Error', execDeployErrorResponse)
@Post('/deploy')
public async deploy(@Body() body: DeployPayload): Promise<DeployResponse> {
return deploy(body)
}
/**
* @summary Get file from SASjs Drive
* @query filePath Location of SAS program
* @example filePath "/Public/somefolder/some.file"
*/
@Example<GetFileResponse>({
status: 'success',
fileContent: 'Contents of the File'
})
@Response<GetFileResponse>(400, 'Unable to get File', {
status: 'failure',
message: 'File request failed.'
})
@Get('/file')
public async getFile(@Query() filePath: string): Promise<GetFileResponse> {
return getFile(filePath)
}
/**
* @summary Create a file in SASjs Drive
*
*/
@Example<UpdateFileResponse>({
status: 'success'
})
@Response<UpdateFileResponse>(400, 'File already exists', {
status: 'failure',
message: 'File request failed.'
})
@Post('/file')
public async saveFile(
@Body() body: FilePayload
): Promise<UpdateFileResponse> {
return saveFile(body)
}
/**
* @summary Modify a file in SASjs Drive
*
*/
@Example<UpdateFileResponse>({
status: 'success'
})
@Response<UpdateFileResponse>(400, 'Unable to get File', {
status: 'failure',
message: 'File request failed.'
})
@Patch('/file')
public async updateFile(
@Body() body: FilePayload
): Promise<UpdateFileResponse> {
return updateFile(body)
}
/**
* @summary Fetch file tree within SASjs Drive.
*
*/
@Get('/filetree')
public async getFileTree(): Promise<GetFileTreeResponse> {
return getFileTree()
}
}
const getFileTree = () => {
const tree = new ExecutionController().buildDirectorytree()
return { status: 'success', tree }
}
const deploy = async (data: DeployPayload) => {
if (!isFileTree(data.fileTree)) {
throw { code: 400, ...invalidDeployFormatResponse }
}
await createFileTree(
data.fileTree.members,
data.appLoc ? data.appLoc.replace(/^\//, '').split('/') : []
).catch((err) => {
throw { code: 500, ...execDeployErrorResponse, ...err }
})
return successDeployResponse
}
const getFile = async (filePath: string): Promise<GetFileResponse> => {
try {
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
await validateFilePath(filePathFull)
const fileContent = await readFile(filePathFull)
return { status: 'success', fileContent: fileContent }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}
const saveFile = async (body: FilePayload): Promise<GetFileResponse> => {
const { filePath, fileContent } = body
try {
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (await fileExists(filePathFull)) {
throw 'DriveController: File already exists.'
}
await createFile(filePathFull, fileContent)
return { status: 'success' }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}
const updateFile = async (body: FilePayload): Promise<GetFileResponse> => {
const { filePath, fileContent } = body
try {
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
await validateFilePath(filePathFull)
await createFile(filePathFull, fileContent)
return { status: 'success' }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}
const validateFilePath = async (filePath: string) => {
if (!(await fileExists(filePath))) {
throw 'DriveController: File does not exists.'
}
}

View File

@@ -0,0 +1,220 @@
import {
Security,
Route,
Tags,
Path,
Example,
Get,
Post,
Delete,
Body
} from 'tsoa'
import Group, { GroupPayload } from '../model/Group'
import User from '../model/User'
import { UserResponse } from './user'
interface GroupResponse {
groupId: number
name: string
description: string
}
interface GroupDetailsResponse {
groupId: number
name: string
description: string
isActive: boolean
users: UserResponse[]
}
@Security('bearerAuth')
@Route('SASjsApi/group')
@Tags('Group')
export class GroupController {
/**
* @summary Get list of all groups (groupName and groupDescription). All users can request this.
*
*/
@Example<GroupResponse[]>([
{
groupId: 123,
name: 'DCGroup',
description: 'This group represents Data Controller Users'
}
])
@Get('/')
public async getAllGroups(): Promise<GroupResponse[]> {
return getAllGroups()
}
/**
* @summary Create a new group. Admin only.
*
*/
@Example<GroupDetailsResponse>({
groupId: 123,
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
users: []
})
@Post('/')
public async createGroup(
@Body() body: GroupPayload
): Promise<GroupDetailsResponse> {
return createGroup(body)
}
/**
* @summary Get list of members of a group (userName). All users can request this.
* @param groupId The group's identifier
* @example groupId 1234
*/
@Get('{groupId}')
public async getGroup(
@Path() groupId: number
): Promise<GroupDetailsResponse> {
return getGroup(groupId)
}
/**
* @summary Add a user to a group. Admin task only.
* @param groupId The group's identifier
* @example groupId "1234"
* @param userId The user's identifier
* @example userId "6789"
*/
@Example<GroupDetailsResponse>({
groupId: 123,
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
users: []
})
@Post('{groupId}/{userId}')
public async addUserToGroup(
@Path() groupId: number,
@Path() userId: number
): Promise<GroupDetailsResponse> {
return addUserToGroup(groupId, userId)
}
/**
* @summary Remove a user to a group. Admin task only.
* @param groupId The group's identifier
* @example groupId "1234"
* @param userId The user's identifier
* @example userId "6789"
*/
@Example<GroupDetailsResponse>({
groupId: 123,
name: 'DCGroup',
description: 'This group represents Data Controller Users',
isActive: true,
users: []
})
@Delete('{groupId}/{userId}')
public async removeUserFromGroup(
@Path() groupId: number,
@Path() userId: number
): Promise<GroupDetailsResponse> {
return removeUserFromGroup(groupId, userId)
}
/**
* @summary Delete a group. Admin task only.
* @param groupId The group's identifier
* @example groupId 1234
*/
@Delete('{groupId}')
public async deleteGroup(@Path() groupId: number) {
const { deletedCount } = await Group.deleteOne({ groupId })
if (deletedCount) return
throw new Error('No Group deleted!')
}
}
const getAllGroups = async (): Promise<GroupResponse[]> =>
await Group.find({})
.select({ _id: 0, groupId: 1, name: 1, description: 1 })
.exec()
const createGroup = async ({
name,
description,
isActive
}: GroupPayload): Promise<GroupDetailsResponse> => {
const group = new Group({
name,
description,
isActive
})
const savedGroup = await group.save()
return {
groupId: savedGroup.groupId,
name: savedGroup.name,
description: savedGroup.description,
isActive: savedGroup.isActive,
users: []
}
}
const getGroup = async (groupId: number): Promise<GroupDetailsResponse> => {
const group = (await Group.findOne(
{ groupId },
'groupId name description isActive users -_id'
).populate(
'users',
'id username displayName -_id'
)) as unknown as GroupDetailsResponse
if (!group) throw new Error('Group not found.')
return {
groupId: group.groupId,
name: group.name,
description: group.description,
isActive: group.isActive,
users: group.users
}
}
const addUserToGroup = async (
groupId: number,
userId: number
): Promise<GroupDetailsResponse> =>
updateUsersListInGroup(groupId, userId, 'addUser')
const removeUserFromGroup = async (
groupId: number,
userId: number
): Promise<GroupDetailsResponse> =>
updateUsersListInGroup(groupId, userId, 'removeUser')
const updateUsersListInGroup = async (
groupId: number,
userId: number,
action: 'addUser' | 'removeUser'
): Promise<GroupDetailsResponse> => {
const group = await Group.findOne({ groupId })
if (!group) throw new Error('Group not found.')
const user = await User.findOne({ id: userId })
if (!user) throw new Error('User not found.')
const updatedGroup = (action === 'addUser'
? await group.addUser(user._id)
: await group.removeUser(user._id)) as unknown as GroupDetailsResponse
if (!updatedGroup) throw new Error('Unable to update group')
return {
groupId: updatedGroup.groupId,
name: updatedGroup.name,
description: updatedGroup.description,
isActive: updatedGroup.isActive,
users: updatedGroup.users
}
}

View File

@@ -0,0 +1,6 @@
export * from './auth'
export * from './client'
export * from './drive'
export * from './group'
export * from './stp'
export * from './user'

View File

@@ -0,0 +1,163 @@
import path from 'path'
import fs from 'fs'
import { getSessionController } from './'
import { readFile, fileExists, createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, TreeNode } from '../../types'
import { generateFileUploadSasCode, getTmpFilesFolderPath } from '../../utils'
export class ExecutionController {
async execute(
programPath: string,
preProgramVariables: PreProgramVars,
vars: { [key: string]: string | number | undefined },
otherArgs?: any,
returnJson?: boolean
) {
if (!(await fileExists(programPath)))
throw 'ExecutionController: SAS file does not exist.'
let program = await readFile(programPath)
const sessionController = getSessionController()
const session = await sessionController.getSession()
session.inUse = true
const logPath = path.join(session.path, 'log.log')
const weboutPath = path.join(session.path, 'webout.txt')
await createFile(weboutPath, '')
const tokenFile = path.join(session.path, 'accessToken.txt')
await createFile(
tokenFile,
preProgramVariables?.accessToken ?? 'accessToken'
)
const varStatments = Object.keys(vars).reduce(
(computed: string, key: string) =>
`${computed}%let ${key}=${vars[key]};\n`,
''
)
const preProgramVarStatments = `
%let _sasjs_tokenfile=${tokenFile};
%let _sasjs_username=${preProgramVariables?.username};
%let _sasjs_userid=${preProgramVariables?.userId};
%let _sasjs_displayname=${preProgramVariables?.displayName};
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
%let _sasjs_apipath=/SASjsApi/stp/execute;
%let _metaperson=&_sasjs_displayname;
%let _metauser=&_sasjs_username;
%let sasjsprocessmode=Stored Program;`
program = `
/* runtime vars */
${varStatments}
filename _webout "${weboutPath}" mod;
/* dynamic user-provided vars */
${preProgramVarStatments}
/* actual job code */
${program}`
// if no files are uploaded filesNamesMap will be undefined
if (otherArgs && otherArgs.filesNamesMap) {
const uploadSasCode = await generateFileUploadSasCode(
otherArgs.filesNamesMap,
session.path
)
//If sas code for the file is generated it will be appended to the top of sasCode
if (uploadSasCode.length > 0) {
program = `${uploadSasCode}` + program
}
}
const codePath = path.join(session.path, 'code.sas')
// Creating this file in a RUNNING session will break out
// the autoexec loop and actually execute the program
// but - given it will take several milliseconds to create
// (which can mean SAS trying to run a partial program, or
// failing due to file lock) we first create the file THEN
// we rename it.
await createFile(codePath + '.bkp', program)
await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session array
while (!session.completed) {
await delay(50)
}
const log =
((await fileExists(logPath)) ? await readFile(logPath) : '') +
session.crashed
const webout = (await fileExists(weboutPath))
? await readFile(weboutPath)
: ''
const debugValue =
typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug
let debugResponse: string | undefined
if ((debugValue && debugValue >= 131) || session.crashed) {
debugResponse = `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
}
session.inUse = false
sessionController.deleteSession(session)
if (returnJson) {
const response: any = {
webout: webout
}
if ((debugValue && debugValue >= 131) || session.crashed) {
response.log = log
}
return response
}
return debugResponse ?? webout
}
buildDirectorytree() {
const root: TreeNode = {
name: 'files',
relativePath: '',
absolutePath: getTmpFilesFolderPath(),
children: []
}
const stack = [root]
while (stack.length) {
const currentNode = stack.pop()
if (currentNode) {
const children = fs.readdirSync(currentNode.absolutePath)
for (let child of children) {
const absoluteChildPath = `${currentNode.absolutePath}/${child}`
const relativeChildPath = `${currentNode.relativePath}/${child}`
const childNode: TreeNode = {
name: child,
relativePath: relativeChildPath,
absolutePath: absoluteChildPath,
children: []
}
currentNode.children.push(childNode)
if (fs.statSync(childNode.absolutePath).isDirectory()) {
stack.push(childNode)
}
}
}
}
return root
}
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -0,0 +1,36 @@
import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.'
const multer = require('multer')
export class FileUploadController {
private storage = multer.diskStorage({
destination: function (req: any, file: any, cb: any) {
//Sending the intercepted files to the sessions subfolder
cb(null, req.sasSession.path)
},
filename: function (req: any, file: any, cb: any) {
//req_file prefix + unique hash added to sas request files
cb(null, `req_file_${uuidv4().replace(/-/gm, '')}`)
}
})
private upload = multer({ storage: this.storage })
//It will intercept request and generate unique uuid to be used as a subfolder name
//that will store the files uploaded
public preuploadMiddleware = async (req: any, res: any, next: any) => {
let session
const sessionController = getSessionController()
session = await sessionController.getSession()
session.inUse = true
req.sasSession = session
next()
}
public getMulterUploadObject() {
return this.upload
}
}

View File

@@ -0,0 +1,166 @@
import path from 'path'
import { Session } from '../../types'
import { promisify } from 'util'
import { execFile } from 'child_process'
import {
getTmpSessionsFolderPath,
generateUniqueFileName,
sysInitCompiledPath
} from '../../utils'
import {
deleteFolder,
createFile,
fileExists,
generateTimestamp,
readFile
} from '@sasjs/utils'
const execFilePromise = promisify(execFile)
export class SessionController {
private sessions: Session[] = []
public async getSession() {
const readySessions = this.sessions.filter((sess: Session) => sess.ready)
const session = readySessions.length
? readySessions[0]
: await this.createSession()
if (readySessions.length < 2) this.createSession()
return session
}
private async createSession() {
const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(getTmpSessionsFolderPath(), sessionId)
const creationTimeStamp = sessionId.split('-').pop() as string
const deathTimeStamp = (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
1000
).toString()
const session: Session = {
id: sessionId,
ready: false,
inUse: false,
completed: false,
creationTimeStamp,
deathTimeStamp,
path: sessionFolder
}
// we do not want to leave sessions running forever
// we clean them up after a predefined period, if unused
this.scheduleSessionDestroy(session)
// Place compiled system init code to autoexec
const compiledSystemInitContent = await readFile(sysInitCompiledPath)
// the autoexec file is executed on SAS startup
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
const contentForAutoExec = `/* compiled systemInit */\n${compiledSystemInitContent}\n/* autoexec */\n${autoExecContent}`
await createFile(autoExecPath, contentForAutoExec)
// create empty code.sas as SAS will not start without a SYSIN
const codePath = path.join(session.path, 'code.sas')
await createFile(codePath, '')
// trigger SAS but don't wait for completion - we need to
// update the session array to say that it is currently running
// however we also need a promise so that we can update the
// session array to say that it has (eventually) finished.
execFilePromise(process.sasLoc, [
'-SYSIN',
codePath,
'-LOG',
path.join(session.path, 'log.log'),
'-WORK',
session.path,
'-AUTOEXEC',
autoExecPath,
process.platform === 'win32' ? '-nosplash' : ''
])
.then(() => {
session.completed = true
console.log('session completed', session)
})
.catch((err) => {
session.completed = true
session.crashed = err.toString()
console.log('session crashed', session.id)
})
// we have a triggered session - add to array
this.sessions.push(session)
// SAS has been triggered but we can't use it until
// the autoexec deletes the code.sas file
await this.waitForSession(session)
return session
}
public async waitForSession(session: Session) {
const codeFilePath = path.join(session.path, 'code.sas')
// TODO: don't wait forever
while ((await fileExists(codeFilePath)) && !session.crashed) {}
console.log('session crashed?', !!session.crashed, session.crashed)
session.ready = true
return Promise.resolve(session)
}
public async deleteSession(session: Session) {
// remove the temporary files, to avoid buildup
await deleteFolder(session.path)
// remove the session from the session array
if (session.ready) {
this.sessions = this.sessions.filter(
(sess: Session) => sess.id !== session.id
)
}
}
private scheduleSessionDestroy(session: Session) {
setTimeout(async () => {
if (session.inUse) {
session.deathTimeStamp = session.deathTimeStamp + 1000 * 10
this.scheduleSessionDestroy(session)
} else {
await this.deleteSession(session)
}
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100)
}
}
export const getSessionController = (): SessionController => {
if (process.sessionController) return process.sessionController
process.sessionController = new SessionController()
return process.sessionController
}
const autoExecContent = `
data _null_;
/* remove the dummy SYSIN */
length fname $8;
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) );
slept=slept+sleep(0.01,1);
end;
stop;
run;
`

View File

@@ -1,10 +1,11 @@
import { MemberType, FolderMember, ServiceMember } from '../types'
import { getTmpFilesFolderPath } from '../utils/file'
import { MemberType, FolderMember, ServiceMember, FileTree } from '../../types'
import { getTmpFilesFolderPath } from '../../utils/file'
import { createFolder, createFile, asyncForEach } from '@sasjs/utils'
import path from 'path'
// REFACTOR: export FileTreeCpntroller
export const createFileTree = async (
members: [FolderMember, ServiceMember],
members: (FolderMember | ServiceMember)[],
parentFolders: string[] = []
) => {
const destinationPath = path.join(
@@ -13,7 +14,9 @@ export const createFileTree = async (
)
await asyncForEach(members, async (member: FolderMember | ServiceMember) => {
const name = member.name
let name = member.name
if (member.type === MemberType.service) name += '.sas'
if (member.type === MemberType.folder) {
await createFolder(path.join(destinationPath, name)).catch((err) =>
@@ -33,19 +36,19 @@ export const createFileTree = async (
return Promise.resolve()
}
export const getTreeExample = () => ({
export const getTreeExample = (): FileTree => ({
members: [
{
name: 'jobs',
type: 'folder',
type: MemberType.folder,
members: [
{
name: 'extract',
type: 'folder',
type: MemberType.folder,
members: [
{
name: 'makedata1',
type: 'service',
type: MemberType.service,
code: '%put Hello World!;'
}
]

View File

@@ -0,0 +1,4 @@
export * from './deploy'
export * from './Session'
export * from './Execution'
export * from './FileUploadController'

147
api/src/controllers/stp.ts Normal file
View File

@@ -0,0 +1,147 @@
import express, { response } from 'express'
import path from 'path'
import {
Request,
Security,
Route,
Tags,
Example,
Post,
Body,
Get,
Query
} from 'tsoa'
import { ExecutionController } from './internal'
import { PreProgramVars } from '../types'
import { getTmpFilesFolderPath, makeFilesNamesMap } from '../utils'
interface ExecuteReturnJsonPayload {
/**
* Location of SAS program
* @example "/Public/somefolder/some.file"
*/
_program?: string
}
interface ExecuteReturnJsonResponse {
status: string
log?: string
_webout?: string
message?: string
}
@Security('bearerAuth')
@Route('SASjsApi/stp')
@Tags('STP')
export class STPController {
/**
* Trigger a SAS program using it's location in the _program parameter.
* Enable debugging using the _debug parameter.
* Additional URL parameters are turned into SAS macro variables.
* Any files provided are placed into the session and
* corresponding _WEBIN_XXX variables are created.
* @summary Execute Stored Program, return raw content
* @query _program Location of SAS program
* @example _program "/Public/somefolder/some.file"
*/
@Get('/execute')
public async executeReturnRaw(
@Request() request: express.Request,
@Query() _program: string
): Promise<string> {
return executeReturnRaw(request, _program)
}
/**
* Trigger a SAS program using it's location in the _program parameter.
* Enable debugging using the _debug parameter.
* Additional URL parameters are turned into SAS macro variables.
* Any files provided are placed into the session and
* corresponding _WEBIN_XXX variables are created.
* @summary Execute Stored Program, return JSON
* @query _program Location of SAS program
* @example _program "/Public/somefolder/some.file"
*/
@Post('/execute')
public async executeReturnJson(
@Request() request: express.Request,
@Body() body?: ExecuteReturnJsonPayload,
@Query() _program?: string
): Promise<ExecuteReturnJsonResponse> {
const program = _program ?? body?._program
return executeReturnJson(request, program!)
}
}
const executeReturnRaw = async (
req: express.Request,
_program: string
): Promise<string> => {
const query = req.query as { [key: string]: string | number | undefined }
const sasCodePath =
path
.join(getTmpFilesFolderPath(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
try {
const result = await new ExecutionController().execute(
sasCodePath,
getPreProgramVariables(req),
query
)
return result as string
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}
const executeReturnJson = async (
req: any,
_program: string
): Promise<ExecuteReturnJsonResponse> => {
const sasCodePath =
path
.join(getTmpFilesFolderPath(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
const filesNamesMap = req.files?.length ? makeFilesNamesMap(req.files) : null
try {
const jsonResult: any = await new ExecutionController().execute(
sasCodePath,
getPreProgramVariables(req),
{ ...req.query, ...req.body },
{ filesNamesMap: filesNamesMap },
true
)
return {
status: 'success',
_webout: jsonResult.webout,
log: jsonResult.log
}
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'Job execution failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}
const getPreProgramVariables = (req: any): PreProgramVars => {
const host = req.get('host')
const protocol = req.protocol + '://'
const { user, accessToken } = req
return {
username: user.username,
userId: user.userId,
displayName: user.displayName,
serverUrl: protocol + host,
accessToken
}
}

220
api/src/controllers/user.ts Normal file
View File

@@ -0,0 +1,220 @@
import {
Security,
Route,
Tags,
Path,
Query,
Example,
Get,
Post,
Patch,
Delete,
Body,
Hidden
} from 'tsoa'
import User, { UserPayload } from '../model/User'
export interface UserResponse {
id: number
username: string
displayName: string
}
interface UserDetailsResponse {
id: number
displayName: string
username: string
isActive: boolean
isAdmin: boolean
}
@Security('bearerAuth')
@Route('SASjsApi/user')
@Tags('User')
export class UserController {
/**
* @summary Get list of all users (username, displayname). All users can request this.
*
*/
@Example<UserResponse[]>([
{
id: 123,
username: 'johnusername',
displayName: 'John'
},
{
id: 456,
username: 'starkusername',
displayName: 'Stark'
}
])
@Get('/')
public async getAllUsers(): Promise<UserResponse[]> {
return getAllUsers()
}
/**
* @summary Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.
*
*/
@Example<UserDetailsResponse>({
id: 1234,
displayName: 'John Snow',
username: 'johnSnow01',
isAdmin: false,
isActive: true
})
@Post('/')
public async createUser(
@Body() body: UserPayload
): Promise<UserDetailsResponse> {
return createUser(body)
}
/**
* @summary Get user properties - such as group memberships, userName, displayName.
* @param userId The user's identifier
* @example userId 1234
*/
@Get('{userId}')
public async getUser(@Path() userId: number): Promise<UserDetailsResponse> {
return getUser(userId)
}
/**
* @summary Update user properties - such as displayName. Can be performed either by admins, or the user in question.
* @param userId The user's identifier
* @example userId "1234"
*/
@Example<UserDetailsResponse>({
id: 1234,
displayName: 'John Snow',
username: 'johnSnow01',
isAdmin: false,
isActive: true
})
@Patch('{userId}')
public async updateUser(
@Path() userId: number,
@Body() body: UserPayload
): Promise<UserDetailsResponse> {
return updateUser(userId, body)
}
/**
* @summary Delete a user. Can be performed either by admins, or the user in question.
* @param userId The user's identifier
* @example userId 1234
*/
@Delete('{userId}')
public async deleteUser(
@Path() userId: number,
@Body() body: { password?: string },
@Query() @Hidden() isAdmin: boolean = false
) {
return deleteUser(userId, isAdmin, body)
}
}
const getAllUsers = async (): Promise<UserResponse[]> =>
await User.find({})
.select({ _id: 0, id: 1, username: 1, displayName: 1 })
.exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive } = data
// Checking if user is already in the database
const usernameExist = await User.findOne({ username })
if (usernameExist) throw new Error('Username already exists.')
// Hash passwords
const hashPassword = User.hashPassword(password)
// Create a new user
const user = new User({
displayName,
username,
password: hashPassword,
isAdmin,
isActive
})
const savedUser = await user.save()
return {
id: savedUser.id,
displayName: savedUser.displayName,
username: savedUser.username,
isActive: savedUser.isActive,
isAdmin: savedUser.isAdmin
}
}
const getUser = async (id: number): Promise<UserDetailsResponse> => {
const user = await User.findOne({ id })
.select({
_id: 0,
id: 1,
username: 1,
displayName: 1,
isAdmin: 1,
isActive: 1
})
.exec()
if (!user) throw new Error('User is not found.')
return user
}
const updateUser = async (
id: number,
data: UserPayload
): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive } = data
const params: any = { displayName, isAdmin, isActive }
if (username) {
// Checking if user is already in the database
const usernameExist = await User.findOne({ username })
if (usernameExist?.id != id) throw new Error('Username already exists.')
params.username = username
}
if (password) {
// Hash passwords
params.password = User.hashPassword(password)
}
const updatedUser = await User.findOneAndUpdate({ id }, params, { new: true })
.select({
_id: 0,
id: 1,
username: 1,
displayName: 1,
isAdmin: 1,
isActive: 1
})
.exec()
if (!updatedUser) throw new Error('Unable to update user')
return updatedUser
}
const deleteUser = async (
id: number,
isAdmin: boolean,
{ password }: { password?: string }
) => {
const user = await User.findOne({ id })
if (!user) throw new Error('User is not found.')
if (!isAdmin) {
const validPass = user.comparePassword(password!)
if (!validPass) throw new Error('Invalid password.')
}
await User.deleteOne({ id })
}

View File

@@ -0,0 +1,69 @@
import jwt from 'jsonwebtoken'
import { verifyTokenInDB } from '../utils'
export const authenticateAccessToken = (req: any, res: any, next: any) => {
authenticateToken(
req,
res,
next,
process.env.ACCESS_TOKEN_SECRET as string,
'accessToken'
)
}
export const authenticateRefreshToken = (req: any, res: any, next: any) => {
authenticateToken(
req,
res,
next,
process.env.REFRESH_TOKEN_SECRET as string,
'refreshToken'
)
}
const authenticateToken = (
req: any,
res: any,
next: any,
key: string,
tokenType: 'accessToken' | 'refreshToken' = 'accessToken'
) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
req.user = {
userId: '1234',
clientId: 'desktopModeClientId',
username: 'desktopModeUsername',
displayName: 'desktopModeDisplayName',
isAdmin: true,
isActive: true
}
req.accessToken = 'desktopModeAccessToken'
return next()
}
const authHeader = req.headers['authorization']
const token = authHeader?.split(' ')[1]
if (!token) return res.sendStatus(401)
jwt.verify(token, key, async (err: any, data: any) => {
if (err) return res.sendStatus(401)
// verify this valid token's entry in DB
const user = await verifyTokenInDB(
data?.userId,
data?.clientId,
token,
tokenType
)
if (user) {
if (user.isActive) {
req.user = user
if (tokenType === 'accessToken') req.accessToken = token
return next()
} else return res.sendStatus(401)
}
return res.sendStatus(401)
})
}

View File

@@ -0,0 +1,7 @@
export const desktopRestrict = (req: any, res: any, next: any) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server')
return res.status(403).send('Not Allowed while in Desktop Mode.')
next()
}

View File

@@ -0,0 +1,4 @@
export * from './authenticateToken'
export * from './desktopRestrict'
export * from './verifyAdmin'
export * from './verifyAdminIfNeeded'

View File

@@ -0,0 +1,8 @@
export const verifyAdmin = (req: any, res: any, next: any) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') return next()
const { user } = req
if (!user?.isAdmin) return res.status(401).send('Admin account required')
next()
}

View File

@@ -0,0 +1,9 @@
export const verifyAdminIfNeeded = (req: any, res: any, next: any) => {
const { user } = req
const userId = parseInt(req.params.userId)
if (!user.isAdmin && user.userId !== userId) {
return res.status(401).send('Admin account required')
}
next()
}

27
api/src/model/Client.ts Normal file
View File

@@ -0,0 +1,27 @@
import mongoose, { Schema } from 'mongoose'
export interface ClientPayload {
/**
* Client ID
* @example "someFormattedClientID1234"
*/
clientId: string
/**
* Client Secret
* @example "someRandomCryptoString"
*/
clientSecret: string
}
const ClientSchema = new Schema<ClientPayload>({
clientId: {
type: String,
required: true
},
clientSecret: {
type: String,
required: true
}
})
export default mongoose.model('Client', ClientSchema)

87
api/src/model/Group.ts Normal file
View File

@@ -0,0 +1,87 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
const AutoIncrement = require('mongoose-sequence')(mongoose)
export interface GroupPayload {
/**
* Name of the group
* @example "DCGroup"
*/
name: string
/**
* Description of the group
* @example "This group represents Data Controller Users"
*/
description: string
/**
* Group should be active or not, defaults to true
* @example "true"
*/
isActive?: boolean
}
interface IGroupDocument extends GroupPayload, Document {
groupId: number
isActive: boolean
users: Schema.Types.ObjectId[]
}
interface IGroup extends IGroupDocument {
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
}
interface IGroupModel extends Model<IGroup> {}
const groupSchema = new Schema<IGroupDocument>({
name: {
type: String,
required: true
},
description: {
type: String,
default: 'Group description.'
},
isActive: {
type: Boolean,
default: true
},
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
})
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
// Hooks
groupSchema.post('save', function (group: IGroup, next: Function) {
group.populate('users', 'id username displayName -_id').then(function () {
next()
})
})
// Instance Methods
groupSchema.method(
'addUser',
async function (userObjectId: Schema.Types.ObjectId) {
const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex === -1) {
this.users.push(userObjectId)
}
this.markModified('users')
return this.save()
}
)
groupSchema.method(
'removeUser',
async function (userObjectId: Schema.Types.ObjectId) {
const userIdIndex = this.users.indexOf(userObjectId)
if (userIdIndex > -1) {
this.users.splice(userIdIndex, 1)
}
this.markModified('users')
return this.save()
}
)
export const Group: IGroupModel = model<IGroup, IGroupModel>(
'Group',
groupSchema
)
export default Group

103
api/src/model/User.ts Normal file
View File

@@ -0,0 +1,103 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
const AutoIncrement = require('mongoose-sequence')(mongoose)
import bcrypt from 'bcryptjs'
export interface UserPayload {
/**
* Display name for user
* @example "John Snow"
*/
displayName: string
/**
* Username for user
* @example "johnSnow01"
*/
username: string
/**
* Password for user
*/
password: string
/**
* Account should be admin or not, defaults to false
* @example "false"
*/
isAdmin?: boolean
/**
* Account should be active or not, defaults to true
* @example "true"
*/
isActive?: boolean
}
interface IUserDocument extends UserPayload, Document {
id: number
isAdmin: boolean
isActive: boolean
groups: Schema.Types.ObjectId[]
tokens: [{ [key: string]: string }]
}
interface IUser extends IUserDocument {
comparePassword(password: string): boolean
}
interface IUserModel extends Model<IUser> {
hashPassword(password: string): string
}
const userSchema = new Schema<IUserDocument>({
displayName: {
type: String,
required: true
},
username: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
},
isAdmin: {
type: Boolean,
default: false
},
isActive: {
type: Boolean,
default: true
},
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
tokens: [
{
clientId: {
type: String,
required: true
},
accessToken: {
type: String,
required: true
},
refreshToken: {
type: String,
required: true
}
}
]
})
userSchema.plugin(AutoIncrement, { inc_field: 'id' })
// Static Methods
userSchema.static('hashPassword', (password: string): string => {
const salt = bcrypt.genSaltSync(10)
return bcrypt.hashSync(password, salt)
})
// Instance Methods
userSchema.method('comparePassword', function (password: string): boolean {
if (bcrypt.compareSync(password, this.password)) return true
return false
})
export const User: IUserModel = model<IUser, IUserModel>('User', userSchema)
export default User

21
api/src/prod-server.ts Normal file
View File

@@ -0,0 +1,21 @@
import path from 'path'
import { readFileSync } from 'fs'
import * as https from 'https'
import appPromise from './app'
const keyPath = path.join('..', 'certificates', 'privkey.pem')
const certPath = path.join('..', 'certificates', 'fullchain.pem')
const key = readFileSync(keyPath)
const cert = readFileSync(certPath)
appPromise.then((app) => {
const httpsServer = https.createServer({ key, cert }, app)
const sasJsPort = process.env.PORT ?? 5000
httpsServer.listen(sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
)
})
})

View File

@@ -0,0 +1,89 @@
import express from 'express'
import { AuthController } from '../../controllers/'
import Client from '../../model/Client'
import {
authenticateAccessToken,
authenticateRefreshToken
} from '../../middlewares'
import {
authorizeValidation,
getDesktopFields,
tokenValidation
} from '../../utils'
import { InfoJWT } from '../../types'
const authRouter = express.Router()
const clientIDs = new Set()
export const populateClients = async () => {
const result = await Client.find()
clientIDs.clear()
result.forEach((r) => {
clientIDs.add(r.clientId)
})
}
authRouter.post('/authorize', async (req, res) => {
const { error, value: body } = authorizeValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const { clientId } = body
// Verify client ID
if (!clientIDs.has(clientId)) {
return res.status(403).send('Invalid clientId.')
}
const controller = new AuthController()
try {
const response = await controller.authorize(body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
authRouter.post('/token', async (req, res) => {
const { error, value: body } = tokenValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new AuthController()
try {
const response = await controller.token(body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
authRouter.post('/refresh', authenticateRefreshToken, async (req: any, res) => {
const userInfo: InfoJWT = req.user
const controller = new AuthController()
try {
const response = await controller.refresh(userInfo)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
authRouter.delete('/logout', authenticateAccessToken, async (req: any, res) => {
const userInfo: InfoJWT = req.user
const controller = new AuthController()
try {
await controller.logout(userInfo)
} catch (e) {}
res.sendStatus(204)
})
export default authRouter

View File

@@ -0,0 +1,20 @@
import express from 'express'
import { ClientController } from '../../controllers'
import { registerClientValidation } from '../../utils'
const clientRouter = express.Router()
clientRouter.post('/', async (req, res) => {
const { error, value: body } = registerClientValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new ClientController()
try {
const response = await controller.createClient(body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
export default clientRouter

View File

@@ -0,0 +1,82 @@
import express from 'express'
import { DriveController } from '../../controllers/'
import { getFileDriveValidation, updateFileDriveValidation } from '../../utils'
const driveRouter = express.Router()
driveRouter.post('/deploy', async (req, res) => {
const controller = new DriveController()
try {
const response = await controller.deploy(req.body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
driveRouter.get('/file', async (req, res) => {
const { error, value: query } = getFileDriveValidation(req.query)
if (error) return res.status(400).send(error.details[0].message)
const controller = new DriveController()
try {
const response = await controller.getFile(query.filePath)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
driveRouter.post('/file', async (req, res) => {
const { error, value: body } = updateFileDriveValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new DriveController()
try {
const response = await controller.saveFile(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
driveRouter.patch('/file', async (req, res) => {
const { error, value: body } = updateFileDriveValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new DriveController()
try {
const response = await controller.updateFile(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
driveRouter.get('/fileTree', async (req, res) => {
const controller = new DriveController()
try {
const response = await controller.getFileTree()
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
export default driveRouter

View File

@@ -0,0 +1,99 @@
import express from 'express'
import { GroupController } from '../../controllers/'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
import { registerGroupValidation } from '../../utils'
const groupRouter = express.Router()
groupRouter.post(
'/',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
const { error, value: body } = registerGroupValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new GroupController()
try {
const response = await controller.createGroup(body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
groupRouter.get('/', authenticateAccessToken, async (req, res) => {
const controller = new GroupController()
try {
const response = await controller.getAllGroups()
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
groupRouter.get('/:groupId', authenticateAccessToken, async (req: any, res) => {
const { groupId } = req.params
const controller = new GroupController()
try {
const response = await controller.getGroup(groupId)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
groupRouter.post(
'/:groupId/:userId',
authenticateAccessToken,
verifyAdmin,
async (req: any, res) => {
const { groupId, userId } = req.params
const controller = new GroupController()
try {
const response = await controller.addUserToGroup(groupId, userId)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
groupRouter.delete(
'/:groupId/:userId',
authenticateAccessToken,
verifyAdmin,
async (req: any, res) => {
const { groupId, userId } = req.params
const controller = new GroupController()
try {
const response = await controller.removeUserFromGroup(groupId, userId)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
groupRouter.delete(
'/:groupId',
authenticateAccessToken,
verifyAdmin,
async (req: any, res) => {
const { groupId } = req.params
const controller = new GroupController()
try {
await controller.deleteGroup(groupId)
res.status(200).send('Group Deleted!')
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
export default groupRouter

View File

@@ -0,0 +1,42 @@
import express from 'express'
import swaggerUi from 'swagger-ui-express'
import {
authenticateAccessToken,
desktopRestrict,
verifyAdmin
} from '../../middlewares'
import driveRouter from './drive'
import stpRouter from './stp'
import userRouter from './user'
import groupRouter from './group'
import clientRouter from './client'
import authRouter from './auth'
const router = express.Router()
router.use('/auth', desktopRestrict, authRouter)
router.use(
'/client',
desktopRestrict,
authenticateAccessToken,
verifyAdmin,
clientRouter
)
router.use('/drive', authenticateAccessToken, driveRouter)
router.use('/group', desktopRestrict, groupRouter)
router.use('/stp', authenticateAccessToken, stpRouter)
router.use('/user', desktopRestrict, userRouter)
router.use(
'/',
swaggerUi.serve,
swaggerUi.setup(undefined, {
swaggerOptions: {
url: '/swagger.yaml'
}
})
)
export default router

View File

@@ -0,0 +1,359 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import {
UserController,
ClientController,
AuthController
} from '../../../controllers/'
import { populateClients } from '../auth'
import { InfoJWT } from '../../../types'
import {
generateAccessToken,
generateAuthCode,
generateRefreshToken,
saveTokensInDB,
verifyTokenInDB
} from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const clientSecret = 'someclientSecret'
const user = {
id: 1234,
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
describe('auth', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
const userController = new UserController()
const clientController = new ClientController()
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
await clientController.createClient({ clientId, clientSecret })
await populateClients()
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('authorize', () => {
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with authorization code', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password,
clientId
})
.expect(200)
expect(res.body).toHaveProperty('code')
})
it('should respond with Bad Request if username is missing', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
password: user.password,
clientId
})
.expect(400)
expect(res.text).toEqual(`"username" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if password is missing', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
clientId
})
.expect(400)
expect(res.text).toEqual(`"password" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if clientId is missing', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password
})
.expect(400)
expect(res.text).toEqual(`"clientId" is required`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if username is incorrect', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password,
clientId
})
.expect(403)
expect(res.text).toEqual('Error: Username is not found.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if password is incorrect', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: 'WrongPassword',
clientId
})
.expect(403)
expect(res.text).toEqual('Error: Invalid password.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if clientId is incorrect', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password,
clientId: 'WrongClientID'
})
.expect(403)
expect(res.text).toEqual('Invalid clientId.')
expect(res.body).toEqual({})
})
})
describe('token', () => {
const userInfo: InfoJWT = {
clientId,
userId: user.id
}
beforeAll(async () => {
await userController.createUser(user)
})
afterAll(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with access and refresh tokens', async () => {
const code = AuthController.saveCode(
userInfo.userId,
userInfo.clientId,
generateAuthCode(userInfo)
)
const res = await request(app)
.post('/SASjsApi/auth/token')
.send({
clientId,
code
})
.expect(200)
expect(res.body).toHaveProperty('accessToken')
expect(res.body).toHaveProperty('refreshToken')
})
it('should respond with Bad Request if code is missing', async () => {
const res = await request(app)
.post('/SASjsApi/auth/token')
.send({
clientId
})
.expect(400)
expect(res.text).toEqual(`"code" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if clientId is missing', async () => {
const code = AuthController.saveCode(
userInfo.userId,
userInfo.clientId,
generateAuthCode(userInfo)
)
const res = await request(app)
.post('/SASjsApi/auth/token')
.send({
code
})
.expect(400)
expect(res.text).toEqual(`"clientId" is required`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if code is invalid', async () => {
const res = await request(app)
.post('/SASjsApi/auth/token')
.send({
clientId,
code: 'InvalidCode'
})
.expect(403)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if clientId is invalid', async () => {
const code = AuthController.saveCode(
userInfo.userId,
userInfo.clientId,
generateAuthCode(userInfo)
)
const res = await request(app)
.post('/SASjsApi/auth/token')
.send({
clientId: 'WrongClientID',
code
})
.expect(403)
expect(res.body).toEqual({})
})
})
describe('refresh', () => {
let refreshToken: string
let currentUser: any
beforeEach(async () => {
currentUser = await userController.createUser(user)
refreshToken = generateRefreshToken({
clientId,
userId: currentUser.id
})
await saveTokensInDB(
currentUser.id,
clientId,
'accessToken',
refreshToken
)
})
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
afterAll(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with new access and refresh tokens', async () => {
const res = await request(app)
.post('/SASjsApi/auth/refresh')
.auth(refreshToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toHaveProperty('accessToken')
expect(res.body).toHaveProperty('refreshToken')
// cannot use same refresh again
const resWithError = await request(app)
.post('/SASjsApi/auth/refresh')
.auth(refreshToken, { type: 'bearer' })
.send()
.expect(401)
expect(resWithError.body).toEqual({})
})
})
describe('logout', () => {
let accessToken: string
let currentUser: any
beforeEach(async () => {
currentUser = await userController.createUser(user)
accessToken = generateAccessToken({
clientId,
userId: currentUser.id
})
await saveTokensInDB(
currentUser.id,
clientId,
accessToken,
'refreshToken'
)
})
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
afterAll(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond no content and remove access/refresh tokens from DB', async () => {
const res = await request(app)
.delete('/SASjsApi/auth/logout')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(204)
expect(res.body).toEqual({})
expect(
await verifyTokenInDB(
currentUser.id,
clientId,
accessToken,
'accessToken'
)
).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,162 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import { UserController, ClientController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const client = {
clientId: 'someclientID',
clientSecret: 'someclientSecret'
}
const adminUser = {
displayName: 'Test Admin',
username: 'testAdminUsername',
password: '12345678',
isAdmin: true,
isActive: true
}
const newClient = {
clientId: 'newClientID',
clientSecret: 'newClientSecret'
}
describe('client', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
const userController = new UserController()
const clientController = new ClientController()
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
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']
await collection.deleteMany({})
})
it('should respond with new client', async () => {
const res = await request(app)
.post('/SASjsApi/client')
.auth(adminAccessToken, { type: 'bearer' })
.send(newClient)
.expect(200)
expect(res.body.clientId).toEqual(newClient.clientId)
expect(res.body.clientSecret).toEqual(newClient.clientSecret)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.post('/SASjsApi/client')
.send(newClient)
.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 1',
username: 'username1',
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)
.post('/SASjsApi/client')
.auth(accessToken, { type: 'bearer' })
.send(newClient)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if clientId is already present', async () => {
await clientController.createClient(newClient)
const res = await request(app)
.post('/SASjsApi/client')
.auth(adminAccessToken, { type: 'bearer' })
.send(newClient)
.expect(403)
expect(res.text).toEqual('Error: Client ID already exists.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if clientId is missing', async () => {
const res = await request(app)
.post('/SASjsApi/client')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...newClient,
clientId: undefined
})
.expect(400)
expect(res.text).toEqual(`"clientId" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if clientSecret is missing', async () => {
const res = await request(app)
.post('/SASjsApi/client')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...newClient,
clientSecret: undefined
})
.expect(400)
expect(res.text).toEqual(`"clientSecret" is required`)
expect(res.body).toEqual({})
})
})
})

View File

@@ -0,0 +1,160 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
import { getTmpFilesFolderPath } from '../../../utils/file'
import { folderExists, fileExists, readFile, deleteFolder } from '@sasjs/utils'
import path from 'path'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { FolderMember, ServiceMember } from '../../../types'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const user = {
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
describe('files', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
const controller = new UserController()
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('deploy', () => {
let accessToken: string
let dbUser: any
beforeAll(async () => {
dbUser = await controller.createUser(user)
accessToken = generateAccessToken({
clientId,
userId: dbUser.id
})
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
})
const shouldFailAssertion = async (payload: any) => {
const res = await request(app)
.post('/SASjsApi/drive/deploy')
.auth(accessToken, { type: 'bearer' })
.send(payload)
expect(res.statusCode).toEqual(400)
expect(res.body).toEqual({
status: 'failure',
message: 'Provided not supported data format.',
example: getTreeExample()
})
}
it('should respond with payload example if valid payload was not provided', async () => {
await shouldFailAssertion(null)
await shouldFailAssertion(undefined)
await shouldFailAssertion('data')
await shouldFailAssertion({})
await shouldFailAssertion({
userId: 1,
title: 'test is cool'
})
await shouldFailAssertion({
membersWRONG: []
})
await shouldFailAssertion({
members: {}
})
await shouldFailAssertion({
members: [
{
nameWRONG: 'jobs',
type: 'folder',
members: []
}
]
})
await shouldFailAssertion({
members: [
{
name: 'jobs',
type: 'WRONG',
members: []
}
]
})
await shouldFailAssertion({
members: [
{
name: 'jobs',
type: 'folder',
members: [
{
name: 'extract',
type: 'folder',
members: [
{
name: 'makedata1',
type: 'service',
codeWRONG: '%put Hello World!;'
}
]
}
]
}
]
})
})
it('should respond with payload example if valid payload was not provided', async () => {
const res = await request(app)
.post('/SASjsApi/drive/deploy')
.auth(accessToken, { type: 'bearer' })
.send({ fileTree: getTreeExample() })
expect(res.statusCode).toEqual(200)
expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
)
await expect(folderExists(getTmpFilesFolderPath())).resolves.toEqual(true)
const testJobFolder = path.join(
getTmpFilesFolderPath(),
'jobs',
'extract'
)
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
const exampleService = getExampleService()
const testJobFile = path.join(testJobFolder, exampleService.name) + '.sas'
console.log(`[testJobFile]`, testJobFile)
await expect(fileExists(testJobFile)).resolves.toEqual(true)
await expect(readFile(testJobFile)).resolves.toEqual(exampleService.code)
await deleteFolder(getTmpFilesFolderPath())
})
})
})
const getExampleService = (): ServiceMember =>
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
.members[0] as ServiceMember

View File

@@ -0,0 +1,489 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import { UserController, GroupController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const adminUser = {
displayName: 'Test Admin',
username: 'testAdminUsername',
password: '12345678',
isAdmin: true,
isActive: true
}
const user = {
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
const group = {
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
const userController = new UserController()
const groupController = new GroupController()
describe('group', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
let adminAccessToken: string
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
adminAccessToken = await generateSaveTokenAndCreateUser()
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('create', () => {
afterEach(async () => {
await deleteAllGroups()
})
it('should respond with new group', async () => {
const res = await request(app)
.post('/SASjsApi/group')
.auth(adminAccessToken, { type: 'bearer' })
.send(group)
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).post('/SASjsApi/group').send().expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'create' + user.username
})
const res = await request(app)
.post('/SASjsApi/group')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if name is missing', async () => {
const res = await request(app)
.post('/SASjsApi/group')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...group,
name: undefined
})
.expect(400)
expect(res.text).toEqual(`"name" is required`)
expect(res.body).toEqual({})
})
})
describe('delete', () => {
afterEach(async () => {
await deleteAllGroups()
})
it('should respond with OK when admin user requests', async () => {
const dbGroup = await groupController.createGroup(group)
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if groupId is incorrect', async () => {
const res = await request(app)
.delete(`/SASjsApi/group/1234`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: No Group deleted!')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not present', async () => {
const res = await request(app)
.delete('/SASjsApi/group/1234')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not of an admin account', async () => {
const dbGroup = await groupController.createGroup(group)
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'delete' + user.username
})
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
})
describe('get', () => {
afterEach(async () => {
await deleteAllGroups()
})
it('should respond with group', async () => {
const { groupId } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/${groupId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([])
})
it('should respond with group when access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'get' + user.username
})
const { groupId } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/${groupId}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.get('/SASjsApi/group/1234')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if groupId is incorrect', async () => {
const res = await request(app)
.get('/SASjsApi/group/1234')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: Group not found.')
expect(res.body).toEqual({})
})
})
describe('getAll', () => {
afterEach(async () => {
await deleteAllGroups()
})
it('should respond with all groups', async () => {
await groupController.createGroup(group)
const res = await request(app)
.get('/SASjsApi/group')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toEqual([
{
groupId: expect.anything(),
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
])
})
it('should respond with all groups when access token is not of an admin account', async () => {
await groupController.createGroup(group)
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'getAllrandomUser'
})
const res = await request(app)
.get('/SASjsApi/group')
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(200)
expect(res.body).toEqual([
{
groupId: expect.anything(),
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).get('/SASjsApi/group').send().expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
})
describe('AddUser', () => {
afterEach(async () => {
await deleteAllGroups()
})
it('should respond with group having new user in it', async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser(user)
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([
{
id: expect.anything(),
username: user.username,
displayName: user.displayName
}
])
})
it('should respond with group without duplicating user', async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({
...user,
username: 'addUserRandomUser'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([
{
id: expect.anything(),
username: 'addUserRandomUser',
displayName: user.displayName
}
])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.post('/SASjsApi/group/123/123')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'addUser' + user.username
})
const res = await request(app)
.post('/SASjsApi/group/123/123')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if groupId is incorrect', async () => {
const res = await request(app)
.post('/SASjsApi/group/123/123')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: Group not found.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group)
const res = await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/123`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: User not found.')
expect(res.body).toEqual({})
})
})
describe('RemoveUser', () => {
afterEach(async () => {
await deleteAllGroups()
})
it('should respond with group having user removed from it', async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({
...user,
username: 'removeUserRandomUser'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groupId).toBeTruthy()
expect(res.body.name).toEqual(group.name)
expect(res.body.description).toEqual(group.description)
expect(res.body.isActive).toEqual(true)
expect(res.body.users).toEqual([])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.delete('/SASjsApi/group/123/123')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'removeUser' + user.username
})
const res = await request(app)
.delete('/SASjsApi/group/123/123')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if groupId is incorrect', async () => {
const res = await request(app)
.delete('/SASjsApi/group/123/123')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: Group not found.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if userId is incorrect', async () => {
const dbGroup = await groupController.createGroup(group)
const res = await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: User not found.')
expect(res.body).toEqual({})
})
})
})
const generateSaveTokenAndCreateUser = async (
someUser?: any
): Promise<string> => {
const dbUser = await userController.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id)
}
const generateAndSaveToken = async (userId: number) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
return adminAccessToken
}
const deleteAllGroups = async () => {
const { collections } = mongoose.connection
const collection = collections['groups']
await collection.deleteMany({})
}

View File

@@ -0,0 +1,516 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const adminUser = {
displayName: 'Test Admin',
username: 'testAdminUsername',
password: '12345678',
isAdmin: true,
isActive: true
}
const user = {
displayName: 'Test User',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
}
const controller = new UserController()
describe('user', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('create', () => {
let adminAccessToken: string
beforeEach(async () => {
adminAccessToken = await generateSaveTokenAndCreateUser()
})
afterEach(async () => {
await deleteAllUsers()
})
it('should respond with new user', async () => {
const res = await request(app)
.post('/SASjsApi/user')
.auth(adminAccessToken, { type: 'bearer' })
.send(user)
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.post('/SASjsApi/user')
.send(user)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not of an admin account', async () => {
const dbUser = await controller.createUser(user)
const accessToken = generateAccessToken({
clientId,
userId: dbUser.id
})
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
const res = await request(app)
.post('/SASjsApi/user')
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if username is already present', async () => {
await controller.createUser(user)
const res = await request(app)
.post('/SASjsApi/user')
.auth(adminAccessToken, { type: 'bearer' })
.send(user)
.expect(403)
expect(res.text).toEqual('Error: Username already exists.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if username is missing', async () => {
const res = await request(app)
.post('/SASjsApi/user')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...user,
username: undefined
})
.expect(400)
expect(res.text).toEqual(`"username" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if password is missing', async () => {
const res = await request(app)
.post('/SASjsApi/user')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...user,
password: undefined
})
.expect(400)
expect(res.text).toEqual(`"password" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if displayName is missing', async () => {
const res = await request(app)
.post('/SASjsApi/user')
.auth(adminAccessToken, { type: 'bearer' })
.send({
...user,
displayName: undefined
})
.expect(400)
expect(res.text).toEqual(`"displayName" is required`)
expect(res.body).toEqual({})
})
})
describe('update', () => {
let adminAccessToken: string
beforeEach(async () => {
adminAccessToken = await generateSaveTokenAndCreateUser()
})
afterEach(async () => {
await deleteAllUsers()
})
it('should respond with updated user when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const newDisplayName = 'My new display Name'
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName })
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(newDisplayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
})
it('should respond with updated user when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const newDisplayName = 'My new display Name'
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`)
.auth(accessToken, { type: 'bearer' })
.send({
displayName: newDisplayName,
username: user.username,
password: user.password
})
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(newDisplayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
})
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const newDisplayName = 'My new display Name'
await request(app)
.patch(`/SASjsApi/user/${dbUser.id}`)
.auth(accessToken, { type: 'bearer' })
.send({ ...user, displayName: newDisplayName })
.expect(400)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.patch('/SASjsApi/user/1234')
.send(user)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not of an admin account or himself', async () => {
const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`)
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if username is already present', async () => {
const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({
...user,
username: 'randomUser'
})
const res = await request(app)
.patch(`/SASjsApi/user/${dbUser1.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ username: dbUser2.username })
.expect(403)
expect(res.text).toEqual('Error: Username already exists.')
expect(res.body).toEqual({})
})
})
describe('delete', () => {
let adminAccessToken: string
beforeEach(async () => {
adminAccessToken = await generateSaveTokenAndCreateUser()
})
afterEach(async () => {
await deleteAllUsers()
})
it('should respond with OK when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toEqual({})
})
it('should respond with OK when user himself requests', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.auth(accessToken, { type: 'bearer' })
.send({ password: user.password })
.expect(200)
expect(res.body).toEqual({})
})
it('should respond with Bad Request when user himself requests and password is missing', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(400)
expect(res.text).toEqual(`"password" is required`)
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not present', async () => {
const res = await request(app)
.delete('/SASjsApi/user/1234')
.send(user)
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Unauthorized when access token is not of an admin account or himself', async () => {
const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({
...user,
username: 'randomUser'
})
const accessToken = await generateAndSaveToken(dbUser2.id)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser1.id}`)
.auth(accessToken, { type: 'bearer' })
.send(user)
.expect(401)
expect(res.text).toEqual('Admin account required')
expect(res.body).toEqual({})
})
it('should respond with Forbidden when user himself requests and password is incorrect', async () => {
const dbUser = await controller.createUser(user)
const accessToken = await generateAndSaveToken(dbUser.id)
const res = await request(app)
.delete(`/SASjsApi/user/${dbUser.id}`)
.auth(accessToken, { type: 'bearer' })
.send({ password: 'incorrectpassword' })
.expect(403)
expect(res.text).toEqual('Error: Invalid password.')
expect(res.body).toEqual({})
})
})
describe('get', () => {
let adminAccessToken: string
beforeEach(async () => {
adminAccessToken = await generateSaveTokenAndCreateUser()
})
afterEach(async () => {
await deleteAllUsers()
})
it('should respond with user', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const res = await request(app)
.get(`/SASjsApi/user/${userId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
})
it('should respond with user when access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'randomUser'
})
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const res = await request(app)
.get(`/SASjsApi/user/${userId}`)
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.username).toEqual(user.username)
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.get('/SASjsApi/user/1234')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if userId is incorrect', async () => {
await controller.createUser(user)
const res = await request(app)
.get('/SASjsApi/user/1234')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: User is not found.')
expect(res.body).toEqual({})
})
})
describe('getAll', () => {
let adminAccessToken: string
beforeEach(async () => {
adminAccessToken = await generateSaveTokenAndCreateUser()
})
afterEach(async () => {
await deleteAllUsers()
})
it('should respond with all users', async () => {
await controller.createUser(user)
const res = await request(app)
.get('/SASjsApi/user')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toEqual([
{
id: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName
},
{
id: expect.anything(),
username: user.username,
displayName: user.displayName
}
])
})
it('should respond with all users when access token is not of an admin account', async () => {
const accessToken = await generateSaveTokenAndCreateUser({
...user,
username: 'randomUser'
})
const res = await request(app)
.get('/SASjsApi/user')
.auth(accessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body).toEqual([
{
id: expect.anything(),
username: adminUser.username,
displayName: adminUser.displayName
},
{
id: expect.anything(),
username: 'randomUser',
displayName: user.displayName
}
])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).get('/SASjsApi/user').send().expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
})
})
const generateSaveTokenAndCreateUser = async (
someUser?: any
): Promise<string> => {
const dbUser = await controller.createUser(someUser ?? adminUser)
return generateAndSaveToken(dbUser.id)
}
const generateAndSaveToken = async (userId: number) => {
const adminAccessToken = generateAccessToken({
clientId,
userId
})
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
return adminAccessToken
}
const deleteAllUsers = async () => {
const { collections } = mongoose.connection
const collection = collections['users']
await collection.deleteMany({})
}

54
api/src/routes/api/stp.ts Normal file
View File

@@ -0,0 +1,54 @@
import express from 'express'
import { executeProgramRawValidation } from '../../utils'
import { STPController } from '../../controllers/'
import { FileUploadController } from '../../controllers/internal'
const stpRouter = express.Router()
const fileUploadController = new FileUploadController()
const controller = new STPController()
stpRouter.get('/execute', async (req, res) => {
const { error, value: query } = executeProgramRawValidation(req.query)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.executeReturnRaw(req, query._program)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
stpRouter.post(
'/execute',
fileUploadController.preuploadMiddleware,
fileUploadController.getMulterUploadObject().any(),
async (req: any, res: any) => {
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
const { error: errB, value: body } = executeProgramRawValidation(req.body)
if (errQ && errB) return res.status(400).send(errB.details[0].message)
try {
const response = await controller.executeReturnJson(
req,
body,
query?._program
)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
}
)
export default stpRouter

View File

@@ -0,0 +1,95 @@
import express from 'express'
import { UserController } from '../../controllers/'
import {
authenticateAccessToken,
verifyAdmin,
verifyAdminIfNeeded
} from '../../middlewares'
import {
deleteUserValidation,
registerUserValidation,
updateUserValidation
} from '../../utils'
const userRouter = express.Router()
userRouter.post('/', authenticateAccessToken, verifyAdmin, async (req, res) => {
const { error, value: body } = registerUserValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new UserController()
try {
const response = await controller.createUser(body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
userRouter.get('/', authenticateAccessToken, async (req, res) => {
const controller = new UserController()
try {
const response = await controller.getAllUsers()
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
userRouter.get('/:userId', authenticateAccessToken, async (req: any, res) => {
const { userId } = req.params
const controller = new UserController()
try {
const response = await controller.getUser(userId)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
userRouter.patch(
'/:userId',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req: any, res) => {
const { user } = req
const { userId } = req.params
// only an admin can update `isActive` and `isAdmin` fields
const { error, value: body } = updateUserValidation(req.body, user.isAdmin)
if (error) return res.status(400).send(error.details[0].message)
const controller = new UserController()
try {
const response = await controller.updateUser(userId, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
userRouter.delete(
'/:userId',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req: any, res) => {
const { user } = req
const { userId } = req.params
// only an admin can delete user without providing password
const { error, value: data } = deleteUserValidation(req.body, user.isAdmin)
if (error) return res.status(400).send(error.details[0].message)
const controller = new UserController()
try {
await controller.deleteUser(userId, data, user.isAdmin)
res.status(200).send('Account Deleted!')
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
export default userRouter

View File

@@ -0,0 +1,8 @@
import express from 'express'
import webRouter from './web'
const router = express.Router()
router.use('/', webRouter)
export default router

34
api/src/routes/web/web.ts Normal file
View File

@@ -0,0 +1,34 @@
import { readFile } from '@sasjs/utils'
import express from 'express'
import path from 'path'
import { getWebBuildFolderPath } from '../../utils'
const webRouter = express.Router()
const codeToInject = `
<script>
localStorage.setItem('accessToken', JSON.stringify('accessToken'))
localStorage.setItem('refreshToken', JSON.stringify('refreshToken'))
</script>`
webRouter.get('/', async (_, res) => {
let content: string
try {
const indexHtmlPath = path.join(getWebBuildFolderPath(), 'index.html')
content = await readFile(indexHtmlPath)
} catch (_) {
return res.send('Web Build is not present')
}
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
const injectedContent = content.replace('</head>', `${codeToInject}</head>`)
res.setHeader('Content-Type', 'text/html')
return res.send(injectedContent)
}
return res.send(content)
})
export default webRouter

10
api/src/server.ts Normal file
View File

@@ -0,0 +1,10 @@
import appPromise from './app'
appPromise.then((app) => {
const sasJsPort = process.env.PORT ?? 5000
app.listen(sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
)
})
})

View File

@@ -1,5 +1,5 @@
export interface FileTree {
members: [FolderMember, ServiceMember]
members: (FolderMember | ServiceMember)[]
}
export enum MemberType {
@@ -10,7 +10,7 @@ export enum MemberType {
export interface FolderMember {
name: string
type: MemberType.folder
members: [FolderMember, ServiceMember]
members: (FolderMember | ServiceMember)[]
}
export interface ServiceMember {

4
api/src/types/InfoJWT.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface InfoJWT {
clientId: string
userId: number
}

View File

@@ -0,0 +1,7 @@
export interface PreProgramVars {
username: string
userId: number
displayName: string
serverUrl: string
accessToken: string
}

7
api/src/types/Process.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare namespace NodeJS {
export interface Process {
sasLoc: string
driveLoc?: string
sessionController?: import('../controllers/internal').SessionController
}
}

17
api/src/types/Request.ts Normal file
View File

@@ -0,0 +1,17 @@
import { MacroVars } from '@sasjs/utils'
export interface ExecutionQuery {
_program: string
macroVars?: MacroVars
_debug?: number
}
export interface FileQuery {
filePath: string
}
export const isExecutionQuery = (arg: any): arg is ExecutionQuery =>
arg && !Array.isArray(arg) && typeof arg._program === 'string'
export const isFileQuery = (arg: any): arg is FileQuery =>
arg && !Array.isArray(arg) && typeof arg.filePath === 'string'

10
api/src/types/Session.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface Session {
id: string
ready: boolean
creationTimeStamp: string
deathTimeStamp: string
path: string
inUse: boolean
completed: boolean
crashed?: string
}

View File

@@ -0,0 +1,6 @@
export interface TreeNode {
name: string
relativePath: string
absolutePath: string
children: Array<TreeNode>
}

10
api/src/types/Upload.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface MulterFile {
fieldname: string
originalname: string
encoding: string
mimetype: string
destination: string
filename: string
path: string
size: number
}

8
api/src/types/index.ts Normal file
View File

@@ -0,0 +1,8 @@
// TODO: uppercase types
export * from './Execution'
export * from './FileTree'
export * from './InfoJWT'
export * from './PreProgramVars'
export * from './Request'
export * from './Session'
export * from './TreeNode'

View File

@@ -0,0 +1,38 @@
import path from 'path'
import mongoose from 'mongoose'
import { configuration } from '../../package.json'
import { getDesktopFields } from '.'
import { populateClients } from '../routes/api/auth'
export const connectDB = async () => {
// NOTE: when exporting app.js as agent for supertest
// we should exlcude connecting to the real database
if (process.env.NODE_ENV !== 'test') {
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
console.log('Running in Destop Mode, no DB to connect.')
const { sasLoc, driveLoc } = await getDesktopFields()
process.sasLoc = sasLoc
process.driveLoc = driveLoc
return
} else {
const { SAS_PATH } = process.env
const sasDir = SAS_PATH ?? configuration.sasPath
process.sasLoc = path.join(sasDir, 'sas')
}
console.log('sasLoc: ', process.sasLoc)
mongoose.connect(process.env.DB_CONNECT as string, async (err) => {
if (err) throw err
console.log('Connected to db!')
await populateClients()
})
}
}

37
api/src/utils/file.ts Normal file
View File

@@ -0,0 +1,37 @@
import path from 'path'
import { getRealPath } from '@sasjs/utils'
export const apiRoot = path.join(__dirname, '..', '..')
export const codebaseRoot = path.join(apiRoot, '..')
export const sysInitCompiledPath = path.join(
apiRoot,
'sasjsbuild',
'systemInitCompiled.sas'
)
export const getWebBuildFolderPath = () =>
path.join(codebaseRoot, 'web', 'build')
export const getTmpFolderPath = () =>
process.driveLoc ?? getRealPath(path.join(process.cwd(), 'tmp'))
export const getTmpFilesFolderPath = () =>
path.join(getTmpFolderPath(), 'files')
export const getTmpLogFolderPath = () => path.join(getTmpFolderPath(), 'logs')
export const getTmpWeboutFolderPath = () =>
path.join(getTmpFolderPath(), 'webouts')
export const getTmpSessionsFolderPath = () =>
path.join(getTmpFolderPath(), 'sessions')
export const generateUniqueFileName = (fileName: string, extension = '') =>
[
fileName,
'-',
Math.round(Math.random() * 100000),
'-',
new Date().getTime(),
extension
].join('')

View File

@@ -0,0 +1,7 @@
import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateAccessToken = (data: InfoJWT) =>
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
expiresIn: '1h'
})

View File

@@ -0,0 +1,7 @@
import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateAuthCode = (data: InfoJWT) =>
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
expiresIn: '30s'
})

View File

@@ -0,0 +1,7 @@
import jwt from 'jsonwebtoken'
import { InfoJWT } from '../types'
export const generateRefreshToken = (data: InfoJWT) =>
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, {
expiresIn: '1day'
})

View File

@@ -0,0 +1,61 @@
import path from 'path'
import { getString } from '@sasjs/utils/input'
import { createFolder, fileExists, folderExists } from '@sasjs/utils'
const isWindows = () => process.platform === 'win32'
export const getDesktopFields = async () => {
const sasLoc = await getSASLocation()
const driveLoc = await getDriveLocation()
return { sasLoc, driveLoc }
}
const getDriveLocation = async (): Promise<string> => {
const validator = async (filePath: string) => {
if (!filePath) return 'Path to files/drive is required.'
const drivePath = path.join(process.cwd(), filePath)
if (!(await folderExists(drivePath))) {
await createFolder(drivePath)
await createFolder(path.join(drivePath, 'files'))
}
return true
}
const defaultLocation = isWindows() ? '.\\tmp\\' : './tmp/'
const targetName = await getString(
'Please enter path to file/drive (relative to executable): ',
validator,
defaultLocation
)
return targetName
}
const getSASLocation = async (): Promise<string> => {
const validator = async (filePath: string) => {
if (!filePath) return 'Path to SAS executable is required.'
if (!(await fileExists(filePath))) {
return 'No file found at provided path.'
}
return true
}
const defaultLocation = isWindows()
? 'C:\\Program Files\\SASHome\\SASFoundation\\9.4\\sas.exe'
: '/opt/sas/sas9/SASHome/SASFoundation/9.4/sasexe/sas'
const targetName = await getString(
'Please enter path to SAS executable (absolute path): ',
validator,
defaultLocation
)
return targetName
}

12
api/src/utils/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export * from './connectDB'
export * from './file'
export * from './generateAccessToken'
export * from './generateAuthCode'
export * from './generateRefreshToken'
export * from './getDesktopFields'
export * from './removeTokensInDB'
export * from './saveTokensInDB'
export * from './sleep'
export * from './upload'
export * from './validation'
export * from './verifyTokenInDB'

View File

@@ -0,0 +1,15 @@
import User from '../model/User'
export const removeTokensInDB = async (userId: number, clientId: string) => {
const user = await User.findOne({ id: userId })
if (!user) return
const tokenObjIndex = user.tokens.findIndex(
(tokenObj: any) => tokenObj.clientId === clientId
)
if (tokenObjIndex > -1) {
user.tokens.splice(tokenObjIndex, 1)
await user.save()
}
}

View File

@@ -0,0 +1,26 @@
import User from '../model/User'
export const saveTokensInDB = async (
userId: number,
clientId: string,
accessToken: string,
refreshToken: string
) => {
const user = await User.findOne({ id: userId })
if (!user) return
const currentTokenObj = user.tokens.find(
(tokenObj: any) => tokenObj.clientId === clientId
)
if (currentTokenObj) {
currentTokenObj.accessToken = accessToken
currentTokenObj.refreshToken = refreshToken
} else {
user.tokens.push({
clientId: clientId,
accessToken: accessToken,
refreshToken: refreshToken
})
}
await user.save()
}

3
api/src/utils/sleep.ts Normal file
View File

@@ -0,0 +1,3 @@
export const sleep = async (delay: number) => {
await new Promise((resolve) => setTimeout(resolve, delay))
}

89
api/src/utils/upload.ts Normal file
View File

@@ -0,0 +1,89 @@
import path from 'path'
import fs from 'fs'
import { getTmpSessionsFolderPath } from '.'
import { MulterFile } from '../types/Upload'
import { listFilesInFolder } from '@sasjs/utils'
/**
* It will create an object that maps hashed file names to the original names
* @param files array of files to be mapped
* @returns object
*/
export const makeFilesNamesMap = (files: MulterFile[]) => {
if (!files) return null
const filesNamesMap: { [key: string]: string } = {}
for (let file of files) {
filesNamesMap[file.filename] = file.fieldname
}
return filesNamesMap
}
/**
* Generates the sas code that references uploaded files in the concurrent request
* @param filesNamesMap object that maps hashed file names and original file names
* @param sasUploadFolder name of the folder that is created for the purpose of files in concurrent request
* @returns generated sas code
*/
export const generateFileUploadSasCode = async (
filesNamesMap: any,
sasSessionFolder: string
): Promise<string> => {
let uploadSasCode = ''
let fileCount = 0
let uploadedFilesMap: {
fileref: string
filepath: string
filename: string
count: number
}[] = []
const sasSessionFolderList: string[] = await listFilesInFolder(
sasSessionFolder
)
sasSessionFolderList.forEach((fileName) => {
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
if (fileName.includes('req_file')) {
fileCount++
uploadedFilesMap.push({
fileref: `_sjs${fileCountString}`,
filepath: `${sasSessionFolder}/${fileName}`,
filename: filesNamesMap[fileName],
count: fileCount
})
}
})
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\nfilename ${uploadedMap.fileref} "${uploadedMap.filepath}";`
}
uploadSasCode += `\n%let _WEBIN_FILE_COUNT=${fileCount};`
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\n%let _WEBIN_FILENAME${uploadedMap.count}=${uploadedMap.filepath};`
}
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\n%let _WEBIN_FILEREF${uploadedMap.count}=${uploadedMap.fileref};`
}
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\n%let _WEBIN_NAME${uploadedMap.count}=${uploadedMap.filename};`
}
if (fileCount > 0) {
uploadSasCode += `\n%let _WEBIN_NAME=&_WEBIN_NAME1;`
uploadSasCode += `\n%let _WEBIN_FILEREF=&_WEBIN_FILEREF1;`
uploadSasCode += `\n%let _WEBIN_FILENAME=&_WEBIN_FILENAME1;`
}
uploadSasCode += `\n`
return uploadSasCode
}

View File

@@ -0,0 +1,85 @@
import Joi from 'joi'
const usernameSchema = Joi.string().alphanum().min(6).max(20)
const passwordSchema = Joi.string().min(6).max(1024)
export const authorizeValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required(),
password: passwordSchema.required(),
clientId: Joi.string().required()
}).validate(data)
export const tokenValidation = (data: any): Joi.ValidationResult =>
Joi.object({
clientId: Joi.string().required(),
code: Joi.string().required()
}).validate(data)
export const registerGroupValidation = (data: any): Joi.ValidationResult =>
Joi.object({
name: Joi.string().min(6).required(),
description: Joi.string(),
isActive: Joi.boolean()
}).validate(data)
export const registerUserValidation = (data: any): Joi.ValidationResult =>
Joi.object({
displayName: Joi.string().min(6).required(),
username: usernameSchema.required(),
password: passwordSchema.required(),
isAdmin: Joi.boolean(),
isActive: Joi.boolean()
}).validate(data)
export const deleteUserValidation = (
data: any,
isAdmin: boolean = false
): Joi.ValidationResult =>
Joi.object(
isAdmin
? {}
: {
password: passwordSchema.required()
}
).validate(data)
export const updateUserValidation = (
data: any,
isAdmin: boolean = false
): Joi.ValidationResult => {
const validationChecks: any = {
displayName: Joi.string().min(6),
username: usernameSchema,
password: passwordSchema
}
if (isAdmin) {
validationChecks.isAdmin = Joi.boolean()
validationChecks.isActive = Joi.boolean()
}
return Joi.object(validationChecks).validate(data)
}
export const registerClientValidation = (data: any): Joi.ValidationResult =>
Joi.object({
clientId: Joi.string().required(),
clientSecret: Joi.string().required()
}).validate(data)
export const getFileDriveValidation = (data: any): Joi.ValidationResult =>
Joi.object({
filePath: Joi.string().required()
}).validate(data)
export const updateFileDriveValidation = (data: any): Joi.ValidationResult =>
Joi.object({
filePath: Joi.string().required(),
fileContent: Joi.string().required()
}).validate(data)
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
Joi.object({
_program: Joi.string().required()
})
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
.validate(data)

View File

@@ -0,0 +1,27 @@
import User from '../model/User'
export const verifyTokenInDB = async (
userId: number,
clientId: string,
token: string,
tokenType: 'accessToken' | 'refreshToken'
) => {
const dbUser = await User.findOne({ id: userId })
if (!dbUser) return undefined
const currentTokenObj = dbUser.tokens.find(
(tokenObj: any) => tokenObj.clientId === clientId
)
return currentTokenObj?.[tokenType] === token
? {
userId: dbUser.id,
clientId,
username: dbUser.username,
displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive
}
: undefined
}

View File

@@ -6,6 +6,11 @@
"outDir": "./build",
"esModuleInterop": true,
"strict": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"ts-node": {
"files": true
}
}

42
api/tsoa.json Normal file
View File

@@ -0,0 +1,42 @@
{
"entryFile": "src/app.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"spec": {
"outputDirectory": "public",
"securityDefinitions": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"tags": [
{
"name": "User",
"description": "Operations about users"
},
{
"name": "Client",
"description": "Operations about clients"
},
{
"name": "Auth",
"description": "Operations about auth"
},
{
"name": "Drive",
"description": "Operations about drive"
},
{
"name": "Group",
"description": "Operations about group"
},
{
"name": "STP",
"description": "Operations about STP"
}
],
"yaml": true,
"specVersion": 3
}
}

14
docker-compose.debug.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3.4'
services:
server:
image: server
build:
context: .
dockerfile: ./Dockerfile
environment:
NODE_ENV: development
ports:
- 3000:3000
- 9229:9229
command: ["node", "--inspect=0.0.0.0:9229", "./src/server.ts"]

46
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,46 @@
version: '3.4'
services:
sasjs_server_prod:
image: sasjs_server_prod
build:
context: .
dockerfile: DockerfileProd
environment:
MODE: server
CORS: disable
PORT: ${PORT_API}
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
AUTH_CODE_SECRET: ${AUTH_CODE_SECRET}
DB_CONNECT: mongodb://mongodb:27017/sasjs
SAS_PATH: /usr/server/sasexe
expose:
- ${PORT_API}
ports:
- ${PORT_API}:${PORT_API}
volumes:
- type: bind
source: ${SAS_EXEC}
target: /usr/server/sasexe
read_only: true
links:
- mongodb
mongodb:
image: mongo:latest
ports:
- 27017:27017
volumes:
- data:/data/db
mongo-seed-users:
build: ./mongo-seed/users
links:
- mongodb
mongo-seed-clients:
build: ./mongo-seed/clients
links:
- mongodb
volumes:
data:

61
docker-compose.yml Normal file
View File

@@ -0,0 +1,61 @@
version: '3.4'
services:
sasjs_server_api:
image: sasjs_server_api
build:
context: .
dockerfile: DockerfileApi
environment:
MODE: ${MODE}
CORS: ${CORS}
PORT: ${PORT_API}
PORT_WEB: ${PORT_WEB}
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
AUTH_CODE_SECRET: ${AUTH_CODE_SECRET}
DB_CONNECT: mongodb://mongodb:27017/sasjs
SAS_PATH: /usr/server/sasexe
expose:
- ${PORT_API}
ports:
- ${PORT_API}:${PORT_API}
volumes:
- ./api:/usr/server/api
- type: bind
source: ${SAS_EXEC}
target: /usr/server/sasexe
read_only: true
links:
- mongodb
sasjs_server_web:
image: sasjs_server_web
build: ./web
environment:
REACT_APP_PORT_API: ${PORT_API}
PORT: ${PORT_WEB}
expose:
- ${PORT_WEB}
ports:
- ${PORT_WEB}:${PORT_WEB}
volumes:
- ./web:/usr/server/web
mongodb:
image: mongo:latest
ports:
- 27017:27017
volumes:
- data:/data/db
mongo-seed-users:
build: ./mongo-seed/users
links:
- mongodb
mongo-seed-clients:
build: ./mongo-seed/clients
links:
- mongodb
volumes:
data:

View File

@@ -0,0 +1,4 @@
FROM mongo
COPY ./clients.json /clients.json
CMD mongoimport --host mongodb --db sasjs --collection clients --type json --file /clients.json --jsonArray

View File

@@ -0,0 +1,6 @@
[
{
"clientId": "clientID1",
"clientSecret": "clientSecret"
}
]

View File

@@ -0,0 +1,4 @@
FROM mongo
COPY ./users.json /users.json
CMD mongoimport --host mongodb --db sasjs --collection users --type json --file /users.json --jsonArray

View File

@@ -0,0 +1,10 @@
[
{
"id": 1,
"displayName": "Super Admin",
"username": "secretuser",
"password": "$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO",
"isAdmin": true,
"isActive": true
}
]

11328
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,21 @@
{
"name": "server",
"version": "0.0.1",
"description": "SASjs server",
"main": "./src/server.ts",
"version": "0.0.9",
"description": "NodeJS wrapper for calling the SAS binary executable",
"scripts": {
"start": "nodemon ./src/server.ts",
"start:prod": "nodemon ./src/prod-server.ts",
"build": "rimraf build && tsc",
"semantic-release": "semantic-release -d",
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
"test": "mkdir -p tmp && jest --coverage",
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack"
},
"release": {
"branches": [
"master"
]
},
"author": "Analytium Ltd",
"dependencies": {
"@sasjs/utils": "^2.23.3",
"express": "^4.17.1"
"server": "npm run server:prepare && npm run server:start",
"server:prepare": "cd web && npm ci && npm run build && cd ../api && npm ci && cd ..",
"server:start": "cd api && npm run start:prod",
"release": "standard-version",
"lint-api:fix": "npx prettier --write \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint-api": "npx prettier --check \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint-web:fix": "npx prettier --write \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint-web": "npx prettier --check \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint": "npm run lint-api && npm run lint-web",
"lint:fix": "npm run lint-api:fix && npm run lint-web:fix"
},
"devDependencies": {
"@types/express": "^4.17.12",
"@types/jest": "^26.0.24",
"@types/node": "^15.12.2",
"@types/supertest": "^2.0.11",
"jest": "^27.0.6",
"nodemon": "^2.0.7",
"prettier": "^2.3.1",
"rimraf": "^3.0.2",
"semantic-release": "^17.4.3",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",
"ts-node": "^10.0.0",
"typescript": "^4.3.2"
},
"configuration": {
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sasexe/sas",
"sasJsPort": 5000
"standard-version": "^9.3.2"
}
}

57
routes.rest Normal file
View File

@@ -0,0 +1,57 @@
###
POST http://localhost:5000/SASjsApi/drive/deploy
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDc2LCJleHAiOjE2MzU4OTA0NzZ9.Cx1F54ILgAUtnkit0Wg1K1YVO2RdNjOnTKdPhUtDm5I
###
POST http://localhost:5000/SASjsApi/user
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InNlY3JldHVzZXIiLCJpc2FkbWluIjp0cnVlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODAzOTc3LCJleHAiOjE2MzU4OTAzNzd9.f-FLgLwryKvB5XrihdzaGZajO3d5E5OHEEuJI_03GRI
Content-Type: application/json
{
"displayname": "User 2",
"username": "username2",
"password": "some password"
}
###
POST http://localhost:5000/SASjsApi/client
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InNlY3JldHVzZXIiLCJpc2FkbWluIjp0cnVlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODAzOTc3LCJleHAiOjE2MzU4OTAzNzd9.f-FLgLwryKvB5XrihdzaGZajO3d5E5OHEEuJI_03GRI
Content-Type: application/json
{
"client_id": "newClientID",
"client_secret": "newClientSecret"
}
###
POST https://sas.analytium.co.uk:5002/SASjsApi/auth/authorize
Content-Type: application/json
{
"username": "secretuser",
"password": "secretpassword",
"client_id": "clientID1"
}
###
POST http://localhost:5000/SASjsApi/auth/token
Content-Type: application/json
{
"client_id": "clientID1",
"client_secret": "clientID1secret",
"code": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDYxLCJleHAiOjE2MzU4MDQwOTF9.jV7DpBWG7XAGODs22zAW_kWOqVLZvOxmmYJGpSNQ-KM"
}
###
DELETE http://localhost:5000/SASjsApi/auth/logout
Users
"username": "username1",
"password": "some password",
"username": "username2",
"password": "some password",
Admins
"username": "secretuser",
"password": "secretpassword",

View File

@@ -1,10 +0,0 @@
import express from 'express'
import indexRouter from './routes'
const app = express()
app.use(express.json({ limit: '50mb' }))
app.use('/', indexRouter)
export default app

View File

@@ -1,2 +0,0 @@
export * from './sas'
export * from './deploy'

View File

@@ -1,99 +0,0 @@
import { readFile, deleteFile, fileExists, createFile } from '@sasjs/utils'
import path from 'path'
import { ExecutionResult, ExecutionQuery } from '../types'
import {
getTmpFilesFolderPath,
getTmpLogFolderPath,
getTmpWeboutFolderPath,
generateUniqueFileName
} from '../utils'
import { configuration } from '../../package.json'
import { promisify } from 'util'
import { execFile } from 'child_process'
const execFilePromise = promisify(execFile)
export const processSas = async (query: ExecutionQuery): Promise<any> => {
const sasCodePath = path
.join(getTmpFilesFolderPath(), query._program)
.replace(new RegExp('/', 'g'), path.sep)
if (!(await fileExists(sasCodePath))) {
return Promise.reject({ error: 'SAS file does not exist.' })
}
const sasFile: string = sasCodePath.split(path.sep).pop() || 'default'
const sasLogPath = path.join(
getTmpLogFolderPath(),
generateUniqueFileName(sasFile.replace(/\.sas/g, ''), '.log')
)
await createFile(sasLogPath, '')
const sasWeboutPath = path.join(
getTmpWeboutFolderPath(),
generateUniqueFileName(sasFile.replace(/\.sas/g, ''), '.txt')
)
await createFile(sasWeboutPath, '')
let sasCode = await readFile(sasCodePath)
const vars: any = query
Object.keys(query).forEach(
(key: string) => (sasCode = `%let ${key}=${vars[key]};\n${sasCode}`)
)
sasCode = `filename _webout "${sasWeboutPath}";\n${sasCode}`
const tmpSasCodePath = sasCodePath.replace(
sasFile,
generateUniqueFileName(sasFile)
)
await createFile(tmpSasCodePath, sasCode)
const { stdout, stderr } = await execFilePromise(configuration.sasPath, [
'-SYSIN',
tmpSasCodePath,
'-log',
sasLogPath,
process.platform === 'win32' ? '-nosplash' : ''
]).catch((err) => ({ stderr: err, stdout: '' }))
let log = ''
if (sasLogPath && (await fileExists(sasLogPath))) {
log = await readFile(sasLogPath)
}
await deleteFile(sasLogPath)
await deleteFile(tmpSasCodePath)
if (stderr) return Promise.reject({ error: stderr, log: log })
if (await fileExists(sasWeboutPath)) {
let webout = await readFile(sasWeboutPath)
await deleteFile(sasWeboutPath)
const debug = Object.keys(query).find(
(key: string) => key.toLowerCase() === '_debug'
)
if (debug && (query as any)[debug] >= 131) {
webout = `<html><body>
${webout}
<div style="text-align:left">
<hr /><h2>SAS Log</h2>
<pre>${log}</pre>
</div>
</body></html>`
}
return Promise.resolve(webout)
} else {
return Promise.resolve({
log: log
})
}
}

View File

@@ -1,19 +0,0 @@
import path from 'path'
import { readFileSync } from 'fs'
import * as https from 'https'
import { configuration } from '../package.json'
import app from './app'
const keyPath = path.join('certificates', 'privkey.pem')
const certPath = path.join('certificates', 'fullchain.pem')
const key = readFileSync(keyPath)
const cert = readFileSync(certPath)
const httpsServer = https.createServer({ key, cert }, app)
httpsServer.listen(configuration.sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at https://localhost:${configuration.sasJsPort}`
)
})

View File

@@ -1,81 +0,0 @@
import express from 'express'
import { processSas, createFileTree, getTreeExample } from '../controllers'
import { ExecutionResult, isRequestQuery, isFileTree } from '../types'
const router = express.Router()
router.get('/', async (req, res) => {
const query = req.query
if (!isRequestQuery(query)) {
res.send('Welcome to @sasjs/server API')
return
}
const result: ExecutionResult = await processSas(query)
res.send(`<b>Executed!</b><br>
<p>Log is located:</p> ${result.logPath}<br>
<p>Log:</p> <textarea style="width: 100%; height: 100%">${result.log}</textarea>`)
})
router.post('/deploy', async (req, res) => {
if (!isFileTree({ members: req.body.members })) {
res.status(400).send({
status: 'failure',
message: 'Provided not supported data format.',
example: getTreeExample()
})
return
}
await createFileTree(
req.body.members,
req.body.appLoc ? req.body.appLoc.replace(/^\//, '').split('/') : []
)
.then(() => {
res.status(200).send({
status: 'success',
message: 'Files deployed successfully to @sasjs/server.'
})
})
.catch((err) => {
res
.status(500)
.send({ status: 'failure', message: 'Deployment failed!', ...err })
})
})
// TODO: respond with HTML page including file tree
router.get('/SASjsExecutor', async (req, res) => {
res.status(200).send({ status: 'success', tree: {} })
})
router.get('/SASjsExecutor/do', async (req, res) => {
const queryEntries = Object.keys(req.query).map((entry: string) =>
entry.toLowerCase()
)
if (isRequestQuery(req.query)) {
await processSas({ ...req.query })
.then((result) => {
res.status(200).send(result)
})
.catch((err) => {
res.status(400).send({
status: 'failure',
message: 'Job execution failed.',
...err
})
})
} else {
res.status(400).send({
status: 'failure',
message: `Please provide the location of SAS code`
})
}
})
export default router

View File

@@ -1,101 +0,0 @@
import request from 'supertest'
import app from '../../app'
import { getTreeExample } from '../../controllers/deploy'
import { getTmpFilesFolderPath } from '../../utils/file'
import { folderExists, fileExists, readFile, deleteFolder } from '@sasjs/utils'
import path from 'path'
describe('deploy', () => {
const shouldFailAssertion = async (payload: any) => {
const res = await request(app).post('/deploy').send(payload)
expect(res.statusCode).toEqual(400)
expect(res.body).toEqual({
status: 'failure',
message: 'Provided not supported data format.',
example: getTreeExample()
})
}
it('should respond with payload example if valid payload was not provided', async () => {
await shouldFailAssertion(null)
await shouldFailAssertion(undefined)
await shouldFailAssertion('data')
await shouldFailAssertion({})
await shouldFailAssertion({
userId: 1,
title: 'test is cool'
})
await shouldFailAssertion({
membersWRONG: []
})
await shouldFailAssertion({
members: {}
})
await shouldFailAssertion({
members: [
{
nameWRONG: 'jobs',
type: 'folder',
members: []
}
]
})
await shouldFailAssertion({
members: [
{
name: 'jobs',
type: 'WRONG',
members: []
}
]
})
await shouldFailAssertion({
members: [
{
name: 'jobs',
type: 'folder',
members: [
{
name: 'extract',
type: 'folder',
members: [
{
name: 'makedata1',
type: 'service',
codeWRONG: '%put Hello World!;'
}
]
}
]
}
]
})
})
it('should respond with payload example if valid payload was not provided', async () => {
const res = await request(app).post('/deploy').send(getTreeExample())
expect(res.statusCode).toEqual(200)
expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
)
await expect(folderExists(getTmpFilesFolderPath())).resolves.toEqual(true)
const testJobFolder = path.join(getTmpFilesFolderPath(), 'jobs', 'extract')
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
const testJobFile = path.join(
testJobFolder,
getTreeExample().members[0].members[0].members[0].name
)
await expect(fileExists(testJobFile)).resolves.toEqual(true)
await expect(readFile(testJobFile)).resolves.toEqual(
getTreeExample().members[0].members[0].members[0].code
)
await deleteFolder(getTmpFilesFolderPath())
})
})

View File

@@ -1,8 +0,0 @@
import app from './app'
import { configuration } from '../package.json'
app.listen(configuration.sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at http://localhost:${configuration.sasJsPort}`
)
})

Some files were not shown because too many files have changed in this diff Show More