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

Compare commits

..

89 Commits

Author SHA1 Message Date
semantic-release-bot
ad82ee7106 chore(release): 0.3.6 [skip ci]
## [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](e6d1989847))
2022-06-02 08:30:39 +00:00
Allan Bowe
d2e9456d81 Merge pull request #185 from sasjs/issue183
fix(appstream): should serve only new files for same app stream name …
2022-06-02 11:25:27 +03:00
Saad Jutt
e6d1989847 fix(appstream): should serve only new files for same app stream name with new deployment 2022-06-02 04:17:12 +05:00
semantic-release-bot
7a932383b4 chore(release): 0.3.5 [skip ci]
## [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](61815f8ae1))
2022-05-30 18:07:50 +00:00
Allan Bowe
576e18347e Merge pull request #182 from sasjs/bumpcore
fix: bumping sasjs/core library
2022-05-30 21:02:59 +03:00
Allan Bowe
61815f8ae1 fix: bumping sasjs/core library 2022-05-30 18:02:30 +00:00
semantic-release-bot
afff27fd21 chore(release): 0.3.4 [skip ci]
## [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](a8ba378fd1))
2022-05-30 16:12:22 +00:00
Saad Jutt
a8ba378fd1 fix(web): system username for DESKTOP mode 2022-05-30 21:08:17 +05:00
semantic-release-bot
73c81a45dc chore(release): 0.3.3 [skip ci]
## [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](12d424acce))
2022-05-30 12:18:45 +00:00
Saad Jutt
12d424acce fix: usage of autoexec API in DESKTOP mode 2022-05-30 17:12:17 +05:00
Saad Jutt
414fb19de3 chore: code changes 2022-05-30 00:32:05 +05:00
semantic-release-bot
cfddf1fb0c chore(release): 0.3.2 [skip ci]
## [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](2c259fe1de))
2022-05-27 19:43:00 +00:00
Muhammad Saad
1f483b1afc Merge pull request #180 from sasjs/desktop-autoexec
fix(web): ability to use get/patch User API in desktop mode.
2022-05-27 12:39:17 -07:00
Saad Jutt
0470239ef1 chore: quick fix 2022-05-27 17:35:58 +05:00
Saad Jutt
2c259fe1de fix(web): ability to use get/patch User API in desktop mode. 2022-05-27 17:01:14 +05:00
semantic-release-bot
b066734398 chore(release): 0.3.1 [skip ci]
## [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](5ad6ee5e0f))
* **web:** reduced width for autoexec input ([7d11cc7](7d11cc7916))
2022-05-26 15:30:45 +00:00
Allan Bowe
3b698fce5f Merge pull request #179 from sasjs/web-profile-fixes
Web profile fixes
2022-05-26 18:26:30 +03:00
Saad Jutt
5ad6ee5e0f fix(api): username should be lowercase 2022-05-26 20:20:02 +05:00
Saad Jutt
7d11cc7916 fix(web): reduced width for autoexec input 2022-05-26 19:48:59 +05:00
semantic-release-bot
ff1def6436 chore(release): 0.3.0 [skip ci]
# [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](c275db184e))
2022-05-25 23:29:22 +00:00
Saad Jutt
c275db184e feat(web): added profile + edit + autoexec changes 2022-05-26 04:25:15 +05:00
semantic-release-bot
e4239fbcc3 chore(release): 0.2.0 [skip ci]
# [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](79dc2dba23))

### Features

* **api:** added autoexec + major type setting changes ([2a7223a](2a7223ad7d))
2022-05-25 05:52:30 +00:00
Muhammad Saad
c6fd8fdd70 Merge pull request #178 from sasjs/issue117
feat(api): added autoexec + major type setting changes
2022-05-24 22:48:29 -07:00
Saad Jutt
79dc2dba23 fix(autoexec): usage in case of desktop from file 2022-05-25 10:44:57 +05:00
Saad Jutt
2a7223ad7d feat(api): added autoexec + major type setting changes 2022-05-24 21:12:32 +05:00
semantic-release-bot
1fed5ea6ac chore(release): 0.1.0 [skip ci]
# [0.1.0](https://github.com/sasjs/server/compare/v0.0.77...v0.1.0) (2022-05-23)

### Bug Fixes

* issue174 + issue175 + issue146 ([80b33c7](80b33c7a18))
* **web:** click to copy + notification ([f37f8e9](f37f8e95d1))

### Features

* **env:** added new env variable LOG_FORMAT_MORGAN ([53bf68a](53bf68a6af))
2022-05-23 21:22:02 +00:00
Muhammad Saad
97f689f292 Merge pull request #177 from sasjs/issue174
fix: issue174 + issue175 + issue146
2022-05-23 14:17:25 -07:00
Saad Jutt
53bf68a6af feat(env): added new env variable LOG_FORMAT_MORGAN 2022-05-23 21:14:37 +05:00
Saad Jutt
f37f8e95d1 fix(web): click to copy + notification 2022-05-23 20:29:29 +05:00
Saad Jutt
80b33c7a18 fix: issue174 + issue175 + issue146 2022-05-23 19:24:56 +05:00
Muhammad Saad
b1803fe385 Merge pull request #170 from sasjs/dummy-release-command
chore: added dummy release command
2022-05-16 09:42:23 -07:00
Saad Jutt
7dd08c3b5b chore: added dummy release command 2022-05-16 21:36:00 +05:00
semantic-release-bot
b780b59b66 chore(release): 0.0.77 [skip ci]
## [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](c017d13061))
2022-05-16 16:26:07 +00:00
Muhammad Saad
7b457eaec5 Merge pull request #169 from saadjutt01/main
Release on main update
2022-05-16 09:21:54 -07:00
Saad Jutt
c017d13061 fix(release): Github workflow without npm token 2022-05-16 21:17:53 +05:00
Saad Jutt
c2b5e353a5 chore(release): 0.0.76 2022-05-16 15:30:15 +05:00
Saad Jutt
f89389bbc6 fix: get csrf token from cookie if not present in header 2022-05-16 15:30:08 +05:00
Saad Jutt
fadcc9bd29 chore(release): 0.0.75 2022-05-12 20:48:35 +05:00
Saad Jutt
182def2f3e chore(api): updated package-lock file 2022-05-12 20:48:21 +05:00
Muhammad Saad
06a5f39fea Merge pull request #166 from sasjs/deprecate-get-auth-code-api
Deprecate get auth code api
2022-05-12 08:47:40 -07:00
Saad Jutt
143b367a0e test: fixed specs 2022-05-12 20:42:50 +05:00
Saad Jutt
b5fd800300 chore: added env SESSION_SECRET to CI 2022-05-12 19:17:09 +05:00
Saad Jutt
a0b52d9982 test(web): moved authorize specs from api to web 2022-05-12 17:59:12 +05:00
Allan Bowe
c4212665c8 chore(release): 0.0.74 2022-05-12 07:53:50 +00:00
Allan Bowe
97d9bc191c Merge pull request #167 from sasjs/cspconfig
fix: csp updates
2022-05-12 10:53:21 +03:00
Allan Bowe
dd2a403985 chore: lint fix 2022-05-11 21:57:19 +00:00
Allan Bowe
7cfa2398e1 fix: csp updates 2022-05-11 21:37:49 +00:00
Saad Jutt
5888f04e08 fix(web): seperate container for auth code 2022-05-11 21:01:59 +05:00
Saad Jutt
b40de8fa6a fix: moved getAuthCode from api to web routes 2022-05-11 21:01:00 +05:00
Allan Bowe
45a2a01532 chore(release): 0.0.73 2022-05-10 11:23:59 +00:00
Allan Bowe
c61fec47c4 Merge pull request #165 from sasjs/issue-164
fix: helmet config on http mode
2022-05-10 14:01:40 +03:00
24d7f00c02 chore: type fix 2022-05-10 10:13:57 +00:00
b0fdaaaa79 fix: helmet config on http mode 2022-05-10 10:04:01 +00:00
Allan Bowe
2467616296 chore(release): 0.0.72 2022-05-09 12:33:32 +00:00
Allan Bowe
ceefbe48e9 chore(release): 0.0.71 2022-05-07 22:35:25 +00:00
Allan Bowe
426e90471e Merge pull request #163 from sasjs/issue159
fix: reqHeadrs.txt will contain headers to access APIs
2022-05-08 01:34:41 +03:00
Allan Bowe
c0b57b9e76 fix: bumping core 2022-05-07 22:31:44 +00:00
Saad Jutt
4a8e32dd20 fix: added more cookies to req 2022-05-08 03:18:04 +05:00
Saad Jutt
636301e664 fix: reqHeadrs.txt will contain headers to access APIs 2022-05-08 02:49:16 +05:00
Allan Bowe
25dc5dd215 chore(release): 0.0.70 2022-05-06 14:45:31 +00:00
Allan Bowe
503994dbd2 Merge pull request #161 from sasjs/csp-disable
Added additional options for HELMET
2022-05-06 17:44:18 +03:00
Saad Jutt
0dceb5c3c3 chore: web package-lock built with LTS 2022-05-06 19:41:02 +05:00
Mihajlo Medjedovic
1af04fa3b3 Merge branch 'csp-disable' of github.com:sasjs/server into csp-disable 2022-05-06 13:40:48 +00:00
Mihajlo Medjedovic
efa81fec77 chore: package-lock 2022-05-06 13:40:40 +00:00
Allan Bowe
10caf1918a chore: updating README 2022-05-06 12:13:45 +00:00
Mihajlo Medjedovic
4ed20a3b75 chore: readme update 2022-05-06 11:49:32 +00:00
Mihajlo Medjedovic
98b2c5fa25 chore: readme update 2022-05-06 11:46:40 +00:00
Mihajlo Medjedovic
3ad327b85f chore: helmet config cleanup 2022-05-06 11:40:12 +00:00
Mihajlo Medjedovic
dd3acce393 feat: CSP_DISABLE env option 2022-05-05 18:25:33 +00:00
Allan Bowe
8065727b9b chore(release): 0.0.69 2022-05-02 15:24:56 +00:00
Allan Bowe
e1223ec3f8 Merge pull request #158 from sasjs/update-csp-policy
fix(upload): appStream uses CSRF + Session authentication
2022-05-02 18:22:35 +03:00
Saad Jutt
1f89279264 fix(upload): appStream uses CSRF + Session authentication 2022-05-02 18:01:28 +05:00
Saad Jutt
a07f47a1ba chore(release): 0.0.68 2022-05-02 05:57:10 +05:00
Saad Jutt
2548c82dfe fix: using monaco editor locally 2022-05-02 05:57:03 +05:00
Saad Jutt
238aa1006f chore(release): 0.0.67 2022-05-02 03:41:07 +05:00
Saad Jutt
35cba97611 chore: commented helmet middleware 2022-05-02 03:40:14 +05:00
Saad Jutt
5f29dec16f chore(release): 0.0.66 2022-05-01 23:31:59 +05:00
Saad Jutt
e2a97fcb7c fix: added swagger ui init file manually 2022-05-01 23:31:48 +05:00
Allan Bowe
6adeeefcf5 chore(release): 0.0.65 2022-05-01 11:36:26 +00:00
Allan Bowe
c9d66b8576 Merge pull request #156 from sasjs/fix-swagger-api-with-csrf
fix: consume swagger api with CSRF
2022-05-01 14:35:23 +03:00
Saad Jutt
5aaac24080 fix: consume swagger api with CSRF 2022-05-01 06:07:17 +05:00
Saad Jutt
6d34206bbc chore(release): 0.0.64 2022-05-01 02:28:57 +05:00
Saad Jutt
7b39cc06d3 fix: removed fileExists for serving web 2022-05-01 02:28:50 +05:00
Saad Jutt
6e7f28a6f8 chore(release): 0.0.63 2022-05-01 02:10:24 +05:00
Saad Jutt
5689169ce4 chore: syntax fix for workflow 2022-05-01 02:10:17 +05:00
Saad Jutt
6139e7bff6 chore(release): 0.0.62 2022-05-01 02:08:03 +05:00
Saad Jutt
2c77317bb9 chore: release using node LTS 2022-05-01 02:07:55 +05:00
Saad Jutt
57b63db9cb chore(release): 0.0.61 2022-05-01 01:59:12 +05:00
Saad Jutt
60a2a4fe32 chore: bumped pkg version 2022-05-01 01:59:04 +05:00
84 changed files with 11814 additions and 2246 deletions

View File

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

View File

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

2
.gitignore vendored
View File

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

43
.releaserc Normal file
View File

@@ -0,0 +1,43 @@
{
"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,6 +1,191 @@
# Changelog ## [0.3.6](https://github.com/sasjs/server/compare/v0.3.5...v0.3.6) (2022-06-02)
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.
### 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) ### [0.0.60](https://github.com/sasjs/server/compare/v0.0.59...v0.0.60) (2022-04-30)

View File

@@ -48,15 +48,22 @@ When launching the app, it will make use of specific environment variables. Thes
Example contents of a `.env` file: Example contents of a `.env` file:
``` ```
# options: [desktop|server] default: `desktop` #
## 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
MODE= MODE=
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop` # Path to SAS executable (sas.exe / sas.sh)
# If enabled, be sure to also configure the WHITELIST of third party servers. SAS_PATH=/path/to/sas/executable.exe
CORS=
# options: <http://localhost:3000 https://abc.com ...> space separated urls # Path to working directory
WHITELIST= # This location is for SAS WORK, staged files, DRIVE, configuration etc
SASJS_ROOT=./sasjs_root
# options: [http|https] default: http # options: [http|https] default: http
PROTOCOL= PROTOCOL=
@@ -65,16 +72,22 @@ PROTOCOL=
PORT= PORT=
# optional #
# for MODE: `desktop`, prompts user ## Additional SAS Options
# for MODE: `server` gets value from api/package.json `configuration.sasPath` #
SAS_PATH=/path/to/sas/executable.exe
# optional # On windows use SAS_OPTIONS and on unix use SASV9_OPTIONS
# for MODE: `desktop`, prompts user # Any options set here are automatically applied in the SAS session
# for MODE: `server` defaults to /tmp # See: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostunx/p0wrdmqp8k0oyyn1xbx3bp3qy2wl.htm
DRIVE_PATH=/tmp # 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
#
# ENV variables required for PROTOCOL: `https` # ENV variables required for PROTOCOL: `https`
PRIVATE_KEY=privkey.pem PRIVATE_KEY=privkey.pem
@@ -87,13 +100,34 @@ AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret> SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# SAS Options # options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
# On windows use SAS_OPTIONS and on unix use SASV9_OPTIONS # If enabled, be sure to also configure the WHITELIST of third party servers.
# Any options set here are automatically applied in the SAS session CORS=
# 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 # options: <http://localhost:3000 https://abc.com ...> space separated urls
SAS_OPTIONS= -NOXCMD WHITELIST=
SASV9_OPTIONS= -NOXCMD
# 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=
``` ```
@@ -117,7 +151,7 @@ Install the npm package [pm2](https://www.npmjs.com/package/pm2) (`npm install p
```bash ```bash
export SAS_PATH=/opt/sas9/SASHome/SASFoundation/9.4/sasexe/sas export SAS_PATH=/opt/sas9/SASHome/SASFoundation/9.4/sasexe/sas
export PORT=5001 export PORT=5001
export DRIVE_PATH=./tmp export SASJS_ROOT=./sasjs_root
pm2 start api-linux pm2 start api-linux
``` ```

View File

@@ -8,6 +8,9 @@ FULL_CHAIN=fullchain.pem
PORT=[5000] default value is 5000 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> ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret> REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret> AUTH_CODE_SECRET=<secret>
@@ -15,4 +18,6 @@ SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority 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 SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
DRIVE_PATH=./tmp SASJS_ROOT=./sasjs_root
LOG_FORMAT_MORGAN=common

View File

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

166
api/package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "api", "name": "api",
"version": "0.0.2", "version": "0.0.2",
"dependencies": { "dependencies": {
"@sasjs/core": "^4.19.0", "@sasjs/core": "^4.27.3",
"@sasjs/utils": "2.42.1", "@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0", "connect-mongo": "^4.6.0",
@@ -24,7 +24,8 @@
"mongoose-sequence": "^5.3.1", "mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.3", "multer": "^1.4.3",
"swagger-ui-express": "^4.1.6" "swagger-ui-express": "4.3.0",
"url": "^0.10.3"
}, },
"bin": { "bin": {
"api": "build/src/server.js" "api": "build/src/server.js"
@@ -49,7 +50,7 @@
"jest": "^27.0.6", "jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0", "mongodb-memory-server": "^8.0.0",
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"pkg": "5.5.2", "pkg": "5.6.0",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"supertest": "^6.1.3", "supertest": "^6.1.3",
@@ -1385,9 +1386,9 @@
} }
}, },
"node_modules/@sasjs/core": { "node_modules/@sasjs/core": {
"version": "4.19.0", "version": "4.27.3",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.19.0.tgz", "resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.27.3.tgz",
"integrity": "sha512-vG2YHJveQUQqN0YBhapXb8y+Qp4OniHzRedlqKRxyL0Pc+kwXx5co4Vo+dcOI5/MX0p+8oERP2aCR77s4FEUJg==" "integrity": "sha512-8AaPPRGMwhmjw244CDSnTqHXdp/77ZBjIJMgwqw4wTrCf8Vzs2Y5hVihbvAniIGQctZHLMR6X5a3X4ccn9gRjg=="
}, },
"node_modules/@sasjs/utils": { "node_modules/@sasjs/utils": {
"version": "2.42.1", "version": "2.42.1",
@@ -2883,7 +2884,7 @@
"node_modules/busboy": { "node_modules/busboy": {
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
"integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=", "integrity": "sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==",
"dependencies": { "dependencies": {
"dicer": "0.2.5", "dicer": "0.2.5",
"readable-stream": "1.1.x" "readable-stream": "1.1.x"
@@ -2995,14 +2996,20 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001243", "version": "1.0.30001340",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001340.tgz",
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA==", "integrity": "sha512-jUNz+a9blQTQVu4uFcn17uAD8IDizPzQkIKh3LCJfg9BkyIqExYYdyc/ZSlWUSKb8iYiXxKsxbv4zYSvkqjrxw==",
"dev": true, "dev": true,
"funding": { "funding": [
"type": "opencollective", {
"url": "https://opencollective.com/browserslist" "type": "opencollective",
} "url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
}
]
}, },
"node_modules/chalk": { "node_modules/chalk": {
"version": "3.0.0", "version": "3.0.0",
@@ -3671,7 +3678,7 @@
"node_modules/dicer": { "node_modules/dicer": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
"integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=", "integrity": "sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg==",
"dependencies": { "dependencies": {
"readable-stream": "1.1.x", "readable-stream": "1.1.x",
"streamsearch": "0.1.2" "streamsearch": "0.1.2"
@@ -7584,9 +7591,10 @@
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "1.4.3", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.3.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4.tgz",
"integrity": "sha512-np0YLKncuZoTzufbkM6wEKp68EhWJXcU6fq6QqrSwkckd2LlMgd1UqhUJLj6NS/5sZ8dE8LYDWslsltJznnXlg==", "integrity": "sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==",
"deprecated": "Multer 1.x is affected by CVE-2022-24434. This is fixed in v1.4.4-lts.1 which drops support for versions of Node.js before 6. Please upgrade to at least Node.js 6 and version 1.4.4-lts.1 of Multer. If you need support for older versions of Node.js, we are open to accepting patches that would fix the CVE on the main 1.x release line, whilst maintaining compatibility with Node.js 0.10.",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^0.2.11", "busboy": "^0.2.11",
@@ -8170,9 +8178,9 @@
} }
}, },
"node_modules/pkg": { "node_modules/pkg": {
"version": "5.5.2", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pkg/-/pkg-5.5.2.tgz", "resolved": "https://registry.npmjs.org/pkg/-/pkg-5.6.0.tgz",
"integrity": "sha512-pD0UB2ud01C6pVv2wpGsTYJrXI/bnvGRYvMLd44wFzA1p+A2jrlTGFPAYa7YEYzmitXhx23PqalaG1eUEnSwcA==", "integrity": "sha512-mHrAVSQWmHA41RnUmRpC7pK9lNnMfdA16CF3cqOI22a8LZxOQzF7M8YWtA2nfs+d7I0MTDXOtkDsAsFXeCpYjg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@babel/parser": "7.16.2", "@babel/parser": "7.16.2",
@@ -8184,7 +8192,7 @@
"into-stream": "^6.0.0", "into-stream": "^6.0.0",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"multistream": "^4.1.0", "multistream": "^4.1.0",
"pkg-fetch": "3.2.6", "pkg-fetch": "3.3.0",
"prebuild-install": "6.1.4", "prebuild-install": "6.1.4",
"progress": "^2.0.3", "progress": "^2.0.3",
"resolve": "^1.20.0", "resolve": "^1.20.0",
@@ -8216,9 +8224,9 @@
} }
}, },
"node_modules/pkg-fetch": { "node_modules/pkg-fetch": {
"version": "3.2.6", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/pkg-fetch/-/pkg-fetch-3.2.6.tgz", "resolved": "https://registry.npmjs.org/pkg-fetch/-/pkg-fetch-3.3.0.tgz",
"integrity": "sha512-Q8fx6SIT022g0cdSE4Axv/xpfHeltspo2gg1KsWRinLQZOTRRAtOOaEFghA1F3jJ8FVsh8hGrL/Pb6Ea5XHIFw==", "integrity": "sha512-xJnIZ1KP+8rNN+VLafwu4tEeV4m8IkFBDdCFqmAJz9K1aiXEtbARmdbEe6HlXWGSVuShSHjFXpfkKRkDBQ5kiA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"chalk": "^4.1.2", "chalk": "^4.1.2",
@@ -8275,9 +8283,9 @@
} }
}, },
"node_modules/pkg-fetch/node_modules/semver": { "node_modules/pkg-fetch/node_modules/semver": {
"version": "7.3.5", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
@@ -8546,6 +8554,15 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -9434,11 +9451,11 @@
"integrity": "sha512-WvfPSfAAMlE/sKS6YkW47nX/hA7StmhYnAHc6wWCXNL0oclwLj6UXv0hQCkLnDgvebi0MEV40SJJpVjKUgH1IQ==" "integrity": "sha512-WvfPSfAAMlE/sKS6YkW47nX/hA7StmhYnAHc6wWCXNL0oclwLj6UXv0hQCkLnDgvebi0MEV40SJJpVjKUgH1IQ=="
}, },
"node_modules/swagger-ui-express": { "node_modules/swagger-ui-express": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.2.0.tgz", "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz",
"integrity": "sha512-znrHTwh9UpvsjqgWopA4noIet7mi7UGuIYZ465YfUDKQ5Dpas0jxnkfUKCo+0aB17YCBv26AhIjiQYDV4uvJFA==", "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==",
"dependencies": { "dependencies": {
"swagger-ui-dist": ">3.52.5" "swagger-ui-dist": ">=4.1.3"
}, },
"engines": { "engines": {
"node": ">= v0.10.32" "node": ">= v0.10.32"
@@ -9936,6 +9953,15 @@
"url": "https://github.com/yeoman/update-notifier?sponsor=1" "url": "https://github.com/yeoman/update-notifier?sponsor=1"
} }
}, },
"node_modules/url": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz",
"integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=",
"dependencies": {
"punycode": "1.3.2",
"querystring": "0.2.0"
}
},
"node_modules/url-parse-lax": { "node_modules/url-parse-lax": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
@@ -9948,6 +9974,11 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/url/node_modules/punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0="
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11358,9 +11389,9 @@
} }
}, },
"@sasjs/core": { "@sasjs/core": {
"version": "4.19.0", "version": "4.27.3",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.19.0.tgz", "resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.27.3.tgz",
"integrity": "sha512-vG2YHJveQUQqN0YBhapXb8y+Qp4OniHzRedlqKRxyL0Pc+kwXx5co4Vo+dcOI5/MX0p+8oERP2aCR77s4FEUJg==" "integrity": "sha512-8AaPPRGMwhmjw244CDSnTqHXdp/77ZBjIJMgwqw4wTrCf8Vzs2Y5hVihbvAniIGQctZHLMR6X5a3X4ccn9gRjg=="
}, },
"@sasjs/utils": { "@sasjs/utils": {
"version": "2.42.1", "version": "2.42.1",
@@ -12623,7 +12654,7 @@
"busboy": { "busboy": {
"version": "0.2.14", "version": "0.2.14",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
"integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=", "integrity": "sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==",
"requires": { "requires": {
"dicer": "0.2.5", "dicer": "0.2.5",
"readable-stream": "1.1.x" "readable-stream": "1.1.x"
@@ -12712,9 +12743,9 @@
"dev": true "dev": true
}, },
"caniuse-lite": { "caniuse-lite": {
"version": "1.0.30001243", "version": "1.0.30001340",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001340.tgz",
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA==", "integrity": "sha512-jUNz+a9blQTQVu4uFcn17uAD8IDizPzQkIKh3LCJfg9BkyIqExYYdyc/ZSlWUSKb8iYiXxKsxbv4zYSvkqjrxw==",
"dev": true "dev": true
}, },
"chalk": { "chalk": {
@@ -13261,7 +13292,7 @@
"dicer": { "dicer": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
"integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=", "integrity": "sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg==",
"requires": { "requires": {
"readable-stream": "1.1.x", "readable-stream": "1.1.x",
"streamsearch": "0.1.2" "streamsearch": "0.1.2"
@@ -16214,9 +16245,9 @@
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}, },
"multer": { "multer": {
"version": "1.4.3", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.3.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4.tgz",
"integrity": "sha512-np0YLKncuZoTzufbkM6wEKp68EhWJXcU6fq6QqrSwkckd2LlMgd1UqhUJLj6NS/5sZ8dE8LYDWslsltJznnXlg==", "integrity": "sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==",
"requires": { "requires": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^0.2.11", "busboy": "^0.2.11",
@@ -16649,9 +16680,9 @@
} }
}, },
"pkg": { "pkg": {
"version": "5.5.2", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pkg/-/pkg-5.5.2.tgz", "resolved": "https://registry.npmjs.org/pkg/-/pkg-5.6.0.tgz",
"integrity": "sha512-pD0UB2ud01C6pVv2wpGsTYJrXI/bnvGRYvMLd44wFzA1p+A2jrlTGFPAYa7YEYzmitXhx23PqalaG1eUEnSwcA==", "integrity": "sha512-mHrAVSQWmHA41RnUmRpC7pK9lNnMfdA16CF3cqOI22a8LZxOQzF7M8YWtA2nfs+d7I0MTDXOtkDsAsFXeCpYjg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@babel/parser": "7.16.2", "@babel/parser": "7.16.2",
@@ -16663,7 +16694,7 @@
"into-stream": "^6.0.0", "into-stream": "^6.0.0",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"multistream": "^4.1.0", "multistream": "^4.1.0",
"pkg-fetch": "3.2.6", "pkg-fetch": "3.3.0",
"prebuild-install": "6.1.4", "prebuild-install": "6.1.4",
"progress": "^2.0.3", "progress": "^2.0.3",
"resolve": "^1.20.0", "resolve": "^1.20.0",
@@ -16720,9 +16751,9 @@
} }
}, },
"pkg-fetch": { "pkg-fetch": {
"version": "3.2.6", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/pkg-fetch/-/pkg-fetch-3.2.6.tgz", "resolved": "https://registry.npmjs.org/pkg-fetch/-/pkg-fetch-3.3.0.tgz",
"integrity": "sha512-Q8fx6SIT022g0cdSE4Axv/xpfHeltspo2gg1KsWRinLQZOTRRAtOOaEFghA1F3jJ8FVsh8hGrL/Pb6Ea5XHIFw==", "integrity": "sha512-xJnIZ1KP+8rNN+VLafwu4tEeV4m8IkFBDdCFqmAJz9K1aiXEtbARmdbEe6HlXWGSVuShSHjFXpfkKRkDBQ5kiA==",
"dev": true, "dev": true,
"requires": { "requires": {
"chalk": "^4.1.2", "chalk": "^4.1.2",
@@ -16764,9 +16795,9 @@
"dev": true "dev": true
}, },
"semver": { "semver": {
"version": "7.3.5", "version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"dev": true, "dev": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
@@ -16927,6 +16958,11 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
}, },
"querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
},
"queue-microtask": { "queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -17601,11 +17637,11 @@
"integrity": "sha512-WvfPSfAAMlE/sKS6YkW47nX/hA7StmhYnAHc6wWCXNL0oclwLj6UXv0hQCkLnDgvebi0MEV40SJJpVjKUgH1IQ==" "integrity": "sha512-WvfPSfAAMlE/sKS6YkW47nX/hA7StmhYnAHc6wWCXNL0oclwLj6UXv0hQCkLnDgvebi0MEV40SJJpVjKUgH1IQ=="
}, },
"swagger-ui-express": { "swagger-ui-express": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.2.0.tgz", "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz",
"integrity": "sha512-znrHTwh9UpvsjqgWopA4noIet7mi7UGuIYZ465YfUDKQ5Dpas0jxnkfUKCo+0aB17YCBv26AhIjiQYDV4uvJFA==", "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==",
"requires": { "requires": {
"swagger-ui-dist": ">3.52.5" "swagger-ui-dist": ">=4.1.3"
} }
}, },
"symbol-tree": { "symbol-tree": {
@@ -17957,6 +17993,22 @@
"xdg-basedir": "^4.0.0" "xdg-basedir": "^4.0.0"
} }
}, },
"url": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz",
"integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=",
"requires": {
"punycode": "1.3.2",
"querystring": "0.2.0"
},
"dependencies": {
"punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0="
}
}
},
"url-parse-lax": { "url-parse-lax": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",

View File

@@ -47,7 +47,7 @@
}, },
"author": "4GL Ltd", "author": "4GL Ltd",
"dependencies": { "dependencies": {
"@sasjs/core": "^4.19.0", "@sasjs/core": "^4.27.3",
"@sasjs/utils": "2.42.1", "@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0", "connect-mongo": "^4.6.0",
@@ -63,7 +63,8 @@
"mongoose-sequence": "^5.3.1", "mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.3", "multer": "^1.4.3",
"swagger-ui-express": "^4.1.6" "swagger-ui-express": "4.3.0",
"url": "^0.10.3"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
@@ -85,7 +86,7 @@
"jest": "^27.0.6", "jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0", "mongodb-memory-server": "^8.0.0",
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"pkg": "5.5.2", "pkg": "5.6.0",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"supertest": "^6.1.3", "supertest": "^6.1.3",
@@ -94,12 +95,9 @@
"tsoa": "3.14.1", "tsoa": "3.14.1",
"typescript": "^4.3.2" "typescript": "^4.3.2"
}, },
"configuration": {
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sas"
},
"nodemonConfig": { "nodemonConfig": {
"ignore": [ "ignore": [
"tmp/**/*" "sasjs_root/**/*"
] ]
} }
} }

View File

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,49 @@
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
}

3
api/public/axios.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,51 +5,6 @@ components:
requestBodies: {} requestBodies: {}
responses: {} responses: {}
schemas: schemas:
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:
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: TokenResponse:
properties: properties:
accessToken: accessToken:
@@ -92,6 +47,41 @@ components:
- userId - userId
type: object type: object
additionalProperties: false 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: ClientPayload:
properties: properties:
clientId: clientId:
@@ -333,6 +323,8 @@ components:
type: boolean type: boolean
isAdmin: isAdmin:
type: boolean type: boolean
autoExec:
type: string
required: required:
- id - id
- displayName - displayName
@@ -362,6 +354,10 @@ components:
type: boolean type: boolean
description: 'Account should be active or not, defaults to true' description: 'Account should be active or not, defaults to true'
example: 'true' example: 'true'
autoExec:
type: string
description: 'User-specific auto-exec code'
example: ""
required: required:
- displayName - displayName
- username - username
@@ -425,14 +421,6 @@ components:
- description - description
type: object type: object
additionalProperties: false additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
type: string
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
additionalProperties: false
InfoResponse: InfoResponse:
properties: properties:
mode: mode:
@@ -452,6 +440,14 @@ components:
- protocol - protocol
type: object type: object
additionalProperties: false additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
type: string
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
additionalProperties: false
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
@@ -465,71 +461,6 @@ info:
name: '4GL Ltd' name: '4GL Ltd'
openapi: 3.0.0 openapi: 3.0.0
paths: paths:
/login:
post:
operationId: Login
responses:
'200':
description: Ok
content:
application/json:
schema:
properties:
user: {properties: {displayName: {type: string}, username: {type: string}}, required: [displayName, username], 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'
/logout:
get:
operationId: Logout
responses:
'200':
description: Ok
content:
application/json:
schema: {}
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
/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: /SASjsApi/auth/token:
post: post:
operationId: Token operationId: Token
@@ -587,6 +518,86 @@ paths:
- -
bearerAuth: [] bearerAuth: []
parameters: [] 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: /SASjsApi/client:
post: post:
operationId: CreateClient operationId: CreateClient
@@ -984,6 +995,7 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserDetailsResponse' $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.' summary: 'Get user properties - such as group memberships, userName, displayName.'
tags: tags:
- User - User
@@ -1233,6 +1245,24 @@ paths:
format: double format: double
type: number type: number
example: '6789' 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: /SASjsApi/session:
get: get:
operationId: Session operationId: Session
@@ -1315,24 +1345,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ExecuteReturnJsonPayload' $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: servers:
- -
url: / url: /

View File

@@ -12,27 +12,54 @@ import helmet from 'helmet'
import { import {
connectDB, connectDB,
copySASjsCore, copySASjsCore,
getWebBuildFolderPath, CorsType,
getWebBuildFolder,
HelmetCoepType,
instantiateLogger,
loadAppStreamConfig, loadAppStreamConfig,
ModeType,
ProtocolType,
ReturnCode,
setProcessVariables, setProcessVariables,
setupFolders setupFolders,
verifyEnvVariables
} from './utils' } from './utils'
import { getEnvCSPDirectives } from './utils/parseHelmetConfig'
dotenv.config() dotenv.config()
instantiateLogger()
if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
const app = express() const app = express()
app.use(cookieParser()) app.use(cookieParser())
app.use(morgan('tiny'))
const { MODE, CORS, WHITELIST, PROTOCOL } = 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 = { export const cookieOptions = {
secure: PROTOCOL === 'https', secure: PROTOCOL === ProtocolType.HTTPS,
httpOnly: true, httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours 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 * * CSRF Protection *
***********************************/ ***********************************/
@@ -41,12 +68,22 @@ export const csrfProtection = csrf({ cookie: cookieOptions })
/*********************************** /***********************************
* Handle security and origin * * Handle security and origin *
***********************************/ ***********************************/
app.use(helmet()) app.use(
helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
...cspConfigJson
}
},
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
})
)
/*********************************** /***********************************
* Enabling CORS * * Enabling CORS *
***********************************/ ***********************************/
if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') { if (CORS === CorsType.ENABLED) {
const whiteList: string[] = [] const whiteList: string[] = []
WHITELIST?.split(' ') WHITELIST?.split(' ')
?.filter((url) => !!url) ?.filter((url) => !!url)
@@ -65,23 +102,28 @@ if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') {
* Express Sessions * * Express Sessions *
* With Mongo Store * * With Mongo Store *
***********************************/ ***********************************/
if (MODE?.trim() === 'server') { if (MODE === ModeType.Server) {
let store: MongoStore | undefined
// NOTE: when exporting app.js as agent for supertest // NOTE: when exporting app.js as agent for supertest
// we should exclude connecting to the real database // we should exclude connecting to the real database
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
const clientPromise = connectDB().then((conn) => conn!.getClient() as any) const clientPromise = connectDB().then((conn) => conn!.getClient() as any)
app.use( store = MongoStore.create({ clientPromise, collectionName: 'sessions' })
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: MongoStore.create({ clientPromise, collectionName: 'sessions' }),
cookie: cookieOptions
})
)
} }
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(express.json({ limit: '100mb' })) app.use(express.json({ limit: '100mb' }))
app.use(express.static(path.join(__dirname, '../public'))) app.use(express.static(path.join(__dirname, '../public')))
@@ -106,7 +148,7 @@ export default setProcessVariables().then(async () => {
// should be served after setting up web route // should be served after setting up web route
// index.html needs to be injected with some js script. // index.html needs to be injected with some js script.
app.use(express.static(getWebBuildFolderPath())) app.use(express.static(getWebBuildFolder()))
app.use(onError) app.use(onError)

View File

@@ -1,11 +1,8 @@
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa' import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import User from '../model/User'
import Client from '../model/Client'
import { InfoJWT } from '../types' import { InfoJWT } from '../types'
import { import {
generateAccessToken, generateAccessToken,
generateAuthCode,
generateRefreshToken, generateRefreshToken,
removeTokensInDB, removeTokensInDB,
saveTokensInDB saveTokensInDB
@@ -25,20 +22,6 @@ export class AuthController {
static deleteCode = (userId: number, clientId: string) => static deleteCode = (userId: number, clientId: string) =>
delete AuthController.authCodes[userId][clientId] 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 * @summary Accepts client/auth code and returns access/refresh tokens
* *
@@ -79,33 +62,6 @@ export class AuthController {
} }
} }
const authorize = async (data: any): Promise<AuthorizeResponse> => {
const { username, password, clientId } = data
const client = await Client.findOne({ clientId })
if (!client) throw new Error('Invalid clientId.')
// 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 token = async (data: any): Promise<TokenResponse> => {
const { clientId, code } = data const { clientId, code } = data
@@ -143,32 +99,6 @@ const logout = async (userInfo: InfoJWT) => {
await removeTokensInDB(userInfo.userId, userInfo.clientId) 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 { interface TokenPayload {
/** /**
* Client ID * Client ID

View File

@@ -1,9 +1,13 @@
import express from 'express' import express from 'express'
import { Request, Security, Route, Tags, Post, Body } from 'tsoa' import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
import { ExecuteReturnJson, ExecutionController } from './internal' import { ExecuteReturnJson, ExecutionController } from './internal'
import { PreProgramVars } from '../types'
import { ExecuteReturnJsonResponse } from '.' import { ExecuteReturnJsonResponse } from '.'
import { parseLogToArray } from '../utils' import {
getPreProgramVariables,
getUserAutoExec,
ModeType,
parseLogToArray
} from '../utils'
interface ExecuteSASCodePayload { interface ExecuteSASCodePayload {
/** /**
@@ -30,14 +34,23 @@ export class CodeController {
} }
} }
const executeSASCode = async (req: any, { code }: ExecuteSASCodePayload) => { const executeSASCode = async (
req: express.Request,
{ code }: ExecuteSASCodePayload
) => {
const { user } = req
const userAutoExec =
process.env.MODE === ModeType.Server
? user?.autoExec
: await getUserAutoExec()
try { try {
const { webout, log, httpHeaders } = const { webout, log, httpHeaders } =
(await new ExecutionController().executeProgram( (await new ExecutionController().executeProgram(
code, code,
getPreProgramVariables(req), getPreProgramVariables(req),
{ ...req.query, _debug: 131 }, { ...req.query, _debug: 131 },
undefined, { userAutoExec },
true true
)) as ExecuteReturnJson )) as ExecuteReturnJson
@@ -56,16 +69,3 @@ const executeSASCode = async (req: any, { code }: ExecuteSASCodePayload) => {
} }
} }
} }
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 { createFileTree, ExecutionController, getTreeExample } from './internal'
import { TreeNode } from '../types' import { TreeNode } from '../types'
import { getTmpFilesFolderPath } from '../utils' import { getFilesFolder } from '../utils'
interface DeployPayload { interface DeployPayload {
appLoc: string appLoc: string
@@ -214,12 +214,12 @@ const getFileTree = () => {
} }
const deploy = async (data: DeployPayload) => { const deploy = async (data: DeployPayload) => {
const driveFilesPath = getTmpFilesFolderPath() const driveFilesPath = getFilesFolder()
const appLocParts = data.appLoc.replace(/^\//, '').split('/') const appLocParts = data.appLoc.replace(/^\//, '').split('/')
const appLocPath = path const appLocPath = path
.join(getTmpFilesFolderPath(), ...appLocParts) .join(getFilesFolder(), ...appLocParts)
.replace(new RegExp('/', 'g'), path.sep) .replace(new RegExp('/', 'g'), path.sep)
if (!appLocPath.includes(driveFilesPath)) { if (!appLocPath.includes(driveFilesPath)) {
@@ -238,10 +238,10 @@ const deploy = async (data: DeployPayload) => {
} }
const getFile = async (req: express.Request, filePath: string) => { const getFile = async (req: express.Request, filePath: string) => {
const driveFilesPath = getTmpFilesFolderPath() const driveFilesPath = getFilesFolder()
const filePathFull = path const filePathFull = path
.join(getTmpFilesFolderPath(), filePath) .join(getFilesFolder(), filePath)
.replace(new RegExp('/', 'g'), path.sep) .replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) { if (!filePathFull.includes(driveFilesPath)) {
@@ -261,11 +261,11 @@ const getFile = async (req: express.Request, filePath: string) => {
} }
const getFolder = async (folderPath?: string) => { const getFolder = async (folderPath?: string) => {
const driveFilesPath = getTmpFilesFolderPath() const driveFilesPath = getFilesFolder()
if (folderPath) { if (folderPath) {
const folderPathFull = path const folderPathFull = path
.join(getTmpFilesFolderPath(), folderPath) .join(getFilesFolder(), folderPath)
.replace(new RegExp('/', 'g'), path.sep) .replace(new RegExp('/', 'g'), path.sep)
if (!folderPathFull.includes(driveFilesPath)) { if (!folderPathFull.includes(driveFilesPath)) {
@@ -291,10 +291,10 @@ const getFolder = async (folderPath?: string) => {
} }
const deleteFile = async (filePath: string) => { const deleteFile = async (filePath: string) => {
const driveFilesPath = getTmpFilesFolderPath() const driveFilesPath = getFilesFolder()
const filePathFull = path const filePathFull = path
.join(getTmpFilesFolderPath(), filePath) .join(getFilesFolder(), filePath)
.replace(new RegExp('/', 'g'), path.sep) .replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) { if (!filePathFull.includes(driveFilesPath)) {
@@ -314,7 +314,7 @@ const saveFile = async (
filePath: string, filePath: string,
multerFile: Express.Multer.File multerFile: Express.Multer.File
): Promise<GetFileResponse> => { ): Promise<GetFileResponse> => {
const driveFilesPath = getTmpFilesFolderPath() const driveFilesPath = getFilesFolder()
const filePathFull = path const filePathFull = path
.join(driveFilesPath, filePath) .join(driveFilesPath, filePath)
@@ -339,7 +339,7 @@ const updateFile = async (
filePath: string, filePath: string,
multerFile: Express.Multer.File multerFile: Express.Multer.File
): Promise<GetFileResponse> => { ): Promise<GetFileResponse> => {
const driveFilesPath = getTmpFilesFolderPath() const driveFilesPath = getFilesFolder()
const filePathFull = path const filePathFull = path
.join(driveFilesPath, filePath) .join(driveFilesPath, filePath)

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { Session } from '../../types'
import { promisify } from 'util' import { promisify } from 'util'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { import {
getTmpSessionsFolderPath, getSessionsFolder,
generateUniqueFileName, generateUniqueFileName,
sysInitCompiledPath sysInitCompiledPath
} from '../../utils' } from '../../utils'
@@ -37,7 +37,7 @@ export class SessionController {
private async createSession(): Promise<Session> { private async createSession(): Promise<Session> {
const sessionId = generateUniqueFileName(generateTimestamp()) const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(getTmpSessionsFolderPath(), sessionId) const sessionFolder = path.join(getSessionsFolder(), sessionId)
const creationTimeStamp = sessionId.split('-').pop() as string const creationTimeStamp = sessionId.split('-').pop() as string
// death time of session is 15 mins from creation // death time of session is 15 mins from creation

View File

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

View File

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

View File

@@ -17,15 +17,16 @@ import {
ExecutionController, ExecutionController,
ExecutionVars ExecutionVars
} from './internal' } from './internal'
import { PreProgramVars } from '../types'
import { import {
getTmpFilesFolderPath, getPreProgramVariables,
getFilesFolder,
HTTPHeaders, HTTPHeaders,
isDebugOn, isDebugOn,
LogLine, LogLine,
makeFilesNamesMap, makeFilesNamesMap,
parseLogToArray parseLogToArray
} from '../utils' } from '../utils'
import { MulterFile } from '../types/Upload'
interface ExecuteReturnJsonPayload { interface ExecuteReturnJsonPayload {
/** /**
@@ -132,7 +133,7 @@ const executeReturnRaw = async (
const query = req.query as ExecutionVars const query = req.query as ExecutionVars
const sasCodePath = const sasCodePath =
path path
.join(getTmpFilesFolderPath(), _program) .join(getFilesFolder(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas' .replace(new RegExp('/', 'g'), path.sep) + '.sas'
try { try {
@@ -167,15 +168,17 @@ const executeReturnRaw = async (
} }
const executeReturnJson = async ( const executeReturnJson = async (
req: any, req: express.Request,
_program: string _program: string
): Promise<ExecuteReturnJsonResponse> => { ): Promise<ExecuteReturnJsonResponse> => {
const sasCodePath = const sasCodePath =
path path
.join(getTmpFilesFolderPath(), _program) .join(getFilesFolder(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas' .replace(new RegExp('/', 'g'), path.sep) + '.sas'
const filesNamesMap = req.files?.length ? makeFilesNamesMap(req.files) : null const filesNamesMap = req.files?.length
? makeFilesNamesMap(req.files as MulterFile[])
: null
try { try {
const { webout, log, httpHeaders } = const { webout, log, httpHeaders } =
@@ -210,16 +213,3 @@ 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,3 +1,4 @@
import express from 'express'
import { import {
Security, Security,
Route, Route,
@@ -10,10 +11,13 @@ import {
Patch, Patch,
Delete, Delete,
Body, Body,
Hidden Hidden,
Request
} from 'tsoa' } from 'tsoa'
import { desktopUser } from '../middlewares'
import User, { UserPayload } from '../model/User' import User, { UserPayload } from '../model/User'
import { getUserAutoExec, updateUserAutoExec, ModeType } from '../utils'
export interface UserResponse { export interface UserResponse {
id: number id: number
@@ -27,6 +31,7 @@ interface UserDetailsResponse {
username: string username: string
isActive: boolean isActive: boolean
isAdmin: boolean isAdmin: boolean
autoExec?: string
} }
@Security('bearerAuth') @Security('bearerAuth')
@@ -73,13 +78,23 @@ export class UserController {
} }
/** /**
* Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName. * @summary Get user properties - such as group memberships, userName, displayName.
* @param userId The user's identifier * @param userId The user's identifier
* @example userId 1234 * @example userId 1234
*/ */
@Get('{userId}') @Get('{userId}')
public async getUser(@Path() userId: number): Promise<UserDetailsResponse> { public async getUser(
return getUser(userId) @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(userId, getAutoExec)
} }
/** /**
@@ -99,6 +114,11 @@ export class UserController {
@Path() userId: number, @Path() userId: number,
@Body() body: UserPayload @Body() body: UserPayload
): Promise<UserDetailsResponse> { ): Promise<UserDetailsResponse> {
const { MODE } = process.env
if (MODE === ModeType.Desktop)
return updateDesktopAutoExec(body.autoExec ?? '')
return updateUser(userId, body) return updateUser(userId, body)
} }
@@ -123,7 +143,7 @@ const getAllUsers = async (): Promise<UserResponse[]> =>
.exec() .exec()
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => { const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive } = data const { displayName, username, password, isAdmin, isActive, autoExec } = data
// Checking if user is already in the database // Checking if user is already in the database
const usernameExist = await User.findOne({ username }) const usernameExist = await User.findOne({ username })
@@ -138,7 +158,8 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
username, username,
password: hashPassword, password: hashPassword,
isAdmin, isAdmin,
isActive isActive,
autoExec
}) })
const savedUser = await user.save() const savedUser = await user.save()
@@ -148,38 +169,50 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
displayName: savedUser.displayName, displayName: savedUser.displayName,
username: savedUser.username, username: savedUser.username,
isActive: savedUser.isActive, isActive: savedUser.isActive,
isAdmin: savedUser.isAdmin isAdmin: savedUser.isAdmin,
autoExec: savedUser.autoExec
} }
} }
const getUser = async (id: number): Promise<UserDetailsResponse> => { const getUser = async (
id: number,
getAutoExec: boolean
): Promise<UserDetailsResponse> => {
const user = await User.findOne({ id }) 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.') if (!user) throw new Error('User is not found.')
return user return {
id: user.id,
displayName: user.displayName,
username: user.username,
isActive: user.isActive,
isAdmin: user.isAdmin,
autoExec: getAutoExec ? user.autoExec ?? '' : undefined
}
}
const getDesktopAutoExec = async () => {
return {
...desktopUser,
id: desktopUser.userId,
autoExec: await getUserAutoExec()
}
} }
const updateUser = async ( const updateUser = async (
id: number, id: number,
data: UserPayload data: Partial<UserPayload>
): Promise<UserDetailsResponse> => { ): Promise<UserDetailsResponse> => {
const { displayName, username, password, isAdmin, isActive } = data const { displayName, username, password, isAdmin, isActive, autoExec } = data
const params: any = { displayName, isAdmin, isActive } const params: any = { displayName, isAdmin, isActive, autoExec }
if (username) { if (username) {
// Checking if user is already in the database // Checking if user is already in the database
const usernameExist = await User.findOne({ username }) const usernameExist = await User.findOne({ username })
if (usernameExist?.id != id) throw new Error('Username already exists.') if (usernameExist && usernameExist.id != id)
throw new Error('Username already exists.')
params.username = username params.username = username
} }
@@ -189,18 +222,26 @@ const updateUser = async (
} }
const updatedUser = await User.findOneAndUpdate({ id }, 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')
return updatedUser if (!updatedUser) throw new Error(`Unable to find user with id: ${id}`)
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
}
} }
const deleteUser = async ( const deleteUser = async (

View File

@@ -1,15 +1,31 @@
import path from 'path'
import express from 'express' import express from 'express'
import { Request, Route, Tags, Post, Body, Get } from 'tsoa' import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
import { readFile } from '@sasjs/utils'
import User from '../model/User' import User from '../model/User'
import Client from '../model/Client'
import { getWebBuildFolder, generateAuthCode } from '../utils'
import { InfoJWT } from '../types'
import { AuthController } from './auth'
@Route('/') @Route('/')
@Tags('Web') @Tags('Web')
export class WebController { export class WebController {
/**
* @summary Render index.html
*
*/
@Get('/')
public async home() {
return home()
}
/** /**
* @summary Accept a valid username/password * @summary Accept a valid username/password
* *
*/ */
@Post('/login') @Post('/SASLogon/login')
public async login( public async login(
@Request() req: express.Request, @Request() req: express.Request,
@Body() body: LoginPayload @Body() body: LoginPayload
@@ -17,6 +33,21 @@ export class WebController {
return login(req, body) 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 * @summary Accept a valid username/password
* *
@@ -31,6 +62,16 @@ export class WebController {
} }
} }
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 ( const login = async (
req: express.Request, req: express.Request,
{ username, password }: LoginPayload { username, password }: LoginPayload
@@ -49,18 +90,44 @@ const login = async (
username: user.username, username: user.username,
displayName: user.displayName, displayName: user.displayName,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isActive: user.isActive isActive: user.isActive,
autoExec: user.autoExec
} }
return { return {
loggedIn: true, loggedIn: true,
user: { user: {
id: user.id,
username: user.username, username: user.username,
displayName: user.displayName 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 { interface LoginPayload {
/** /**
* Username for user * Username for user
@@ -73,3 +140,19 @@ interface LoginPayload {
*/ */
password: string password: string
} }
interface AuthorizePayload {
/**
* Client ID
* @example "clientID1"
*/
clientId: string
}
interface AuthorizeResponse {
/**
* Authorization code
* @example "someRandomCryptoString"
*/
code: string
}

View File

@@ -1,14 +1,34 @@
import { RequestHandler, Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { csrfProtection } from '../app' import { csrfProtection } from '../app'
import { verifyTokenInDB } from '../utils' 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()
}
export const authenticateAccessToken = (req: any, res: any, next: any) => {
// if request is coming from web and has valid session // if request is coming from web and has valid session
// we can validate the request and check for CSRF Token // it can be validated.
if (req.session?.loggedIn) { if (req.session?.loggedIn) {
req.user = req.session.user if (req.session.user) {
const user = await fetchLatestAutoExec(req.session.user)
return csrfProtection(req, res, next) if (user) {
if (user.isActive) {
req.user = user
return csrfProtection(req, res, next)
} else return res.sendStatus(401)
}
}
return res.sendStatus(401)
} }
authenticateToken( authenticateToken(
@@ -20,7 +40,7 @@ export const authenticateAccessToken = (req: any, res: any, next: any) => {
) )
} }
export const authenticateRefreshToken = (req: any, res: any, next: any) => { export const authenticateRefreshToken: RequestHandler = (req, res, next) => {
authenticateToken( authenticateToken(
req, req,
res, res,
@@ -31,16 +51,16 @@ export const authenticateRefreshToken = (req: any, res: any, next: any) => {
} }
const authenticateToken = ( const authenticateToken = (
req: any, req: Request,
res: any, res: Response,
next: any, next: NextFunction,
key: string, key: string,
tokenType: 'accessToken' | 'refreshToken' tokenType: 'accessToken' | 'refreshToken'
) => { ) => {
const { MODE } = process.env const { MODE } = process.env
if (MODE?.trim() !== 'server') { if (MODE?.trim() !== 'server') {
req.user = { req.user = {
userId: '1234', userId: 1234,
clientId: 'desktopModeClientId', clientId: 'desktopModeClientId',
username: 'desktopModeUsername', username: 'desktopModeUsername',
displayName: 'desktopModeDisplayName', displayName: 'desktopModeDisplayName',

View File

@@ -1,18 +1,37 @@
export const desktopRestrict = (req: any, res: any, next: any) => { 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) => {
const { MODE } = process.env const { MODE } = process.env
if (MODE?.trim() !== 'server')
return res.status(403).send('Not Allowed while in Desktop Mode.') if (MODE === ModeType.Desktop) {
if (!reqAllowedInDesktopMode(req))
return res.status(403).send('Not Allowed while in Desktop Mode.')
}
next() 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'
})
next() export const desktopUser: RequestUser = {
userId: 12345,
clientId: 'desktop_app',
username: userInfo().username,
displayName: userInfo().username,
isAdmin: true,
isActive: true
} }

View File

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

View File

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

View File

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

View File

@@ -27,12 +27,18 @@ export interface UserPayload {
* @example "true" * @example "true"
*/ */
isActive?: boolean isActive?: boolean
/**
* User-specific auto-exec code
* @example ""
*/
autoExec?: string
} }
interface IUserDocument extends UserPayload, Document { interface IUserDocument extends UserPayload, Document {
id: number id: number
isAdmin: boolean isAdmin: boolean
isActive: boolean isActive: boolean
autoExec: string
groups: Schema.Types.ObjectId[] groups: Schema.Types.ObjectId[]
tokens: [{ [key: string]: string }] tokens: [{ [key: string]: string }]
} }
@@ -66,6 +72,9 @@ const userSchema = new Schema<IUserDocument>({
type: Boolean, type: Boolean,
default: true default: true
}, },
autoExec: {
type: String
},
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }], groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
tokens: [ tokens: [
{ {

View File

@@ -13,19 +13,6 @@ import { InfoJWT } from '../../types'
const authRouter = express.Router() const authRouter = express.Router()
const controller = new AuthController() const controller = new AuthController()
authRouter.post('/authorize', 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(body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
authRouter.post('/token', async (req, res) => { authRouter.post('/token', async (req, res) => {
const { error, value: body } = tokenValidation(req.body) const { error, value: body } = tokenValidation(req.body)
if (error) return res.status(400).send(error.details[0].message) if (error) return res.status(400).send(error.details[0].message)
@@ -39,8 +26,11 @@ authRouter.post('/token', async (req, res) => {
} }
}) })
authRouter.post('/refresh', authenticateRefreshToken, async (req: any, res) => { authRouter.post('/refresh', authenticateRefreshToken, async (req, res) => {
const userInfo: InfoJWT = req.user const userInfo: InfoJWT = {
userId: req.user!.userId!,
clientId: req.user!.clientId!
}
try { try {
const response = await controller.refresh(userInfo) const response = await controller.refresh(userInfo)
@@ -51,8 +41,11 @@ authRouter.post('/refresh', authenticateRefreshToken, async (req: any, res) => {
} }
}) })
authRouter.delete('/logout', authenticateAccessToken, async (req: any, res) => { authRouter.delete('/logout', authenticateAccessToken, async (req, res) => {
const userInfo: InfoJWT = req.user const userInfo: InfoJWT = {
userId: req.user!.userId!,
clientId: req.user!.clientId!
}
try { try {
await controller.logout(userInfo) await controller.logout(userInfo)

View File

@@ -33,12 +33,12 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
} }
}) })
groupRouter.get('/:groupId', authenticateAccessToken, async (req: any, res) => { groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
const { groupId } = req.params const { groupId } = req.params
const controller = new GroupController() const controller = new GroupController()
try { try {
const response = await controller.getGroup(groupId) const response = await controller.getGroup(parseInt(groupId))
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())
@@ -49,12 +49,15 @@ groupRouter.post(
'/:groupId/:userId', '/:groupId/:userId',
authenticateAccessToken, authenticateAccessToken,
verifyAdmin, verifyAdmin,
async (req: any, res) => { async (req, res) => {
const { groupId, userId } = req.params const { groupId, userId } = req.params
const controller = new GroupController() const controller = new GroupController()
try { try {
const response = await controller.addUserToGroup(groupId, userId) const response = await controller.addUserToGroup(
parseInt(groupId),
parseInt(userId)
)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())
@@ -66,12 +69,15 @@ groupRouter.delete(
'/:groupId/:userId', '/:groupId/:userId',
authenticateAccessToken, authenticateAccessToken,
verifyAdmin, verifyAdmin,
async (req: any, res) => { async (req, res) => {
const { groupId, userId } = req.params const { groupId, userId } = req.params
const controller = new GroupController() const controller = new GroupController()
try { try {
const response = await controller.removeUserFromGroup(groupId, userId) const response = await controller.removeUserFromGroup(
parseInt(groupId),
parseInt(userId)
)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())
@@ -83,12 +89,12 @@ groupRouter.delete(
'/:groupId', '/:groupId',
authenticateAccessToken, authenticateAccessToken,
verifyAdmin, verifyAdmin,
async (req: any, res) => { async (req, res) => {
const { groupId } = req.params const { groupId } = req.params
const controller = new GroupController() const controller = new GroupController()
try { try {
await controller.deleteGroup(groupId) await controller.deleteGroup(parseInt(groupId))
res.status(200).send('Group Deleted!') res.status(200).send('Group Deleted!')
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())

View File

@@ -5,7 +5,6 @@ import swaggerUi from 'swagger-ui-express'
import { import {
authenticateAccessToken, authenticateAccessToken,
desktopRestrict, desktopRestrict,
desktopUsername,
verifyAdmin verifyAdmin
} from '../../middlewares' } from '../../middlewares'
@@ -22,7 +21,7 @@ import sessionRouter from './session'
const router = express.Router() const router = express.Router()
router.use('/info', infoRouter) router.use('/info', infoRouter)
router.use('/session', desktopUsername, authenticateAccessToken, sessionRouter) router.use('/session', authenticateAccessToken, sessionRouter)
router.use('/auth', desktopRestrict, authRouter) router.use('/auth', desktopRestrict, authRouter)
router.use( router.use(
'/client', '/client',
@@ -36,12 +35,22 @@ router.use('/group', desktopRestrict, groupRouter)
router.use('/stp', authenticateAccessToken, stpRouter) router.use('/stp', authenticateAccessToken, stpRouter)
router.use('/code', authenticateAccessToken, codeRouter) router.use('/code', authenticateAccessToken, codeRouter)
router.use('/user', desktopRestrict, userRouter) router.use('/user', desktopRestrict, userRouter)
router.use( router.use(
'/', '/',
swaggerUi.serve, swaggerUi.serve,
swaggerUi.setup(undefined, { swaggerUi.setup(undefined, {
swaggerOptions: { swaggerOptions: {
url: '/swagger.yaml' 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
}
} }
}) })
) )

View File

@@ -49,114 +49,6 @@ describe('auth', () => {
await mongoServer.stop() 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('Error: Invalid clientId.')
expect(res.body).toEqual({})
})
})
describe('token', () => { describe('token', () => {
const userInfo: InfoJWT = { const userInfo: InfoJWT = {
clientId, clientId,

View File

@@ -21,17 +21,17 @@ import * as fileUtilModules from '../../../utils/file'
const timestamp = generateTimestamp() const timestamp = generateTimestamp()
const tmpFolder = path.join(process.cwd(), `tmp-${timestamp}`) const tmpFolder = path.join(process.cwd(), `tmp-${timestamp}`)
jest jest
.spyOn(fileUtilModules, 'getTmpFolderPath') .spyOn(fileUtilModules, 'getSasjsRootFolder')
.mockImplementation(() => tmpFolder) .mockImplementation(() => tmpFolder)
jest jest
.spyOn(fileUtilModules, 'getTmpUploadsPath') .spyOn(fileUtilModules, 'getUploadsFolder')
.mockImplementation(() => path.join(tmpFolder, 'uploads')) .mockImplementation(() => path.join(tmpFolder, 'uploads'))
import appPromise from '../../../app' import appPromise from '../../../app'
import { UserController } from '../../../controllers/' import { UserController } from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal' import { getTreeExample } from '../../../controllers/internal'
import { generateAccessToken, saveTokensInDB } from '../../../utils/' import { generateAccessToken, saveTokensInDB } from '../../../utils/'
const { getTmpFilesFolderPath } = fileUtilModules const { getFilesFolder } = fileUtilModules
const clientId = 'someclientID' const clientId = 'someclientID'
const user = { const user = {
@@ -157,10 +157,10 @@ describe('drive', () => {
expect(res.text).toEqual( expect(res.text).toEqual(
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}' '{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
) )
await expect(folderExists(getTmpFilesFolderPath())).resolves.toEqual(true) await expect(folderExists(getFilesFolder())).resolves.toEqual(true)
const testJobFolder = path.join( const testJobFolder = path.join(
getTmpFilesFolderPath(), getFilesFolder(),
'public', 'public',
'jobs', 'jobs',
'extract' 'extract'
@@ -174,7 +174,7 @@ describe('drive', () => {
await expect(readFile(testJobFile)).resolves.toEqual(exampleService.code) await expect(readFile(testJobFile)).resolves.toEqual(exampleService.code)
await deleteFolder(path.join(getTmpFilesFolderPath(), 'public')) await deleteFolder(path.join(getFilesFolder(), 'public'))
}) })
}) })
@@ -192,7 +192,7 @@ describe('drive', () => {
}) })
it('should get a SAS folder on drive having _folderPath as query param', async () => { it('should get a SAS folder on drive having _folderPath as query param', async () => {
const pathToDrive = fileUtilModules.getTmpFilesFolderPath() const pathToDrive = fileUtilModules.getFilesFolder()
const dirLevel1 = 'level1' const dirLevel1 = 'level1'
const dirLevel2 = 'level2' const dirLevel2 = 'level2'
@@ -267,10 +267,7 @@ describe('drive', () => {
const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas') const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas')
const filePath = '/my/path/code.sas' const filePath = '/my/path/code.sas'
const pathToCopy = path.join( const pathToCopy = path.join(fileUtilModules.getFilesFolder(), filePath)
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
await copy(fileToCopyPath, pathToCopy) await copy(fileToCopyPath, pathToCopy)
const res = await request(app) const res = await request(app)
@@ -333,7 +330,7 @@ describe('drive', () => {
const pathToUpload = `/my/path/code-${generateTimestamp()}.sas` const pathToUpload = `/my/path/code-${generateTimestamp()}.sas`
const pathToCopy = path.join( const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(), fileUtilModules.getFilesFolder(),
pathToUpload pathToUpload
) )
await copy(fileToAttachPath, pathToCopy) await copy(fileToAttachPath, pathToCopy)
@@ -445,7 +442,7 @@ describe('drive', () => {
const pathToUpload = '/my/path/code.sas' const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join( const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(), fileUtilModules.getFilesFolder(),
pathToUpload pathToUpload
) )
await copy(fileToAttachPath, pathToCopy) await copy(fileToAttachPath, pathToCopy)
@@ -467,7 +464,7 @@ describe('drive', () => {
const pathToUpload = '/my/path/code.sas' const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join( const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(), fileUtilModules.getFilesFolder(),
pathToUpload pathToUpload
) )
await copy(fileToAttachPath, pathToCopy) await copy(fileToAttachPath, pathToCopy)
@@ -603,10 +600,7 @@ describe('drive', () => {
const fileToCopyContent = await readFile(fileToCopyPath) const fileToCopyContent = await readFile(fileToCopyPath)
const filePath = '/my/path/code.sas' const filePath = '/my/path/code.sas'
const pathToCopy = path.join( const pathToCopy = path.join(fileUtilModules.getFilesFolder(), filePath)
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
await copy(fileToCopyPath, pathToCopy) await copy(fileToCopyPath, pathToCopy)
const res = await request(app) const res = await request(app)

View File

@@ -9,17 +9,18 @@ import { generateAccessToken, saveTokensInDB } from '../../../utils'
const clientId = 'someclientID' const clientId = 'someclientID'
const adminUser = { const adminUser = {
displayName: 'Test Admin', displayName: 'Test Admin',
username: 'testAdminUsername', username: 'testadminusername',
password: '12345678', password: '12345678',
isAdmin: true, isAdmin: true,
isActive: true isActive: true
} }
const user = { const user = {
displayName: 'Test User', displayName: 'Test User',
username: 'testUsername', username: 'testusername',
password: '87654321', password: '87654321',
isAdmin: false, isAdmin: false,
isActive: true isActive: true,
autoExec: 'some sas code for auto exec;'
} }
const controller = new UserController() const controller = new UserController()
@@ -64,6 +65,21 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName) expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin) expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive) 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 () => { it('should respond with Unauthorized if access token is not present', async () => {
@@ -242,7 +258,7 @@ describe('user', () => {
const dbUser1 = await controller.createUser(user) const dbUser1 = await controller.createUser(user)
const dbUser2 = await controller.createUser({ const dbUser2 = await controller.createUser({
...user, ...user,
username: 'randomUser' username: 'randomuser'
}) })
const res = await request(app) const res = await request(app)
@@ -360,7 +376,25 @@ describe('user', () => {
await deleteAllUsers() await deleteAllUsers()
}) })
it('should respond with user', async () => { 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)
})
it('should respond with user autoExec when admin user requests', async () => {
const dbUser = await controller.createUser(user) const dbUser = await controller.createUser(user)
const userId = dbUser.id const userId = dbUser.id
@@ -374,6 +408,7 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName) expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin) expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive) 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 () => { it('should respond with user when access token is not of an admin account', async () => {
@@ -395,6 +430,7 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName) expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin) expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive) expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toBeUndefined()
}) })
it('should respond with Unauthorized if access token is not present', async () => { it('should respond with Unauthorized if access token is not present', async () => {

View File

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

View File

@@ -36,12 +36,12 @@ userRouter.get('/', authenticateAccessToken, async (req, res) => {
} }
}) })
userRouter.get('/:userId', authenticateAccessToken, async (req: any, res) => { userRouter.get('/:userId', authenticateAccessToken, async (req, res) => {
const { userId } = req.params const { userId } = req.params
const controller = new UserController() const controller = new UserController()
try { try {
const response = await controller.getUser(userId) const response = await controller.getUser(req, parseInt(userId))
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())
@@ -52,17 +52,17 @@ userRouter.patch(
'/:userId', '/:userId',
authenticateAccessToken, authenticateAccessToken,
verifyAdminIfNeeded, verifyAdminIfNeeded,
async (req: any, res) => { async (req, res) => {
const { user } = req const { user } = req
const { userId } = req.params const { userId } = req.params
// only an admin can update `isActive` and `isAdmin` fields // 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) if (error) return res.status(400).send(error.details[0].message)
const controller = new UserController() const controller = new UserController()
try { try {
const response = await controller.updateUser(userId, body) const response = await controller.updateUser(parseInt(userId), body)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())
@@ -74,17 +74,17 @@ userRouter.delete(
'/:userId', '/:userId',
authenticateAccessToken, authenticateAccessToken,
verifyAdminIfNeeded, verifyAdminIfNeeded,
async (req: any, res) => { async (req, res) => {
const { user } = req const { user } = req
const { userId } = req.params const { userId } = req.params
// only an admin can delete user without providing password // 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) if (error) return res.status(400).send(error.details[0].message)
const controller = new UserController() const controller = new UserController()
try { try {
await controller.deleteUser(userId, data, user.isAdmin) await controller.deleteUser(parseInt(userId), data, user!.isAdmin)
res.status(200).send('Account Deleted!') res.status(200).send('Account Deleted!')
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())

View File

@@ -1,5 +1,4 @@
import { AppStreamConfig } from '../../types' import { AppStreamConfig } from '../../types'
import { script } from './script'
import { style } from './style' import { style } from './style'
const defaultAppLogo = '/sasjs-logo.svg' const defaultAppLogo = '/sasjs-logo.svg'
@@ -39,6 +38,7 @@ export const appStreamHtml = (appStreamConfig: AppStreamConfig) => `
<span id="uploadMessage">Upload New App</span> <span id="uploadMessage">Upload New App</span>
</a> </a>
</div> </div>
${script} <script src="/axios.min.js"></script>
<script src="/app-streams-script.js"></script>
</body> </body>
</html>` </html>`

View File

@@ -1,15 +1,23 @@
import path from 'path' import path from 'path'
import express from 'express' import express, { Request } from 'express'
import { folderExists } from '@sasjs/utils' import { folderExists } from '@sasjs/utils'
import { addEntryToAppStreamConfig, getTmpFilesFolderPath } from '../../utils' import {
addEntryToAppStreamConfig,
getFilesFolder,
getFullUrl
} from '../../utils'
import { appStreamHtml } from './appStreamHtml' import { appStreamHtml } from './appStreamHtml'
const appStreams: { [key: string]: string } = {}
const router = express.Router() const router = express.Router()
router.get('/', async (_, res) => { router.get('/', async (req, res) => {
const content = appStreamHtml(process.appStreamConfig) const content = appStreamHtml(process.appStreamConfig)
res.cookie('XSRF-TOKEN', req.csrfToken())
return res.send(content) return res.send(content)
}) })
@@ -20,7 +28,7 @@ export const publishAppStream = async (
streamLogo?: string, streamLogo?: string,
addEntryToFile: boolean = true addEntryToFile: boolean = true
) => { ) => {
const driveFilesPath = getTmpFilesFolderPath() const driveFilesPath = getFilesFolder()
const appLocParts = appLoc.replace(/^\//, '')?.split('/') const appLocParts = appLoc.replace(/^\//, '')?.split('/')
const appLocPath = path.join(driveFilesPath, ...appLocParts) const appLocPath = path.join(driveFilesPath, ...appLocParts)
@@ -42,7 +50,7 @@ export const publishAppStream = async (
streamServiceName = `AppStreamName${appCount + 1}` streamServiceName = `AppStreamName${appCount + 1}`
} }
router.use(`/${streamServiceName}`, express.static(pathToDeployment)) appStreams[streamServiceName] = pathToDeployment
addEntryToAppStreamConfig( addEntryToAppStreamConfig(
streamServiceName, streamServiceName,
@@ -62,4 +70,24 @@ export const publishAppStream = async (
return {} return {}
} }
router.get(`/*`, function (req: Request, res, next) {
const reqPath = req.path.replace(/^\//, '')
// Redirecting to url with trailing slash for base appStream URL only
if (reqPath.split('/').length === 1 && !reqPath.endsWith('/'))
return res.redirect(301, `${getFullUrl(req)}/`)
const appStream = reqPath.split('/')[0]
const appStreamFilesPath = appStreams[appStream]
if (appStreamFilesPath) {
const resourcePath = reqPath.split('/')[1] || '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 export default router

View File

@@ -1,58 +0,0 @@
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

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

View File

@@ -1,11 +1,8 @@
import express from 'express' import express from 'express'
import { csrfProtection } from '../../app'
import webRouter from './web' import webRouter from './web'
const router = express.Router() const router = express.Router()
router.use(csrfProtection)
router.use('/', webRouter) router.use('/', webRouter)
export default router export default router

View File

@@ -1,42 +1,59 @@
import path from 'path'
import express from 'express' import express from 'express'
import { fileExists } from '@sasjs/utils'
import { WebController } from '../../controllers/web' import { WebController } from '../../controllers/web'
import { getWebBuildFolderPath, loginWebValidation } from '../../utils' import { authenticateAccessToken, desktopRestrict } from '../../middlewares'
import { authorizeValidation, loginWebValidation } from '../../utils'
const webRouter = express.Router() const webRouter = express.Router()
const controller = new WebController()
webRouter.get('/', async (req, res) => { webRouter.get('/', async (req, res) => {
const indexHtmlPath = path.join(getWebBuildFolderPath(), 'index.html') let response
try {
if (await fileExists(indexHtmlPath)) { response = await controller.home()
} catch (_) {
response = 'Web Build is not present'
} finally {
res.cookie('XSRF-TOKEN', req.csrfToken()) res.cookie('XSRF-TOKEN', req.csrfToken())
return res.sendFile(indexHtmlPath)
}
return res.send('Web Build is not present') return res.send(response)
}
}) })
webRouter.post('/login', async (req, res) => { webRouter.post('/SASLogon/login', desktopRestrict, async (req, res) => {
const { error, value: body } = loginWebValidation(req.body) const { error, value: body } = loginWebValidation(req.body)
if (error) return res.status(400).send(error.details[0].message) if (error) return res.status(400).send(error.details[0].message)
const controller = new WebController()
try { try {
const response = await controller.login(req, body) const response = await controller.login(req, body)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(400).send(err.toString()) res.status(403).send(err.toString())
} }
}) })
webRouter.get('/logout', async (req, res) => { webRouter.post(
const controller = new WebController() '/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 { try {
await controller.logout(req) await controller.logout(req)
res.status(200).send() res.status(200).send('OK!')
} catch (err: any) { } catch (err: any) {
res.status(400).send(err.toString()) res.status(403).send(err.toString())
} }
}) })

View File

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

View File

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

View File

@@ -5,3 +5,4 @@ export * from './InfoJWT'
export * from './PreProgramVars' export * from './PreProgramVars'
export * from './Session' export * from './Session'
export * from './TreeNode' export * from './TreeNode'
export * from './RequestUser'

View File

@@ -2,13 +2,6 @@ import express from 'express'
declare module 'express-session' { declare module 'express-session' {
interface SessionData { interface SessionData {
loggedIn: boolean loggedIn: boolean
user: { user: import('../').RequestUser
userId: number
clientId: string
username: string
displayName: string
isAdmin: boolean
isActive: boolean
}
} }
} }

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

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

View File

@@ -4,5 +4,6 @@ declare namespace NodeJS {
driveLoc: string driveLoc: string
sessionController?: import('../../controllers/internal').SessionController sessionController?: import('../../controllers/internal').SessionController
appStreamConfig: import('../').AppStreamConfig 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 { publishAppStream } from '../routes/appStream'
import { AppStreamConfig } from '../types' import { AppStreamConfig } from '../types'
import { getTmpAppStreamConfigPath } from './file' import { getAppStreamConfigPath } from './file'
export const loadAppStreamConfig = async () => { export const loadAppStreamConfig = async () => {
if (process.env.NODE_ENV === 'test') return if (process.env.NODE_ENV === 'test') return
const appStreamConfigPath = getTmpAppStreamConfigPath() const appStreamConfigPath = getAppStreamConfigPath()
const content = (await fileExists(appStreamConfigPath)) const content = (await fileExists(appStreamConfigPath))
? await readFile(appStreamConfigPath) ? await readFile(appStreamConfigPath)
@@ -63,7 +63,7 @@ export const removeEntryFromAppStreamConfig = (streamServiceName: string) => {
} }
const saveAppStreamConfig = async () => { const saveAppStreamConfig = async () => {
const appStreamConfigPath = getTmpAppStreamConfigPath() const appStreamConfigPath = getAppStreamConfigPath()
try { try {
await createFile( await createFile(

View File

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

View File

@@ -0,0 +1,8 @@
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,4 +1,5 @@
import path from 'path' import path from 'path'
import { homedir } from 'os'
export const apiRoot = path.join(__dirname, '..', '..') export const apiRoot = path.join(__dirname, '..', '..')
export const codebaseRoot = path.join(apiRoot, '..') export const codebaseRoot = path.join(apiRoot, '..')
@@ -11,28 +12,31 @@ export const sysInitCompiledPath = path.join(
export const sasJSCoreMacros = path.join(apiRoot, 'sasjscore') export const sasJSCoreMacros = path.join(apiRoot, 'sasjscore')
export const sasJSCoreMacrosInfo = path.join(sasJSCoreMacros, '.macrolist') export const sasJSCoreMacrosInfo = path.join(sasJSCoreMacros, '.macrolist')
export const getWebBuildFolderPath = () => export const getWebBuildFolder = () => path.join(codebaseRoot, 'web', 'build')
path.join(codebaseRoot, 'web', 'build')
export const getTmpFolderPath = () => process.driveLoc export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server')
export const getTmpAppStreamConfigPath = () => export const getDesktopUserAutoExecPath = () =>
path.join(getTmpFolderPath(), 'appStreamConfig.json') path.join(getSasjsHomeFolder(), 'user-autoexec.sas')
export const getTmpMacrosPath = () => path.join(getTmpFolderPath(), 'sasjscore') export const getSasjsRootFolder = () => process.driveLoc
export const getTmpUploadsPath = () => path.join(getTmpFolderPath(), 'uploads') export const getAppStreamConfigPath = () =>
path.join(getSasjsRootFolder(), 'appStreamConfig.json')
export const getTmpFilesFolderPath = () => export const getMacrosFolder = () =>
path.join(getTmpFolderPath(), 'files') path.join(getSasjsRootFolder(), 'sasjscore')
export const getTmpLogFolderPath = () => path.join(getTmpFolderPath(), 'logs') export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads')
export const getTmpWeboutFolderPath = () => export const getFilesFolder = () => path.join(getSasjsRootFolder(), 'files')
path.join(getTmpFolderPath(), 'webouts')
export const getTmpSessionsFolderPath = () => export const getLogFolder = () => path.join(getSasjsRootFolder(), 'logs')
path.join(getTmpFolderPath(), 'sessions')
export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts')
export const getSessionsFolder = () =>
path.join(getSasjsRootFolder(), 'sessions')
export const generateUniqueFileName = (fileName: string, extension = '') => export const generateUniqueFileName = (fileName: string, extension = '') =>
[ [

View File

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

View File

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,15 @@
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,6 +1,7 @@
export * from './appStreamConfig' export * from './appStreamConfig'
export * from './connectDB' export * from './connectDB'
export * from './copySASjsCore' export * from './copySASjsCore'
export * from './desktopAutoExec'
export * from './extractHeaders' export * from './extractHeaders'
export * from './file' export * from './file'
export * from './generateAccessToken' export * from './generateAccessToken'
@@ -8,6 +9,9 @@ export * from './generateAuthCode'
export * from './generateRefreshToken' export * from './generateRefreshToken'
export * from './getCertificates' export * from './getCertificates'
export * from './getDesktopFields' export * from './getDesktopFields'
export * from './getPreProgramVariables'
export * from './getServerUrl'
export * from './instantiateLogger'
export * from './isDebugOn' export * from './isDebugOn'
export * from './parseLogToArray' export * from './parseLogToArray'
export * from './removeTokensInDB' export * from './removeTokensInDB'
@@ -17,4 +21,5 @@ export * from './setProcessVariables'
export * from './setupFolders' export * from './setupFolders'
export * from './upload' export * from './upload'
export * from './validation' export * from './validation'
export * from './verifyEnvVariables'
export * from './verifyTokenInDB' export * from './verifyTokenInDB'

View File

@@ -0,0 +1,7 @@
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

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

View File

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

View File

@@ -1,6 +1,6 @@
import Joi from 'joi' import Joi from 'joi'
const usernameSchema = Joi.string().alphanum().min(3).max(16) const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16)
const passwordSchema = Joi.string().min(6).max(1024) const passwordSchema = Joi.string().min(6).max(1024)
export const blockFileRegex = /\.(exe|sh|htaccess)$/i export const blockFileRegex = /\.(exe|sh|htaccess)$/i
@@ -13,8 +13,6 @@ export const loginWebValidation = (data: any): Joi.ValidationResult =>
export const authorizeValidation = (data: any): Joi.ValidationResult => export const authorizeValidation = (data: any): Joi.ValidationResult =>
Joi.object({ Joi.object({
username: usernameSchema.required(),
password: passwordSchema.required(),
clientId: Joi.string().required() clientId: Joi.string().required()
}).validate(data) }).validate(data)
@@ -37,7 +35,8 @@ export const registerUserValidation = (data: any): Joi.ValidationResult =>
username: usernameSchema.required(), username: usernameSchema.required(),
password: passwordSchema.required(), password: passwordSchema.required(),
isAdmin: Joi.boolean(), isAdmin: Joi.boolean(),
isActive: Joi.boolean() isActive: Joi.boolean(),
autoExec: Joi.string().allow('')
}).validate(data) }).validate(data)
export const deleteUserValidation = ( export const deleteUserValidation = (
@@ -59,7 +58,8 @@ export const updateUserValidation = (
const validationChecks: any = { const validationChecks: any = {
displayName: Joi.string().min(6), displayName: Joi.string().min(6),
username: usernameSchema, username: usernameSchema,
password: passwordSchema password: passwordSchema,
autoExec: Joi.string().allow('')
} }
if (isAdmin) { if (isAdmin) {
validationChecks.isAdmin = Joi.boolean() validationChecks.isAdmin = Joi.boolean()

View File

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

10584
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
{ {
"name": "server", "name": "server",
"version": "0.0.60", "version": "0.0.76",
"description": "NodeJS wrapper for calling the SAS binary executable", "description": "NodeJS wrapper for calling the SAS binary executable",
"repository": "https://github.com/sasjs/server", "repository": "https://github.com/sasjs/server",
"scripts": { "scripts": {
"server": "npm run server:prepare && npm run server:start", "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:prepare": "cd web && npm ci && npm run build && cd ../api && npm ci && npm run build && cd ..",
"server:start": "cd api && npm run start:prod", "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: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-api": "npx prettier --check \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint-web:fix": "npx prettier --write \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "lint-web:fix": "npx prettier --write \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
@@ -16,7 +15,9 @@
"lint:fix": "npm run lint-api:fix && npm run lint-web:fix" "lint:fix": "npm run lint-api:fix && npm run lint-web:fix"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^2.3.1", "@semantic-release/changelog": "^6.0.1",
"standard-version": "^9.3.2" "@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^8.0.4"
} }
} }

View File

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

463
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,82 +1,39 @@
import axios from 'axios' import axios from 'axios'
import React, { useState, useContext } from 'react' import React, { useState, useContext } from 'react'
import { useLocation } from 'react-router-dom'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { CssBaseline, Box, TextField, Button, Typography } from '@mui/material' import { CssBaseline, Box, TextField, Button } from '@mui/material'
import { AppContext } from '../context/appContext' import { AppContext } from '../context/appContext'
const getAuthCode = async (credentials: any) =>
axios.post('/SASjsApi/auth/authorize', credentials).then((res) => res.data)
const login = async (payload: { username: string; password: string }) => const login = async (payload: { username: string; password: string }) =>
axios.post('/login', payload).then((res) => res.data) axios.post('/SASLogon/login', payload).then((res) => res.data)
const Login = ({ getCodeOnly }: any) => { const Login = () => {
const location = useLocation()
const appContext = useContext(AppContext) const appContext = useContext(AppContext)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
let error: boolean
const [displayCode, setDisplayCode] = useState(null)
const handleSubmit = async (e: any) => { const handleSubmit = async (e: any) => {
error = false
setErrorMessage('') setErrorMessage('')
e.preventDefault() e.preventDefault()
if (getCodeOnly) {
const params = new URLSearchParams(location.search)
const responseType = params.get('response_type')
if (responseType === 'code') {
const clientId = params.get('client_id')
const { code } = await getAuthCode({
clientId,
username,
password
}).catch((err: any) => {
error = true
setErrorMessage(err.response.data)
return {}
})
if (!error) return setDisplayCode(code)
return
}
}
const { loggedIn, user } = await login({ const { loggedIn, user } = await login({
username, username,
password password
}).catch((err: any) => { }).catch((err: any) => {
error = true
setErrorMessage(err.response.data) setErrorMessage(err.response.data)
return {} return {}
}) })
if (loggedIn) { if (loggedIn) {
appContext.setLoggedIn?.(loggedIn) appContext.setUserId?.(user.id)
appContext.setUsername?.(user.username) appContext.setUsername?.(user.username)
appContext.setDisplayName?.(user.displayName) appContext.setDisplayName?.(user.displayName)
appContext.setLoggedIn?.(loggedIn)
} }
} }
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 ( return (
<Box <Box
className="main" className="main"
@@ -89,13 +46,6 @@ const Login = ({ getCodeOnly }: any) => {
<CssBaseline /> <CssBaseline />
<br /> <br />
<h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2> <h2 style={{ width: 'auto' }}>Welcome to SASjs Server!</h2>
{getCodeOnly && (
<p style={{ width: 'auto' }}>
Provide credentials to get authorization code.
</p>
)}
<br />
<TextField <TextField
id="username" id="username"
label="Username" label="Username"

View File

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

View File

@@ -0,0 +1,55 @@
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

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

View File

@@ -9,14 +9,22 @@ import React, {
} from 'react' } from 'react'
import axios from 'axios' import axios from 'axios'
export enum ModeType {
Server = 'server',
Desktop = 'desktop'
}
interface AppContextProps { interface AppContextProps {
checkingSession: boolean checkingSession: boolean
loggedIn: boolean loggedIn: boolean
setLoggedIn: Dispatch<SetStateAction<boolean>> | null setLoggedIn: Dispatch<SetStateAction<boolean>> | null
userId: number
setUserId: Dispatch<SetStateAction<number>> | null
username: string username: string
setUsername: Dispatch<SetStateAction<string>> | null setUsername: Dispatch<SetStateAction<string>> | null
displayName: string displayName: string
setDisplayName: Dispatch<SetStateAction<string>> | null setDisplayName: Dispatch<SetStateAction<string>> | null
mode: ModeType
logout: (() => void) | null logout: (() => void) | null
} }
@@ -24,10 +32,13 @@ export const AppContext = createContext<AppContextProps>({
checkingSession: false, checkingSession: false,
loggedIn: false, loggedIn: false,
setLoggedIn: null, setLoggedIn: null,
userId: 0,
setUserId: null,
username: '', username: '',
setUsername: null, setUsername: null,
displayName: '', displayName: '',
setDisplayName: null, setDisplayName: null,
mode: ModeType.Server,
logout: null logout: null
}) })
@@ -35,8 +46,10 @@ const AppContextProvider = (props: { children: ReactNode }) => {
const { children } = props const { children } = props
const [checkingSession, setCheckingSession] = useState(false) const [checkingSession, setCheckingSession] = useState(false)
const [loggedIn, setLoggedIn] = 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 [displayName, setDisplayName] = useState('')
const [mode, setMode] = useState(ModeType.Server)
useEffect(() => { useEffect(() => {
setCheckingSession(true) setCheckingSession(true)
@@ -46,14 +59,23 @@ const AppContextProvider = (props: { children: ReactNode }) => {
.then((res) => res.data) .then((res) => res.data)
.then((data: any) => { .then((data: any) => {
setCheckingSession(false) setCheckingSession(false)
setLoggedIn(true) setUserId(data.id)
setUsername(data.username) setUsername(data.username)
setDisplayName(data.displayName) setDisplayName(data.displayName)
setLoggedIn(true)
}) })
.catch(() => { .catch(() => {
setLoggedIn(false) setLoggedIn(false)
axios.get('/') // get CSRF TOKEN axios.get('/') // get CSRF TOKEN
}) })
axios
.get('/SASjsApi/info')
.then((res) => res.data)
.then((data: any) => {
setMode(data.mode)
})
.catch(() => {})
}, []) }, [])
const logout = useCallback(() => { const logout = useCallback(() => {
@@ -70,10 +92,13 @@ const AppContextProvider = (props: { children: ReactNode }) => {
checkingSession, checkingSession,
loggedIn, loggedIn,
setLoggedIn, setLoggedIn,
userId,
setUserId,
username, username,
setUsername, setUsername,
displayName, displayName,
setDisplayName, setDisplayName,
mode,
logout logout
}} }}
> >

View File

@@ -1,4 +1,5 @@
import path from 'path' import path from 'path'
import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'
import { Configuration } from 'webpack' import { Configuration } from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin' import HtmlWebpackPlugin from 'html-webpack-plugin'
import CopyPlugin from 'copy-webpack-plugin' import CopyPlugin from 'copy-webpack-plugin'
@@ -53,7 +54,8 @@ const config: Configuration = {
new CopyPlugin({ new CopyPlugin({
patterns: [{ from: 'public' }] patterns: [{ from: 'public' }]
}), }),
new dotenv() new dotenv(),
new MonacoWebpackPlugin()
] ]
} }