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

Compare commits

..

1 Commits

Author SHA1 Message Date
cc6f8a64b5 fix(web-header): show users display name instead of username 2022-04-27 22:43:23 +05:00
101 changed files with 2527 additions and 14318 deletions

View File

@@ -54,7 +54,6 @@ jobs:
ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}}
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}
SESSION_SECRET: ${{secrets.SESSION_SECRET}}
- name: Build Package
working-directory: ./api

View File

@@ -2,26 +2,16 @@ name: SASjs Server Executable Release
on:
push:
branches:
- main
tags:
- 'v*.*.*'
jobs:
release:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- name: Checkout
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 WEB
working-directory: ./web
run: npm ci
@@ -49,11 +39,10 @@ jobs:
zip macos.zip api-macos
zip windows.zip api-win.exe
- name: Install Semantic Release and plugins
run: |
npm i
npm i -g semantic-release
- name: Release
run: |
GITHUB_TOKEN=${{ secrets.GH_TOKEN }} semantic-release
uses: softprops/action-gh-release@v1
with:
files: |
./executables/linux.zip
./executables/macos.zip
./executables/windows.zip

2
.gitignore vendored
View File

@@ -4,7 +4,6 @@ node_modules/
.DS_Store
.env*
sas/
sasjs_root/
tmp/
build/
sasjsbuild/
@@ -12,4 +11,3 @@ sasjscore/
certificates/
executables/
.env
api/csp.config.json

View File

@@ -1,43 +0,0 @@
{
"branches": [
"main"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/git",
{
"assets": [
"CHANGELOG.md"
]
}
],
[
"@semantic-release/github",
{
"assets": [
{
"path": "./executables/linux.zip",
"label": "Linux Executable Binary"
},
{
"path": "./executables/macos.zip",
"label": "Macos Executable Binary"
},
{
"path": "./executables/windows.zip",
"label": "Windows Executable Binary"
}
]
}
],
[
"@semantic-release/exec",
{
"publishCmd": "echo 'publish command'"
}
]
]
}

View File

@@ -1,289 +1,6 @@
## [0.6.1](https://github.com/sasjs/server/compare/v0.6.0...v0.6.1) (2022-06-17)
# Changelog
### Bug Fixes
* home page wording. Using fix to force previous change through.. ([8702a4e](https://github.com/sasjs/server/commit/8702a4e8fd1bbfaf4f426b75e8b85a87ede0e0b0))
# [0.6.0](https://github.com/sasjs/server/compare/v0.5.0...v0.6.0) (2022-06-16)
### Features
* get group by group name ([6b0b94a](https://github.com/sasjs/server/commit/6b0b94ad38215ae58e62279a4f73ac3ed2d9d0e8))
# [0.5.0](https://github.com/sasjs/server/compare/v0.4.2...v0.5.0) (2022-06-16)
### Bug Fixes
* npm audit fix to avoid warnings on npm i ([28a6a36](https://github.com/sasjs/server/commit/28a6a36bb708b93fb5c2b74d587e9b2e055582be))
### Features
* **api:** deployment through zipped/compressed file ([b81d742](https://github.com/sasjs/server/commit/b81d742c6c70d4cf1cab365b0e3efc087441db00))
## [0.4.2](https://github.com/sasjs/server/compare/v0.4.1...v0.4.2) (2022-06-15)
### Bug Fixes
* appStream redesign ([73792fb](https://github.com/sasjs/server/commit/73792fb574c90bd280c4324e0b41c6fee7d572b6))
## [0.4.1](https://github.com/sasjs/server/compare/v0.4.0...v0.4.1) (2022-06-15)
### Bug Fixes
* add/remove group to User when adding/removing user from group and return group membership on getting user ([e08bbcc](https://github.com/sasjs/server/commit/e08bbcc5435cbabaee40a41a7fb667d4a1f078e6))
# [0.4.0](https://github.com/sasjs/server/compare/v0.3.10...v0.4.0) (2022-06-14)
### Features
* new APIs added for GET|PATCH|DELETE of user by username ([aef411a](https://github.com/sasjs/server/commit/aef411a0eac625c33274dfe3e88b6f75115c44d8))
## [0.3.10](https://github.com/sasjs/server/compare/v0.3.9...v0.3.10) (2022-06-14)
### Bug Fixes
* correct syntax for encoding option ([32d372b](https://github.com/sasjs/server/commit/32d372b42fbf56b6c0779e8f704164eaae1c7548))
## [0.3.9](https://github.com/sasjs/server/compare/v0.3.8...v0.3.9) (2022-06-14)
### Bug Fixes
* forcing utf 8 encoding. Closes [#76](https://github.com/sasjs/server/issues/76) ([8734489](https://github.com/sasjs/server/commit/8734489cf014aedaca3f325e689493e4fe0b71ca))
## [0.3.8](https://github.com/sasjs/server/compare/v0.3.7...v0.3.8) (2022-06-13)
### Bug Fixes
* execution controller better error handling ([8a617a7](https://github.com/sasjs/server/commit/8a617a73ae63233332f5788c90f173d6cd5e1283))
* execution controller error details ([3fa2a7e](https://github.com/sasjs/server/commit/3fa2a7e2e32f90050f6b09e30ce3ef725eb0b15f))
## [0.3.7](https://github.com/sasjs/server/compare/v0.3.6...v0.3.7) (2022-06-08)
### Bug Fixes
* **appstream:** redirect to relative + nested resource should be accessed ([5ab35b0](https://github.com/sasjs/server/commit/5ab35b02c4417132dddb5a800982f31d0d50ef66))
## [0.3.6](https://github.com/sasjs/server/compare/v0.3.5...v0.3.6) (2022-06-02)
### Bug Fixes
* **appstream:** should serve only new files for same app stream name with new deployment ([e6d1989](https://github.com/sasjs/server/commit/e6d1989847761fbe562d7861ffa0ee542839b125))
## [0.3.5](https://github.com/sasjs/server/compare/v0.3.4...v0.3.5) (2022-05-30)
### Bug Fixes
* bumping sasjs/core library ([61815f8](https://github.com/sasjs/server/commit/61815f8ae18be132e17c199cd8e3afbcc2fa0b60))
## [0.3.4](https://github.com/sasjs/server/compare/v0.3.3...v0.3.4) (2022-05-30)
### Bug Fixes
* **web:** system username for DESKTOP mode ([a8ba378](https://github.com/sasjs/server/commit/a8ba378fd1ff374ba025a96fdfae5c6c36954465))
## [0.3.3](https://github.com/sasjs/server/compare/v0.3.2...v0.3.3) (2022-05-30)
### Bug Fixes
* usage of autoexec API in DESKTOP mode ([12d424a](https://github.com/sasjs/server/commit/12d424acce8108a6f53aefbac01fddcdc5efb48f))
## [0.3.2](https://github.com/sasjs/server/compare/v0.3.1...v0.3.2) (2022-05-27)
### Bug Fixes
* **web:** ability to use get/patch User API in desktop mode. ([2c259fe](https://github.com/sasjs/server/commit/2c259fe1de95d84e6929e311aaa6b895e66b42a3))
## [0.3.1](https://github.com/sasjs/server/compare/v0.3.0...v0.3.1) (2022-05-26)
### Bug Fixes
* **api:** username should be lowercase ([5ad6ee5](https://github.com/sasjs/server/commit/5ad6ee5e0f5d7d6faa45b72215f1d9d55cfc37db))
* **web:** reduced width for autoexec input ([7d11cc7](https://github.com/sasjs/server/commit/7d11cc79161e5a07f6c5392d742ef6b9d8658071))
# [0.3.0](https://github.com/sasjs/server/compare/v0.2.0...v0.3.0) (2022-05-25)
### Features
* **web:** added profile + edit + autoexec changes ([c275db1](https://github.com/sasjs/server/commit/c275db184e874f0ee3a4f08f2592cfacf1e90742))
# [0.2.0](https://github.com/sasjs/server/compare/v0.1.0...v0.2.0) (2022-05-25)
### Bug Fixes
* **autoexec:** usage in case of desktop from file ([79dc2db](https://github.com/sasjs/server/commit/79dc2dba23dc48ec218a973119392a45cb3856b5))
### Features
* **api:** added autoexec + major type setting changes ([2a7223a](https://github.com/sasjs/server/commit/2a7223ad7d6b8f3d4682447fd25d9426a7c79ac3))
# [0.1.0](https://github.com/sasjs/server/compare/v0.0.77...v0.1.0) (2022-05-23)
### Bug Fixes
* issue174 + issue175 + issue146 ([80b33c7](https://github.com/sasjs/server/commit/80b33c7a18c1b7727316ffeca71658346733e935))
* **web:** click to copy + notification ([f37f8e9](https://github.com/sasjs/server/commit/f37f8e95d1a85e00ceca2413dbb5e1f3f3f72255))
### Features
* **env:** added new env variable LOG_FORMAT_MORGAN ([53bf68a](https://github.com/sasjs/server/commit/53bf68a6aff44bb7b2f40d40d6554809253a01a8))
## [0.0.77](https://github.com/sasjs/server/compare/v0.0.76...v0.0.77) (2022-05-16)
### Bug Fixes
* **release:** Github workflow without npm token ([c017d13](https://github.com/sasjs/server/commit/c017d13061d21aeacd0690367992d12ca57a115b))
### [0.0.76](https://github.com/sasjs/server/compare/v0.0.75...v0.0.76) (2022-05-16)
### Bug Fixes
* get csrf token from cookie if not present in header ([f89389b](https://github.com/sasjs/server/commit/f89389bbc6f1f8f7060db2bdeb89746cbd60f533))
### [0.0.75](https://github.com/sasjs/server/compare/v0.0.69...v0.0.75) (2022-05-12)
### Features
* CSP_DISABLE env option ([dd3acce](https://github.com/sasjs/server/commit/dd3acce3935e7cfc0b2c44a401314306915a3a10))
### Bug Fixes
* added more cookies to req ([4a8e32d](https://github.com/sasjs/server/commit/4a8e32dd20b540b6dc92d749fad90d6c7fc69376))
* bumping core ([c0b57b9](https://github.com/sasjs/server/commit/c0b57b9e76d6db33fc64a68556a8be979dd69e40))
* csp updates ([7cfa239](https://github.com/sasjs/server/commit/7cfa2398e12c5e515d27c896f36ff91604c2124d))
* helmet config on http mode ([b0fdaaa](https://github.com/sasjs/server/commit/b0fdaaaa79e3135699c51effac0388d8ec5ab23b))
* moved getAuthCode from api to web routes ([b40de8f](https://github.com/sasjs/server/commit/b40de8fa6a5aa763ed25a6fe6a381e483e0ab824))
* reqHeadrs.txt will contain headers to access APIs ([636301e](https://github.com/sasjs/server/commit/636301e664416fb085f704d83deb7f39ee0a91a7))
* **web:** seperate container for auth code ([5888f04](https://github.com/sasjs/server/commit/5888f04e08a32c6d2c7bcfcbc3a1d32425bff3b3))
### [0.0.74](https://github.com/sasjs/server/compare/v0.0.73...v0.0.74) (2022-05-12)
### Bug Fixes
* csp updates ([7cfa239](https://github.com/sasjs/server/commit/7cfa2398e12c5e515d27c896f36ff91604c2124d))
### [0.0.73](https://github.com/sasjs/server/compare/v0.0.72...v0.0.73) (2022-05-10)
### Bug Fixes
* helmet config on http mode ([b0fdaaa](https://github.com/sasjs/server/commit/b0fdaaaa79e3135699c51effac0388d8ec5ab23b))
### [0.0.72](https://github.com/sasjs/server/compare/v0.0.71...v0.0.72) (2022-05-09)
### [0.0.71](https://github.com/sasjs/server/compare/v0.0.70...v0.0.71) (2022-05-07)
### Bug Fixes
* added more cookies to req ([4a8e32d](https://github.com/sasjs/server/commit/4a8e32dd20b540b6dc92d749fad90d6c7fc69376))
* bumping core ([c0b57b9](https://github.com/sasjs/server/commit/c0b57b9e76d6db33fc64a68556a8be979dd69e40))
* reqHeadrs.txt will contain headers to access APIs ([636301e](https://github.com/sasjs/server/commit/636301e664416fb085f704d83deb7f39ee0a91a7))
### [0.0.70](https://github.com/sasjs/server/compare/v0.0.69...v0.0.70) (2022-05-06)
### Features
* CSP_DISABLE env option ([dd3acce](https://github.com/sasjs/server/commit/dd3acce3935e7cfc0b2c44a401314306915a3a10))
### [0.0.69](https://github.com/sasjs/server/compare/v0.0.68...v0.0.69) (2022-05-02)
### Bug Fixes
* **upload:** appStream uses CSRF + Session authentication ([1f89279](https://github.com/sasjs/server/commit/1f8927926405887f3d134c0a1dd6452ffa33876e))
### [0.0.68](https://github.com/sasjs/server/compare/v0.0.67...v0.0.68) (2022-05-02)
### Bug Fixes
* using monaco editor locally ([2548c82](https://github.com/sasjs/server/commit/2548c82dfe1149e62a570a00546dddd9e30049b1))
### [0.0.67](https://github.com/sasjs/server/compare/v0.0.66...v0.0.67) (2022-05-01)
### [0.0.66](https://github.com/sasjs/server/compare/v0.0.64...v0.0.66) (2022-05-01)
### Bug Fixes
* added swagger ui init file manually ([e2a97fc](https://github.com/sasjs/server/commit/e2a97fcb7c54a57a7ca118677cfce93fe9430d8f))
* consume swagger api with CSRF ([5aaac24](https://github.com/sasjs/server/commit/5aaac24080362d6ce0c5d1157798a9343f40ae2a))
### [0.0.65](https://github.com/sasjs/server/compare/v0.0.64...v0.0.65) (2022-05-01)
### Bug Fixes
* consume swagger api with CSRF ([5aaac24](https://github.com/sasjs/server/commit/5aaac24080362d6ce0c5d1157798a9343f40ae2a))
### [0.0.64](https://github.com/sasjs/server/compare/v0.0.63...v0.0.64) (2022-04-30)
### Bug Fixes
* removed fileExists for serving web ([7b39cc0](https://github.com/sasjs/server/commit/7b39cc06d358f5ffecb87955040c4eb0fcc7469e))
### [0.0.63](https://github.com/sasjs/server/compare/v0.0.62...v0.0.63) (2022-04-30)
### [0.0.62](https://github.com/sasjs/server/compare/v0.0.61...v0.0.62) (2022-04-30)
### [0.0.61](https://github.com/sasjs/server/compare/v0.0.59...v0.0.61) (2022-04-30)
### Bug Fixes
* added CSRF check for granting access via session authentication ([b060ad1](https://github.com/sasjs/server/commit/b060ad1b8e0bbc61c20dc25be553bba4cc4d2716))
* setting CSRF Token for only rendering SPA ([b4b60c6](https://github.com/sasjs/server/commit/b4b60c69cf67a42f4797f7f1afe68b7a5eec2998))
### [0.0.60](https://github.com/sasjs/server/compare/v0.0.59...v0.0.60) (2022-04-30)
### Bug Fixes
* added CSRF check for granting access via session authentication ([b060ad1](https://github.com/sasjs/server/commit/b060ad1b8e0bbc61c20dc25be553bba4cc4d2716))
* setting CSRF Token for only rendering SPA ([b4b60c6](https://github.com/sasjs/server/commit/b4b60c69cf67a42f4797f7f1afe68b7a5eec2998))
### [0.0.59](https://github.com/sasjs/server/compare/v0.0.58...v0.0.59) (2022-04-29)
### Features
* enabled csrf tokens for web component ([e462aeb](https://github.com/sasjs/server/commit/e462aebdc01f3c0068ed0074473a2063412dcf45))
* enabled session based authentication for web ([5da93f3](https://github.com/sasjs/server/commit/5da93f318aad10b1c67032a467191e4dbb99f411))
### Bug Fixes
* fetch client from DB for each request ([4ad8c81](https://github.com/sasjs/server/commit/4ad8c81e4927c1a82220ec015a781b095c8e859e))
* **web:** show display name instead of username ([e57443f](https://github.com/sasjs/server/commit/e57443f1ed662a022494bb93d79c3d2f10a2d082))
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.58](https://github.com/sasjs/server/compare/v0.0.57...v0.0.58) (2022-04-24)

View File

@@ -48,22 +48,15 @@ When launching the app, it will make use of specific environment variables. Thes
Example contents of a `.env` file:
```
#
## Core Settings
#
# MODE options: [desktop|server] default: `desktop`
# Desktop mode is single user and designed for workstation use
# Server mode is multi-user and suitable for intranet / internet use
# options: [desktop|server] default: `desktop`
MODE=
# Path to SAS executable (sas.exe / sas.sh)
SAS_PATH=/path/to/sas/executable.exe
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
# If enabled, be sure to also configure the WHITELIST of third party servers.
CORS=
# Path to working directory
# This location is for SAS WORK, staged files, DRIVE, configuration etc
SASJS_ROOT=./sasjs_root
# options: <http://localhost:3000 https://abc.com ...> space separated urls
WHITELIST=
# options: [http|https] default: http
PROTOCOL=
@@ -72,22 +65,16 @@ PROTOCOL=
PORT=
#
## Additional SAS Options
#
# optional
# for MODE: `desktop`, prompts user
# for MODE: `server` gets value from api/package.json `configuration.sasPath`
SAS_PATH=/path/to/sas/executable.exe
# On windows use SAS_OPTIONS and on unix use SASV9_OPTIONS
# Any options set here are automatically applied in the SAS session
# See: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostunx/p0wrdmqp8k0oyyn1xbx3bp3qy2wl.htm
# And: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostwin/p0drw76qo0gig2n1kcoliekh605k.htm#p09y7hx0grw1gin1giuvrjyx61m6
SAS_OPTIONS= -NOXCMD
SASV9_OPTIONS= -NOXCMD
#
## Additional Web Server Options
#
# optional
# for MODE: `desktop`, prompts user
# for MODE: `server` defaults to /tmp
DRIVE_PATH=/tmp
# ENV variables required for PROTOCOL: `https`
PRIVATE_KEY=privkey.pem
@@ -97,37 +84,15 @@ FULL_CHAIN=fullchain.pem
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
# If enabled, be sure to also configure the WHITELIST of third party servers.
CORS=
# options: <http://localhost:3000 https://abc.com ...> space separated urls
WHITELIST=
# HELMET Cross Origin Embedder Policy
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
# options: [true|false] default: true
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
HELMET_COEP=
# HELMET Content Security Policy
# Path to a json file containing HELMET `contentSecurityPolicy` directives
# Docs: https://helmetjs.github.io/#reference
#
# Example config:
# {
# "img-src": ["'self'", "data:"],
# "script-src": ["'self'", "'unsafe-inline'"],
# "script-src-attr": ["'self'", "'unsafe-inline'"]
# }
HELMET_CSP_CONFIG_PATH=./csp.config.json
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
LOG_FORMAT_MORGAN=
# SAS Options
# On windows use SAS_OPTIONS and on unix use SASV9_OPTIONS
# Any options set here are automatically applied in the SAS session
# See: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostunx/p0wrdmqp8k0oyyn1xbx3bp3qy2wl.htm
# And: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostwin/p0drw76qo0gig2n1kcoliekh605k.htm#p09y7hx0grw1gin1giuvrjyx61m6
SAS_OPTIONS= -NOXCMD
SASV9_OPTIONS= -NOXCMD
```
@@ -142,7 +107,7 @@ Normally the server process will stop when your terminal dies. To keep it going
Trigger the command using NOHUP, redirecting the output commands, eg `nohup ./api-linux > server.log 2>&1 &`.
You can now see the job running using the `jobs` command. To ensure that it will still run when your terminal is closed, execute the `disown` command. To kill it later, use the `kill -9 <pid>` command. You can see your sessions using `top -u <userid>`. Type `c` to see the commands being run against each pid.
You can now see the job running using the `jobs` command. To ensure that it will still run when your terminal is closed, execute the `disown` command. To kill it later, use the `kill -9 <pid>` command. You can see your sessions using `top -u <userid>`. Type `c` to see the commands being run against each pid.
### PM2
@@ -151,7 +116,7 @@ Install the npm package [pm2](https://www.npmjs.com/package/pm2) (`npm install p
```bash
export SAS_PATH=/opt/sas9/SASHome/SASFoundation/9.4/sasexe/sas
export PORT=5001
export SASJS_ROOT=./sasjs_root
export DRIVE_PATH=./tmp
pm2 start api-linux
```

View File

@@ -1,23 +1,14 @@
MODE=[desktop|server] default considered as desktop
CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE
WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
PROTOCOL=[http|https] default considered as http
PRIVATE_KEY=privkey.pem
FULL_CHAIN=fullchain.pem
PORT=[5000] default value is 5000
HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
HELMET_COEP=[true|false] if omitted HELMET default will be used
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
SASJS_ROOT=./sasjs_root
LOG_FORMAT_MORGAN=common
DRIVE_PATH=./tmp

View File

@@ -1,5 +0,0 @@
{
"img-src": ["'self'", "data:"],
"script-src": ["'self'", "'unsafe-inline'"],
"script-src-attr": ["'self'", "'unsafe-inline'"]
}

953
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,34 +47,25 @@
},
"author": "4GL Ltd",
"dependencies": {
"@sasjs/core": "^4.27.3",
"@sasjs/core": "^4.19.0",
"@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"csurf": "^1.11.0",
"express": "^4.17.1",
"express-session": "^1.17.2",
"helmet": "^5.0.2",
"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.3.0",
"unzipper": "^0.10.11",
"url": "^0.10.3"
"swagger-ui-express": "^4.1.6"
},
"devDependencies": {
"@types/adm-zip": "^0.5.0",
"@types/bcryptjs": "^2.4.2",
"@types/cookie-parser": "^1.4.2",
"@types/cors": "^2.8.12",
"@types/csurf": "^1.11.2",
"@types/express": "^4.17.12",
"@types/express-session": "^1.17.4",
"@types/jest": "^26.0.24",
"@types/jsonwebtoken": "^8.5.5",
"@types/mongoose-sequence": "^3.0.6",
@@ -83,14 +74,12 @@
"@types/node": "^15.12.2",
"@types/supertest": "^2.0.11",
"@types/swagger-ui-express": "^4.1.3",
"@types/unzipper": "^0.10.5",
"adm-zip": "^0.5.9",
"dotenv": "^10.0.0",
"http-headers-validation": "^0.0.1",
"jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0",
"nodemon": "^2.0.7",
"pkg": "5.6.0",
"pkg": "5.5.2",
"prettier": "^2.3.1",
"rimraf": "^3.0.2",
"supertest": "^6.1.3",
@@ -99,9 +88,12 @@
"tsoa": "3.14.1",
"typescript": "^4.3.2"
},
"configuration": {
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sas"
},
"nodemonConfig": {
"ignore": [
"sasjs_root/**/*"
"tmp/**/*"
]
}
}

View File

@@ -1,50 +0,0 @@
window.onload = function () {
// Build a system
var url = window.location.search.match(/url=([^&]+)/)
if (url && url.length > 1) {
url = decodeURIComponent(url[1])
} else {
url = window.location.origin
}
var options = {
customOptions: {
url: '/swagger.yaml',
requestInterceptor: function (request) {
request.credentials = 'include'
var cookie = document.cookie
var startIndex = cookie.indexOf('XSRF-TOKEN')
var csrf = cookie.slice(startIndex + 11).split('; ')[0]
request.headers['X-XSRF-TOKEN'] = csrf
return request
}
}
}
url = options.swaggerUrl || url
var urls = options.swaggerUrls
var customOptions = options.customOptions
var spec1 = options.swaggerDoc
var swaggerOptions = {
spec: spec1,
url: url,
urls: urls,
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: 'StandaloneLayout'
}
for (var attrname in customOptions) {
swaggerOptions[attrname] = customOptions[attrname]
}
var ui = SwaggerUIBundle(swaggerOptions)
if (customOptions.oauth) {
ui.initOAuth(customOptions.oauth)
}
if (customOptions.authAction) {
ui.authActions.authorize(customOptions.authAction)
}
window.ui = ui
}

View File

@@ -1,49 +0,0 @@
const inputElement = document.getElementById('fileId')
document.getElementById('uploadButton').addEventListener('click', function () {
inputElement.click()
})
inputElement.addEventListener(
'change',
function () {
const fileList = this.files /* now you can work with the file list */
updateFileUploadMessage('Requesting ...')
const file = fileList[0]
const formData = new FormData()
formData.append('file', file)
axios
.post('/SASjsApi/drive/deploy/upload', formData)
.then((res) => res.data)
.then((data) => {
return (
data.message +
'\nstreamServiceName: ' +
data.streamServiceName +
'\nrefreshing page once alert box closes.'
)
})
.then((message) => {
alert(message)
location.reload()
})
.catch((error) => {
alert(error.response.data)
resetFileUpload()
updateFileUploadMessage('Upload New App')
})
},
false
)
function updateFileUploadMessage(message) {
document.getElementById('uploadMessage').innerHTML = message
}
function resetFileUpload() {
inputElement.value = null
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,36 @@ components:
requestBodies: {}
responses: {}
schemas:
AuthorizeResponse:
properties:
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- code
type: object
additionalProperties: false
AuthorizePayload:
properties:
username:
type: string
description: 'Username for user'
example: secretuser
password:
type: string
description: 'Password for user'
example: secretpassword
clientId:
type: string
description: 'Client ID'
example: clientID1
required:
- username
- password
- clientId
type: object
additionalProperties: false
TokenResponse:
properties:
accessToken:
@@ -47,41 +77,6 @@ components:
- userId
type: object
additionalProperties: false
LoginPayload:
properties:
username:
type: string
description: 'Username for user'
example: secretuser
password:
type: string
description: 'Password for user'
example: secretpassword
required:
- username
- password
type: object
additionalProperties: false
AuthorizeResponse:
properties:
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- code
type: object
additionalProperties: false
AuthorizePayload:
properties:
clientId:
type: string
description: 'Client ID'
example: clientID1
required:
- clientId
type: object
additionalProperties: false
ClientPayload:
properties:
clientId:
@@ -310,21 +305,6 @@ components:
- displayName
type: object
additionalProperties: false
GroupResponse:
properties:
groupId:
type: number
format: double
name:
type: string
description:
type: string
required:
- groupId
- name
- description
type: object
additionalProperties: false
UserDetailsResponse:
properties:
id:
@@ -338,12 +318,6 @@ components:
type: boolean
isAdmin:
type: boolean
autoExec:
type: string
groups:
items:
$ref: '#/components/schemas/GroupResponse'
type: array
required:
- id
- displayName
@@ -373,16 +347,27 @@ components:
type: boolean
description: 'Account should be active or not, defaults to true'
example: 'true'
autoExec:
type: string
description: 'User-specific auto-exec code'
example: ""
required:
- displayName
- username
- password
type: object
additionalProperties: false
GroupResponse:
properties:
groupId:
type: number
format: double
name:
type: string
description:
type: string
required:
- groupId
- name
- description
type: object
additionalProperties: false
GroupDetailsResponse:
properties:
groupId:
@@ -425,27 +410,14 @@ components:
- description
type: object
additionalProperties: false
_LeanDocument__LeanDocument_T__:
properties: {}
type: object
Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__:
ExecuteReturnJsonPayload:
properties:
id:
description: 'The string version of this documents _id.'
_id:
$ref: '#/components/schemas/_LeanDocument__LeanDocument_T__'
description: 'This documents _id.'
__v:
description: 'This documents __v.'
_program:
type: string
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
description: 'From T, pick a set of properties whose keys are in the union K'
Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_:
$ref: '#/components/schemas/Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__'
description: 'Construct a type with the properties of T except for those in type K.'
LeanDocument_this_:
$ref: '#/components/schemas/Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_'
IGroup:
$ref: '#/components/schemas/LeanDocument_this_'
additionalProperties: false
InfoResponse:
properties:
mode:
@@ -465,14 +437,6 @@ components:
- protocol
type: object
additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
type: string
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
additionalProperties: false
securitySchemes:
bearerAuth:
type: http
@@ -486,6 +450,30 @@ info:
name: '4GL Ltd'
openapi: 3.0.0
paths:
/SASjsApi/auth/authorize:
post:
operationId: Authorize
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizeResponse'
examples:
'Example 1':
value: {code: someRandomCryptoString}
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
tags:
- Auth
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizePayload'
/SASjsApi/auth/token:
post:
operationId: Token
@@ -543,86 +531,6 @@ paths:
-
bearerAuth: []
parameters: []
/:
get:
operationId: Home
responses:
'200':
description: Ok
content:
application/json:
schema:
type: string
summary: 'Render index.html'
tags:
- Web
security: []
parameters: []
/SASLogon/login:
post:
operationId: Login
responses:
'200':
description: Ok
content:
application/json:
schema:
properties:
user: {properties: {displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [displayName, username, id], type: object}
loggedIn: {type: boolean}
required:
- user
- loggedIn
type: object
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginPayload'
/SASLogon/authorize:
post:
operationId: Authorize
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizeResponse'
examples:
'Example 1':
value: {code: someRandomCryptoString}
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizePayload'
/logout:
get:
operationId: Logout
responses:
'200':
description: Ok
content:
application/json:
schema: {}
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
/SASjsApi/client:
post:
operationId: CreateClient
@@ -748,8 +656,7 @@ paths:
examples:
'Example 1':
value: {status: failure, message: 'Deployment failed!'}
description: "Accepts JSON file and zipped compressed JSON file as well.\nCompressed file should only contain one JSON file and should have same name\nas of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip\nAny other file or JSON file in zipped will be ignored!"
summary: 'Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.'
summary: 'Creates/updates files within SASjs Drive using uploaded JSON file.'
tags:
- Drive
security:
@@ -1011,94 +918,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/UserPayload'
'/SASjsApi/user/by/username/{username}':
get:
operationId: GetUserByUsername
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/UserDetailsResponse'
description: 'Only Admin or user itself will get user autoExec code.'
summary: 'Get user properties - such as group memberships, userName, displayName.'
tags:
- User
security:
-
bearerAuth: []
parameters:
-
description: 'The User''s username'
in: path
name: username
required: true
schema:
type: string
example: johnSnow01
patch:
operationId: UpdateUserByUsername
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/UserDetailsResponse'
examples:
'Example 1':
value: {id: 1234, displayName: 'John Snow', username: johnSnow01, isAdmin: false, isActive: true}
summary: 'Update user properties - such as displayName. Can be performed either by admins, or the user in question.'
tags:
- User
security:
-
bearerAuth: []
parameters:
-
description: 'The User''s username'
in: path
name: username
required: true
schema:
type: string
example: johnSnow01
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserPayload'
delete:
operationId: DeleteUserByUsername
responses:
'204':
description: 'No content'
summary: 'Delete a user. Can be performed either by admins, or the user in question.'
tags:
- User
security:
-
bearerAuth: []
parameters:
-
description: 'The User''s username'
in: path
name: username
required: true
schema:
type: string
example: johnSnow01
requestBody:
required: true
content:
application/json:
schema:
properties:
password:
type: string
type: object
'/SASjsApi/user/{userId}':
get:
operationId: GetUser
@@ -1109,7 +928,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/UserDetailsResponse'
description: 'Only Admin or user itself will get user autoExec code.'
summary: 'Get user properties - such as group memberships, userName, displayName.'
tags:
- User
@@ -1237,30 +1055,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GroupPayload'
'/SASjsApi/group/by/groupname/{name}':
get:
operationId: GetGroupByGroupName
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/GroupDetailsResponse'
summary: 'Get list of members of a group (userName). All users can request this.'
tags:
- Group
security:
-
bearerAuth: []
parameters:
-
description: 'The group''s name'
in: path
name: name
required: true
schema:
type: string
'/SASjsApi/group/{groupId}':
get:
operationId: GetGroup
@@ -1290,14 +1084,8 @@ paths:
delete:
operationId: DeleteGroup
responses:
'200':
description: Ok
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/IGroup'}
- {properties: {_id: {}}, required: [_id], type: object}
'204':
description: 'No content'
summary: 'Delete a group. Admin task only.'
tags:
- Group
@@ -1389,24 +1177,6 @@ paths:
format: double
type: number
example: '6789'
/SASjsApi/info:
get:
operationId: Info
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/InfoResponse'
examples:
'Example 1':
value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http}
summary: 'Get server info (mode, cors, whiteList, protocol).'
tags:
- Info
security: []
parameters: []
/SASjsApi/session:
get:
operationId: Session
@@ -1489,6 +1259,24 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReturnJsonPayload'
/SASjsApi/info:
get:
operationId: Info
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/InfoResponse'
examples:
'Example 1':
value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http}
summary: 'Get server info (mode, cors, whiteList, protocol).'
tags:
- Info
security: []
parameters: []
servers:
-
url: /
@@ -1520,6 +1308,3 @@ tags:
-
name: CODE
description: 'Operations on SAS code'
-
name: Web
description: 'Operations on Web'

View File

@@ -1,89 +1,26 @@
import path from 'path'
import express, { ErrorRequestHandler } from 'express'
import csrf from 'csurf'
import session from 'express-session'
import MongoStore from 'connect-mongo'
import morgan from 'morgan'
import cookieParser from 'cookie-parser'
import dotenv from 'dotenv'
import cors from 'cors'
import helmet from 'helmet'
import {
connectDB,
copySASjsCore,
CorsType,
getWebBuildFolder,
HelmetCoepType,
instantiateLogger,
getWebBuildFolderPath,
loadAppStreamConfig,
ModeType,
ProtocolType,
ReturnCode,
setProcessVariables,
setupFolders,
verifyEnvVariables
setupFolders
} from './utils'
import { getEnvCSPDirectives } from './utils/parseHelmetConfig'
dotenv.config()
instantiateLogger()
if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
const app = express()
app.use(cookieParser())
const { MODE, CORS, WHITELIST } = process.env
const {
MODE,
CORS,
WHITELIST,
PROTOCOL,
HELMET_CSP_CONFIG_PATH,
HELMET_COEP,
LOG_FORMAT_MORGAN
} = process.env
app.use(morgan(LOG_FORMAT_MORGAN as string))
export const cookieOptions = {
secure: PROTOCOL === ProtocolType.HTTPS,
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
HELMET_CSP_CONFIG_PATH
)
if (PROTOCOL === ProtocolType.HTTP)
cspConfigJson['upgrade-insecure-requests'] = null
/***********************************
* CSRF Protection *
***********************************/
export const csrfProtection = csrf({ cookie: cookieOptions })
/***********************************
* Handle security and origin *
***********************************/
app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
...cspConfigJson
}
},
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
})
)
/***********************************
* Enabling CORS *
***********************************/
if (CORS === CorsType.ENABLED) {
if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') {
const whiteList: string[] = []
WHITELIST?.split(' ')
?.filter((url) => !!url)
@@ -97,40 +34,12 @@ if (CORS === CorsType.ENABLED) {
app.use(cors({ credentials: true, origin: whiteList }))
}
/***********************************
* DB Connection & *
* Express Sessions *
* With Mongo Store *
***********************************/
if (MODE === ModeType.Server) {
let store: MongoStore | undefined
// NOTE: when exporting app.js as agent for supertest
// we should exclude connecting to the real database
if (process.env.NODE_ENV !== 'test') {
const clientPromise = connectDB().then((conn) => conn!.getClient() as any)
store = MongoStore.create({ clientPromise, collectionName: 'sessions' })
}
app.use(
session({
secret: process.env.SESSION_SECRET as string,
saveUninitialized: false, // don't create session until something stored
resave: false, //don't save session if unmodified
store,
cookie: cookieOptions
})
)
}
app.use(cookieParser())
app.use(morgan('tiny'))
app.use(express.json({ limit: '100mb' }))
app.use(express.static(path.join(__dirname, '../public')))
const onError: ErrorRequestHandler = (err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN')
return res.status(400).send('Invalid CSRF token!')
console.error(err.stack)
res.status(500).send('Something broke!')
}
@@ -148,9 +57,10 @@ export default setProcessVariables().then(async () => {
// should be served after setting up web route
// index.html needs to be injected with some js script.
app.use(express.static(getWebBuildFolder()))
app.use(express.static(getWebBuildFolderPath()))
app.use(onError)
await connectDB()
return app
})

View File

@@ -1,8 +1,10 @@
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
@@ -22,6 +24,20 @@ export class AuthController {
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
*
@@ -62,6 +78,30 @@ export class AuthController {
}
}
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
@@ -99,6 +139,32 @@ 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

View File

@@ -1,13 +1,9 @@
import express from 'express'
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
import { ExecuteReturnJson, ExecutionController } from './internal'
import { PreProgramVars } from '../types'
import { ExecuteReturnJsonResponse } from '.'
import {
getPreProgramVariables,
getUserAutoExec,
ModeType,
parseLogToArray
} from '../utils'
import { parseLogToArray } from '../utils'
interface ExecuteSASCodePayload {
/**
@@ -34,23 +30,14 @@ export class CodeController {
}
}
const executeSASCode = async (
req: express.Request,
{ code }: ExecuteSASCodePayload
) => {
const { user } = req
const userAutoExec =
process.env.MODE === ModeType.Server
? user?.autoExec
: await getUserAutoExec()
const executeSASCode = async (req: any, { code }: ExecuteSASCodePayload) => {
try {
const { webout, log, httpHeaders } =
(await new ExecutionController().executeProgram(
code,
getPreProgramVariables(req),
{ ...req.query, _debug: 131 },
{ userAutoExec },
undefined,
true
)) as ExecuteReturnJson
@@ -69,3 +56,16 @@ const executeSASCode = async (
}
}
}
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
}
}

View File

@@ -32,7 +32,7 @@ import {
import { createFileTree, ExecutionController, getTreeExample } from './internal'
import { TreeNode } from '../types'
import { getFilesFolder } from '../utils'
import { getTmpFilesFolderPath } from '../utils'
interface DeployPayload {
appLoc: string
@@ -96,12 +96,7 @@ export class DriveController {
}
/**
* Accepts JSON file and zipped compressed JSON file as well.
* Compressed file should only contain one JSON file and should have same name
* as of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip
* Any other file or JSON file in zipped will be ignored!
*
* @summary Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.
* @summary Creates/updates files within SASjs Drive using uploaded JSON file.
*
*/
@Example<DeployResponse>(successDeployResponse)
@@ -219,12 +214,12 @@ const getFileTree = () => {
}
const deploy = async (data: DeployPayload) => {
const driveFilesPath = getFilesFolder()
const driveFilesPath = getTmpFilesFolderPath()
const appLocParts = data.appLoc.replace(/^\//, '').split('/')
const appLocPath = path
.join(getFilesFolder(), ...appLocParts)
.join(getTmpFilesFolderPath(), ...appLocParts)
.replace(new RegExp('/', 'g'), path.sep)
if (!appLocPath.includes(driveFilesPath)) {
@@ -243,10 +238,10 @@ const deploy = async (data: DeployPayload) => {
}
const getFile = async (req: express.Request, filePath: string) => {
const driveFilesPath = getFilesFolder()
const driveFilesPath = getTmpFilesFolderPath()
const filePathFull = path
.join(getFilesFolder(), filePath)
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
@@ -266,11 +261,11 @@ const getFile = async (req: express.Request, filePath: string) => {
}
const getFolder = async (folderPath?: string) => {
const driveFilesPath = getFilesFolder()
const driveFilesPath = getTmpFilesFolderPath()
if (folderPath) {
const folderPathFull = path
.join(getFilesFolder(), folderPath)
.join(getTmpFilesFolderPath(), folderPath)
.replace(new RegExp('/', 'g'), path.sep)
if (!folderPathFull.includes(driveFilesPath)) {
@@ -296,10 +291,10 @@ const getFolder = async (folderPath?: string) => {
}
const deleteFile = async (filePath: string) => {
const driveFilesPath = getFilesFolder()
const driveFilesPath = getTmpFilesFolderPath()
const filePathFull = path
.join(getFilesFolder(), filePath)
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
@@ -319,7 +314,7 @@ const saveFile = async (
filePath: string,
multerFile: Express.Multer.File
): Promise<GetFileResponse> => {
const driveFilesPath = getFilesFolder()
const driveFilesPath = getTmpFilesFolderPath()
const filePathFull = path
.join(driveFilesPath, filePath)
@@ -344,7 +339,7 @@ const updateFile = async (
filePath: string,
multerFile: Express.Multer.File
): Promise<GetFileResponse> => {
const driveFilesPath = getFilesFolder()
const driveFilesPath = getTmpFilesFolderPath()
const filePathFull = path
.join(driveFilesPath, filePath)

View File

@@ -14,7 +14,7 @@ import Group, { GroupPayload } from '../model/Group'
import User from '../model/User'
import { UserResponse } from './user'
export interface GroupResponse {
interface GroupResponse {
groupId: number
name: string
description: string
@@ -28,11 +28,6 @@ interface GroupDetailsResponse {
users: UserResponse[]
}
interface GetGroupBy {
groupId?: number
name?: string
}
@Security('bearerAuth')
@Route('SASjsApi/group')
@Tags('Group')
@@ -71,18 +66,6 @@ export class GroupController {
return createGroup(body)
}
/**
* @summary Get list of members of a group (userName). All users can request this.
* @param name The group's name
* @example dcgroup
*/
@Get('by/groupname/{name}')
public async getGroupByGroupName(
@Path() name: string
): Promise<GroupDetailsResponse> {
return getGroup({ name })
}
/**
* @summary Get list of members of a group (userName). All users can request this.
* @param groupId The group's identifier
@@ -92,7 +75,7 @@ export class GroupController {
public async getGroup(
@Path() groupId: number
): Promise<GroupDetailsResponse> {
return getGroup({ groupId })
return getGroup(groupId)
}
/**
@@ -146,13 +129,9 @@ export class GroupController {
*/
@Delete('{groupId}')
public async deleteGroup(@Path() groupId: number) {
const group = await Group.findOne({ groupId })
if (group) return await group.remove()
throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
const { deletedCount } = await Group.deleteOne({ groupId })
if (deletedCount) return
throw new Error('No Group deleted!')
}
}
@@ -166,15 +145,6 @@ const createGroup = async ({
description,
isActive
}: GroupPayload): Promise<GroupDetailsResponse> => {
// Checking if user is already in the database
const groupnameExist = await Group.findOne({ name })
if (groupnameExist)
throw {
code: 409,
status: 'Conflict',
message: 'Group name already exists.'
}
const group = new Group({
name,
description,
@@ -192,20 +162,15 @@ const createGroup = async ({
}
}
const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
const getGroup = async (groupId: number): Promise<GroupDetailsResponse> => {
const group = (await Group.findOne(
findBy,
{ groupId },
'groupId name description isActive users -_id'
).populate(
'users',
'id username displayName -_id'
)) as unknown as GroupDetailsResponse
if (!group)
throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
if (!group) throw new Error('Group not found.')
return {
groupId: group.groupId,
@@ -234,34 +199,16 @@ const updateUsersListInGroup = async (
action: 'addUser' | 'removeUser'
): Promise<GroupDetailsResponse> => {
const group = await Group.findOne({ groupId })
if (!group)
throw {
code: 404,
status: 'Not Found',
message: 'Group not found.'
}
if (!group) throw new Error('Group not found.')
const user = await User.findOne({ id: userId })
if (!user)
throw {
code: 404,
status: 'Not Found',
message: 'User not found.'
}
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 {
code: 400,
status: 'Bad Request',
message: 'Unable to update group.'
}
if (action === 'addUser') user.addGroup(group._id)
else user.removeGroup(group._id)
if (!updatedGroup) throw new Error('Unable to update group')
return {
groupId: updatedGroup.groupId,

View File

@@ -3,8 +3,7 @@ export * from './client'
export * from './code'
export * from './drive'
export * from './group'
export * from './info'
export * from './session'
export * from './stp'
export * from './user'
export * from './web'
export * from './info'

View File

@@ -25,8 +25,9 @@ export class InfoController {
const response = {
mode: process.env.MODE ?? 'desktop',
cors:
process.env.CORS ||
(process.env.MODE === 'server' ? 'disable' : 'enable'),
process.env.CORS ?? process.env.MODE === 'server'
? 'disable'
: 'enable',
whiteList:
process.env.WHITELIST?.split(' ')?.filter((url) => !!url) ?? [],
protocol: process.env.PROTOCOL ?? 'http'

View File

@@ -12,8 +12,8 @@ import { PreProgramVars, Session, TreeNode } from '../../types'
import {
extractHeaders,
generateFileUploadSasCode,
getFilesFolder,
getMacrosFolder,
getTmpFilesFolderPath,
getTmpMacrosPath,
HTTPHeaders,
isDebugOn
} from '../../utils'
@@ -43,7 +43,7 @@ export class ExecutionController {
session?: Session
) {
if (!(await fileExists(programPath)))
throw `The Stored Program at (${vars._program}) does not exist, or you do not have permission to view it.`
throw 'ExecutionController: SAS file does not exist.'
const program = await readFile(programPath)
@@ -75,12 +75,12 @@ export class ExecutionController {
const logPath = path.join(session.path, 'log.log')
const headersPath = path.join(session.path, 'stpsrv_header.txt')
const weboutPath = path.join(session.path, 'webout.txt')
const tokenFile = path.join(session.path, 'reqHeaders.txt')
const tokenFile = path.join(session.path, 'accessToken.txt')
await createFile(weboutPath, '')
await createFile(
tokenFile,
preProgramVariables?.httpHeaders.join('\n') ?? ''
preProgramVariables?.accessToken ?? 'accessToken'
)
const varStatments = Object.keys(vars).reduce(
@@ -110,7 +110,7 @@ export class ExecutionController {
`
program = `
options insert=(SASAUTOS="${getMacrosFolder()}");
options insert=(SASAUTOS="${getTmpMacrosPath()}");
/* runtime vars */
${varStatments}
@@ -119,10 +119,6 @@ filename _webout "${weboutPath}" mod;
/* dynamic user-provided vars */
${preProgramVarStatments}
/* user autoexec starts */
${otherArgs?.userAutoExec ?? ''}
/* user autoexec ends */
/* actual job code */
${program}`
@@ -195,7 +191,7 @@ ${program}`
const root: TreeNode = {
name: 'files',
relativePath: '',
absolutePath: getFilesFolder(),
absolutePath: getTmpFilesFolderPath(),
children: []
}

View File

@@ -1,15 +1,14 @@
import { Request, RequestHandler } from 'express'
import multer from 'multer'
import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.'
export class FileUploadController {
private storage = multer.diskStorage({
destination: function (req: Request, file: any, cb: any) {
destination: function (req: any, file: any, cb: any) {
//Sending the intercepted files to the sessions subfolder
cb(null, req.sasSession?.path)
cb(null, req.sasSession.path)
},
filename: function (req: Request, file: any, cb: any) {
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, '')}`)
}
@@ -19,7 +18,7 @@ export class FileUploadController {
//It will intercept request and generate unique uuid to be used as a subfolder name
//that will store the files uploaded
public preUploadMiddleware: RequestHandler = async (req, res, next) => {
public preUploadMiddleware = async (req: any, res: any, next: any) => {
let session
const sessionController = getSessionController()

View File

@@ -3,7 +3,7 @@ import { Session } from '../../types'
import { promisify } from 'util'
import { execFile } from 'child_process'
import {
getSessionsFolder,
getTmpSessionsFolderPath,
generateUniqueFileName,
sysInitCompiledPath
} from '../../utils'
@@ -37,7 +37,7 @@ export class SessionController {
private async createSession(): Promise<Session> {
const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(getSessionsFolder(), sessionId)
const sessionFolder = path.join(getTmpSessionsFolderPath(), sessionId)
const creationTimeStamp = sessionId.split('-').pop() as string
// death time of session is 15 mins from creation
@@ -82,8 +82,6 @@ ${autoExecContent}`
// however we also need a promise so that we can update the
// session array to say that it has (eventually) finished.
// Additional windows specific options to avoid the desktop popups.
execFilePromise(process.sasLoc, [
'-SYSIN',
codePath,
@@ -95,11 +93,7 @@ ${autoExecContent}`
session.path,
'-AUTOEXEC',
autoExecPath,
'-ENCODING',
'UTF-8',
process.platform === 'win32' ? '-nosplash' : '',
process.platform === 'win32' ? '-icon' : '',
process.platform === 'win32' ? '-nologo' : ''
process.platform === 'win32' ? '-nosplash' : ''
])
.then(() => {
session.completed = true

View File

@@ -1,5 +1,5 @@
import path from 'path'
import { getFilesFolder } from '../../utils/file'
import { getTmpFilesFolderPath } from '../../utils/file'
import {
createFolder,
createFile,
@@ -17,7 +17,7 @@ export const createFileTree = async (
parentFolders: string[] = []
) => {
const destinationPath = path.join(
getFilesFolder(),
getTmpFilesFolderPath(),
path.join(...parentFolders)
)

View File

@@ -23,8 +23,8 @@ export class SessionController {
}
}
const session = (req: express.Request) => ({
id: req.user!.userId,
username: req.user!.username,
displayName: req.user!.displayName
const session = (req: any) => ({
id: req.user.id,
username: req.user.username,
displayName: req.user.displayName
})

View File

@@ -17,16 +17,15 @@ import {
ExecutionController,
ExecutionVars
} from './internal'
import { PreProgramVars } from '../types'
import {
getPreProgramVariables,
getFilesFolder,
getTmpFilesFolderPath,
HTTPHeaders,
isDebugOn,
LogLine,
makeFilesNamesMap,
parseLogToArray
} from '../utils'
import { MulterFile } from '../types/Upload'
interface ExecuteReturnJsonPayload {
/**
@@ -133,7 +132,7 @@ const executeReturnRaw = async (
const query = req.query as ExecutionVars
const sasCodePath =
path
.join(getFilesFolder(), _program)
.join(getTmpFilesFolderPath(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
try {
@@ -168,17 +167,15 @@ const executeReturnRaw = async (
}
const executeReturnJson = async (
req: express.Request,
req: any,
_program: string
): Promise<ExecuteReturnJsonResponse> => {
const sasCodePath =
path
.join(getFilesFolder(), _program)
.join(getTmpFilesFolderPath(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
const filesNamesMap = req.files?.length
? makeFilesNamesMap(req.files as MulterFile[])
: null
const filesNamesMap = req.files?.length ? makeFilesNamesMap(req.files) : null
try {
const { webout, log, httpHeaders } =
@@ -213,3 +210,16 @@ const executeReturnJson = async (
}
}
}
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
}
}

View File

@@ -1,4 +1,3 @@
import express from 'express'
import {
Security,
Route,
@@ -11,14 +10,10 @@ import {
Patch,
Delete,
Body,
Hidden,
Request
Hidden
} from 'tsoa'
import { desktopUser } from '../middlewares'
import User, { UserPayload } from '../model/User'
import { getUserAutoExec, updateUserAutoExec, ModeType } from '../utils'
import { GroupResponse } from './group'
export interface UserResponse {
id: number
@@ -32,8 +27,6 @@ interface UserDetailsResponse {
username: string
isActive: boolean
isAdmin: boolean
autoExec?: string
groups?: GroupResponse[]
}
@Security('bearerAuth')
@@ -80,68 +73,13 @@ export class UserController {
}
/**
* Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName.
* @param username The User's username
* @example username "johnSnow01"
*/
@Get('by/username/{username}')
public async getUserByUsername(
@Request() req: express.Request,
@Path() username: string
): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
const { user } = req
const getAutoExec = user!.isAdmin || user!.username == username
return getUser({ username }, getAutoExec)
}
/**
* Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName.
* @param userId The user's identifier
* @example userId 1234
*/
@Get('{userId}')
public async getUser(
@Request() req: express.Request,
@Path() userId: number
): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
const { user } = req
const getAutoExec = user!.isAdmin || user!.userId == userId
return getUser({ id: userId }, getAutoExec)
}
/**
* @summary Update user properties - such as displayName. Can be performed either by admins, or the user in question.
* @param username The User's username
* @example username "johnSnow01"
*/
@Example<UserDetailsResponse>({
id: 1234,
displayName: 'John Snow',
username: 'johnSnow01',
isAdmin: false,
isActive: true
})
@Patch('by/username/{username}')
public async updateUserByUsername(
@Path() username: string,
@Body() body: UserPayload
): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop)
return updateDesktopAutoExec(body.autoExec ?? '')
return updateUser({ username }, body)
public async getUser(@Path() userId: number): Promise<UserDetailsResponse> {
return getUser(userId)
}
/**
@@ -161,26 +99,7 @@ export class UserController {
@Path() userId: number,
@Body() body: UserPayload
): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop)
return updateDesktopAutoExec(body.autoExec ?? '')
return updateUser({ id: userId }, body)
}
/**
* @summary Delete a user. Can be performed either by admins, or the user in question.
* @param username The User's username
* @example username "johnSnow01"
*/
@Delete('by/username/{username}')
public async deleteUserByUsername(
@Path() username: string,
@Body() body: { password?: string },
@Query() @Hidden() isAdmin: boolean = false
) {
return deleteUser({ username }, isAdmin, body)
return updateUser(userId, body)
}
/**
@@ -194,7 +113,7 @@ export class UserController {
@Body() body: { password?: string },
@Query() @Hidden() isAdmin: boolean = false
) {
return deleteUser({ id: userId }, isAdmin, body)
return deleteUser(userId, isAdmin, body)
}
}
@@ -204,7 +123,7 @@ const getAllUsers = async (): Promise<UserResponse[]> =>
.exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive, autoExec } = data
const { displayName, username, password, isAdmin, isActive } = data
// Checking if user is already in the database
const usernameExist = await User.findOne({ username })
@@ -219,8 +138,7 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
username,
password: hashPassword,
isAdmin,
isActive,
autoExec
isActive
})
const savedUser = await user.save()
@@ -230,67 +148,38 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
displayName: savedUser.displayName,
username: savedUser.username,
isActive: savedUser.isActive,
isAdmin: savedUser.isAdmin,
autoExec: savedUser.autoExec
isAdmin: savedUser.isAdmin
}
}
interface GetUserBy {
id?: number
username?: string
}
const getUser = async (
findBy: GetUserBy,
getAutoExec: boolean
): Promise<UserDetailsResponse> => {
const user = (await User.findOne(
findBy,
`id displayName username isActive isAdmin autoExec -_id`
).populate(
'groups',
'groupId name description -_id'
)) as unknown as UserDetailsResponse
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 {
id: user.id,
displayName: user.displayName,
username: user.username,
isActive: user.isActive,
isAdmin: user.isAdmin,
autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
groups: user.groups
}
}
const getDesktopAutoExec = async () => {
return {
...desktopUser,
id: desktopUser.userId,
autoExec: await getUserAutoExec()
}
return user
}
const updateUser = async (
findBy: GetUserBy,
data: Partial<UserPayload>
id: number,
data: UserPayload
): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive, autoExec } = data
const { displayName, username, password, isAdmin, isActive } = data
const params: any = { displayName, isAdmin, isActive, autoExec }
const params: any = { displayName, isAdmin, isActive }
if (username) {
// Checking if user is already in the database
const usernameExist = await User.findOne({ username })
if (usernameExist) {
if (
(findBy.id && usernameExist.id != findBy.id) ||
(findBy.username && usernameExist.username != findBy.username)
)
throw new Error('Username already exists.')
}
if (usernameExist?.id != id) throw new Error('Username already exists.')
params.username = username
}
@@ -299,36 +188,27 @@ const updateUser = async (
params.password = User.hashPassword(password)
}
const updatedUser = await User.findOneAndUpdate(findBy, params, { new: true })
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')
if (!updatedUser)
throw new Error(`Unable to find user with ${findBy.id || findBy.username}`)
return {
id: updatedUser.id,
username: updatedUser.username,
displayName: updatedUser.displayName,
isAdmin: updatedUser.isAdmin,
isActive: updatedUser.isActive,
autoExec: updatedUser.autoExec
}
}
const updateDesktopAutoExec = async (autoExec: string) => {
await updateUserAutoExec(autoExec)
return {
...desktopUser,
id: desktopUser.userId,
autoExec
}
return updatedUser
}
const deleteUser = async (
findBy: GetUserBy,
id: number,
isAdmin: boolean,
{ password }: { password?: string }
) => {
const user = await User.findOne(findBy)
const user = await User.findOne({ id })
if (!user) throw new Error('User is not found.')
if (!isAdmin) {
@@ -336,5 +216,5 @@ const deleteUser = async (
if (!validPass) throw new Error('Invalid password.')
}
await User.deleteOne(findBy)
await User.deleteOne({ id })
}

View File

@@ -1,158 +0,0 @@
import path from 'path'
import express from 'express'
import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
import { readFile } from '@sasjs/utils'
import User from '../model/User'
import Client from '../model/Client'
import { getWebBuildFolder, generateAuthCode } from '../utils'
import { InfoJWT } from '../types'
import { AuthController } from './auth'
@Route('/')
@Tags('Web')
export class WebController {
/**
* @summary Render index.html
*
*/
@Get('/')
public async home() {
return home()
}
/**
* @summary Accept a valid username/password
*
*/
@Post('/SASLogon/login')
public async login(
@Request() req: express.Request,
@Body() body: LoginPayload
) {
return login(req, body)
}
/**
* @summary Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE
*
*/
@Example<AuthorizeResponse>({
code: 'someRandomCryptoString'
})
@Post('/SASLogon/authorize')
public async authorize(
@Request() req: express.Request,
@Body() body: AuthorizePayload
): Promise<AuthorizeResponse> {
return authorize(req, body.clientId)
}
/**
* @summary Accept a valid username/password
*
*/
@Get('/logout')
public async logout(@Request() req: express.Request) {
return new Promise((resolve) => {
req.session.destroy(() => {
resolve(true)
})
})
}
}
const home = async () => {
const indexHtmlPath = path.join(getWebBuildFolder(), 'index.html')
// Attention! Cannot use fileExists here,
// due to limitation after building executable
const content = await readFile(indexHtmlPath)
return content
}
const login = async (
req: express.Request,
{ username, password }: LoginPayload
) => {
// 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.')
req.session.loggedIn = true
req.session.user = {
userId: user.id,
clientId: 'web_app',
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin,
isActive: user.isActive,
autoExec: user.autoExec
}
return {
loggedIn: true,
user: {
id: user.id,
username: user.username,
displayName: user.displayName
}
}
}
const authorize = async (
req: express.Request,
clientId: string
): Promise<AuthorizeResponse> => {
const userId = req.session.user?.userId
if (!userId) throw new Error('Invalid userId.')
const client = await Client.findOne({ clientId })
if (!client) throw new Error('Invalid clientId.')
// generate authorization code against clientId
const userInfo: InfoJWT = {
clientId,
userId
}
const code = AuthController.saveCode(
userId,
clientId,
generateAuthCode(userInfo)
)
return { code }
}
interface LoginPayload {
/**
* Username for user
* @example "secretuser"
*/
username: string
/**
* Password for user
* @example "secretpassword"
*/
password: string
}
interface AuthorizePayload {
/**
* Client ID
* @example "clientID1"
*/
clientId: string
}
interface AuthorizeResponse {
/**
* Authorization code
* @example "someRandomCryptoString"
*/
code: string
}

View File

@@ -1,36 +1,7 @@
import { RequestHandler, Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { csrfProtection } from '../app'
import { fetchLatestAutoExec, ModeType, verifyTokenInDB } from '../utils'
import { desktopUser } from './desktop'
export const authenticateAccessToken: RequestHandler = async (
req,
res,
next
) => {
const { MODE } = process.env
if (MODE === ModeType.Desktop) {
req.user = desktopUser
return next()
}
// if request is coming from web and has valid session
// it can be validated.
if (req.session?.loggedIn) {
if (req.session.user) {
const user = await fetchLatestAutoExec(req.session.user)
if (user) {
if (user.isActive) {
req.user = user
return csrfProtection(req, res, next)
} else return res.sendStatus(401)
}
}
return res.sendStatus(401)
}
import { verifyTokenInDB } from '../utils'
export const authenticateAccessToken = (req: any, res: any, next: any) => {
authenticateToken(
req,
res,
@@ -40,7 +11,7 @@ export const authenticateAccessToken: RequestHandler = async (
)
}
export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
export const authenticateRefreshToken = (req: any, res: any, next: any) => {
authenticateToken(
req,
res,
@@ -51,16 +22,16 @@ export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
}
const authenticateToken = (
req: Request,
res: Response,
next: NextFunction,
req: any,
res: any,
next: any,
key: string,
tokenType: 'accessToken' | 'refreshToken'
) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
req.user = {
userId: 1234,
userId: '1234',
clientId: 'desktopModeClientId',
username: 'desktopModeUsername',
displayName: 'desktopModeDisplayName',
@@ -72,7 +43,9 @@ const authenticateToken = (
}
const authHeader = req.headers['authorization']
const token = authHeader?.split(' ')[1]
const token =
authHeader?.split(' ')[1] ??
(tokenType === 'accessToken' ? req.cookies.accessToken : '')
if (!token) return res.sendStatus(401)
jwt.verify(token, key, async (err: any, data: any) => {

View File

@@ -1,37 +1,18 @@
import { RequestHandler, Request } from 'express'
import { userInfo } from 'os'
import { RequestUser } from '../types'
import { ModeType } from '../utils'
const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1
const allowedInDesktopMode: { [key: string]: RegExp[] } = {
GET: [regexUser],
PATCH: [regexUser]
}
const reqAllowedInDesktopMode = (request: Request): boolean => {
const { method, originalUrl: url } = request
return !!allowedInDesktopMode[method]?.find((urlRegex) => urlRegex.test(url))
}
export const desktopRestrict: RequestHandler = (req, res, next) => {
export const desktopRestrict = (req: any, res: any, next: any) => {
const { MODE } = process.env
if (MODE === ModeType.Desktop) {
if (!reqAllowedInDesktopMode(req))
return res.status(403).send('Not Allowed while in Desktop Mode.')
}
if (MODE?.trim() !== 'server')
return res.status(403).send('Not Allowed while in Desktop Mode.')
next()
}
export const desktopUsername = (req: any, res: any, next: any) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server')
return res.status(200).send({
userId: 12345,
username: 'DESKTOPusername',
displayName: 'DESKTOP User'
})
export const desktopUser: RequestUser = {
userId: 12345,
clientId: 'desktop_app',
username: userInfo().username,
displayName: userInfo().username,
isAdmin: true,
isActive: true
next()
}

View File

@@ -1,13 +1,13 @@
import path from 'path'
import { Request } from 'express'
import multer, { FileFilterCallback, Options } from 'multer'
import { blockFileRegex, getUploadsFolder } from '../utils'
import { blockFileRegex, getTmpUploadsPath } from '../utils'
const fieldNameSize = 300
const fileSize = 104857600 // 100 MB
const storage = multer.diskStorage({
destination: getUploadsFolder(),
destination: getTmpUploadsPath(),
filename: function (
_req: Request,
file: Express.Multer.File,

View File

@@ -1,6 +1,4 @@
import { RequestHandler } from 'express'
export const verifyAdmin: RequestHandler = (req, res, next) => {
export const verifyAdmin = (req: any, res: any, next: any) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') return next()

View File

@@ -1,22 +1,9 @@
import { RequestHandler } from 'express'
// This middleware checks if a non-admin user trying to
// access information of other user
export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => {
export const verifyAdminIfNeeded = (req: any, res: any, next: any) => {
const { user } = req
const userId = parseInt(req.params.userId)
if (!user?.isAdmin) {
let adminAccountRequired: boolean = true
if (req.params.userId) {
adminAccountRequired = user?.userId !== parseInt(req.params.userId)
} else if (req.params.username) {
adminAccountRequired = user?.username !== req.params.username
}
if (adminAccountRequired)
return res.status(401).send('Admin account required')
if (!user.isAdmin && user.userId !== userId) {
return res.status(401).send('Admin account required')
}
next()
}

View File

@@ -1,5 +1,4 @@
import mongoose, { Schema, model, Document, Model } from 'mongoose'
import User from './User'
const AutoIncrement = require('mongoose-sequence')(mongoose)
export interface GroupPayload {
@@ -35,8 +34,7 @@ interface IGroupModel extends Model<IGroup> {}
const groupSchema = new Schema<IGroupDocument>({
name: {
type: String,
required: true,
unique: true
required: true
},
description: {
type: String,
@@ -48,7 +46,6 @@ const groupSchema = new Schema<IGroupDocument>({
},
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
})
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
// Hooks
@@ -58,17 +55,6 @@ groupSchema.post('save', function (group: IGroup, next: Function) {
})
})
// pre remove hook to remove all references of group from users
groupSchema.pre('remove', async function () {
const userIds = this.users
await Promise.all(
userIds.map(async (userId) => {
const user = await User.findById(userId)
user?.removeGroup(this._id)
})
)
})
// Instance Methods
groupSchema.method(
'addUser',

View File

@@ -27,26 +27,18 @@ export interface UserPayload {
* @example "true"
*/
isActive?: boolean
/**
* User-specific auto-exec code
* @example ""
*/
autoExec?: string
}
interface IUserDocument extends UserPayload, Document {
id: number
isAdmin: boolean
isActive: boolean
autoExec: string
groups: Schema.Types.ObjectId[]
tokens: [{ [key: string]: string }]
}
interface IUser extends IUserDocument {
comparePassword(password: string): boolean
addGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
removeGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
}
interface IUserModel extends Model<IUser> {
hashPassword(password: string): string
@@ -74,9 +66,6 @@ const userSchema = new Schema<IUserDocument>({
type: Boolean,
default: true
},
autoExec: {
type: String
},
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
tokens: [
{
@@ -108,28 +97,6 @@ userSchema.method('comparePassword', function (password: string): boolean {
if (bcrypt.compareSync(password, this.password)) return true
return false
})
userSchema.method(
'addGroup',
async function (groupObjectId: Schema.Types.ObjectId) {
const groupIdIndex = this.groups.indexOf(groupObjectId)
if (groupIdIndex === -1) {
this.groups.push(groupObjectId)
}
this.markModified('groups')
return this.save()
}
)
userSchema.method(
'removeGroup',
async function (groupObjectId: Schema.Types.ObjectId) {
const groupIdIndex = this.groups.indexOf(groupObjectId)
if (groupIdIndex > -1) {
this.groups.splice(groupIdIndex, 1)
}
this.markModified('groups')
return this.save()
}
)
export const User: IUserModel = model<IUser, IUserModel>('User', userSchema)

View File

@@ -1,24 +1,46 @@
import express from 'express'
import { AuthController } from '../../controllers/'
import Client from '../../model/Client'
import {
authenticateAccessToken,
authenticateRefreshToken
} from '../../middlewares'
import { authorizeValidation, tokenValidation } from '../../utils'
import {
authorizeValidation,
getDesktopFields,
tokenValidation
} from '../../utils'
import { InfoJWT } from '../../types'
const authRouter = express.Router()
const controller = new AuthController()
authRouter.post('/token', async (req, res) => {
const { error, value: body } = tokenValidation(req.body)
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.token(body)
const response = await controller.authorize(body)
res.send(response)
} catch (err: any) {
@@ -26,12 +48,25 @@ authRouter.post('/token', async (req, res) => {
}
})
authRouter.post('/refresh', authenticateRefreshToken, async (req, res) => {
const userInfo: InfoJWT = {
userId: req.user!.userId!,
clientId: req.user!.clientId!
}
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)
const { accessToken } = response
res.cookie('accessToken', accessToken).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)
@@ -41,12 +76,10 @@ authRouter.post('/refresh', authenticateRefreshToken, async (req, res) => {
}
})
authRouter.delete('/logout', authenticateAccessToken, async (req, res) => {
const userInfo: InfoJWT = {
userId: req.user!.userId!,
clientId: req.user!.clientId!
}
authRouter.delete('/logout', authenticateAccessToken, async (req: any, res) => {
const userInfo: InfoJWT = req.user
const controller = new AuthController()
try {
await controller.logout(userInfo)
} catch (e) {}

View File

@@ -7,12 +7,9 @@ import { multerSingle } from '../../middlewares/multer'
import { DriveController } from '../../controllers/'
import {
deployValidation,
extractJSONFromZip,
extractName,
fileBodyValidation,
fileParamValidation,
folderParamValidation,
isZipFile
folderParamValidation
} from '../../utils'
const controller = new DriveController()
@@ -52,24 +49,7 @@ driveRouter.post(
async (req, res) => {
if (!req.file) return res.status(400).send('"file" is not present.')
let fileContent: string = ''
const { value: zipFile } = isZipFile(req.file)
if (zipFile) {
fileContent = await extractJSONFromZip(zipFile)
const fileInZip = extractName(zipFile.originalname)
if (!fileContent) {
deleteFile(req.file.path)
return res
.status(400)
.send(
`No content present in ${fileInZip} of compressed file ${zipFile.originalname}`
)
}
} else {
fileContent = await readFile(req.file.path)
}
const fileContent = await readFile(req.file.path)
let jsonContent
try {

View File

@@ -1,7 +1,7 @@
import express from 'express'
import { GroupController } from '../../controllers/'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
import { getGroupValidation, registerGroupValidation } from '../../utils'
import { registerGroupValidation } from '../../utils'
const groupRouter = express.Router()
@@ -18,11 +18,7 @@ groupRouter.post(
const response = await controller.createGroup(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
res.status(403).send(err.toString())
}
}
)
@@ -33,73 +29,35 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
const response = await controller.getAllGroups()
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
res.status(403).send(err.toString())
}
})
groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
groupRouter.get('/:groupId', authenticateAccessToken, async (req: any, res) => {
const { groupId } = req.params
const controller = new GroupController()
try {
const response = await controller.getGroup(parseInt(groupId))
const response = await controller.getGroup(groupId)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
res.status(403).send(err.toString())
}
})
groupRouter.get(
'/by/groupname/:name',
authenticateAccessToken,
async (req, res) => {
const { error, value: params } = getGroupValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
const { name } = params
const controller = new GroupController()
try {
const response = await controller.getGroupByGroupName(name)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
}
}
)
groupRouter.post(
'/:groupId/:userId',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
async (req: any, res) => {
const { groupId, userId } = req.params
const controller = new GroupController()
try {
const response = await controller.addUserToGroup(
parseInt(groupId),
parseInt(userId)
)
const response = await controller.addUserToGroup(groupId, userId)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
res.status(403).send(err.toString())
}
}
)
@@ -108,22 +66,15 @@ groupRouter.delete(
'/:groupId/:userId',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
async (req: any, res) => {
const { groupId, userId } = req.params
const controller = new GroupController()
try {
const response = await controller.removeUserFromGroup(
parseInt(groupId),
parseInt(userId)
)
const response = await controller.removeUserFromGroup(groupId, userId)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
res.status(403).send(err.toString())
}
}
)
@@ -132,19 +83,15 @@ groupRouter.delete(
'/:groupId',
authenticateAccessToken,
verifyAdmin,
async (req, res) => {
async (req: any, res) => {
const { groupId } = req.params
const controller = new GroupController()
try {
await controller.deleteGroup(parseInt(groupId))
await controller.deleteGroup(groupId)
res.status(200).send('Group Deleted!')
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err.message)
res.status(403).send(err.toString())
}
}
)

View File

@@ -5,6 +5,7 @@ import swaggerUi from 'swagger-ui-express'
import {
authenticateAccessToken,
desktopRestrict,
desktopUsername,
verifyAdmin
} from '../../middlewares'
@@ -21,7 +22,7 @@ import sessionRouter from './session'
const router = express.Router()
router.use('/info', infoRouter)
router.use('/session', authenticateAccessToken, sessionRouter)
router.use('/session', desktopUsername, authenticateAccessToken, sessionRouter)
router.use('/auth', desktopRestrict, authRouter)
router.use(
'/client',
@@ -35,22 +36,12 @@ router.use('/group', desktopRestrict, groupRouter)
router.use('/stp', authenticateAccessToken, stpRouter)
router.use('/code', authenticateAccessToken, codeRouter)
router.use('/user', desktopRestrict, userRouter)
router.use(
'/',
swaggerUi.serve,
swaggerUi.setup(undefined, {
swaggerOptions: {
url: '/swagger.yaml',
requestInterceptor: (request: any) => {
request.credentials = 'include'
const cookie = document.cookie
const startIndex = cookie.indexOf('XSRF-TOKEN')
const csrf = cookie.slice(startIndex + 11).split('; ')[0]
request.headers['X-XSRF-TOKEN'] = csrf
return request
}
url: '/swagger.yaml'
}
})
)

View File

@@ -8,6 +8,7 @@ import {
ClientController,
AuthController
} from '../../../controllers/'
import { populateClients } from '../auth'
import { InfoJWT } from '../../../types'
import {
generateAccessToken,
@@ -41,6 +42,7 @@ describe('auth', () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
await clientController.createClient({ clientId, clientSecret })
await populateClients()
})
afterAll(async () => {
@@ -49,6 +51,114 @@ describe('auth', () => {
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,

View File

@@ -3,7 +3,6 @@ import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import AdmZip from 'adm-zip'
import {
folderExists,
@@ -22,17 +21,17 @@ import * as fileUtilModules from '../../../utils/file'
const timestamp = generateTimestamp()
const tmpFolder = path.join(process.cwd(), `tmp-${timestamp}`)
jest
.spyOn(fileUtilModules, 'getSasjsRootFolder')
.spyOn(fileUtilModules, 'getTmpFolderPath')
.mockImplementation(() => tmpFolder)
jest
.spyOn(fileUtilModules, 'getUploadsFolder')
.spyOn(fileUtilModules, 'getTmpUploadsPath')
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
import { generateAccessToken, saveTokensInDB } from '../../../utils/'
const { getFilesFolder } = fileUtilModules
const { getTmpFilesFolderPath } = fileUtilModules
const clientId = 'someclientID'
const user = {
@@ -73,52 +72,11 @@ describe('drive', () => {
})
describe('deploy', () => {
const makeRequest = async (payload: any, type: string = 'payload') => {
const requestUrl =
type === 'payload'
? '/SASjsApi/drive/deploy'
: '/SASjsApi/drive/deploy/upload'
if (type === 'payload') {
return await request(app)
.post(requestUrl)
.auth(accessToken, { type: 'bearer' })
.send({ appLoc: '/Public', fileTree: payload })
}
if (type === 'file') {
const deployContents = JSON.stringify({
appLoc: '/Public',
fileTree: payload
})
return await request(app)
.post(requestUrl)
.auth(accessToken, { type: 'bearer' })
.attach('file', Buffer.from(deployContents), 'deploy.json')
} else {
const deployContents = JSON.stringify({
appLoc: '/Public',
fileTree: payload
})
const zip = new AdmZip()
// add file directly
zip.addFile(
'deploy.json',
Buffer.from(deployContents, 'utf8'),
'entry comment goes here'
)
return await request(app)
.post(requestUrl)
.auth(accessToken, { type: 'bearer' })
.attach('file', zip.toBuffer(), 'deploy.json.zip')
}
}
const shouldFailAssertion = async (
payload: any,
type: string = 'payload'
) => {
const res = await makeRequest(payload, type)
const shouldFailAssertion = async (payload: any) => {
const res = await request(app)
.post('/SASjsApi/drive/deploy')
.auth(accessToken, { type: 'bearer' })
.send({ appLoc: '/Public', fileTree: payload })
expect(res.statusCode).toEqual(400)
@@ -199,10 +157,10 @@ describe('drive', () => {
expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
)
await expect(folderExists(getFilesFolder())).resolves.toEqual(true)
await expect(folderExists(getTmpFilesFolderPath())).resolves.toEqual(true)
const testJobFolder = path.join(
getFilesFolder(),
getTmpFilesFolderPath(),
'public',
'jobs',
'extract'
@@ -216,241 +174,7 @@ describe('drive', () => {
await expect(readFile(testJobFile)).resolves.toEqual(exampleService.code)
await deleteFolder(path.join(getFilesFolder(), 'public'))
})
describe('upload', () => {
it('should respond with payload example if valid JSON file was not provided', async () => {
await shouldFailAssertion(null, 'file')
await shouldFailAssertion(undefined, 'file')
await shouldFailAssertion('data', 'file')
await shouldFailAssertion({}, 'file')
await shouldFailAssertion(
{
userId: 1,
title: 'test is cool'
},
'file'
)
await shouldFailAssertion(
{
membersWRONG: []
},
'file'
)
await shouldFailAssertion(
{
members: {}
},
'file'
)
await shouldFailAssertion(
{
members: [
{
nameWRONG: 'jobs',
type: 'folder',
members: []
}
]
},
'file'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'WRONG',
members: []
}
]
},
'file'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'folder',
members: [
{
name: 'extract',
type: 'folder',
members: [
{
name: 'makedata1',
type: 'service',
codeWRONG: '%put Hello World!;'
}
]
}
]
}
]
},
'file'
)
})
it('should successfully deploy if valid JSON file was provided', async () => {
const deployContents = JSON.stringify({
appLoc: '/public',
fileTree: getTreeExample()
})
const res = await request(app)
.post('/SASjsApi/drive/deploy/upload')
.auth(accessToken, { type: 'bearer' })
.attach('file', Buffer.from(deployContents), 'deploy.json')
expect(res.statusCode).toEqual(200)
expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
)
await expect(folderExists(getFilesFolder())).resolves.toEqual(true)
const testJobFolder = path.join(
getFilesFolder(),
'public',
'jobs',
'extract'
)
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
const exampleService = getExampleService()
const testJobFile =
path.join(testJobFolder, exampleService.name) + '.sas'
await expect(fileExists(testJobFile)).resolves.toEqual(true)
await expect(readFile(testJobFile)).resolves.toEqual(
exampleService.code
)
await deleteFolder(path.join(getFilesFolder(), 'public'))
})
})
describe('upload - zipped', () => {
it('should respond with payload example if valid Zipped file was not provided', async () => {
await shouldFailAssertion(null, 'zip')
await shouldFailAssertion(undefined, 'zip')
await shouldFailAssertion('data', 'zip')
await shouldFailAssertion({}, 'zip')
await shouldFailAssertion(
{
userId: 1,
title: 'test is cool'
},
'zip'
)
await shouldFailAssertion(
{
membersWRONG: []
},
'zip'
)
await shouldFailAssertion(
{
members: {}
},
'zip'
)
await shouldFailAssertion(
{
members: [
{
nameWRONG: 'jobs',
type: 'folder',
members: []
}
]
},
'zip'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'WRONG',
members: []
}
]
},
'zip'
)
await shouldFailAssertion(
{
members: [
{
name: 'jobs',
type: 'folder',
members: [
{
name: 'extract',
type: 'folder',
members: [
{
name: 'makedata1',
type: 'service',
codeWRONG: '%put Hello World!;'
}
]
}
]
}
]
},
'zip'
)
})
it('should successfully deploy if valid Zipped file was provided', async () => {
const deployContents = JSON.stringify({
appLoc: '/public',
fileTree: getTreeExample()
})
const zip = new AdmZip()
// add file directly
zip.addFile(
'deploy.json',
Buffer.from(deployContents, 'utf8'),
'entry comment goes here'
)
const res = await request(app)
.post('/SASjsApi/drive/deploy/upload')
.auth(accessToken, { type: 'bearer' })
.attach('file', zip.toBuffer(), 'deploy.json.zip')
expect(res.statusCode).toEqual(200)
expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
)
await expect(folderExists(getFilesFolder())).resolves.toEqual(true)
const testJobFolder = path.join(
getFilesFolder(),
'public',
'jobs',
'extract'
)
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
const exampleService = getExampleService()
const testJobFile =
path.join(testJobFolder, exampleService.name) + '.sas'
await expect(fileExists(testJobFile)).resolves.toEqual(true)
await expect(readFile(testJobFile)).resolves.toEqual(
exampleService.code
)
await deleteFolder(path.join(getFilesFolder(), 'public'))
})
await deleteFolder(path.join(getTmpFilesFolderPath(), 'public'))
})
})
@@ -468,7 +192,7 @@ describe('drive', () => {
})
it('should get a SAS folder on drive having _folderPath as query param', async () => {
const pathToDrive = fileUtilModules.getFilesFolder()
const pathToDrive = fileUtilModules.getTmpFilesFolderPath()
const dirLevel1 = 'level1'
const dirLevel2 = 'level2'
@@ -543,7 +267,10 @@ describe('drive', () => {
const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas')
const filePath = '/my/path/code.sas'
const pathToCopy = path.join(fileUtilModules.getFilesFolder(), filePath)
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
await copy(fileToCopyPath, pathToCopy)
const res = await request(app)
@@ -606,7 +333,7 @@ describe('drive', () => {
const pathToUpload = `/my/path/code-${generateTimestamp()}.sas`
const pathToCopy = path.join(
fileUtilModules.getFilesFolder(),
fileUtilModules.getTmpFilesFolderPath(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
@@ -718,7 +445,7 @@ describe('drive', () => {
const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getFilesFolder(),
fileUtilModules.getTmpFilesFolderPath(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
@@ -740,7 +467,7 @@ describe('drive', () => {
const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getFilesFolder(),
fileUtilModules.getTmpFilesFolderPath(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
@@ -876,7 +603,10 @@ describe('drive', () => {
const fileToCopyContent = await readFile(fileToCopyPath)
const filePath = '/my/path/code.sas'
const pathToCopy = path.join(fileUtilModules.getFilesFolder(), filePath)
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
await copy(fileToCopyPath, pathToCopy)
const res = await request(app)

View File

@@ -23,7 +23,7 @@ const user = {
}
const group = {
name: 'dcgroup1',
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
@@ -70,32 +70,6 @@ describe('group', () => {
expect(res.body.users).toEqual([])
})
it('should respond with Conflict when group already exists with same name', async () => {
await groupController.createGroup(group)
const res = await request(app)
.post('/SASjsApi/group')
.auth(adminAccessToken, { type: 'bearer' })
.send(group)
.expect(409)
expect(res.text).toEqual('Group name already exists.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request when group name does not match the group name schema', async () => {
const res = await request(app)
.post('/SASjsApi/group')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...group, name: 'Wrong Group Name' })
.expect(400)
expect(res.text).toEqual(
'"name" must only contain alpha-numeric characters'
)
expect(res.body).toEqual({})
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).post('/SASjsApi/group').send().expect(401)
@@ -151,51 +125,14 @@ describe('group', () => {
expect(res.body).toEqual({})
})
it(`should delete group's reference from users' groups array`, async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser1 = await userController.createUser({
...user,
username: 'deletegroup1'
})
const dbUser2 = await userController.createUser({
...user,
username: 'deletegroup2'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id)
await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id)
await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res1 = await request(app)
.get(`/SASjsApi/user/${dbUser1.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res1.body.groups).toEqual([])
const res2 = await request(app)
.get(`/SASjsApi/user/${dbUser2.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res2.body.groups).toEqual([])
})
it('should respond with Not Found if groupId is incorrect', async () => {
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(404)
.expect(403)
expect(res.text).toEqual('Group not found.')
expect(res.text).toEqual('Error: No Group deleted!')
expect(res.body).toEqual({})
})
@@ -279,76 +216,16 @@ describe('group', () => {
expect(res.body).toEqual({})
})
it('should respond with Not Found if groupId is incorrect', async () => {
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(404)
.expect(403)
expect(res.text).toEqual('Group not found.')
expect(res.text).toEqual('Error: Group not found.')
expect(res.body).toEqual({})
})
describe('by group name', () => {
it('should respond with group', async () => {
const { name } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/by/groupname/${name}`)
.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: 'getbyname' + user.username
})
const { name } = await groupController.createGroup(group)
const res = await request(app)
.get(`/SASjsApi/group/by/groupname/${name}`)
.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/by/groupname/dcgroup')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Not Found if groupname is incorrect', async () => {
const res = await request(app)
.get('/SASjsApi/group/by/groupname/randomCharacters')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(404)
expect(res.text).toEqual('Group not found.')
expect(res.body).toEqual({})
})
})
})
describe('getAll', () => {
@@ -368,8 +245,8 @@ describe('group', () => {
expect(res.body).toEqual([
{
groupId: expect.anything(),
name: group.name,
description: group.description
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
])
})
@@ -390,8 +267,8 @@ describe('group', () => {
expect(res.body).toEqual([
{
groupId: expect.anything(),
name: group.name,
description: group.description
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
])
})
@@ -432,34 +309,6 @@ describe('group', () => {
])
})
it(`should add group to user's groups array`, async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({
...user,
username: 'addUserToGroup'
})
await request(app)
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groups).toEqual([
{
groupId: expect.anything(),
name: group.name,
description: group.description
}
])
})
it('should respond with group without duplicating user', async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({
@@ -513,26 +362,26 @@ describe('group', () => {
expect(res.body).toEqual({})
})
it('should respond with Not Found if groupId is incorrect', async () => {
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(404)
.expect(403)
expect(res.text).toEqual('Group not found.')
expect(res.text).toEqual('Error: Group not found.')
expect(res.body).toEqual({})
})
it('should respond with Not Found if userId is incorrect', async () => {
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(404)
.expect(403)
expect(res.text).toEqual('User not found.')
expect(res.text).toEqual('Error: User not found.')
expect(res.body).toEqual({})
})
})
@@ -563,29 +412,6 @@ describe('group', () => {
expect(res.body.users).toEqual([])
})
it(`should remove group from user's groups array`, async () => {
const dbGroup = await groupController.createGroup(group)
const dbUser = await userController.createUser({
...user,
username: 'removeGroupFromUser'
})
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
await request(app)
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
const res = await request(app)
.get(`/SASjsApi/user/${dbUser.id}`)
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(200)
expect(res.body.groups).toEqual([])
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.delete('/SASjsApi/group/123/123')
@@ -612,26 +438,26 @@ describe('group', () => {
expect(res.body).toEqual({})
})
it('should respond with Not Found if groupId is incorrect', async () => {
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(404)
.expect(403)
expect(res.text).toEqual('Group not found.')
expect(res.text).toEqual('Error: Group not found.')
expect(res.body).toEqual({})
})
it('should respond with Not Found if userId is incorrect', async () => {
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(404)
.expect(403)
expect(res.text).toEqual('User not found.')
expect(res.text).toEqual('Error: User not found.')
expect(res.body).toEqual({})
})
})

View File

@@ -3,24 +3,23 @@ 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 { UserController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
const clientId = 'someclientID'
const adminUser = {
displayName: 'Test Admin',
username: 'testadminusername',
username: 'testAdminUsername',
password: '12345678',
isAdmin: true,
isActive: true
}
const user = {
displayName: 'Test User',
username: 'testusername',
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true,
autoExec: 'some sas code for auto exec;'
isActive: true
}
const controller = new UserController()
@@ -65,21 +64,6 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec)
})
it('should respond with new user having username as lowercase', async () => {
const res = await request(app)
.post('/SASjsApi/user')
.auth(adminAccessToken, { type: 'bearer' })
.send({ ...user, username: user.username.toUpperCase() })
.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)
expect(res.body.autoExec).toEqual(user.autoExec)
})
it('should respond with Unauthorized if access token is not present', async () => {
@@ -258,7 +242,7 @@ describe('user', () => {
const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({
...user,
username: 'randomuser'
username: 'randomUser'
})
const res = await request(app)
@@ -270,102 +254,6 @@ describe('user', () => {
expect(res.text).toEqual('Error: Username already exists.')
expect(res.body).toEqual({})
})
describe('by username', () => {
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/by/username/${user.username}`)
.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/by/username/${user.username}`)
.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/by/username/${user.username}`)
.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/by/username/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/by/username/${dbUser1.username}`)
.auth(adminAccessToken, { type: 'bearer' })
.send({ username: dbUser2.username })
.expect(403)
expect(res.text).toEqual('Error: Username already exists.')
expect(res.body).toEqual({})
})
})
})
describe('delete', () => {
@@ -459,89 +347,6 @@ describe('user', () => {
expect(res.text).toEqual('Error: Invalid password.')
expect(res.body).toEqual({})
})
describe('by username', () => {
it('should respond with OK when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const res = await request(app)
.delete(`/SASjsApi/user/by/username/${dbUser.username}`)
.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/by/username/${dbUser.username}`)
.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/by/username/${dbUser.username}`)
.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/by/username/RandomUsername')
.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/by/username/${dbUser1.username}`)
.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/by/username/${dbUser.username}`)
.auth(accessToken, { type: 'bearer' })
.send({ password: 'incorrectpassword' })
.expect(403)
expect(res.text).toEqual('Error: Invalid password.')
expect(res.body).toEqual({})
})
})
})
describe('get', () => {
@@ -555,26 +360,7 @@ describe('user', () => {
await deleteAllUsers()
})
it('should respond with user autoExec when same user requests', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const accessToken = await generateAndSaveToken(userId)
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)
expect(res.body.autoExec).toEqual(user.autoExec)
expect(res.body.groups).toEqual([])
})
it('should respond with user autoExec when admin user requests', async () => {
it('should respond with user', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
@@ -588,8 +374,6 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec)
expect(res.body.groups).toEqual([])
})
it('should respond with user when access token is not of an admin account', async () => {
@@ -611,35 +395,6 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toBeUndefined()
expect(res.body.groups).toEqual([])
})
it('should respond with user along with associated groups', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const accessToken = await generateAndSaveToken(userId)
const group = {
name: 'DCGroup1',
description: 'DC group for testing purposes.'
}
const groupController = new GroupController()
const dbGroup = await groupController.createGroup(group)
await groupController.addUserToGroup(dbGroup.groupId, 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)
expect(res.body.autoExec).toEqual(user.autoExec)
expect(res.body.groups.length).toBeGreaterThan(0)
})
it('should respond with Unauthorized if access token is not present', async () => {
@@ -664,86 +419,6 @@ describe('user', () => {
expect(res.text).toEqual('Error: User is not found.')
expect(res.body).toEqual({})
})
describe('by username', () => {
it('should respond with user autoExec when same user requests', async () => {
const dbUser = await controller.createUser(user)
const userId = dbUser.id
const accessToken = await generateAndSaveToken(userId)
const res = await request(app)
.get(`/SASjsApi/user/by/username/${dbUser.username}`)
.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)
expect(res.body.autoExec).toEqual(user.autoExec)
})
it('should respond with user autoExec when admin user requests', async () => {
const dbUser = await controller.createUser(user)
const res = await request(app)
.get(`/SASjsApi/user/by/username/${dbUser.username}`)
.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)
expect(res.body.autoExec).toEqual(user.autoExec)
})
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 res = await request(app)
.get(`/SASjsApi/user/by/username/${dbUser.username}`)
.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)
expect(res.body.autoExec).toBeUndefined()
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.get('/SASjsApi/user/by/username/randomUsername')
.send()
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if username is incorrect', async () => {
await controller.createUser(user)
const res = await request(app)
.get('/SASjsApi/user/by/username/randomUsername')
.auth(adminAccessToken, { type: 'bearer' })
.send()
.expect(403)
expect(res.text).toEqual('Error: User is not found.')
expect(res.body).toEqual({})
})
})
})
describe('getAll', () => {

View File

@@ -1,182 +0,0 @@
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/'
const clientId = 'someclientID'
const clientSecret = 'someclientSecret'
const user = {
id: 1234,
displayName: 'Test User',
username: 'testusername',
password: '87654321',
isAdmin: false,
isActive: true
}
describe('web', () => {
let app: Express
let con: Mongoose
let mongoServer: MongoMemoryServer
const userController = new UserController()
const clientController = new ClientController()
beforeAll(async () => {
app = await appPromise
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
await clientController.createClient({ clientId, clientSecret })
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
})
describe('home', () => {
it('should respond with CSRF Token', async () => {
await request(app)
.get('/')
.expect(
'set-cookie',
/_csrf=.*; Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=.*; Path=\//
)
})
})
describe('SASLogon/login', () => {
let csrfToken: string
let cookies: string
beforeAll(async () => {
;({ csrfToken, cookies } = await getCSRF(app))
})
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with successful login', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASLogon/login')
.set('Cookie', cookies)
.set('x-xsrf-token', csrfToken)
.send({
username: user.username,
password: user.password
})
.expect(200)
expect(res.body.loggedIn).toBeTruthy()
expect(res.body.user).toEqual({
id: expect.any(Number),
username: user.username,
displayName: user.displayName
})
})
})
describe('SASLogon/authorize', () => {
let csrfToken: string
let cookies: string
let authCookies: string
beforeAll(async () => {
;({ csrfToken, cookies } = await getCSRF(app))
await userController.createUser(user)
const credentials = {
username: user.username,
password: user.password
}
;({ cookies: authCookies } = await performLogin(
app,
credentials,
cookies,
csrfToken
))
})
afterAll(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with authorization code', async () => {
const res = await request(app)
.post('/SASLogon/authorize')
.set('Cookie', [authCookies, cookies].join('; '))
.set('x-xsrf-token', csrfToken)
.send({ clientId })
expect(res.body).toHaveProperty('code')
})
it('should respond with Bad Request if clientId is missing', async () => {
const res = await request(app)
.post('/SASLogon/authorize')
.set('Cookie', [authCookies, cookies].join('; '))
.set('x-xsrf-token', csrfToken)
.send({})
.expect(400)
expect(res.text).toEqual(`"clientId" is required`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if clientId is incorrect', async () => {
const res = await request(app)
.post('/SASLogon/authorize')
.set('Cookie', [authCookies, cookies].join('; '))
.set('x-xsrf-token', csrfToken)
.send({
clientId: 'WrongClientID'
})
.expect(403)
expect(res.text).toEqual('Error: Invalid clientId.')
expect(res.body).toEqual({})
})
})
})
const getCSRF = async (app: Express) => {
// make request to get CSRF
const { header } = await request(app).get('/')
const cookies = header['set-cookie'].join()
const csrfToken = extractCSRF(cookies)
return { csrfToken, cookies }
}
const performLogin = async (
app: Express,
credentials: { username: string; password: string },
cookies: string,
csrfToken: string
) => {
const { header } = await request(app)
.post('/SASLogon/login')
.set('Cookie', cookies)
.set('x-xsrf-token', csrfToken)
.send(credentials)
const newCookies: string = header['set-cookie'].join()
return { cookies: newCookies }
}
const extractCSRF = (cookies: string) =>
/_csrf=(.*); Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=(.*); Path=\//.exec(
cookies
)![2]

View File

@@ -34,7 +34,7 @@ stpRouter.post(
'/execute',
fileUploadController.preUploadMiddleware,
fileUploadController.getMulterUploadObject().any(),
async (req, res: any) => {
async (req: any, res: any) => {
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
const { error: errB, value: body } = executeProgramRawValidation(req.body)
@@ -47,11 +47,10 @@ stpRouter.post(
query?._program
)
// TODO: investigate if this code is required
// if (response instanceof Buffer) {
// res.writeHead(200, (req as any).sasHeaders)
// return res.end(response)
// }
if (response instanceof Buffer) {
res.writeHead(200, (req as any).sasHeaders)
return res.end(response)
}
res.send(response)
} catch (err: any) {

View File

@@ -7,7 +7,6 @@ import {
} from '../../middlewares'
import {
deleteUserValidation,
getUserValidation,
registerUserValidation,
updateUserValidation
} from '../../utils'
@@ -37,80 +36,33 @@ userRouter.get('/', authenticateAccessToken, async (req, res) => {
}
})
userRouter.get(
'/by/username/:username',
authenticateAccessToken,
async (req, res) => {
const { error, value: params } = getUserValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
const { username } = params
const controller = new UserController()
try {
const response = await controller.getUserByUsername(req, username)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
userRouter.get('/:userId', authenticateAccessToken, async (req: any, res) => {
const { userId } = req.params
const controller = new UserController()
try {
const response = await controller.getUser(req, parseInt(userId))
const response = await controller.getUser(userId)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
userRouter.patch(
'/by/username/:username',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
const { user } = req
const { error: errorUsername, value: params } = getUserValidation(
req.params
)
if (errorUsername)
return res.status(400).send(errorUsername.details[0].message)
const { username } = 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.updateUserByUsername(username, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
userRouter.patch(
'/:userId',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
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)
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(parseInt(userId), body)
const response = await controller.updateUser(userId, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
@@ -118,49 +70,21 @@ userRouter.patch(
}
)
userRouter.delete(
'/by/username/:username',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
const { user } = req
const { error: errorUsername, value: params } = getUserValidation(
req.params
)
if (errorUsername)
return res.status(400).send(errorUsername.details[0].message)
const { username } = 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.deleteUserByUsername(username, data, user!.isAdmin)
res.status(200).send('Account Deleted!')
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
userRouter.delete(
'/:userId',
authenticateAccessToken,
verifyAdminIfNeeded,
async (req, res) => {
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)
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(parseInt(userId), data, user!.isAdmin)
await controller.deleteUser(userId, data, user.isAdmin)
res.status(200).send('Account Deleted!')
} catch (err: any) {
res.status(403).send(err.toString())

View File

@@ -1,4 +1,5 @@
import { AppStreamConfig } from '../../types'
import { script } from './script'
import { style } from './style'
const defaultAppLogo = '/sasjs-logo.svg'
@@ -23,21 +24,13 @@ export const appStreamHtml = (appStreamConfig: AppStreamConfig) => `
${style}
</head>
<body>
<header>
<a href="/"><img src="/logo.png" alt="logo" class="logo"></a>
<h1>App Stream</h1>
</header>
<h1>App Stream</h1>
<div class="app-container">
${Object.entries(appStreamConfig)
.map(([streamServiceName, entry]) =>
singleAppStreamHtml(
streamServiceName,
entry.appLoc,
entry.streamLogo
)
)
.join('')}
${Object.entries(appStreamConfig)
.map(([streamServiceName, entry]) =>
singleAppStreamHtml(streamServiceName, entry.appLoc, entry.streamLogo)
)
.join('')}
<a class="app" title="Upload build.json">
<input id="fileId" type="file" hidden />
<button id="uploadButton" style="margin-bottom: 5px; cursor: pointer">
@@ -46,7 +39,6 @@ export const appStreamHtml = (appStreamConfig: AppStreamConfig) => `
<span id="uploadMessage">Upload New App</span>
</a>
</div>
<script src="/axios.min.js"></script>
<script src="/app-streams-script.js"></script>
${script}
</body>
</html>`

View File

@@ -1,19 +1,15 @@
import path from 'path'
import express, { Request } from 'express'
import express from 'express'
import { folderExists } from '@sasjs/utils'
import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils'
import { addEntryToAppStreamConfig, getTmpFilesFolderPath } from '../../utils'
import { appStreamHtml } from './appStreamHtml'
const appStreams: { [key: string]: string } = {}
const router = express.Router()
router.get('/', async (req, res) => {
router.get('/', async (_, res) => {
const content = appStreamHtml(process.appStreamConfig)
res.cookie('XSRF-TOKEN', req.csrfToken())
return res.send(content)
})
@@ -24,7 +20,7 @@ export const publishAppStream = async (
streamLogo?: string,
addEntryToFile: boolean = true
) => {
const driveFilesPath = getFilesFolder()
const driveFilesPath = getTmpFilesFolderPath()
const appLocParts = appLoc.replace(/^\//, '')?.split('/')
const appLocPath = path.join(driveFilesPath, ...appLocParts)
@@ -46,7 +42,7 @@ export const publishAppStream = async (
streamServiceName = `AppStreamName${appCount + 1}`
}
appStreams[streamServiceName] = pathToDeployment
router.use(`/${streamServiceName}`, express.static(pathToDeployment))
addEntryToAppStreamConfig(
streamServiceName,
@@ -66,26 +62,4 @@ export const publishAppStream = async (
return {}
}
router.get(`/*`, function (req: Request, res, next) {
const reqPath = req.path.replace(/^\//, '')
// Redirecting to url with trailing slash for appStream base URL only
if (reqPath.split('/').length === 1 && !reqPath.endsWith('/'))
// navigating to same url with slash at start
return res.redirect(301, `${reqPath}/`)
const appStream = reqPath.split('/')[0]
const appStreamFilesPath = appStreams[appStream]
if (appStreamFilesPath) {
// resourcePath is without appStream base path
const resourcePath = reqPath.split('/').slice(1).join('/') || 'index.html'
req.url = resourcePath
return express.static(appStreamFilesPath)(req, res, next)
}
return res.send("There's no App Stream available here.")
})
export default router

View File

@@ -0,0 +1,58 @@
export const script = `<script>
const inputElement = document.getElementById('fileId')
document
.getElementById('uploadButton')
.addEventListener('click', function () {
inputElement.click()
})
inputElement.addEventListener(
'change',
function () {
const fileList = this.files /* now you can work with the file list */
updateFileUploadMessage('Requesting ...')
const file = fileList[0]
const formData = new FormData()
formData.append('file', file)
fetch('/SASjsApi/drive/deploy/upload', {
method: 'POST',
body: formData
})
.then(async (res) => {
const { status, ok } = res
if (status === 200 && ok) {
const data = await res.json()
return (
data.message +
'\\nstreamServiceName: ' +
data.streamServiceName +
'\\nrefreshing page once alert box closes.'
)
}
throw await res.text()
})
.then((message) => {
alert(message)
location.reload()
})
.catch((error) => {
alert(error)
resetFileUpload()
updateFileUploadMessage('Upload New App')
})
},
false
)
function updateFileUploadMessage(message) {
document.getElementById('uploadMessage').innerHTML = message
}
function resetFileUpload() {
inputElement.value = null
}
</script>`

View File

@@ -5,71 +5,18 @@ export const style = `<style>
.app-container {
display: flex;
flex-wrap: wrap;
align-items: center;
align-items: baseline;
justify-content: center;
padding-top: 50px;
}
.app-container .app {
width: 150px;
height: 180px;
margin: 10px;
overflow: hidden;
text-align: center;
text-decoration: none;
color: black;
background: #efefef;
padding: 10px;
border-radius: 7px;
border: 1px solid #d7d7d7;
}
.app-container .app img{
width: 100%;
margin-bottom: 10px;
border-radius: 10px;
}
#uploadButton {
border: 0
}
#uploadButton:focus {
outline: 0
}
#uploadMessage {
position: relative;
bottom: -5px;
}
header {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
box-shadow: rgb(0 0 0 / 20%) 0px 2px 4px -1px, rgb(0 0 0 / 14%) 0px 4px 5px 0px, rgb(0 0 0 / 12%) 0px 1px 10px 0px;
display: flex;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
position: fixed;
top: 0px;
left: auto;
right: 0px;
background-color: rgb(0, 0, 0);
color: rgb(255, 255, 255);
z-index: 1201;
}
header h1 {
margin: 13px;
font-size: 20px;
}
header a {
align-self: center;
}
header .logo {
width: 35px;
margin-left: 10px;
align-self: center;
}
</style>`

View File

@@ -4,16 +4,13 @@ import webRouter from './web'
import apiRouter from './api'
import appStreamRouter from './appStream'
import { csrfProtection } from '../app'
export const setupRoutes = (app: Express) => {
app.use('/', webRouter)
app.use('/SASjsApi', apiRouter)
app.use('/AppStream', csrfProtection, function (req, res, next) {
app.use('/AppStream', function (req, res, next) {
// this needs to be a function to hook on
// whatever the current router is
appStreamRouter(req, res, next)
})
app.use('/', csrfProtection, webRouter)
}

View File

@@ -1,60 +1,37 @@
import { readFile } from '@sasjs/utils'
import express from 'express'
import { WebController } from '../../controllers/web'
import { authenticateAccessToken, desktopRestrict } from '../../middlewares'
import { authorizeValidation, loginWebValidation } from '../../utils'
import path from 'path'
import { getWebBuildFolderPath } from '../../utils'
const webRouter = express.Router()
const controller = new WebController()
webRouter.get('/', async (req, res) => {
let response
const jsCodeForDesktopMode = `
<script>
localStorage.setItem('accessToken', JSON.stringify('accessToken'))
localStorage.setItem('refreshToken', JSON.stringify('refreshToken'))
</script>`
const jsCodeForServerMode = `
<script>
localStorage.setItem('CLIENT_ID', '${process.env.CLIENT_ID}')
</script>`
webRouter.get('/', async (_, res) => {
let content: string
try {
response = await controller.home()
const indexHtmlPath = path.join(getWebBuildFolderPath(), 'index.html')
content = await readFile(indexHtmlPath)
} catch (_) {
response = 'Web Build is not present'
} finally {
res.cookie('XSRF-TOKEN', req.csrfToken())
return res.send(response)
return res.send('Web Build is not present')
}
})
webRouter.post('/SASLogon/login', desktopRestrict, async (req, res) => {
const { error, value: body } = loginWebValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const { MODE } = process.env
const codeToInject =
MODE?.trim() === 'server' ? jsCodeForServerMode : jsCodeForDesktopMode
const injectedContent = content.replace('</head>', `${codeToInject}</head>`)
try {
const response = await controller.login(req, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
webRouter.post(
'/SASLogon/authorize',
desktopRestrict,
authenticateAccessToken,
async (req, res) => {
const { error, value: body } = authorizeValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.authorize(req, body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
}
)
webRouter.get('/logout', desktopRestrict, async (req, res) => {
try {
await controller.logout(req)
res.status(200).send('OK!')
} catch (err: any) {
res.status(403).send(err.toString())
}
res.setHeader('Content-Type', 'text/html')
return res.send(injectedContent)
})
export default webRouter

View File

@@ -3,5 +3,5 @@ export interface PreProgramVars {
userId: number
displayName: string
serverUrl: string
httpHeaders: string[]
accessToken: string
}

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

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

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'

View File

@@ -1,9 +0,0 @@
export interface RequestUser {
userId: number
clientId: string
username: string
displayName: string
isAdmin: boolean
isActive: boolean
autoExec?: string
}

View File

@@ -3,6 +3,6 @@ export * from './AppStreamConfig'
export * from './Execution'
export * from './InfoJWT'
export * from './PreProgramVars'
export * from './Request'
export * from './Session'
export * from './TreeNode'
export * from './RequestUser'

View File

@@ -1,7 +0,0 @@
import express from 'express'
declare module 'express-session' {
interface SessionData {
loggedIn: boolean
user: import('../').RequestUser
}
}

View File

@@ -1,7 +0,0 @@
declare namespace Express {
export interface Request {
accessToken?: string
user?: import('../').RequestUser
sasSession?: import('../').Session
}
}

View File

@@ -1 +0,0 @@
import 'jest-extended'

View File

@@ -1,9 +0,0 @@
declare namespace NodeJS {
export interface Process {
sasLoc: string
driveLoc: string
sessionController?: import('../../controllers/internal').SessionController
appStreamConfig: import('../').AppStreamConfig
logger: import('@sasjs/utils/logger').Logger
}
}

View File

@@ -2,12 +2,12 @@ import { createFile, fileExists, readFile } from '@sasjs/utils'
import { publishAppStream } from '../routes/appStream'
import { AppStreamConfig } from '../types'
import { getAppStreamConfigPath } from './file'
import { getTmpAppStreamConfigPath } from './file'
export const loadAppStreamConfig = async () => {
if (process.env.NODE_ENV === 'test') return
const appStreamConfigPath = getAppStreamConfigPath()
const appStreamConfigPath = getTmpAppStreamConfigPath()
const content = (await fileExists(appStreamConfigPath))
? await readFile(appStreamConfigPath)
@@ -63,7 +63,7 @@ export const removeEntryFromAppStreamConfig = (streamServiceName: string) => {
}
const saveAppStreamConfig = async () => {
const appStreamConfigPath = getAppStreamConfigPath()
const appStreamConfigPath = getTmpAppStreamConfigPath()
try {
await createFile(

View File

@@ -1,15 +1,28 @@
import mongoose from 'mongoose'
import { populateClients } from '../routes/api/auth'
import { seedDB } from './seedDB'
export const connectDB = async () => {
try {
await mongoose.connect(process.env.DB_CONNECT as string)
} catch (err) {
throw new Error('Unable to connect to DB!')
// NOTE: when exporting app.js as agent for supertest
// we should exclude connecting to the real database
if (process.env.NODE_ENV === 'test') {
return
}
console.log('Connected to DB!')
await seedDB()
const { MODE } = process.env
return mongoose.connection
if (MODE?.trim() !== 'server') {
console.log('Running in Destop Mode, no DB to connect.')
return
}
mongoose.connect(process.env.DB_CONNECT as string, async (err) => {
if (err) throw err
console.log('Connected to db!')
await seedDB()
await populateClients()
})
}

View File

@@ -7,14 +7,14 @@ import {
readFile
} from '@sasjs/utils'
import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
import { getTmpMacrosPath, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
export const copySASjsCore = async () => {
if (process.env.NODE_ENV === 'test') return
console.log('Copying Macros from container to drive(tmp).')
const macrosDrivePath = getMacrosFolder()
const macrosDrivePath = getTmpMacrosPath()
await deleteFolder(macrosDrivePath)
await createFolder(macrosDrivePath)

View File

@@ -1,8 +0,0 @@
import { createFile, readFile } from '@sasjs/utils'
import { getDesktopUserAutoExecPath } from './file'
export const getUserAutoExec = async (): Promise<string> =>
readFile(getDesktopUserAutoExecPath())
export const updateUserAutoExec = async (autoExecContent: string) =>
createFile(getDesktopUserAutoExecPath(), autoExecContent)

View File

@@ -1,6 +0,0 @@
import path from 'path'
export const extractName = (filePath: string) => {
const extension = path.extname(filePath)
return path.basename(filePath, extension)
}

View File

@@ -1,6 +1,4 @@
import path from 'path'
import { homedir } from 'os'
import fs from 'fs-extra'
export const apiRoot = path.join(__dirname, '..', '..')
export const codebaseRoot = path.join(apiRoot, '..')
@@ -13,31 +11,28 @@ export const sysInitCompiledPath = path.join(
export const sasJSCoreMacros = path.join(apiRoot, 'sasjscore')
export const sasJSCoreMacrosInfo = path.join(sasJSCoreMacros, '.macrolist')
export const getWebBuildFolder = () => path.join(codebaseRoot, 'web', 'build')
export const getWebBuildFolderPath = () =>
path.join(codebaseRoot, 'web', 'build')
export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server')
export const getTmpFolderPath = () => process.driveLoc
export const getDesktopUserAutoExecPath = () =>
path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
export const getTmpAppStreamConfigPath = () =>
path.join(getTmpFolderPath(), 'appStreamConfig.json')
export const getSasjsRootFolder = () => process.driveLoc
export const getTmpMacrosPath = () => path.join(getTmpFolderPath(), 'sasjscore')
export const getAppStreamConfigPath = () =>
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
export const getTmpUploadsPath = () => path.join(getTmpFolderPath(), 'uploads')
export const getMacrosFolder = () =>
path.join(getSasjsRootFolder(), 'sasjscore')
export const getTmpFilesFolderPath = () =>
path.join(getTmpFolderPath(), 'files')
export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
export const getTmpLogFolderPath = () => path.join(getTmpFolderPath(), 'logs')
export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
export const getTmpWeboutFolderPath = () =>
path.join(getTmpFolderPath(), 'webouts')
export const getLogFolder = () => path.join(getSasjsRootFolder(), 'logs')
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
export const getSessionsFolder = () =>
path.join(getSasjsRootFolder(), 'sessions')
export const getTmpSessionsFolderPath = () =>
path.join(getTmpFolderPath(), 'sessions')
export const generateUniqueFileName = (fileName: string, extension = '') =>
[
@@ -48,6 +43,3 @@ export const generateUniqueFileName = (fileName: string, extension = '') =>
new Date().getTime(),
extension
].join('')
export const createReadStream = async (filePath: string) =>
fs.createReadStream(filePath)

View File

@@ -5,12 +5,12 @@ import { createFolder, fileExists, folderExists } from '@sasjs/utils'
const isWindows = () => process.platform === 'win32'
export const getDesktopFields = async () => {
const { SAS_PATH } = process.env
const { SAS_PATH, DRIVE_PATH } = process.env
const sasLoc = SAS_PATH ?? (await getSASLocation())
// const driveLoc = DRIVE_PATH ?? (await getDriveLocation())
const driveLoc = DRIVE_PATH ?? (await getDriveLocation())
return { sasLoc }
return { sasLoc, driveLoc }
}
const getDriveLocation = async (): Promise<string> => {

View File

@@ -1,30 +0,0 @@
import { Request } from 'express'
import { PreProgramVars } from '../types'
export const getPreProgramVariables = (req: Request): PreProgramVars => {
const host = req.get('host')
const protocol = req.protocol + '://'
const { user, accessToken } = req
const csrfToken = req.headers['x-xsrf-token'] || req.cookies['XSRF-TOKEN']
const sessionId = req.cookies['connect.sid']
const { _csrf } = req.cookies
const httpHeaders: string[] = []
if (accessToken) httpHeaders.push(`Authorization: Bearer ${accessToken}`)
if (csrfToken) httpHeaders.push(`x-xsrf-token: ${csrfToken}`)
const cookies: string[] = []
if (sessionId) cookies.push(`connect.sid=${sessionId}`)
if (_csrf) cookies.push(`_csrf=${_csrf}`)
if (cookies.length) httpHeaders.push(`cookie: ${cookies.join('; ')}`)
return {
username: user!.username,
userId: user!.userId,
displayName: user!.displayName,
serverUrl: protocol + host,
httpHeaders
}
}

View File

@@ -1,15 +0,0 @@
import express from 'express'
import url from 'url'
export const getFullUrl = (req: express.Request) =>
url.format({
protocol: req.protocol,
host: req.get('host'),
pathname: req.originalUrl
})
export const getServerUrl = (req: express.Request) =>
url.format({
protocol: req.protocol,
host: req.get('x-forwarded-host') || req.get('host')
})

View File

@@ -1,20 +1,14 @@
export * from './appStreamConfig'
export * from './connectDB'
export * from './copySASjsCore'
export * from './desktopAutoExec'
export * from './extractHeaders'
export * from './extractName'
export * from './file'
export * from './generateAccessToken'
export * from './generateAuthCode'
export * from './generateRefreshToken'
export * from './getCertificates'
export * from './getDesktopFields'
export * from './getPreProgramVariables'
export * from './getServerUrl'
export * from './instantiateLogger'
export * from './isDebugOn'
export * from './zipped'
export * from './parseLogToArray'
export * from './removeTokensInDB'
export * from './saveTokensInDB'
@@ -23,5 +17,4 @@ export * from './setProcessVariables'
export * from './setupFolders'
export * from './upload'
export * from './validation'
export * from './verifyEnvVariables'
export * from './verifyTokenInDB'

View File

@@ -1,7 +0,0 @@
import { LogLevel, Logger } from '@sasjs/utils/logger'
export const instantiateLogger = () => {
const logLevel = (process.env.LOG_LEVEL || LogLevel.Info) as LogLevel
const logger = new Logger(logLevel)
process.logger = logger
}

View File

@@ -1,35 +0,0 @@
import path from 'path'
import fs from 'fs'
export const getEnvCSPDirectives = (
HELMET_CSP_CONFIG_PATH: string | undefined
) => {
let cspConfigJson = {
'img-src': ["'self'", 'data:'],
'script-src': ["'self'", "'unsafe-inline'"],
'script-src-attr': ["'self'", "'unsafe-inline'"]
}
if (
typeof HELMET_CSP_CONFIG_PATH === 'string' &&
HELMET_CSP_CONFIG_PATH.length > 0
) {
const cspConfigPath = path.join(process.cwd(), HELMET_CSP_CONFIG_PATH)
try {
let file = fs.readFileSync(cspConfigPath).toString()
try {
cspConfigJson = JSON.parse(file)
} catch (e) {
console.error(
'Parsing Content Security Policy JSON config failed. Make sure it is valid json'
)
}
} catch (e) {
console.error('Error reading HELMET CSP config file', e)
}
}
return cspConfigJson
}

View File

@@ -1,29 +1,30 @@
import path from 'path'
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
import { getAbsolutePath, getRealPath } from '@sasjs/utils'
import { getDesktopFields, ModeType } from '.'
import { configuration } from '../../package.json'
import { getDesktopFields } from '.'
export const setProcessVariables = async () => {
if (process.env.NODE_ENV === 'test') {
process.driveLoc = path.join(process.cwd(), 'sasjs_root')
process.driveLoc = path.join(process.cwd(), 'tmp')
return
}
const { MODE } = process.env
if (MODE === ModeType.Server) {
process.sasLoc = process.env.SAS_PATH as string
if (MODE?.trim() === 'server') {
const { SAS_PATH, DRIVE_PATH } = process.env
process.sasLoc = SAS_PATH ?? configuration.sasPath
const absPath = getAbsolutePath(DRIVE_PATH ?? 'tmp', process.cwd())
process.driveLoc = getRealPath(absPath)
} else {
const { sasLoc } = await getDesktopFields()
const { sasLoc, driveLoc } = await getDesktopFields()
process.sasLoc = sasLoc
process.driveLoc = driveLoc
}
const { SASJS_ROOT } = process.env
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
await createFolder(absPath)
process.driveLoc = getRealPath(absPath)
console.log('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc)
}

View File

@@ -1,14 +1,7 @@
import { createFile, createFolder, fileExists } from '@sasjs/utils'
import { getDesktopUserAutoExecPath, getFilesFolder } from './file'
import { ModeType } from './verifyEnvVariables'
import { createFolder } from '@sasjs/utils'
import { getTmpFilesFolderPath } from './file'
export const setupFolders = async () => {
const drivePath = getFilesFolder()
const drivePath = getTmpFilesFolderPath()
await createFolder(drivePath)
if (process.env.MODE === ModeType.Desktop) {
if (!(await fileExists(getDesktopUserAutoExecPath()))) {
await createFile(getDesktopUserAutoExecPath(), '')
}
}
}

View File

@@ -1,24 +1,14 @@
import Joi from 'joi'
const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
const usernameSchema = Joi.string().alphanum().min(3).max(16)
const passwordSchema = Joi.string().min(6).max(1024)
const groupnameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
export const blockFileRegex = /\.(exe|sh|htaccess)$/i
export const getUserValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required()
}).validate(data)
export const loginWebValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required(),
password: passwordSchema.required()
}).validate(data)
export const authorizeValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required(),
password: passwordSchema.required(),
clientId: Joi.string().required()
}).validate(data)
@@ -30,24 +20,18 @@ export const tokenValidation = (data: any): Joi.ValidationResult =>
export const registerGroupValidation = (data: any): Joi.ValidationResult =>
Joi.object({
name: groupnameSchema.required(),
name: Joi.string().min(6).required(),
description: Joi.string(),
isActive: Joi.boolean()
}).validate(data)
export const getGroupValidation = (data: any): Joi.ValidationResult =>
Joi.object({
name: groupnameSchema.required()
}).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(),
autoExec: Joi.string().allow('')
isActive: Joi.boolean()
}).validate(data)
export const deleteUserValidation = (
@@ -69,8 +53,7 @@ export const updateUserValidation = (
const validationChecks: any = {
displayName: Joi.string().min(6),
username: usernameSchema,
password: passwordSchema,
autoExec: Joi.string().allow('')
password: passwordSchema
}
if (isAdmin) {
validationChecks.isAdmin = Joi.boolean()

View File

@@ -1,211 +0,0 @@
export enum ModeType {
Server = 'server',
Desktop = 'desktop'
}
export enum ProtocolType {
HTTP = 'http',
HTTPS = 'https'
}
export enum CorsType {
ENABLED = 'enable',
DISABLED = 'disable'
}
export enum HelmetCoepType {
TRUE = 'true',
FALSE = 'false'
}
export enum LOG_FORMAT_MORGANType {
Combined = 'combined',
Common = 'common',
Dev = 'dev',
Short = 'short',
tiny = 'tiny'
}
export enum ReturnCode {
Success,
InvalidEnv
}
export const verifyEnvVariables = (): ReturnCode => {
const errors: string[] = []
errors.push(...verifyMODE())
errors.push(...verifyPROTOCOL())
errors.push(...verifyPORT())
errors.push(...verifyCORS())
errors.push(...verifyHELMET_COEP())
errors.push(...verifyLOG_FORMAT_MORGAN())
if (errors.length) {
process.logger?.error(
`Invalid environment variable(s) provided: \n${errors.join('\n')}`
)
return ReturnCode.InvalidEnv
}
return ReturnCode.Success
}
const verifyMODE = (): string[] => {
const errors: string[] = []
const { MODE } = process.env
if (MODE) {
const modeTypes = Object.values(ModeType)
if (!modeTypes.includes(MODE as ModeType))
errors.push(`- MODE '${MODE}'\n - valid options ${modeTypes}`)
} else {
process.env.MODE = DEFAULTS.MODE
}
if (process.env.MODE === ModeType.Server) {
const {
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET,
AUTH_CODE_SECRET,
SESSION_SECRET,
DB_CONNECT
} = process.env
if (!ACCESS_TOKEN_SECRET)
errors.push(
`- ACCESS_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!REFRESH_TOKEN_SECRET)
errors.push(
`- REFRESH_TOKEN_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!AUTH_CODE_SECRET)
errors.push(
`- AUTH_CODE_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (!SESSION_SECRET)
errors.push(
`- SESSION_SECRET is required for PROTOCOL '${ModeType.Server}'`
)
if (process.env.NODE_ENV !== 'test')
if (!DB_CONNECT)
errors.push(
`- DB_CONNECT is required for PROTOCOL '${ModeType.Server}'`
)
}
return errors
}
const verifyPROTOCOL = (): string[] => {
const errors: string[] = []
const { PROTOCOL } = process.env
if (PROTOCOL) {
const protocolTypes = Object.values(ProtocolType)
if (!protocolTypes.includes(PROTOCOL as ProtocolType))
errors.push(`- PROTOCOL '${PROTOCOL}'\n - valid options ${protocolTypes}`)
} else {
process.env.PROTOCOL = DEFAULTS.PROTOCOL
}
if (process.env.PROTOCOL === ProtocolType.HTTPS) {
const { PRIVATE_KEY, FULL_CHAIN } = process.env
if (!PRIVATE_KEY)
errors.push(
`- PRIVATE_KEY is required for PROTOCOL '${ProtocolType.HTTPS}'`
)
if (!FULL_CHAIN)
errors.push(
`- FULL_CHAIN is required for PROTOCOL '${ProtocolType.HTTPS}'`
)
}
return errors
}
const verifyCORS = (): string[] => {
const errors: string[] = []
const { CORS } = process.env
if (CORS) {
const corsTypes = Object.values(CorsType)
if (!corsTypes.includes(CORS as CorsType))
errors.push(`- CORS '${CORS}'\n - valid options ${corsTypes}`)
} else {
const { MODE } = process.env
process.env.CORS =
MODE === ModeType.Server ? CorsType.DISABLED : CorsType.ENABLED
}
return errors
}
const verifyPORT = (): string[] => {
const errors: string[] = []
const { PORT } = process.env
if (PORT) {
if (Number.isNaN(parseInt(PORT)))
errors.push(`- PORT '${PORT}'\n - should be a valid number`)
} else {
process.env.PORT = DEFAULTS.PORT
}
return errors
}
const verifyHELMET_COEP = (): string[] => {
const errors: string[] = []
const { HELMET_COEP } = process.env
if (HELMET_COEP) {
const helmetCoepTypes = Object.values(HelmetCoepType)
if (!helmetCoepTypes.includes(HELMET_COEP as HelmetCoepType))
errors.push(
`- HELMET_COEP '${HELMET_COEP}'\n - valid options ${helmetCoepTypes}`
)
HELMET_COEP
} else {
process.env.HELMET_COEP = DEFAULTS.HELMET_COEP
}
return errors
}
const verifyLOG_FORMAT_MORGAN = (): string[] => {
const errors: string[] = []
const { LOG_FORMAT_MORGAN } = process.env
if (LOG_FORMAT_MORGAN) {
const logFormatMorganTypes = Object.values(LOG_FORMAT_MORGANType)
if (
!logFormatMorganTypes.includes(LOG_FORMAT_MORGAN as LOG_FORMAT_MORGANType)
)
errors.push(
`- LOG_FORMAT_MORGAN '${LOG_FORMAT_MORGAN}'\n - valid options ${logFormatMorganTypes}`
)
LOG_FORMAT_MORGAN
} else {
process.env.LOG_FORMAT_MORGAN = DEFAULTS.LOG_FORMAT_MORGAN
}
return errors
}
const DEFAULTS = {
MODE: ModeType.Desktop,
PROTOCOL: ProtocolType.HTTP,
PORT: '5000',
HELMET_COEP: HelmetCoepType.TRUE,
LOG_FORMAT_MORGAN: LOG_FORMAT_MORGANType.Common
}

View File

@@ -1,30 +1,11 @@
import User from '../model/User'
import { RequestUser } from '../types'
export const fetchLatestAutoExec = async (
reqUser: RequestUser
): Promise<RequestUser | undefined> => {
const dbUser = await User.findOne({ id: reqUser.userId })
if (!dbUser) return undefined
return {
userId: reqUser.userId,
clientId: reqUser.clientId,
username: dbUser.username,
displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive,
autoExec: dbUser.autoExec
}
}
export const verifyTokenInDB = async (
userId: number,
clientId: string,
token: string,
tokenType: 'accessToken' | 'refreshToken'
): Promise<RequestUser | undefined> => {
) => {
const dbUser = await User.findOne({ id: userId })
if (!dbUser) return undefined
@@ -40,8 +21,7 @@ export const verifyTokenInDB = async (
username: dbUser.username,
displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive,
autoExec: dbUser.autoExec
isActive: dbUser.isActive
}
: undefined
}

View File

@@ -1,40 +0,0 @@
import path from 'path'
import unZipper from 'unzipper'
import { extractName } from './extractName'
import { createReadStream } from './file'
export const isZipFile = (
file: Express.Multer.File
): { error?: string; value?: Express.Multer.File } => {
const fileExtension = path.extname(file.originalname)
if (fileExtension.toUpperCase() !== '.ZIP')
return { error: `"file" has invalid extension ${fileExtension}` }
const allowedMimetypes = ['application/zip', 'application/x-zip-compressed']
if (!allowedMimetypes.includes(file.mimetype))
return { error: `"file" has invalid type ${file.mimetype}` }
return { value: file }
}
export const extractJSONFromZip = async (zipFile: Express.Multer.File) => {
let fileContent: string = ''
const fileInZip = extractName(zipFile.originalname)
const zip = (await createReadStream(zipFile.path)).pipe(
unZipper.Parse({ forceStream: true })
)
for await (const entry of zip) {
const fileName = entry.path as string
if (fileName.toUpperCase().endsWith('.JSON') && fileName === fileInZip) {
fileContent = await entry.buffer()
break
} else {
entry.autodrain()
}
}
return fileContent
}

View File

@@ -46,10 +46,6 @@
{
"name": "CODE",
"description": "Operations on SAS code"
},
{
"name": "Web",
"description": "Operations on Web"
}
],
"yaml": true,

10438
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
{
"name": "server",
"version": "0.0.76",
"version": "0.0.58",
"description": "NodeJS wrapper for calling the SAS binary executable",
"repository": "https://github.com/sasjs/server",
"scripts": {
"server": "npm run server:prepare && npm run server:start",
"server:prepare": "cd web && npm ci && npm run build && cd ../api && npm ci && npm run build && 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}\"",
@@ -15,9 +16,7 @@
"lint:fix": "npm run lint-api:fix && npm run lint-web:fix"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.1",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^8.0.4"
"prettier": "^2.3.1",
"standard-version": "^9.3.2"
}
}

View File

@@ -1,3 +1,2 @@
### Get current user's info via session ID
### Get current user's info via access token
GET http://localhost:5000/SASjsApi/session
cookie: connect.sid=s:G2DeFdKuWhnmTOsTHmTWrxAXPx2P6TLD.JyNLxfACC1w3NlFQFfL5chyxtrqbPYmS6iButRc1goE

View File

@@ -1 +1,2 @@
PORT_API=[place sasjs server port] default value is 5000
PORT_API=[place sasjs server port] default value is 5000
CLIENT_ID=<place clientId here>

474
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@
"dependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@monaco-editor/react": "^4.3.1",
"@mui/icons-material": "^5.0.3",
"@mui/lab": "^5.0.0-alpha.50",
"@mui/material": "^5.0.3",
@@ -20,14 +21,10 @@
"@types/node": "^12.20.28",
"@types/react": "^17.0.27",
"axios": "^0.24.0",
"monaco-editor": "^0.33.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"jwt-decode": "3.1.2",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2",
"react-monaco-editor": "^0.48.0",
"react-router-dom": "^5.3.0",
"react-toastify": "^9.0.1"
"react-router-dom": "^5.3.0"
},
"devDependencies": {
"@babel/core": "^7.16.0",
@@ -39,7 +36,6 @@
"@types/dotenv-webpack": "^7.0.3",
"@types/prismjs": "^1.16.6",
"@types/react": "^17.0.37",
"@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-dom": "^17.0.11",
"@types/react-router-dom": "^5.3.1",
"babel-loader": "^8.2.3",

View File

@@ -8,21 +8,21 @@ import Header from './components/header'
import Home from './components/home'
import Drive from './containers/Drive'
import Studio from './containers/Studio'
import Settings from './containers/Settings'
import { AppContext } from './context/appContext'
import AuthCode from './containers/AuthCode'
import { ToastContainer } from 'react-toastify'
function App() {
const appContext = useContext(AppContext)
if (!appContext.loggedIn) {
if (!appContext.tokens) {
return (
<ThemeProvider theme={theme}>
<HashRouter>
<Header />
<Switch>
<Route exact path="/SASjsLogon">
<Login getCodeOnly />
</Route>
<Route path="/">
<Login />
</Route>
@@ -46,14 +46,10 @@ function App() {
<Route exact path="/SASjsStudio">
<Studio />
</Route>
<Route exact path="/SASjsSettings">
<Settings />
</Route>
<Route exact path="/SASjsLogon">
<AuthCode />
<Login getCodeOnly />
</Route>
</Switch>
<ToastContainer />
</HashRouter>
</ThemeProvider>
)

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useContext } from 'react'
import React, { useState, useContext } from 'react'
import { Link, useHistory, useLocation } from 'react-router-dom'
import {
@@ -11,9 +11,8 @@ import {
MenuItem
} from '@mui/material'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import SettingsIcon from '@mui/icons-material/Settings'
import Username from './username'
import UserName from './userName'
import { AppContext } from '../context/appContext'
const NODE_ENV = process.env.NODE_ENV
@@ -21,23 +20,15 @@ const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
const validTabs = ['/', '/SASjsDrive', '/SASjsStudio']
const Header = (props: any) => {
const history = useHistory()
const { pathname } = useLocation()
const appContext = useContext(AppContext)
const [tabValue, setTabValue] = useState(
validTabs.includes(pathname) ? pathname : '/'
)
const [tabValue, setTabValue] = useState(pathname)
const [anchorEl, setAnchorEl] = useState<
(EventTarget & HTMLButtonElement) | null
>(null)
useEffect(() => {
setTabValue(validTabs.includes(pathname) ? pathname : '/')
}, [pathname])
const handleMenu = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
@@ -53,10 +44,7 @@ const Header = (props: any) => {
}
const handleLogout = () => {
if (appContext.logout) {
handleClose()
appContext.logout()
}
if (appContext.logout) appContext.logout()
}
return (
<AppBar
@@ -125,8 +113,8 @@ const Header = (props: any) => {
justifyContent: 'flex-end'
}}
>
<Username
username={appContext.displayName || appContext.username}
<UserName
userName={appContext.displayName}
onClickHandler={handleMenu}
/>
<Menu
@@ -144,18 +132,6 @@ const Header = (props: any) => {
open={!!anchorEl}
onClose={handleClose}
>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/SASjsSettings"
onClick={handleClose}
variant="contained"
color="primary"
startIcon={<SettingsIcon />}
>
Settings
</Button>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ justifyContent: 'center' }}>
<Button variant="contained" color="primary">
Logout

View File

@@ -9,8 +9,8 @@ const Home = () => {
<CssBaseline />
<h2>Welcome to SASjs Server!</h2>
<p>
SASjs Server provides a REST interface for executing Stored Programs
and ad hoc code (studio) against SAS and JS executables. The source is
This portal provides an interface for executing Stored Programs (drive)
and ad hoc code (studio) against a SAS executable. The source code is
available on{' '}
<a
href="https://github.com/sasjs/server"

View File

@@ -1,39 +1,98 @@
import axios from 'axios'
import React, { useState, useContext } from 'react'
import { useLocation } from 'react-router-dom'
import PropTypes from 'prop-types'
import { CssBaseline, Box, TextField, Button } from '@mui/material'
import { CssBaseline, Box, TextField, Button, Typography } from '@mui/material'
import { AppContext } from '../context/appContext'
const login = async (payload: { username: string; password: string }) =>
axios.post('/SASLogon/login', payload).then((res) => res.data)
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json'
}
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
const Login = () => {
const getAuthCode = async (credentials: any) => {
return fetch(`${baseUrl}/SASjsApi/auth/authorize`, {
method: 'POST',
headers,
body: JSON.stringify(credentials)
}).then(async (response) => {
const resText = await response.text()
if (response.status !== 200) throw resText
return JSON.parse(resText)
})
}
const getTokens = async (payload: any) => {
return fetch(`${baseUrl}/SASjsApi/auth/token`, {
method: 'POST',
headers,
body: JSON.stringify(payload)
}).then((data) => data.json())
}
const Login = ({ getCodeOnly }: any) => {
const location = useLocation()
const appContext = useContext(AppContext)
const [username, setUsername] = useState('')
const [username, setUserName] = useState('')
const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('')
let error: boolean
const [displayCode, setDisplayCode] = useState(null)
const handleSubmit = async (e: any) => {
error = false
setErrorMessage('')
e.preventDefault()
let clientId = process.env.CLIENT_ID ?? localStorage.getItem('CLIENT_ID')
const { loggedIn, user } = await login({
if (getCodeOnly) {
const params = new URLSearchParams(location.search)
const responseType = params.get('response_type')
if (responseType === 'code') clientId = params.get('client_id')
}
const { code } = await getAuthCode({
clientId,
username,
password
}).catch((err: any) => {
setErrorMessage(err.response.data)
}).catch((err: string) => {
error = true
setErrorMessage(err)
return {}
})
if (loggedIn) {
appContext.setUserId?.(user.id)
appContext.setUsername?.(user.username)
appContext.setDisplayName?.(user.displayName)
appContext.setLoggedIn?.(loggedIn)
if (!error) {
if (getCodeOnly) return setDisplayCode(code)
const { accessToken, refreshToken } = await getTokens({
clientId,
code
})
if (appContext.setTokens) appContext.setTokens(accessToken, refreshToken)
if (appContext.setUserName) appContext.setUserName(username)
}
}
if (displayCode) {
return (
<Box className="main">
<CssBaseline />
<br />
<h2>Authorization Code</h2>
<Typography m={2} p={3} style={{ overflowWrap: 'anywhere' }}>
{displayCode}
</Typography>
<br />
</Box>
)
}
return (
<Box
className="main"
@@ -46,12 +105,19 @@ const Login = () => {
<CssBaseline />
<br />
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
{getCodeOnly && (
<p style={{ width: 'auto' }}>
Provide credentials to get authorization code.
</p>
)}
<br />
<TextField
id="username"
label="Username"
type="text"
variant="outlined"
onChange={(e: any) => setUsername(e.target.value)}
onChange={(e: any) => setUserName(e.target.value)}
required
/>
<TextField
@@ -63,11 +129,7 @@ const Login = () => {
required
/>
{errorMessage && <span>{errorMessage}</span>}
<Button
type="submit"
variant="outlined"
disabled={!appContext.setLoggedIn}
>
<Button type="submit" variant="outlined" disabled={!appContext.setTokens}>
Submit
</Button>
</Box>

View File

@@ -0,0 +1,94 @@
import axios from 'axios'
import { useEffect, useState } from 'react'
export default function useTokens() {
const getTokens = () => {
const accessToken = localStorage.getItem('accessToken')
const refreshToken = localStorage.getItem('refreshToken')
if (accessToken && refreshToken) {
setAxiosRequestHeader(accessToken)
return { accessToken, refreshToken }
}
return undefined
}
const [tokens, setTokens] = useState(getTokens())
useEffect(() => {
if (tokens === undefined) {
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
}
}, [tokens])
setAxiosResponse(setTokens)
const saveTokens = (accessToken: string, refreshToken: string) => {
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', refreshToken)
setAxiosRequestHeader(accessToken)
setTokens({ accessToken, refreshToken })
}
return {
setTokens: saveTokens,
tokens
}
}
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
const isAbsoluteURLRegex = /^(?:\w+:)\/\//
const setAxiosRequestHeader = (accessToken: string) => {
axios.interceptors.request.use(function (config) {
if (baseUrl && !isAbsoluteURLRegex.test(config.url as string)) {
config.url = baseUrl + config.url
}
config.headers!['Authorization'] = `Bearer ${accessToken}`
config.withCredentials = true
return config
})
}
const setAxiosResponse = (setTokens: Function) => {
// Add a response interceptor
axios.interceptors.response.use(
function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
return response
},
async function (error) {
if (error.response?.status === 401) {
// refresh token
// const { accessToken, refreshToken: newRefresh } = await refreshMyToken(
// refreshToken
// )
// if (accessToken && newRefresh) {
// setTokens(accessToken, newRefresh)
// error.config.headers['Authorization'] = 'Bearer ' + accessToken
// error.config.baseURL = undefined
// return axios.request(error.config)
// }
setTokens(undefined)
}
return Promise.reject(error)
}
)
}
// const refreshMyToken = async (refreshToken: string) => {
// return fetch('http://localhost:5000/SASjsApi/auth/refresh', {
// method: 'POST',
// headers: {
// Authorization: `Bearer ${refreshToken}`
// }
// }).then((data) => data.json())
// }

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { Typography, IconButton } from '@mui/material'
import AccountCircle from '@mui/icons-material/AccountCircle'
const Username = (props: any) => {
const UserName = (props: any) => {
return (
<IconButton
aria-label="account of current user"
@@ -21,10 +21,10 @@ const Username = (props: any) => {
<AccountCircle></AccountCircle>
)}
<Typography variant="h6" sx={{ color: 'white', padding: '0 8px' }}>
{props.username}
{props.userName}
</Typography>
</IconButton>
)
}
export default Username
export default UserName

View File

@@ -1,78 +0,0 @@
import axios from 'axios'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import React, { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
import { useLocation } from 'react-router-dom'
import { CssBaseline, Box, Typography, Button } from '@mui/material'
const getAuthCode = async (credentials: any) =>
axios.post('/SASLogon/authorize', credentials).then((res) => res.data)
const AuthCode = () => {
const location = useLocation()
const [displayCode, setDisplayCode] = useState('')
const [errorMessage, setErrorMessage] = useState('')
useEffect(() => {
requestAuthCode()
}, [])
const requestAuthCode = async () => {
setErrorMessage('')
const params = new URLSearchParams(location.search)
const responseType = params.get('response_type')
if (responseType !== 'code')
return setErrorMessage('response type is not support')
const clientId = params.get('client_id')
if (!clientId) return setErrorMessage('clientId is not provided')
setErrorMessage('Fetching auth code... ')
const { code } = await getAuthCode({
clientId
})
.then((res) => {
setErrorMessage('')
return res
})
.catch((err: any) => {
setErrorMessage(err.response.data)
return { code: null }
})
return setDisplayCode(code)
}
return (
<Box className="main">
<CssBaseline />
<br />
<h2>Authorization Code</h2>
{displayCode && (
<Typography m={2} p={3} style={{ overflowWrap: 'anywhere' }}>
{displayCode}
</Typography>
)}
{errorMessage && <Typography>{errorMessage}</Typography>}
<br />
<CopyToClipboard
text={displayCode}
onCopy={() =>
toast.info('Code copied to ClipBoard', {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
}
>
<Button variant="contained">Copy to Clipboard</Button>
</CopyToClipboard>
</Box>
)
}
export default AuthCode

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import Editor from 'react-monaco-editor'
import Editor from '@monaco-editor/react'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
@@ -125,7 +125,6 @@ const Main = (props: Props) => {
{!isLoading && props?.selectedFilePath && editMode && (
<Editor
height="95%"
language="sas"
value={fileContent}
onChange={(val) => {
if (val) setFileContent(val)

View File

@@ -1,55 +0,0 @@
import * as React from 'react'
import { Box, Paper, Tab, styled } from '@mui/material'
import TabContext from '@mui/lab/TabContext'
import TabList from '@mui/lab/TabList'
import TabPanel from '@mui/lab/TabPanel'
import Profile from './profile'
const StyledTab = styled(Tab)({
background: 'black',
margin: '0 5px 5px 0'
})
const StyledTabpanel = styled(TabPanel)({
flexGrow: 1
})
const Settings = () => {
const [value, setValue] = React.useState('profile')
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
setValue(newValue)
}
return (
<Box
sx={{
display: 'flex',
marginTop: '65px'
}}
>
<TabContext value={value}>
<Box component={Paper} sx={{ margin: '0 5px', height: '92vh' }}>
<TabList
TabIndicatorProps={{
style: {
display: 'none'
}
}}
orientation="vertical"
onChange={handleChange}
>
<StyledTab label="Profile" value="profile" />
</TabList>
</Box>
<StyledTabpanel value="profile">
<Profile />
</StyledTabpanel>
</TabContext>
</Box>
)
}
export default Settings

View File

@@ -1,150 +0,0 @@
import React, { useState, useEffect, useContext } from 'react'
import axios from 'axios'
import {
Grid,
CircularProgress,
Card,
CardHeader,
Divider,
CardContent,
TextField,
CardActions,
Button,
FormGroup,
FormControlLabel,
Checkbox
} from '@mui/material'
import { toast } from 'react-toastify'
import { AppContext, ModeType } from '../../context/appContext'
const Profile = () => {
const [isLoading, setIsLoading] = useState(false)
const appContext = useContext(AppContext)
const [user, setUser] = useState({} as any)
useEffect(() => {
setIsLoading(true)
axios
.get(`/SASjsApi/user/${appContext.userId}`)
.then((res: any) => {
setUser(res.data)
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setIsLoading(false)
})
}, [])
const handleChange = (event: any) => {
const { name, value } = event.target
setUser({ ...user, [name]: value })
}
const handleSubmit = () => {
setIsLoading(true)
axios
.patch(`/SASjsApi/user/${appContext.userId}`, {
username: user.username,
displayName: user.displayName,
autoExec: user.autoExec
})
.then((res: any) => {
toast.success('User information updated', {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.catch((err) => {
toast.error('Failed: ' + err.response?.data || err.text, {
theme: 'dark',
position: toast.POSITION.BOTTOM_RIGHT
})
})
.finally(() => {
setIsLoading(false)
})
}
return isLoading ? (
<CircularProgress
style={{ position: 'absolute', left: '50%', top: '50%' }}
/>
) : (
<Card>
<CardHeader title="Profile Information" />
<Divider />
<CardContent>
<Grid container spacing={4}>
<Grid item md={6} xs={12}>
<TextField
fullWidth
error={user.displayName?.length === 0}
helperText="Please specify display name"
label="Display Name"
name="displayName"
onChange={handleChange}
required
value={user.displayName}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/>
</Grid>
<Grid item md={6} xs={12}>
<TextField
fullWidth
error={user.username?.length === 0}
helperText="Please specify username"
label="Username"
name="username"
onChange={handleChange}
required
value={user.username}
variant="outlined"
disabled={appContext.mode === ModeType.Desktop}
/>
</Grid>
<Grid item lg={6} md={8} sm={12} xs={12}>
<TextField
fullWidth
label="autoExec"
name="autoExec"
onChange={handleChange}
multiline
rows="10"
value={user.autoExec}
variant="outlined"
/>
</Grid>
<Grid item xs={6}>
<FormGroup row>
<FormControlLabel
disabled
control={<Checkbox checked={user.isActive} />}
label="isActive"
/>
<FormControlLabel
disabled
control={<Checkbox checked={user.isAdmin} />}
label="isAdmin"
/>
</FormGroup>
</Grid>
</Grid>
</CardContent>
<Divider />
<CardActions>
<Button type="submit" variant="contained" onClick={handleSubmit}>
Save Changes
</Button>
</CardActions>
</Card>
)
}
export default Profile

View File

@@ -4,7 +4,7 @@ import axios from 'axios'
import Box from '@mui/material/Box'
import { Button, Paper, Stack, Tab, Tooltip } from '@mui/material'
import { makeStyles } from '@mui/styles'
import Editor, { EditorDidMount } from 'react-monaco-editor'
import Editor, { OnMount } from '@monaco-editor/react'
import { useLocation } from 'react-router-dom'
import { TabContext, TabList, TabPanel } from '@mui/lab'
@@ -42,7 +42,7 @@ const Studio = () => {
}
const editorRef = useRef(null as any)
const handleEditorDidMount: EditorDidMount = (editor) => {
const handleEditorDidMount: OnMount = (editor) => {
editor.focus()
editorRef.current = editor
}
@@ -141,7 +141,6 @@ const Studio = () => {
<Tooltip title="CTRL+ENTER will also run SAS code">
<Button onClick={handleRunBtnClick} className={classes.runButton}>
<img
alt=""
draggable="false"
style={{ width: '25px' }}
src="/running-sas.png"
@@ -162,9 +161,8 @@ const Studio = () => {
>
<Editor
height="98%"
language="sas"
value={fileContent}
editorDidMount={handleEditorDidMount}
onMount={handleEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => {
if (val) setFileContent(val)

View File

@@ -7,98 +7,147 @@ import React, {
useCallback,
ReactNode
} from 'react'
import axios from 'axios'
export enum ModeType {
Server = 'server',
Desktop = 'desktop'
import axios from 'axios'
import jwt_decode from 'jwt-decode'
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
const isAbsoluteURLRegex = /^(?:\w+:)\/\//
const setAxiosRequestHeader = (accessToken: string) => {
axios.interceptors.request.use(function (config) {
if (baseUrl && !isAbsoluteURLRegex.test(config.url as string)) {
config.url = baseUrl + config.url
}
console.log('axios.interceptors.request.use', accessToken)
config.headers!['Authorization'] = `Bearer ${accessToken}`
config.withCredentials = true
return config
})
}
const setAxiosResponse = (setTokens: Function) => {
// Add a response interceptor
axios.interceptors.response.use(
function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
return response
},
async function (error) {
if (error.response?.status === 401) {
// refresh token
// const { accessToken, refreshToken: newRefresh } = await refreshMyToken(
// refreshToken
// )
// if (accessToken && newRefresh) {
// setTokens(accessToken, newRefresh)
// error.config.headers['Authorization'] = 'Bearer ' + accessToken
// error.config.baseURL = undefined
// return axios.request(error.config)
// }
console.log(53)
setTokens(undefined)
}
return Promise.reject(error)
}
)
}
const getTokens = () => {
const accessToken = localStorage.getItem('accessToken')
const refreshToken = localStorage.getItem('refreshToken')
if (accessToken && refreshToken) {
setAxiosRequestHeader(accessToken)
return { accessToken, refreshToken }
}
return undefined
}
interface AppContextProps {
checkingSession: boolean
loggedIn: boolean
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
userId: number
setUserId: Dispatch<SetStateAction<number>> | null
username: string
setUsername: Dispatch<SetStateAction<string>> | null
userName: string
displayName: string
setDisplayName: Dispatch<SetStateAction<string>> | null
mode: ModeType
setUserName: Dispatch<SetStateAction<string>> | null
tokens?: { accessToken: string; refreshToken: string }
setTokens: ((accessToken: string, refreshToken: string) => void) | null
logout: (() => void) | null
}
export const AppContext = createContext<AppContextProps>({
checkingSession: false,
loggedIn: false,
setLoggedIn: null,
userId: 0,
setUserId: null,
username: '',
setUsername: null,
userName: '',
displayName: '',
setDisplayName: null,
mode: ModeType.Server,
tokens: getTokens(),
setUserName: null,
setTokens: null,
logout: null
})
const AppContextProvider = (props: { children: ReactNode }) => {
const { children } = props
const [checkingSession, setCheckingSession] = useState(false)
const [loggedIn, setLoggedIn] = useState(false)
const [userId, setUserId] = useState(0)
const [username, setUsername] = useState('')
const [userName, setUserName] = useState('')
const [displayName, setDisplayName] = useState('')
const [mode, setMode] = useState(ModeType.Server)
const [tokens, setTokens] = useState(getTokens())
useEffect(() => {
setCheckingSession(true)
axios
.get('/SASjsApi/session')
.then((res) => res.data)
.then((data: any) => {
setCheckingSession(false)
setUserId(data.id)
setUsername(data.username)
setDisplayName(data.displayName)
setLoggedIn(true)
})
.catch(() => {
setLoggedIn(false)
axios.get('/') // get CSRF TOKEN
})
axios
.get('/SASjsApi/info')
.then((res) => res.data)
.then((data: any) => {
setMode(data.mode)
})
.catch(() => {})
setAxiosResponse(setTokens)
}, [])
const logout = useCallback(() => {
axios.get('/logout').then(() => {
setLoggedIn(false)
setUsername('')
useEffect(() => {
if (tokens === undefined) {
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
setUserName('')
setDisplayName('')
})
} else {
const decoded: any = jwt_decode(tokens.accessToken)
if (decoded.userId) {
axios
.get(`/SASjsApi/user/${decoded.userId}`)
.then((res: any) => {
if (res.data && res.data?.displayName) {
setDisplayName(res.data.displayName)
} else if (res.data && res.data?.username) {
setDisplayName(res.data.username)
}
})
.catch((err) => {
console.log(err)
})
}
}
}, [tokens])
const saveTokens = useCallback(
(accessToken: string, refreshToken: string) => {
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', refreshToken)
setAxiosRequestHeader(accessToken)
setTokens({ accessToken, refreshToken })
},
[]
)
const logout = useCallback(() => {
setUserName('')
setTokens(undefined)
}, [])
return (
<AppContext.Provider
value={{
checkingSession,
loggedIn,
setLoggedIn,
userId,
setUserId,
username,
setUsername,
userName,
displayName,
setDisplayName,
mode,
setUserName,
tokens,
setTokens: saveTokens,
logout
}}
>

View File

@@ -4,18 +4,6 @@ import './index.css'
import App from './App'
import AppContextProvider from './context/appContext'
import axios from 'axios'
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
axios.defaults = Object.assign(axios.defaults, {
withCredentials: true,
baseURL: baseUrl
})
ReactDOM.render(
<React.StrictMode>
<AppContextProvider>

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