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

Compare commits

..

57 Commits

Author SHA1 Message Date
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
80 changed files with 11395 additions and 2193 deletions

View File

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

View File

@@ -2,8 +2,8 @@ name: SASjs Server Executable Release
on:
push:
tags:
- 'v*.*.*'
branches:
- main
jobs:
release:
@@ -49,10 +49,11 @@ jobs:
zip macos.zip api-macos
zip windows.zip api-win.exe
- name: Install Semantic Release and plugins
run: |
npm i
npm i -g semantic-release
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./executables/linux.zip
./executables/macos.zip
./executables/windows.zip
run: |
GITHUB_TOKEN=${{ secrets.GH_TOKEN }} semantic-release

2
.gitignore vendored
View File

@@ -4,6 +4,7 @@ node_modules/
.DS_Store
.env*
sas/
sasjs_root/
tmp/
build/
sasjsbuild/
@@ -11,3 +12,4 @@ sasjscore/
certificates/
executables/
.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,114 @@
# Changelog
# [0.3.0](https://github.com/sasjs/server/compare/v0.2.0...v0.3.0) (2022-05-25)
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.
### 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)

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:
```
# 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=
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
# If enabled, be sure to also configure the WHITELIST of third party servers.
CORS=
# Path to SAS executable (sas.exe / sas.sh)
SAS_PATH=/path/to/sas/executable.exe
# options: <http://localhost:3000 https://abc.com ...> space separated urls
WHITELIST=
# Path to working directory
# This location is for SAS WORK, staged files, DRIVE, configuration etc
SASJS_ROOT=./sasjs_root
# options: [http|https] default: http
PROTOCOL=
@@ -65,16 +72,22 @@ PROTOCOL=
PORT=
# optional
# for MODE: `desktop`, prompts user
# for MODE: `server` gets value from api/package.json `configuration.sasPath`
SAS_PATH=/path/to/sas/executable.exe
#
## Additional SAS Options
#
# optional
# for MODE: `desktop`, prompts user
# for MODE: `server` defaults to /tmp
DRIVE_PATH=/tmp
# On windows use SAS_OPTIONS and on unix use SASV9_OPTIONS
# Any options set here are automatically applied in the SAS session
# See: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostunx/p0wrdmqp8k0oyyn1xbx3bp3qy2wl.htm
# And: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostwin/p0drw76qo0gig2n1kcoliekh605k.htm#p09y7hx0grw1gin1giuvrjyx61m6
SAS_OPTIONS= -NOXCMD
SASV9_OPTIONS= -NOXCMD
#
## Additional Web Server Options
#
# ENV variables required for PROTOCOL: `https`
PRIVATE_KEY=privkey.pem
@@ -87,13 +100,34 @@ AUTH_CODE_SECRET=<secret>
SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# SAS Options
# On windows use SAS_OPTIONS and on unix use SASV9_OPTIONS
# Any options set here are automatically applied in the SAS session
# See: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostunx/p0wrdmqp8k0oyyn1xbx3bp3qy2wl.htm
# And: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostwin/p0drw76qo0gig2n1kcoliekh605k.htm#p09y7hx0grw1gin1giuvrjyx61m6
SAS_OPTIONS= -NOXCMD
SASV9_OPTIONS= -NOXCMD
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
# If enabled, be sure to also configure the WHITELIST of third party servers.
CORS=
# options: <http://localhost:3000 https://abc.com ...> space separated urls
WHITELIST=
# HELMET Cross Origin Embedder Policy
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
# options: [true|false] default: true
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
HELMET_COEP=
# HELMET Content Security Policy
# Path to a json file containing HELMET `contentSecurityPolicy` directives
# Docs: https://helmetjs.github.io/#reference
#
# Example config:
# {
# "img-src": ["'self'", "data:"],
# "script-src": ["'self'", "'unsafe-inline'"],
# "script-src-attr": ["'self'", "'unsafe-inline'"]
# }
HELMET_CSP_CONFIG_PATH=./csp.config.json
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
LOG_FORMAT_MORGAN=
```
@@ -117,7 +151,7 @@ Install the npm package [pm2](https://www.npmjs.com/package/pm2) (`npm install p
```bash
export SAS_PATH=/opt/sas9/SASHome/SASFoundation/9.4/sasexe/sas
export PORT=5001
export DRIVE_PATH=./tmp
export SASJS_ROOT=./sasjs_root
pm2 start api-linux
```

View File

@@ -8,6 +8,9 @@ FULL_CHAIN=fullchain.pem
PORT=[5000] default value is 5000
HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
HELMET_COEP=[true|false] if omitted HELMET default will be used
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
@@ -15,4 +18,6 @@ SESSION_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
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'"]
}

40
api/package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "api",
"version": "0.0.2",
"dependencies": {
"@sasjs/core": "^4.19.0",
"@sasjs/core": "^4.23.1",
"@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0",
@@ -1385,9 +1385,9 @@
}
},
"node_modules/@sasjs/core": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.19.0.tgz",
"integrity": "sha512-vG2YHJveQUQqN0YBhapXb8y+Qp4OniHzRedlqKRxyL0Pc+kwXx5co4Vo+dcOI5/MX0p+8oERP2aCR77s4FEUJg=="
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.23.1.tgz",
"integrity": "sha512-9d6yEPJRRvPLMUkpyaiQ62SXNMMyt2l815jxWgFjnVOxKeUQv9TPyZqZ0FpmWdVe6EY8dv8GLlyaBpOLDnY6Vg=="
},
"node_modules/@sasjs/utils": {
"version": "2.42.1",
@@ -2995,14 +2995,20 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001243",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz",
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA==",
"version": "1.0.30001340",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001340.tgz",
"integrity": "sha512-jUNz+a9blQTQVu4uFcn17uAD8IDizPzQkIKh3LCJfg9BkyIqExYYdyc/ZSlWUSKb8iYiXxKsxbv4zYSvkqjrxw==",
"dev": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
}
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
}
]
},
"node_modules/chalk": {
"version": "3.0.0",
@@ -11358,9 +11364,9 @@
}
},
"@sasjs/core": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.19.0.tgz",
"integrity": "sha512-vG2YHJveQUQqN0YBhapXb8y+Qp4OniHzRedlqKRxyL0Pc+kwXx5co4Vo+dcOI5/MX0p+8oERP2aCR77s4FEUJg=="
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.23.1.tgz",
"integrity": "sha512-9d6yEPJRRvPLMUkpyaiQ62SXNMMyt2l815jxWgFjnVOxKeUQv9TPyZqZ0FpmWdVe6EY8dv8GLlyaBpOLDnY6Vg=="
},
"@sasjs/utils": {
"version": "2.42.1",
@@ -12712,9 +12718,9 @@
"dev": true
},
"caniuse-lite": {
"version": "1.0.30001243",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz",
"integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA==",
"version": "1.0.30001340",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001340.tgz",
"integrity": "sha512-jUNz+a9blQTQVu4uFcn17uAD8IDizPzQkIKh3LCJfg9BkyIqExYYdyc/ZSlWUSKb8iYiXxKsxbv4zYSvkqjrxw==",
"dev": true
},
"chalk": {

View File

@@ -47,7 +47,7 @@
},
"author": "4GL Ltd",
"dependencies": {
"@sasjs/core": "^4.19.0",
"@sasjs/core": "^4.23.1",
"@sasjs/utils": "2.42.1",
"bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0",
@@ -94,9 +94,6 @@
"tsoa": "3.14.1",
"typescript": "^4.3.2"
},
"configuration": {
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sas"
},
"nodemonConfig": {
"ignore": [
"tmp/**/*"

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: {}
responses: {}
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:
properties:
accessToken:
@@ -92,6 +47,41 @@ components:
- userId
type: object
additionalProperties: false
LoginPayload:
properties:
username:
type: string
description: 'Username for user'
example: secretuser
password:
type: string
description: 'Password for user'
example: secretpassword
required:
- username
- password
type: object
additionalProperties: false
AuthorizeResponse:
properties:
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- code
type: object
additionalProperties: false
AuthorizePayload:
properties:
clientId:
type: string
description: 'Client ID'
example: clientID1
required:
- clientId
type: object
additionalProperties: false
ClientPayload:
properties:
clientId:
@@ -333,6 +323,8 @@ components:
type: boolean
isAdmin:
type: boolean
autoExec:
type: string
required:
- id
- displayName
@@ -362,6 +354,10 @@ components:
type: boolean
description: 'Account should be active or not, defaults to true'
example: 'true'
autoExec:
type: string
description: 'User-specific auto-exec code'
example: ""
required:
- displayName
- username
@@ -425,14 +421,6 @@ components:
- description
type: object
additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
type: string
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
additionalProperties: false
InfoResponse:
properties:
mode:
@@ -452,6 +440,14 @@ components:
- protocol
type: object
additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
type: string
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
additionalProperties: false
securitySchemes:
bearerAuth:
type: http
@@ -465,86 +461,6 @@ info:
name: '4GL Ltd'
openapi: 3.0.0
paths:
/:
get:
operationId: Home
responses:
'200':
description: Ok
content:
application/json:
schema:
type: string
summary: 'Render index.html'
tags:
- Web
security: []
parameters: []
/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:
post:
operationId: Token
@@ -602,6 +518,86 @@ paths:
-
bearerAuth: []
parameters: []
/:
get:
operationId: Home
responses:
'200':
description: Ok
content:
application/json:
schema:
type: string
summary: 'Render index.html'
tags:
- Web
security: []
parameters: []
/SASLogon/login:
post:
operationId: Login
responses:
'200':
description: Ok
content:
application/json:
schema:
properties:
user: {properties: {displayName: {type: string}, username: {type: string}, id: {type: number, format: double}}, required: [displayName, username, id], type: object}
loggedIn: {type: boolean}
required:
- user
- loggedIn
type: object
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginPayload'
/SASLogon/authorize:
post:
operationId: Authorize
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizeResponse'
examples:
'Example 1':
value: {code: someRandomCryptoString}
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
tags:
- Web
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizePayload'
/logout:
get:
operationId: Logout
responses:
'200':
description: Ok
content:
application/json:
schema: {}
summary: 'Accept a valid username/password'
tags:
- Web
security: []
parameters: []
/SASjsApi/client:
post:
operationId: CreateClient
@@ -999,6 +995,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/UserDetailsResponse'
description: 'Only Admin or user itself will get user autoExec code.'
summary: 'Get user properties - such as group memberships, userName, displayName.'
tags:
- User
@@ -1248,6 +1245,24 @@ paths:
format: double
type: number
example: '6789'
/SASjsApi/info:
get:
operationId: Info
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/InfoResponse'
examples:
'Example 1':
value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http}
summary: 'Get server info (mode, cors, whiteList, protocol).'
tags:
- Info
security: []
parameters: []
/SASjsApi/session:
get:
operationId: Session
@@ -1330,24 +1345,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReturnJsonPayload'
/SASjsApi/info:
get:
operationId: Info
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/InfoResponse'
examples:
'Example 1':
value: {mode: desktop, cors: enable, whiteList: ['http://example.com', 'http://example2.com'], protocol: http}
summary: 'Get server info (mode, cors, whiteList, protocol).'
tags:
- Info
security: []
parameters: []
servers:
-
url: /

View File

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

View File

@@ -1,11 +1,8 @@
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
import jwt from 'jsonwebtoken'
import User from '../model/User'
import Client from '../model/Client'
import { InfoJWT } from '../types'
import {
generateAccessToken,
generateAuthCode,
generateRefreshToken,
removeTokensInDB,
saveTokensInDB
@@ -25,20 +22,6 @@ export class AuthController {
static deleteCode = (userId: number, clientId: string) =>
delete AuthController.authCodes[userId][clientId]
/**
* @summary Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE
*
*/
@Example<AuthorizeResponse>({
code: 'someRandomCryptoString'
})
@Post('/authorize')
public async authorize(
@Body() body: AuthorizePayload
): Promise<AuthorizeResponse> {
return authorize(body)
}
/**
* @summary Accepts client/auth code and returns access/refresh tokens
*
@@ -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 { clientId, code } = data
@@ -143,32 +99,6 @@ const logout = async (userInfo: InfoJWT) => {
await removeTokensInDB(userInfo.userId, userInfo.clientId)
}
interface AuthorizePayload {
/**
* Username for user
* @example "secretuser"
*/
username: string
/**
* Password for user
* @example "secretpassword"
*/
password: string
/**
* Client ID
* @example "clientID1"
*/
clientId: string
}
interface AuthorizeResponse {
/**
* Authorization code
* @example "someRandomCryptoString"
*/
code: string
}
interface TokenPayload {
/**
* Client ID

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,15 +17,16 @@ import {
ExecutionController,
ExecutionVars
} from './internal'
import { PreProgramVars } from '../types'
import {
getTmpFilesFolderPath,
getPreProgramVariables,
getFilesFolder,
HTTPHeaders,
isDebugOn,
LogLine,
makeFilesNamesMap,
parseLogToArray
} from '../utils'
import { MulterFile } from '../types/Upload'
interface ExecuteReturnJsonPayload {
/**
@@ -132,7 +133,7 @@ const executeReturnRaw = async (
const query = req.query as ExecutionVars
const sasCodePath =
path
.join(getTmpFilesFolderPath(), _program)
.join(getFilesFolder(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
try {
@@ -167,15 +168,17 @@ const executeReturnRaw = async (
}
const executeReturnJson = async (
req: any,
req: express.Request,
_program: string
): Promise<ExecuteReturnJsonResponse> => {
const sasCodePath =
path
.join(getTmpFilesFolderPath(), _program)
.join(getFilesFolder(), _program)
.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 {
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 {
Security,
Route,
@@ -10,7 +11,8 @@ import {
Patch,
Delete,
Body,
Hidden
Hidden,
Request
} from 'tsoa'
import User, { UserPayload } from '../model/User'
@@ -27,6 +29,7 @@ interface UserDetailsResponse {
username: string
isActive: boolean
isAdmin: boolean
autoExec?: string
}
@Security('bearerAuth')
@@ -73,13 +76,19 @@ export class UserController {
}
/**
* Only Admin or user itself will get user autoExec code.
* @summary Get user properties - such as group memberships, userName, displayName.
* @param userId The user's identifier
* @example userId 1234
*/
@Get('{userId}')
public async getUser(@Path() userId: number): Promise<UserDetailsResponse> {
return getUser(userId)
public async getUser(
@Request() req: express.Request,
@Path() userId: number
): Promise<UserDetailsResponse> {
const { user } = req
const getAutoExec = user!.isAdmin || user!.userId == userId
return getUser(userId, getAutoExec)
}
/**
@@ -123,7 +132,7 @@ const getAllUsers = async (): Promise<UserResponse[]> =>
.exec()
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
const usernameExist = await User.findOne({ username })
@@ -138,7 +147,8 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
username,
password: hashPassword,
isAdmin,
isActive
isActive,
autoExec
})
const savedUser = await user.save()
@@ -148,38 +158,42 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
displayName: savedUser.displayName,
username: savedUser.username,
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 })
.select({
_id: 0,
id: 1,
username: 1,
displayName: 1,
isAdmin: 1,
isActive: 1
})
.exec()
if (!user) throw new Error('User is not found.')
return user
return {
id: user.id,
displayName: user.displayName,
username: user.username,
isActive: user.isActive,
isAdmin: user.isAdmin,
autoExec: getAutoExec ? user.autoExec ?? '' : undefined
}
}
const updateUser = async (
id: number,
data: UserPayload
data: Partial<UserPayload>
): 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) {
// Checking if user is already in the database
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
}
@@ -189,18 +203,17 @@ const updateUser = async (
}
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 deleteUser = async (

View File

@@ -1,10 +1,13 @@
import path from 'path'
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 { getWebBuildFolderPath } from '../utils'
import Client from '../model/Client'
import { getWebBuildFolder, generateAuthCode } from '../utils'
import { InfoJWT } from '../types'
import { AuthController } from './auth'
@Route('/')
@Tags('Web')
@@ -14,15 +17,15 @@ export class WebController {
*
*/
@Get('/')
public async home(@Request() req: express.Request) {
return home(req)
public async home() {
return home()
}
/**
* @summary Accept a valid username/password
*
*/
@Post('/login')
@Post('/SASLogon/login')
public async login(
@Request() req: express.Request,
@Body() body: LoginPayload
@@ -30,6 +33,21 @@ export class WebController {
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
*
@@ -44,16 +62,13 @@ export class WebController {
}
}
const home = async (req: express.Request) => {
const indexHtmlPath = path.join(getWebBuildFolderPath(), 'index.html')
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)
req.res?.cookie('XSRF-TOKEN', req.csrfToken())
req.res?.setHeader('Content-Type', 'text/html')
return content
}
@@ -75,18 +90,44 @@ const login = async (
username: user.username,
displayName: user.displayName,
isAdmin: user.isAdmin,
isActive: user.isActive
isActive: user.isActive,
autoExec: user.autoExec
}
return {
loggedIn: true,
user: {
id: user.id,
username: user.username,
displayName: user.displayName
}
}
}
const authorize = async (
req: express.Request,
clientId: string
): Promise<AuthorizeResponse> => {
const userId = req.session.user?.userId
if (!userId) throw new Error('Invalid userId.')
const client = await Client.findOne({ clientId })
if (!client) throw new Error('Invalid clientId.')
// generate authorization code against clientId
const userInfo: InfoJWT = {
clientId,
userId
}
const code = AuthController.saveCode(
userId,
clientId,
generateAuthCode(userInfo)
)
return { code }
}
interface LoginPayload {
/**
* Username for user
@@ -99,3 +140,19 @@ interface LoginPayload {
*/
password: string
}
interface AuthorizePayload {
/**
* Client ID
* @example "clientID1"
*/
clientId: string
}
interface AuthorizeResponse {
/**
* Authorization code
* @example "someRandomCryptoString"
*/
code: string
}

View File

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

View File

@@ -1,11 +1,13 @@
export const desktopRestrict = (req: any, res: any, next: any) => {
import { RequestHandler } from 'express'
export const desktopRestrict: RequestHandler = (req, res, next) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server')
return res.status(403).send('Not Allowed while in Desktop Mode.')
next()
}
export const desktopUsername = (req: any, res: any, next: any) => {
export const desktopUsername: RequestHandler = (req, res, next) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server')
return res.status(200).send({

View File

@@ -1,13 +1,13 @@
import path from 'path'
import { Request } from 'express'
import multer, { FileFilterCallback, Options } from 'multer'
import { blockFileRegex, getTmpUploadsPath } from '../utils'
import { blockFileRegex, getUploadsFolder } from '../utils'
const fieldNameSize = 300
const fileSize = 104857600 // 100 MB
const storage = multer.diskStorage({
destination: getTmpUploadsPath(),
destination: getUploadsFolder(),
filename: function (
_req: Request,
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
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 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')
}
next()

View File

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

View File

@@ -13,19 +13,6 @@ import { InfoJWT } from '../../types'
const authRouter = express.Router()
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) => {
const { error, value: body } = tokenValidation(req.body)
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) => {
const userInfo: InfoJWT = req.user
authRouter.post('/refresh', authenticateRefreshToken, async (req, res) => {
const userInfo: InfoJWT = {
userId: req.user!.userId!,
clientId: req.user!.clientId!
}
try {
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) => {
const userInfo: InfoJWT = req.user
authRouter.delete('/logout', authenticateAccessToken, async (req, res) => {
const userInfo: InfoJWT = {
userId: req.user!.userId!,
clientId: req.user!.clientId!
}
try {
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 controller = new GroupController()
try {
const response = await controller.getGroup(groupId)
const response = await controller.getGroup(parseInt(groupId))
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
@@ -49,12 +49,15 @@ groupRouter.post(
'/:groupId/:userId',
authenticateAccessToken,
verifyAdmin,
async (req: any, res) => {
async (req, res) => {
const { groupId, userId } = req.params
const controller = new GroupController()
try {
const response = await controller.addUserToGroup(groupId, userId)
const response = await controller.addUserToGroup(
parseInt(groupId),
parseInt(userId)
)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
@@ -66,12 +69,15 @@ groupRouter.delete(
'/:groupId/:userId',
authenticateAccessToken,
verifyAdmin,
async (req: any, res) => {
async (req, res) => {
const { groupId, userId } = req.params
const controller = new GroupController()
try {
const response = await controller.removeUserFromGroup(groupId, userId)
const response = await controller.removeUserFromGroup(
parseInt(groupId),
parseInt(userId)
)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
@@ -83,12 +89,12 @@ groupRouter.delete(
'/:groupId',
authenticateAccessToken,
verifyAdmin,
async (req: any, res) => {
async (req, res) => {
const { groupId } = req.params
const controller = new GroupController()
try {
await controller.deleteGroup(groupId)
await controller.deleteGroup(parseInt(groupId))
res.status(200).send('Group Deleted!')
} catch (err: any) {
res.status(403).send(err.toString())

View File

@@ -49,114 +49,6 @@ describe('auth', () => {
await mongoServer.stop()
})
describe('authorize', () => {
afterEach(async () => {
const collections = mongoose.connection.collections
const collection = collections['users']
await collection.deleteMany({})
})
it('should respond with authorization code', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password,
clientId
})
.expect(200)
expect(res.body).toHaveProperty('code')
})
it('should respond with Bad Request if username is missing', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
password: user.password,
clientId
})
.expect(400)
expect(res.text).toEqual(`"username" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if password is missing', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
clientId
})
.expect(400)
expect(res.text).toEqual(`"password" is required`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if clientId is missing', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password
})
.expect(400)
expect(res.text).toEqual(`"clientId" is required`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if username is incorrect', async () => {
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password,
clientId
})
.expect(403)
expect(res.text).toEqual('Error: Username is not found.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if password is incorrect', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: 'WrongPassword',
clientId
})
.expect(403)
expect(res.text).toEqual('Error: Invalid password.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if clientId is incorrect', async () => {
await userController.createUser(user)
const res = await request(app)
.post('/SASjsApi/auth/authorize')
.send({
username: user.username,
password: user.password,
clientId: 'WrongClientID'
})
.expect(403)
expect(res.text).toEqual('Error: Invalid clientId.')
expect(res.body).toEqual({})
})
})
describe('token', () => {
const userInfo: InfoJWT = {
clientId,

View File

@@ -21,17 +21,17 @@ import * as fileUtilModules from '../../../utils/file'
const timestamp = generateTimestamp()
const tmpFolder = path.join(process.cwd(), `tmp-${timestamp}`)
jest
.spyOn(fileUtilModules, 'getTmpFolderPath')
.spyOn(fileUtilModules, 'getSasjsRootFolder')
.mockImplementation(() => tmpFolder)
jest
.spyOn(fileUtilModules, 'getTmpUploadsPath')
.spyOn(fileUtilModules, 'getUploadsFolder')
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
import { generateAccessToken, saveTokensInDB } from '../../../utils/'
const { getTmpFilesFolderPath } = fileUtilModules
const { getFilesFolder } = fileUtilModules
const clientId = 'someclientID'
const user = {
@@ -157,10 +157,10 @@ describe('drive', () => {
expect(res.text).toEqual(
'{"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(
getTmpFilesFolderPath(),
getFilesFolder(),
'public',
'jobs',
'extract'
@@ -174,7 +174,7 @@ describe('drive', () => {
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 () => {
const pathToDrive = fileUtilModules.getTmpFilesFolderPath()
const pathToDrive = fileUtilModules.getFilesFolder()
const dirLevel1 = 'level1'
const dirLevel2 = 'level2'
@@ -267,10 +267,7 @@ describe('drive', () => {
const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas')
const filePath = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
const pathToCopy = path.join(fileUtilModules.getFilesFolder(), filePath)
await copy(fileToCopyPath, pathToCopy)
const res = await request(app)
@@ -333,7 +330,7 @@ describe('drive', () => {
const pathToUpload = `/my/path/code-${generateTimestamp()}.sas`
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
fileUtilModules.getFilesFolder(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
@@ -445,7 +442,7 @@ describe('drive', () => {
const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
fileUtilModules.getFilesFolder(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
@@ -467,7 +464,7 @@ describe('drive', () => {
const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
fileUtilModules.getFilesFolder(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
@@ -603,10 +600,7 @@ describe('drive', () => {
const fileToCopyContent = await readFile(fileToCopyPath)
const filePath = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
const pathToCopy = path.join(fileUtilModules.getFilesFolder(), filePath)
await copy(fileToCopyPath, pathToCopy)
const res = await request(app)

View File

@@ -19,7 +19,8 @@ const user = {
username: 'testUsername',
password: '87654321',
isAdmin: false,
isActive: true
isActive: true,
autoExec: 'some sas code for auto exec;'
}
const controller = new UserController()
@@ -64,6 +65,7 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec)
})
it('should respond with Unauthorized if access token is not present', async () => {
@@ -360,7 +362,25 @@ describe('user', () => {
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 userId = dbUser.id
@@ -374,6 +394,7 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toEqual(user.autoExec)
})
it('should respond with user when access token is not of an admin account', async () => {
@@ -395,6 +416,7 @@ describe('user', () => {
expect(res.body.displayName).toEqual(user.displayName)
expect(res.body.isAdmin).toEqual(user.isAdmin)
expect(res.body.isActive).toEqual(user.isActive)
expect(res.body.autoExec).toBeUndefined()
})
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({
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()
console.log('cookies', cookies)
const csrfToken = extractCSRF(cookies)
return { csrfToken, cookies }
}
const performLogin = async (
app: Express,
credentials: { username: string; password: string },
cookies: string,
csrfToken: string
) => {
const { header } = await request(app)
.post('/SASLogon/login')
.set('Cookie', cookies)
.set('x-xsrf-token', csrfToken)
.send(credentials)
const newCookies: string = header['set-cookie'].join()
return { cookies: newCookies }
}
const extractCSRF = (cookies: string) =>
/_csrf=(.*); Max-Age=86400000; Path=\/; HttpOnly,XSRF-TOKEN=(.*); Path=\//.exec(
cookies
)![2]

View File

@@ -34,7 +34,7 @@ stpRouter.post(
'/execute',
fileUploadController.preUploadMiddleware,
fileUploadController.getMulterUploadObject().any(),
async (req: any, res: any) => {
async (req, res: any) => {
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
const { error: errB, value: body } = executeProgramRawValidation(req.body)

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

View File

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

View File

@@ -2,14 +2,16 @@ import path from 'path'
import express from 'express'
import { folderExists } from '@sasjs/utils'
import { addEntryToAppStreamConfig, getTmpFilesFolderPath } from '../../utils'
import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils'
import { appStreamHtml } from './appStreamHtml'
const router = express.Router()
router.get('/', async (_, res) => {
router.get('/', async (req, res) => {
const content = appStreamHtml(process.appStreamConfig)
res.cookie('XSRF-TOKEN', req.csrfToken())
return res.send(content)
})
@@ -20,7 +22,7 @@ export const publishAppStream = async (
streamLogo?: string,
addEntryToFile: boolean = true
) => {
const driveFilesPath = getTmpFilesFolderPath()
const driveFilesPath = getFilesFolder()
const appLocParts = appLoc.replace(/^\//, '')?.split('/')
const appLocPath = path.join(driveFilesPath, ...appLocParts)

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 appStreamRouter from './appStream'
import { csrfProtection } from '../app'
export const setupRoutes = (app: Express) => {
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
// whatever the current router is
appStreamRouter(req, res, next)
})
app.use('/', webRouter)
app.use('/', csrfProtection, webRouter)
}

View File

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

View File

@@ -1,20 +1,25 @@
import express from 'express'
import { WebController } from '../../controllers/web'
import { loginWebValidation } from '../../utils'
import { authenticateAccessToken } from '../../middlewares'
import { authorizeValidation, loginWebValidation } from '../../utils'
const webRouter = express.Router()
const controller = new WebController()
webRouter.get('/', async (req, res) => {
let response
try {
const response = await controller.home(req)
return res.send(response)
response = await controller.home()
} catch (_) {
return res.send('Web Build is not present')
response = 'Web Build is not present'
} finally {
res.cookie('XSRF-TOKEN', req.csrfToken())
return res.send(response)
}
})
webRouter.post('/login', async (req, res) => {
webRouter.post('/SASLogon/login', async (req, res) => {
const { error, value: body } = loginWebValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
@@ -22,16 +27,32 @@ webRouter.post('/login', async (req, res) => {
const response = await controller.login(req, body)
res.send(response)
} catch (err: any) {
res.status(400).send(err.toString())
res.status(403).send(err.toString())
}
})
webRouter.post(
'/SASLogon/authorize',
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', async (req, res) => {
try {
await controller.logout(req)
res.status(200).send('OK!')
} 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
displayName: 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 './Session'
export * from './TreeNode'
export * from './RequestUser'

View File

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

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
sessionController?: import('../../controllers/internal').SessionController
appStreamConfig: import('../').AppStreamConfig
logger: import('@sasjs/utils/logger').Logger
}
}

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,12 @@ import { createFolder, fileExists, folderExists } from '@sasjs/utils'
const isWindows = () => process.platform === 'win32'
export const getDesktopFields = async () => {
const { SAS_PATH, DRIVE_PATH } = process.env
const { SAS_PATH } = process.env
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> => {

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

@@ -8,6 +8,8 @@ export * from './generateAuthCode'
export * from './generateRefreshToken'
export * from './getCertificates'
export * from './getDesktopFields'
export * from './getPreProgramVariables'
export * from './instantiateLogger'
export * from './isDebugOn'
export * from './parseLogToArray'
export * from './removeTokensInDB'
@@ -17,4 +19,5 @@ export * from './setProcessVariables'
export * from './setupFolders'
export * from './upload'
export * from './validation'
export * from './verifyEnvVariables'
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 { getAbsolutePath, getRealPath } from '@sasjs/utils'
import { createFolder, getAbsolutePath, getRealPath } from '@sasjs/utils'
import { configuration } from '../../package.json'
import { getDesktopFields } from '.'
import { getDesktopFields, ModeType } from '.'
export const setProcessVariables = async () => {
if (process.env.NODE_ENV === 'test') {
process.driveLoc = path.join(process.cwd(), 'tmp')
process.driveLoc = path.join(process.cwd(), 'sasjs_root')
return
}
const { MODE } = process.env
if (MODE?.trim() === 'server') {
const { SAS_PATH, DRIVE_PATH } = process.env
process.sasLoc = SAS_PATH ?? configuration.sasPath
const absPath = getAbsolutePath(DRIVE_PATH ?? 'tmp', process.cwd())
process.driveLoc = getRealPath(absPath)
if (MODE === ModeType.Server) {
process.sasLoc = process.env.SAS_PATH as string
} else {
const { sasLoc, driveLoc } = await getDesktopFields()
const { sasLoc } = await getDesktopFields()
process.sasLoc = sasLoc
process.driveLoc = driveLoc
}
const { SASJS_ROOT } = process.env
const absPath = getAbsolutePath(SASJS_ROOT ?? 'sasjs_root', process.cwd())
await createFolder(absPath)
process.driveLoc = getRealPath(absPath)
console.log('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc)
}

View File

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

View File

@@ -13,8 +13,6 @@ export const loginWebValidation = (data: any): Joi.ValidationResult =>
export const authorizeValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required(),
password: passwordSchema.required(),
clientId: Joi.string().required()
}).validate(data)
@@ -37,7 +35,8 @@ export const registerUserValidation = (data: any): Joi.ValidationResult =>
username: usernameSchema.required(),
password: passwordSchema.required(),
isAdmin: Joi.boolean(),
isActive: Joi.boolean()
isActive: Joi.boolean(),
autoExec: Joi.string().allow('')
}).validate(data)
export const deleteUserValidation = (
@@ -59,7 +58,8 @@ export const updateUserValidation = (
const validationChecks: any = {
displayName: Joi.string().min(6),
username: usernameSchema,
password: passwordSchema
password: passwordSchema,
autoExec: Joi.string().allow('')
}
if (isAdmin) {
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 { RequestUser } from '../types'
export const fetchLatestAutoExec = async (
reqUser: RequestUser
): Promise<RequestUser | undefined> => {
const dbUser = await User.findOne({ id: reqUser.userId })
if (!dbUser) return undefined
return {
userId: reqUser.userId,
clientId: reqUser.clientId,
username: dbUser.username,
displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive,
autoExec: dbUser.autoExec
}
}
export const verifyTokenInDB = async (
userId: number,
clientId: string,
token: string,
tokenType: 'accessToken' | 'refreshToken'
) => {
): Promise<RequestUser | undefined> => {
const dbUser = await User.findOne({ id: userId })
if (!dbUser) return undefined
@@ -21,7 +40,8 @@ export const verifyTokenInDB = async (
username: dbUser.username,
displayName: dbUser.displayName,
isAdmin: dbUser.isAdmin,
isActive: dbUser.isActive
isActive: dbUser.isActive,
autoExec: dbUser.autoExec
}
: undefined
}

10584
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
{
"name": "server",
"version": "0.0.66",
"version": "0.0.76",
"description": "NodeJS wrapper for calling the SAS binary executable",
"repository": "https://github.com/sasjs/server",
"scripts": {
"server": "npm run server:prepare && npm run server:start",
"server:prepare": "cd web && npm ci && npm run build && cd ../api && npm ci && npm run build && cd ..",
"server:start": "cd api && npm run start:prod",
"release": "standard-version",
"lint-api:fix": "npx prettier --write \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint-api": "npx prettier --check \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint-web:fix": "npx prettier --write \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
@@ -16,7 +15,9 @@
"lint:fix": "npm run lint-api:fix && npm run lint-web:fix"
},
"devDependencies": {
"prettier": "^2.3.1",
"standard-version": "^9.3.2"
"@semantic-release/changelog": "^6.0.1",
"@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
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": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@monaco-editor/react": "^4.3.1",
"@mui/icons-material": "^5.0.3",
"@mui/lab": "^5.0.0-alpha.50",
"@mui/material": "^5.0.3",
@@ -21,9 +20,14 @@
"@types/node": "^12.20.28",
"@types/react": "^17.0.27",
"axios": "^0.24.0",
"monaco-editor": "^0.33.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
"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": {
"@babel/core": "^7.16.0",
@@ -35,6 +39,7 @@
"@types/dotenv-webpack": "^7.0.3",
"@types/prismjs": "^1.16.6",
"@types/react": "^17.0.37",
"@types/react-copy-to-clipboard": "^5.0.2",
"@types/react-dom": "^17.0.11",
"@types/react-router-dom": "^5.3.1",
"babel-loader": "^8.2.3",

View File

@@ -8,8 +8,11 @@ import Header from './components/header'
import Home from './components/home'
import Drive from './containers/Drive'
import Studio from './containers/Studio'
import Settings from './containers/Settings'
import { AppContext } from './context/appContext'
import AuthCode from './containers/AuthCode'
import { ToastContainer } from 'react-toastify'
function App() {
const appContext = useContext(AppContext)
@@ -20,9 +23,6 @@ function App() {
<HashRouter>
<Header />
<Switch>
<Route exact path="/SASjsLogon">
<Login getCodeOnly />
</Route>
<Route path="/">
<Login />
</Route>
@@ -46,10 +46,14 @@ function App() {
<Route exact path="/SASjsStudio">
<Studio />
</Route>
<Route exact path="/SASjsSettings">
<Settings />
</Route>
<Route exact path="/SASjsLogon">
<Login getCodeOnly />
<AuthCode />
</Route>
</Switch>
<ToastContainer />
</HashRouter>
</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 {
@@ -11,6 +11,7 @@ import {
MenuItem
} from '@mui/material'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import SettingsIcon from '@mui/icons-material/Settings'
import Username from './username'
import { AppContext } from '../context/appContext'
@@ -20,15 +21,23 @@ const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
const validTabs = ['/', '/SASjsDrive', '/SASjsStudio']
const Header = (props: any) => {
const history = useHistory()
const { pathname } = useLocation()
const appContext = useContext(AppContext)
const [tabValue, setTabValue] = useState(pathname)
const [tabValue, setTabValue] = useState(
validTabs.includes(pathname) ? pathname : '/'
)
const [anchorEl, setAnchorEl] = useState<
(EventTarget & HTMLButtonElement) | null
>(null)
useEffect(() => {
setTabValue(validTabs.includes(pathname) ? pathname : '/')
}, [pathname])
const handleMenu = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
@@ -44,7 +53,10 @@ const Header = (props: any) => {
}
const handleLogout = () => {
if (appContext.logout) appContext.logout()
if (appContext.logout) {
handleClose()
appContext.logout()
}
}
return (
<AppBar
@@ -132,6 +144,18 @@ const Header = (props: any) => {
open={!!anchorEl}
onClose={handleClose}
>
<MenuItem sx={{ justifyContent: 'center' }}>
<Button
component={Link}
to="/SASjsSettings"
onClick={handleClose}
variant="contained"
color="primary"
startIcon={<SettingsIcon />}
>
Setting
</Button>
</MenuItem>
<MenuItem onClick={handleLogout} sx={{ justifyContent: 'center' }}>
<Button variant="contained" color="primary">
Logout

View File

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

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 axios from 'axios'
import Editor from '@monaco-editor/react'
import Editor from 'react-monaco-editor'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
@@ -125,6 +125,7 @@ const Main = (props: Props) => {
{!isLoading && props?.selectedFilePath && editMode && (
<Editor
height="95%"
language="sas"
value={fileContent}
onChange={(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,148 @@
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 { AppContext } from '../../context/appContext'
import { toast } from 'react-toastify'
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"
/>
</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"
/>
</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 item xs={12}>
<TextField
fullWidth
label="autoExec"
name="autoExec"
onChange={handleChange}
multiline
rows="4"
value={user.autoExec}
variant="outlined"
/>
</Grid>
</Grid>
</CardContent>
<Divider />
<CardActions>
<Button type="submit" variant="contained" onClick={handleSubmit}>
Save Changes
</Button>
</CardActions>
</Card>
)
}
export default Profile

View File

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

View File

@@ -13,6 +13,8 @@ interface AppContextProps {
checkingSession: boolean
loggedIn: boolean
setLoggedIn: Dispatch<SetStateAction<boolean>> | null
userId: number
setUserId: Dispatch<SetStateAction<number>> | null
username: string
setUsername: Dispatch<SetStateAction<string>> | null
displayName: string
@@ -24,6 +26,8 @@ export const AppContext = createContext<AppContextProps>({
checkingSession: false,
loggedIn: false,
setLoggedIn: null,
userId: 0,
setUserId: null,
username: '',
setUsername: null,
displayName: '',
@@ -35,6 +39,7 @@ const AppContextProvider = (props: { children: ReactNode }) => {
const { children } = props
const [checkingSession, setCheckingSession] = useState(false)
const [loggedIn, setLoggedIn] = useState(false)
const [userId, setUserId] = useState(0)
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
@@ -46,9 +51,10 @@ const AppContextProvider = (props: { children: ReactNode }) => {
.then((res) => res.data)
.then((data: any) => {
setCheckingSession(false)
setLoggedIn(true)
setUserId(data.id)
setUsername(data.username)
setDisplayName(data.displayName)
setLoggedIn(true)
})
.catch(() => {
setLoggedIn(false)
@@ -70,6 +76,8 @@ const AppContextProvider = (props: { children: ReactNode }) => {
checkingSession,
loggedIn,
setLoggedIn,
userId,
setUserId,
username,
setUsername,
displayName,

View File

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