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

Compare commits

..

1 Commits

Author SHA1 Message Date
Saad Jutt
b03a5db22f chore: draw.io diagram of authentication 2022-02-10 18:38:24 +05:00
97 changed files with 40373 additions and 10494 deletions

View File

@@ -1,5 +1,4 @@
SAS_EXEC_PATH=<path to folder containing SAS executable>
SAS_EXEC_NAME=<name of SAS executable file>
SAS_EXEC=<path to folder containing SAS executable 'sas'>
PORT_API=<port for sasjs server (api)>
PORT_WEB=<port for sasjs web component(react)>
ACCESS_TOKEN_SECRET=<secret>

View File

@@ -1,119 +0,0 @@
# CONTRIBUTING
Contributions are very welcome! Feel free to raise an issue or start a discussion, for help in getting started.
## Configuration
Configuration is made in the `configuration` section of `package.json`:
- Provide path to SAS9 executable.
### Using dockers:
There is `.env.example` file present at root of the project. [for Production]
There is `.env.example` file present at `./api` of the project. [for Development]
There is `.env.example` file present at `./web` of the project. [for Development]
Remember to provide enviornment variables.
#### Development
Command to run docker for development:
```
docker-compose up -d
```
It uses default docker compose file i.e. `docker-compose.yml` present at root.
It will build following images if running first time:
- `sasjs_server_api` - image for sasjs api server app based on _ExpressJS_
- `sasjs_server_web` - image for sasjs web component app based on _ReactJS_
- `mongodb` - image for mongo database
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
#### Production
Command to run docker for production:
```
docker-compose -f docker-compose.prod.yml up -d
```
It uses specified docker compose file i.e. `docker-compose.prod.yml` present at root.
It will build following images if running first time:
- `sasjs_server_prod` - image for sasjs server app containing api and web component's build served at route `/`
- `mongodb` - image for mongo database
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
### Using node:
#### Development (running api and web seperately):
##### API
Navigate to `./api`
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
npm install
npm start
```
##### Web
Navigate to `./web`
There is `.env.example` file present at `./web` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
npm install
npm start
```
#### Development (running only api server and have web build served):
##### API server also serving Web build files
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
cd ./web && npm i && npm build && cd ../
cd ./api && npm i && npm start
```
#### Production
##### API & WEB
```
npm run server
```
This will install/build `web` and install `api`, then start prod server.
## Executables
Command to generate executables
```
cd ./web && npm i && npm build && cd ../
cd ./api && npm i && npm run exe
```
This will install/build web app and install/create executables of sasjs server at root `./executables`
## Releases
To cut a release, run `npm run release` on the main branch, then push the tags (per the console log link)

1
.gitignore vendored
View File

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

View File

@@ -1,10 +0,0 @@
# This configuration file was automatically generated by Gitpod.
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
# and commit this file to your remote git repository to share the goodness with others.
tasks:
- init: npm install
vscode:
extensions:
- dbaeumer.vscode-eslint
- sasjs.sasjs-for-vscode

1
.nvmrc
View File

@@ -1 +0,0 @@
v16.14.0

View File

@@ -2,293 +2,6 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
### [0.0.51](https://github.com/sasjs/server/compare/v0.0.50...v0.0.51) (2022-04-15)
### Features
* run button running man, sub menu added ([68e84b0](https://github.com/sasjs/server/commit/68e84b0994a3fa6ff56b07635c637c6e3a57bfda))
* running code with CTRL+ENTER ([b93a0da](https://github.com/sasjs/server/commit/b93a0da3a380926c87548b69309b2d0c1b7e617f))
### [0.0.50](https://github.com/sasjs/server/compare/v0.0.49...v0.0.50) (2022-04-07)
### Features
* **appstream:** Upload an app from appStream page ([74ba65f](https://github.com/sasjs/server/commit/74ba65f9f330bf8c98c12a9c66bb60773d5a7b77))
### Bug Fixes
* session death time has to be a valid string number ([23db7e7](https://github.com/sasjs/server/commit/23db7e7b7df2f22bbf7ce16865f83091624d8047))
* web component added tooltip for webout in studio ([61080d4](https://github.com/sasjs/server/commit/61080d4694859306049346d2e3174f27bb6dac16))
* web component UI fix for studio scrolling ([f257602](https://github.com/sasjs/server/commit/f25760283492140cc1f14e51ed27673ec28baaf3))
### [0.0.49](https://github.com/sasjs/server/compare/v0.0.48...v0.0.49) (2022-04-02)
### Bug Fixes
* **stp:** read file in non-binary mode if debug one ([527f70e](https://github.com/sasjs/server/commit/527f70e90dd7369766e375ac2d6fc38b2a114d11))
### [0.0.48](https://github.com/sasjs/server/compare/v0.0.47...v0.0.48) (2022-04-02)
### Features
* **deploy:** new route added for deploy with build.json ([18d0604](https://github.com/sasjs/server/commit/18d0604bdd0b20ad468f9345474b4de034ee3a67))
### Bug Fixes
* remove uploaded build.json from temp folder in all cases ([9d167ab](https://github.com/sasjs/server/commit/9d167abe2adb743bca161862b4561bf573182c00))
* **stp:** return log+webout for debug on ([3ff6f5e](https://github.com/sasjs/server/commit/3ff6f5e86581cd2ac23bbe0b8e2c367fbea890ed))
### [0.0.47](https://github.com/sasjs/server/compare/v0.0.46...v0.0.47) (2022-03-29)
### Bug Fixes
* **web:** updated STUDIO log and webout ([f700561](https://github.com/sasjs/server/commit/f700561e1a8d06c18ca2bdbe4605d7ab34f7a761))
### [0.0.46](https://github.com/sasjs/server/compare/v0.0.45...v0.0.46) (2022-03-29)
### Features
* **drive:** GET folder contents API added ([0ac9e4a](https://github.com/sasjs/server/commit/0ac9e4af7d67c4431053e80eb2384bf5bdc3f8b3))
### [0.0.45](https://github.com/sasjs/server/compare/v0.0.43...v0.0.45) (2022-03-29)
### Bug Fixes
* DELETE req cannot have body ([0a5aece](https://github.com/sasjs/server/commit/0a5aeceab560b022197d0c30c3da7f091b261b1e))
* increased req body size ([6dc39c0](https://github.com/sasjs/server/commit/6dc39c0d91ac13d6d9b8c0a2240446bfc45bdd7f))
* proving a PRINT destination during SAS invocation. ([7f4201b](https://github.com/sasjs/server/commit/7f4201ba855743144fa6d3efac2b11e816d4696e)), closes [#111](https://github.com/sasjs/server/issues/111)
* **session:** increased session + bug fixed ([117a53c](https://github.com/sasjs/server/commit/117a53ceeadf487a6326384ae11c10e98646631f))
* **stp:** use same session from file upload ([dd56a95](https://github.com/sasjs/server/commit/dd56a95314f0b61480489118734e45877e1745ef))
### [0.0.44](https://github.com/sasjs/server/compare/v0.0.43...v0.0.44) (2022-03-29)
### Bug Fixes
* DELETE req cannot have body ([0a5aece](https://github.com/sasjs/server/commit/0a5aeceab560b022197d0c30c3da7f091b261b1e))
* increased req body size ([6dc39c0](https://github.com/sasjs/server/commit/6dc39c0d91ac13d6d9b8c0a2240446bfc45bdd7f))
* **session:** increased session + bug fixed ([117a53c](https://github.com/sasjs/server/commit/117a53ceeadf487a6326384ae11c10e98646631f))
* **stp:** use same session from file upload ([dd56a95](https://github.com/sasjs/server/commit/dd56a95314f0b61480489118734e45877e1745ef))
### [0.0.43](https://github.com/sasjs/server/compare/v0.0.42...v0.0.43) (2022-03-23)
### Bug Fixes
* **deploy:** user can deploy to same appName with different/same appLoc ([9ace33d](https://github.com/sasjs/server/commit/9ace33d7830a9def42d741c23b46090afe0c5510))
* fallback logo on AppStream ([5655311](https://github.com/sasjs/server/commit/5655311b9663225823c192b39a03f39d17dda730))
### [0.0.42](https://github.com/sasjs/server/compare/v0.0.41...v0.0.42) (2022-03-23)
### Bug Fixes
* execute api, webout as raw ([9c75187](https://github.com/sasjs/server/commit/9c751877d1ed0d0677aff816169a1df7c34c6bf5))
### [0.0.41](https://github.com/sasjs/server/compare/v0.0.40...v0.0.41) (2022-03-23)
### Bug Fixes
* **scroll:** closes [#100](https://github.com/sasjs/server/issues/100) ([f4eb75f](https://github.com/sasjs/server/commit/f4eb75ff347e78ac334e55ee26fbdd247bb8eaa2))
### [0.0.40](https://github.com/sasjs/server/compare/v0.0.39...v0.0.40) (2022-03-23)
### Bug Fixes
* **deploy:** validating empty file or service in filetree ([27e260e](https://github.com/sasjs/server/commit/27e260e6a453e9978830db63ab669bd48c029897))
* macros available for SAS ([7a70d40](https://github.com/sasjs/server/commit/7a70d40dbf0cd91cb3af156755f10006b860f917))
* moved macros from codebase to drive ([d27e070](https://github.com/sasjs/server/commit/d27e070fc83894854278df22a8223b8016a1f5f7))
### [0.0.39](https://github.com/sasjs/server/compare/v0.0.38...v0.0.39) (2022-03-23)
### Bug Fixes
* included sasjs core macros at compile time ([e680901](https://github.com/sasjs/server/commit/e68090181acd844f86f3e81153cb5a4e3f4a307f))
### [0.0.38](https://github.com/sasjs/server/compare/v0.0.37...v0.0.38) (2022-03-23)
### Bug Fixes
* quick fix for executables ([9e53470](https://github.com/sasjs/server/commit/9e53470947350f4b8d835a2cb6b70e3dabf247c4))
### [0.0.37](https://github.com/sasjs/server/compare/v0.0.36...v0.0.37) (2022-03-23)
### Bug Fixes
* appStream html view ([cd00aa2](https://github.com/sasjs/server/commit/cd00aa2af8c7e0df851050a02152dfeddaec7b0f))
* moved macros from codebase to drive ([9ac3191](https://github.com/sasjs/server/commit/9ac3191891bf53ff07135ccec6ddc83b34ea871a))
* **webin:** closes [#99](https://github.com/sasjs/server/issues/99) ([0147bcb](https://github.com/sasjs/server/commit/0147bcb701a209266144147a3746baf1eb1ccc63))
### [0.0.36](https://github.com/sasjs/server/compare/v0.0.35...v0.0.36) (2022-03-21)
### Features
* App Stream, load on startup, new route added ([98a00ec](https://github.com/sasjs/server/commit/98a00ec7ace5da765f049864799be44ba6538e8a))
### Bug Fixes
* **appstream:** app logo + improvements ([df6003d](https://github.com/sasjs/server/commit/df6003df942fd52b956f3d4069d6d7615441d372))
### [0.0.35](https://github.com/sasjs/server/compare/v0.0.33...v0.0.35) (2022-03-21)
### Features
* **cors:** whitelisting is configurable through .env variables ([99f91fb](https://github.com/sasjs/server/commit/99f91fbce2a029dd963ed30c9007a9b046ea6560))
* **web:** directory tree in sidebar of drive should be expanded by default at root level ([3d89b75](https://github.com/sasjs/server/commit/3d89b753f023beed4d51a64db4f74e1011437aab))
### Bug Fixes
* **cors:** removed trailing slashes of urls ([4fd5bf9](https://github.com/sasjs/server/commit/4fd5bf948e4ad8a274d3176d5509163e67980061))
* desktop mode web index.html js script included ([75291f9](https://github.com/sasjs/server/commit/75291f939770de963d48c2ff1c967da9493bd668))
* preferred to show param errors from query ([fd26298](https://github.com/sasjs/server/commit/fd2629862f10ec16e2266d68420499e715b5d58c))
* **stp:** write original file name in sas code for upload ([8822de9](https://github.com/sasjs/server/commit/8822de95df1d2d01dadfe6957391c254172f2819))
* **web-drive:** upon delete remove entry of deleted file from directory tree in sidebar ([fb77d99](https://github.com/sasjs/server/commit/fb77d99177851e7dc2a71e0b8f516daa3da29e36))
### [0.0.34](https://github.com/sasjs/server/compare/v0.0.33...v0.0.34) (2022-03-18)
### Features
* **web:** directory tree in sidebar of drive should be expanded by default at root level ([3d89b75](https://github.com/sasjs/server/commit/3d89b753f023beed4d51a64db4f74e1011437aab))
### Bug Fixes
* desktop mode web index.html js script included ([75291f9](https://github.com/sasjs/server/commit/75291f939770de963d48c2ff1c967da9493bd668))
* preferred to show param errors from query ([fd26298](https://github.com/sasjs/server/commit/fd2629862f10ec16e2266d68420499e715b5d58c))
* **stp:** write original file name in sas code for upload ([8822de9](https://github.com/sasjs/server/commit/8822de95df1d2d01dadfe6957391c254172f2819))
* **web-drive:** upon delete remove entry of deleted file from directory tree in sidebar ([fb77d99](https://github.com/sasjs/server/commit/fb77d99177851e7dc2a71e0b8f516daa3da29e36))
### [0.0.33](https://github.com/sasjs/server/compare/v0.0.32...v0.0.33) (2022-03-16)
### Features
* serve deployed streaming apps ([d6fa877](https://github.com/sasjs/server/commit/d6fa87794155880adc23c2552c37c86ad606c292))
### Bug Fixes
* adde validation + code improvement ([1ff6965](https://github.com/sasjs/server/commit/1ff6965dd2f44ad74136af04b4fba8c76979ecba))
* added api button on web component ([6b708fc](https://github.com/sasjs/server/commit/6b708fcad30d92c21713f9c97bca173c148cc875))
### [0.0.32](https://github.com/sasjs/server/compare/v0.0.31...v0.0.32) (2022-03-14)
### Features
* **web:** added delete option in Drive ([7a6e6c8](https://github.com/sasjs/server/commit/7a6e6c8becab31410d0a36bcc22e13d5359a6cdf))
### [0.0.31](https://github.com/sasjs/server/compare/v0.0.30...v0.0.31) (2022-03-14)
### Features
* **drive:** new route delete file api ([3d583ff](https://github.com/sasjs/server/commit/3d583ff21d344a71aa861c7e5b1426ebc2d54c22))
### Bug Fixes
* added cookie for accessToken ([698180a](https://github.com/sasjs/server/commit/698180ab7e44d67d46c84352ececca5b6c83b230))
* **drive:** update file API is same as create file ([7072e28](https://github.com/sasjs/server/commit/7072e282b1cd1a296d81512c57130237610c1c1e))
* show content of get file api ([6ab42ca](https://github.com/sasjs/server/commit/6ab42ca4868366874f5f21bd711b7b8b72e36774))
* **stp:** return plain/text header for GET & debug ([145ac45](https://github.com/sasjs/server/commit/145ac450365ed39279248ec9321bbe4918bee9fa))
### [0.0.30](https://github.com/sasjs/server/compare/v0.0.29...v0.0.30) (2022-03-05)
### Features
* parse log to array ([c5ad72c](https://github.com/sasjs/server/commit/c5ad72c931ec8fbd7d5a6475838adcbd380c8aee))
* set response headers provded by SAS Code execution ([2c4aa42](https://github.com/sasjs/server/commit/2c4aa420b3119890cafde4265ed5dddbc9d6a636))
### Bug Fixes
* added http headers to /code api as well ([da899b9](https://github.com/sasjs/server/commit/da899b90e26d5ee393eefc302be985eb7c9055a5))
* code api is updated return type ([e2a6810](https://github.com/sasjs/server/commit/e2a6810e9531a8102d3c51fd8df2e1f78f0d965f))
* **file:** fixes response headers ([ef41691](https://github.com/sasjs/server/commit/ef41691e408ef1c1c7a921cc1050bdd533651331))
* get file instead of it's content ([efaf38d](https://github.com/sasjs/server/commit/efaf38d3039391392ce0e14a3accddd8f34ea7d6))
* hot fix for web component ([0a4b202](https://github.com/sasjs/server/commit/0a4b202428e14effc8014a6813cecf7761ce3715))
* improvement in flow of uploading ([8c1941a](https://github.com/sasjs/server/commit/8c1941a87bc184be4e0e09eeff73fc6cb69e3041))
* macros are available Sessions with SASAUTOS ([95843fa](https://github.com/sasjs/server/commit/95843fa4c711aa695ee63ad265b8def4ba56360d))
* minor changes ([0b5f958](https://github.com/sasjs/server/commit/0b5f958f456d291ec7a8697236657c7819d5c654))
* multi-part file upload + validations + specs ([e60f172](https://github.com/sasjs/server/commit/e60f17268d1fa9ab623313026d46bd3f63756f69))
* organized code for usage of multer ([ce0a5e1](https://github.com/sasjs/server/commit/ce0a5e1229bed69c450061fac2bc19711448da56))
* return buffer in case of file response ([3e6234e](https://github.com/sasjs/server/commit/3e6234e6019c5f3ae4280fac079ecc9cb0effc07))
* **stp:** return json for webout ([5005f20](https://github.com/sasjs/server/commit/5005f203b8d6b1d577cdf094b83886bd1fc817a2))
* updating docs ([7312763](https://github.com/sasjs/server/commit/7312763339d6769826328561e2c8d11bbfc0c9f4))
* **upload:** added query param as well for filepath ([feeec4e](https://github.com/sasjs/server/commit/feeec4eb149e9a47e5a52320d1fc95243bf5eb15))
### [0.0.29](https://github.com/sasjs/server/compare/v0.0.28...v0.0.29) (2022-02-16)
### Bug Fixes
* adding .. in folder path ([5931fc1](https://github.com/sasjs/server/commit/5931fc1e712c545ef80454dea5b36e684017c367))
* adding sasjs stpsrv_header() path to autoexec. Relates to [#58](https://github.com/sasjs/server/issues/58) ([ce9bde5](https://github.com/sasjs/server/commit/ce9bde5717369de2d76dc183319be8830b2362b2))
### [0.0.28](https://github.com/sasjs/server/compare/v0.0.27...v0.0.28) (2022-02-16)
### Features
* default macros and bumping core ([6f19d3d](https://github.com/sasjs/server/commit/6f19d3d0ea3815815f246a3e455495c72c8604c7))
### Bug Fixes
* moving core ([f10138b](https://github.com/sasjs/server/commit/f10138b0f2005a958f63cb3a8351e1afa52f086a))
### [0.0.27](https://github.com/sasjs/server/compare/v0.0.26...v0.0.27) (2022-02-16)
### Features
* removing stpsrv_header and updating README with auth details ([d3674c7](https://github.com/sasjs/server/commit/d3674c7f9449d77977e482cd63ccdf7e974fa838))
* **stp-execution:** add returnLog option to execution query ([bf5767e](https://github.com/sasjs/server/commit/bf5767eadfb87f7ed902659347a18361a6a6c74b))
### [0.0.26](https://github.com/sasjs/server/compare/v0.0.25...v0.0.26) (2022-02-14)
### Bug Fixes
* refactored + removed unused package ([d7e1aca](https://github.com/sasjs/server/commit/d7e1aca7e33c3264c784d406fa766e29a6b15ae9))
* release should also has https protocol ([0cfe724](https://github.com/sasjs/server/commit/0cfe724ffa089b84a9f8bca49c9033b56f51c9cb))
* updated token expiry times ([d17a3dd](https://github.com/sasjs/server/commit/d17a3dd5900d5eb88120af8575e3fc7c2cb71ed6))
### [0.0.25](https://github.com/sasjs/server/compare/v0.0.24...v0.0.25) (2022-02-11)
### Bug Fixes
* adding global macvar and bumping sasjs/core with additional server support ([404f1ec](https://github.com/sasjs/server/commit/404f1ec0593a027ed5e84b1d6a84cb9f2d09d99e))
### [0.0.24](https://github.com/sasjs/server/compare/v0.0.23...v0.0.24) (2022-02-11)
### Bug Fixes
* removing sysmacdelete ([480ee4d](https://github.com/sasjs/server/commit/480ee4da831d2a89888c58ebec26bd89802ee2f5))
### [0.0.23](https://github.com/sasjs/server/compare/v0.0.22...v0.0.23) (2022-02-08)
### [0.0.22](https://github.com/sasjs/server/compare/v0.0.17...v0.0.22) (2022-02-08)

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 SASjs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

195
README.md
View File

@@ -1,139 +1,134 @@
# SASjs Server
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or locally on your desktop. It provides:
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or it could even run locally on your desktop. It provides the following functionality:
- Virtual filesystem for storing SAS programs and other content
- Ability to execute Stored Programs from a URL
- Ability to create web apps using simple Desktop SAS
- REST API with Swagger Docs
One major benefit of using SASjs Server alongside other components of the SASjs framework such as the [CLI](https://cli.sasjs.io), [Adapter](https://adapter.sasjs.io) and [Core](https://core.sasjs.io) library, is that the projects you create can be very easily ported to SAS 9 (Stored Process server) or Viya (Job Execution server).
SASjs Server is available in two modes - Desktop (without authentication) and Server (with authentication, and a database)
One major benefit of using SASjs Server (alongside other components of the SASjs framework such as the [CLI](https://cli.sasjs.io), [Adapter](https://adapter.sasjs.io) and [Core](https://core.sasjs.io) library) is that the projects you create can be very easily ported to SAS 9 (Stored Process server) or Viya (Job Execution server).
## Installation
Installation can be made programmatically using command line, or by manually downloading and running the executable.
### Programmatic
Fetch the relevant package from github using `curl`, eg as follows (for linux):
First, download the relevant package from the [releases](https://github.com/sasjs/server/releases) page - either manually, or with commandline, eg as follow:
```bash
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
unzip linux.zip
./api-linux
```
The app can then be launched with `./api-linux` and prompts followed (if ENV vars not set).
Second, trigger by double clicking (windows) or executing from commandline.
### Manual
You are presented with two prompts:
1. Download the relevant package from the [releases](https://github.com/sasjs/server/releases) page
2. Trigger by double clicking (windows) or executing from commandline.
* Location of your `sas.exe` / `sas.sh` executable
* Path to a filesystem location for Stored Programs and temporary files
You are presented with two prompts (if not set as ENV vars):
## Configuration
- Location of your `sas.exe` / `sas.sh` executable
- Path to a filesystem location for Stored Programs and temporary files
Configuration is made in the `configuration` section of `package.json`:
## ENV Var configuration
- Provide path to SAS9 executable.
When launching the app, it will make use of specific environment variables. These can be set in the following places:
### Using dockers:
- Configured globally in `/etc/environment` file
- Export in terminal or shell script (`export VAR=VALUE`)
- Prepended in the command
- Enter in the `.env` file alongside the executable
There is `.env.example` file present at root of the project. [for Production]
Example contents of a `.env` file:
There is `.env.example` file present at `./api` of the project. [for Development]
There is `.env.example` file present at `./web` of the project. [for Development]
Remember to provide enviornment variables.
#### Development
Command to run docker for development:
```
# options: [desktop|server] default: `desktop`
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=
# options: <http://localhost:3000 https://abc.com ...> space separated urls
WHITELIST=
# options: [http|https] default: http
PROTOCOL=
# default: 5000
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
# optional
# for MODE: `desktop`, prompts user
# for MODE: `server` defaults to /tmp
DRIVE_PATH=/tmp
# ENV variables required for PROTOCOL: `https`
PRIVATE_KEY=privkey.pem
FULL_CHAIN=fullchain.pem
# ENV variables required for MODE: `server`
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
# 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
docker-compose up -d
```
## Persisting the Session
It uses default docker compose file i.e. `docker-compose.yml` present at root.
It will build following images if running first time:
Normally the server process will stop when your terminal dies. To keep it going you can use the npm package [pm2](https://www.npmjs.com/package/pm2) (`npm install pm2@latest -g`) as follows:
- `sasjs_server_api` - image for sasjs api server app based on _ExpressJS_
- `sasjs_server_web` - image for sasjs web component app based on _ReactJS_
- `mongodb` - image for mongo database
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
```bash
export SAS_PATH=/opt/sas9/SASHome/SASFoundation/9.4/sasexe/sas
export PORT=5001
export DRIVE_PATH=./tmp
#### Production
pm2 start api-linux
```
To get the logs (and some useful commands):
```bash
pm2 [list|ls|status]
pm2 logs
pm2 logs --lines 200
```
Managing processes:
Command to run docker for production:
```
pm2 restart app_name
pm2 reload app_name
pm2 stop app_name
pm2 delete app_name
docker-compose -f docker-compose.prod.yml up -d
```
Instead of `app_name` you can pass:
It uses specified docker compose file i.e. `docker-compose.prod.yml` present at root.
It will build following images if running first time:
- `all` to act on all processes
- `id` to act on a specific process id
- `sasjs_server_prod` - image for sasjs server app containing api and web component's build served at route `/`
- `mongodb` - image for mongo database
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
## Server Version
### Using node:
The following credentials can be used for the initial connection to SASjs/server. It is recommended to change these on first use.
#### Development (running api and web seperately):
- CLIENTID: `clientID1`
- USERNAME: `secretuser`
- PASSWORD: `secretpassword`
##### API
Navigate to `./api`
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
npm install
npm start
```
##### Web
Navigate to `./web`
There is `.env.example` file present at `./web` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
npm install
npm start
```
#### Development (running only api server and have web build served):
##### API server also serving Web build files
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
Command to install and run api server.
```
cd ./web && npm i && npm build && cd ../
cd ./api && npm i && npm start
```
#### Production
##### API & WEB
```
npm run server
```
This will install/build `web` and install `api`, then start prod server.
## Executables
Command to generate executables
```
cd ./web && npm i && npm build && cd ../
cd ./api && npm i && npm run exe
```
This will install/build web app and install/create executables of sasjs server at root `./executables`

View File

@@ -1,84 +1,206 @@
<mxfile host="65bd71144e">
<diagram id="HJy_QFGaI9JSrArARLup" name="Page-1">
<mxGraphModel dx="1908" dy="2140" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<mxGraphModel dx="3103" dy="2723" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="4" value="End user" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontStyle=0" vertex="1" parent="1">
<mxGeometry x="-360" y="-120" width="40" height="80" as="geometry"/>
<mxCell id="7" value="SASjs Server" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;fontSize=30;" parent="1" vertex="1">
<mxGeometry x="30" y="-150" width="360" height="1250" as="geometry"/>
</mxCell>
<mxCell id="7" value="SASjs Server" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;fontSize=30;" vertex="1" parent="1">
<mxGeometry x="30" y="-150" width="360" height="850" as="geometry"/>
<mxCell id="36" value="&lt;font style=&quot;font-size: 22px&quot;&gt;Internal Authentication&lt;/font&gt;" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="50" y="-60" width="320" height="330" as="geometry"/>
</mxCell>
<mxCell id="8" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" edge="1" parent="1" target="28">
<mxCell id="4" value="End user" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontStyle=0" parent="1" vertex="1">
<mxGeometry x="-740" y="-120" width="40" height="80" as="geometry"/>
</mxCell>
<mxCell id="8" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" parent="1" target="28" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-340" y="23" as="sourcePoint"/>
<mxPoint x="-530" y="23" as="sourcePoint"/>
<mxPoint x="115" y="22.586363636363558" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="11" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;span style=&quot;color: #a31515&quot;&gt;/SASjsApi/auth/authorize&lt;br&gt;(username,password,clientId)&lt;/span&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="8">
<mxCell id="11" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;span style=&quot;color: #a31515&quot;&gt;/SASjsApi/auth/authorize&lt;br&gt;(username,password,clientId)&lt;/span&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="8" vertex="1" connectable="0">
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="14" value="" style="edgeStyle=none;html=1;exitX=-0.002;exitY=0.874;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="28">
<mxCell id="14" value="" style="edgeStyle=none;html=1;exitX=-0.002;exitY=0.874;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="28" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="110" y="80" as="sourcePoint"/>
<mxPoint x="-340" y="80" as="targetPoint"/>
<mxPoint x="-530" y="80" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="16" value="&lt;font color=&quot;#a31515&quot; face=&quot;menlo, monaco, courier new, monospace&quot;&gt;&lt;span style=&quot;font-size: 12px&quot;&gt;`code`&lt;/span&gt;&lt;/font&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="14">
<mxCell id="16" value="&lt;font color=&quot;#a31515&quot; face=&quot;menlo, monaco, courier new, monospace&quot;&gt;&lt;span style=&quot;font-size: 12px&quot;&gt;`code`&lt;/span&gt;&lt;/font&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="14" vertex="1" connectable="0">
<mxGeometry x="0.1931" y="-1" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="21" value="End user" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontStyle=0" vertex="1" parent="1">
<mxGeometry x="-360" y="545" width="40" height="80" as="geometry"/>
<mxCell id="21" value="End user" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontStyle=0" parent="1" vertex="1">
<mxGeometry x="-730" y="1100" width="40" height="80" as="geometry"/>
</mxCell>
<mxCell id="22" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" edge="1" parent="1" target="30">
<mxCell id="22" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" parent="1" target="30" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-340" y="165" as="sourcePoint"/>
<mxPoint x="-530" y="163" as="sourcePoint"/>
<mxPoint x="115" y="165" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="23" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; line-height: 18px&quot;&gt;&lt;span style=&quot;color: #a31515&quot;&gt;/SASjsApi/auth/token&lt;/span&gt;&lt;/div&gt;&lt;span style=&quot;color: #a31515&quot;&gt;(clientId,code)&lt;/span&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="22">
<mxCell id="23" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; line-height: 18px&quot;&gt;&lt;span style=&quot;color: #a31515&quot;&gt;/SASjsApi/auth/token&lt;/span&gt;&lt;/div&gt;&lt;span style=&quot;color: #a31515&quot;&gt;(clientId,code)&lt;/span&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="22" vertex="1" connectable="0">
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="24" value="" style="edgeStyle=none;html=1;exitX=0.009;exitY=0.905;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="30">
<mxCell id="24" value="" style="edgeStyle=none;html=1;exitX=0.009;exitY=0.905;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="30" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="210" y="222.5" as="sourcePoint"/>
<mxPoint x="-340" y="223" as="targetPoint"/>
<mxPoint x="-530" y="223" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="25" value="&lt;font color=&quot;#a31515&quot; face=&quot;menlo, monaco, courier new, monospace&quot;&gt;&lt;span style=&quot;font-size: 12px&quot;&gt;`&lt;/span&gt;&lt;/font&gt;&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;accessToken&lt;/span&gt;&lt;span style=&quot;font-size: 12px ; color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace&quot;&gt;` &amp;amp; `&lt;/span&gt;&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;refreshToken&lt;/span&gt;&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;`&lt;/span&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="24">
<mxCell id="25" value="&lt;font color=&quot;#a31515&quot; face=&quot;menlo, monaco, courier new, monospace&quot;&gt;&lt;span style=&quot;font-size: 12px&quot;&gt;`&lt;/span&gt;&lt;/font&gt;&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;accessToken&lt;/span&gt;&lt;span style=&quot;font-size: 12px ; color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace&quot;&gt;` &amp;amp; `&lt;/span&gt;&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;refreshToken&lt;/span&gt;&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;`&lt;/span&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="24" vertex="1" connectable="0">
<mxGeometry x="0.1931" y="-1" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="26" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;" edge="1" parent="1" source="21" target="4">
<mxCell id="26" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;" parent="1" source="21" target="4" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="40" y="240" as="sourcePoint"/>
<mxPoint x="90" y="190" as="targetPoint"/>
<mxPoint x="-340" y="240" as="sourcePoint"/>
<mxPoint x="-290" y="190" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="28" value="&lt;span&gt;Validates&lt;/span&gt;&lt;br&gt;&lt;span&gt;username/password/clientId&lt;/span&gt;&lt;br&gt;&lt;span&gt;and issue short&lt;/span&gt;&lt;br&gt;&lt;span&gt;Authorization code&lt;/span&gt;" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxCell id="28" value="&lt;span&gt;Validates&lt;/span&gt;&lt;br&gt;&lt;span&gt;username/password/clientId&lt;/span&gt;&lt;br&gt;&lt;span&gt;and issue short&lt;/span&gt;&lt;br&gt;&lt;span&gt;Authorization code&lt;/span&gt;" style="rounded=1;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="115" width="190" height="90" as="geometry"/>
</mxCell>
<mxCell id="30" value="Validates&lt;br&gt;clientId &amp;amp; authorization code&lt;br&gt;and issue&lt;br&gt;Access Token &amp;amp; Refresh Token" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxCell id="30" value="Validates&lt;br&gt;clientId &amp;amp; authorization code&lt;br&gt;and issue&lt;br&gt;Access Token &amp;amp; Refresh Token" style="rounded=1;whiteSpace=wrap;html=1;" parent="1" vertex="1">
<mxGeometry x="115" y="140" width="190" height="90" as="geometry"/>
</mxCell>
<mxCell id="32" value="Protected APIs&lt;br&gt;Authenticate requests &lt;br&gt;with provided Bearer Token" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="50" y="280" width="320" height="400" as="geometry"/>
<mxCell id="32" value="Protected APIs&lt;br&gt;Authenticate requests &lt;br&gt;with provided Bearer Token" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;" parent="1" vertex="1">
<mxGeometry x="50" y="920" width="320" height="150" as="geometry"/>
</mxCell>
<mxCell id="33" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.373;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" target="32">
<mxCell id="33" value="" style="edgeStyle=none;html=1;entryX=-0.012;entryY=0.384;entryDx=0;entryDy=0;entryPerimeter=0;" parent="1" target="32" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-340" y="432.5" as="sourcePoint"/>
<mxPoint x="-10" y="430" as="targetPoint"/>
<mxPoint x="-520" y="978" as="sourcePoint"/>
<mxPoint x="-80" y="819" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="34" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; line-height: 18px&quot;&gt;&lt;font color=&quot;#a31515&quot;&gt;Request with Access Token&lt;/font&gt;&lt;/div&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="33">
<mxCell id="34" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; line-height: 18px&quot;&gt;&lt;font color=&quot;#a31515&quot;&gt;Request with Access Token&lt;/font&gt;&lt;/div&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="33" vertex="1" connectable="0">
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="37" value="Browser" style="rounded=0;whiteSpace=wrap;html=1;fontSize=22;" vertex="1" parent="1">
<mxGeometry x="-590" y="-100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="38" value="Browser" style="rounded=0;whiteSpace=wrap;html=1;fontSize=22;" vertex="1" parent="1">
<mxGeometry x="-590" y="1110" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="39" value="" style="endArrow=none;dashed=1;html=1;fontSize=22;entryX=0.5;entryY=1;entryDx=0;entryDy=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="1" source="38" target="37">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="-340" y="390" as="sourcePoint"/>
<mxPoint x="-290" y="340" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="40" value="&lt;font style=&quot;font-size: 22px&quot;&gt;Okta Authentication&lt;/font&gt;" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="50" y="300" width="320" height="560" as="geometry"/>
</mxCell>
<mxCell id="41" value="" style="edgeStyle=none;html=1;entryX=-0.013;entryY=0.092;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" target="49">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-530" y="373" as="sourcePoint"/>
<mxPoint x="115" y="372.58636363636356" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="42" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;span style=&quot;color: #a31515&quot;&gt;/SASjsApi/auth/okta/authorize&lt;br&gt;&lt;/span&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="41">
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="49" value="redirects to okta server&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;OKTA OIDC middleware&lt;br&gt;https://github.com/okta/okta-oidc-middleware&lt;br&gt;&lt;br&gt;&lt;br&gt;&lt;br&gt;OKTA nodeJS Express implementation&lt;br&gt;https://github.com/okta/samples-nodejs-express-4/tree/master/okta-hosted-login&lt;br&gt;" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="115" y="350" width="190" height="280" as="geometry"/>
</mxCell>
<mxCell id="75" style="edgeStyle=none;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;fontSize=14;" edge="1" parent="1" source="50" target="32">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="50" value="Validates express session &lt;br&gt;through OKTA OIDC middleware" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="115" y="710" width="190" height="90" as="geometry"/>
</mxCell>
<mxCell id="51" value="&lt;span style=&quot;font-size: 22px&quot;&gt;Okta Authorization Server&lt;br&gt;&lt;/span&gt;" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="600" y="300" width="320" height="380" as="geometry"/>
</mxCell>
<mxCell id="52" value="" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="665" y="350" width="190" height="280" as="geometry"/>
</mxCell>
<mxCell id="53" value="" style="endArrow=classic;html=1;fontSize=22;exitX=1.002;exitY=0.123;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="49">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="480" as="sourcePoint"/>
<mxPoint x="660" y="384" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="54" value="&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;/authorize&lt;/span&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=22;" vertex="1" connectable="0" parent="53">
<mxGeometry x="0.0222" y="1" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="58" value="" style="endArrow=classic;html=1;fontSize=22;exitX=-0.016;exitY=0.291;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="52">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-300" y="470" as="sourcePoint"/>
<mxPoint x="-530" y="431" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="61" value="302 redirect to authentication prompt&amp;nbsp;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=22;" vertex="1" connectable="0" parent="58">
<mxGeometry x="-0.659" y="3" relative="1" as="geometry">
<mxPoint x="-630" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="62" value="" style="endArrow=classic;html=1;fontSize=22;" edge="1" parent="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-530" y="500" as="sourcePoint"/>
<mxPoint x="665" y="500.48" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="64" value="Authentication &amp;amp; Consent" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=22;" vertex="1" connectable="0" parent="62">
<mxGeometry x="-0.4695" y="6" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="65" value="" style="endArrow=classic;html=1;fontSize=22;exitX=0;exitY=0.679;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.995;entryY=0.679;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" source="52" target="49">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="230" y="560" as="sourcePoint"/>
<mxPoint x="280" y="510" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="66" value="Authorization Code to redirect uri" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=14;" vertex="1" connectable="0" parent="65">
<mxGeometry x="0.0583" y="1" relative="1" as="geometry">
<mxPoint x="26" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="67" value="" style="endArrow=classic;html=1;fontSize=22;exitX=0;exitY=0.679;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.995;entryY=0.679;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="304.0500000000002" y="569.9999999999999" as="sourcePoint"/>
<mxPoint x="665.0000000000005" y="569.9999999999999" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="68" value="&lt;span style=&quot;color: rgb(163 , 21 , 21) ; font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px&quot;&gt;/token&lt;/span&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=14;" vertex="1" connectable="0" parent="67">
<mxGeometry x="0.0583" y="1" relative="1" as="geometry">
<mxPoint x="-5" y="1" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="69" value="" style="endArrow=classic;html=1;fontSize=22;exitX=0;exitY=0.679;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0.995;entryY=0.679;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="665.9500000000005" y="599.9999999999999" as="sourcePoint"/>
<mxPoint x="305.00000000000017" y="599.9999999999999" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="70" value="Access Token" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=14;" vertex="1" connectable="0" parent="69">
<mxGeometry x="0.0583" y="1" relative="1" as="geometry">
<mxPoint x="26" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="71" value="" style="edgeStyle=none;html=1;entryX=-0.012;entryY=0.384;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1">
<mxGeometry relative="1" as="geometry">
<mxPoint x="-530" y="760" as="sourcePoint"/>
<mxPoint x="115.00000000000031" y="759.9999999999999" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="72" value="&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; font-size: 12px ; line-height: 18px&quot;&gt;&lt;div style=&quot;font-family: &amp;#34;menlo&amp;#34; , &amp;#34;monaco&amp;#34; , &amp;#34;courier new&amp;#34; , monospace ; line-height: 18px&quot;&gt;&lt;font color=&quot;#a31515&quot;&gt;Request with OKTA mechanism&lt;/font&gt;&lt;/div&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="71">
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>

3
SASjsServer.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,10 +1,7 @@
MODE=[desktop|server] default considered as desktop
CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE
WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
PROTOCOL=[http|https] default considered as http
PRIVATE_KEY=privkey.pem
FULL_CHAIN=fullchain.pem
CORS=[disable|enable] default considered as disable
PORT=[5000] default value is 5000
PORT_WEB=[port for sasjs web component(react)] default value is 3000
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>

View File

@@ -1 +0,0 @@
v16.14.0

View File

@@ -1,13 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch via NPM",
"request": "launch",
"runtimeArgs": ["run-script", "start"],
"runtimeExecutable": "npm",
"skipFiles": ["<node_internals>/**"],
"type": "pwa-node"
}
]
}

9484
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,32 +4,32 @@
"description": "Api of SASjs server",
"main": "./src/server.ts",
"scripts": {
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore",
"initial": "npm run swagger && npm run compileSysInit",
"prestart": "npm run initial",
"prestart:prod": "npm run initial",
"prebuild": "npm run initial",
"start": "nodemon ./src/server.ts",
"start:prod": "nodemon ./src/prod-server.ts",
"build": "rimraf build && tsc",
"postbuild": "npm run copy:files",
"swagger": "tsoa spec",
"semantic-release": "semantic-release -d",
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
"exe": "npm run build && pkg .",
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sasjscore:copy && npm run web:copy",
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
"exe": "npm run build && npm run exe:copy && pkg .",
"exe:copy": "npm run public:copy && npm run sasjsbuild:copy && npm run web:copy",
"public:copy": "cp -r ./public/ ./build/public/",
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
"sasjscore:copy": "cp -r ./sasjscore/ ./build/sasjscore/",
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts"
"compileSysInit": "ts-node ./scripts/compileSysInit.ts"
},
"bin": "./build/src/server.js",
"pkg": {
"assets": [
"./build/public/**/*",
"./build/sasjsbuild/**/*",
"./build/sasjscore/**/*",
"./web/build/**/*"
],
"targets": [
@@ -46,10 +46,9 @@
},
"author": "4GL Ltd",
"dependencies": {
"@sasjs/core": "4.9.0",
"@sasjs/utils": "2.42.1",
"@sasjs/core": "3.11.1",
"@sasjs/utils": "2.34.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.17.1",
"joi": "^17.4.2",
@@ -58,11 +57,11 @@
"mongoose-sequence": "^5.3.1",
"morgan": "^1.10.0",
"multer": "^1.4.3",
"swagger-ui-express": "^4.1.6"
"swagger-ui-express": "^4.1.6",
"tsoa": "^3.14.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/cookie-parser": "^1.4.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.12",
"@types/jest": "^26.0.24",
@@ -74,25 +73,19 @@
"@types/supertest": "^2.0.11",
"@types/swagger-ui-express": "^4.1.3",
"dotenv": "^10.0.0",
"http-headers-validation": "^0.0.1",
"jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0",
"nodemon": "^2.0.7",
"pkg": "5.5.2",
"pkg": "^5.4.1",
"prettier": "^2.3.1",
"rimraf": "^3.0.2",
"semantic-release": "^17.4.3",
"supertest": "^6.1.3",
"ts-jest": "^27.0.3",
"ts-node": "^10.0.0",
"tsoa": "3.14.1",
"typescript": "^4.3.2"
},
"configuration": {
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sas"
},
"nodemonConfig": {
"ignore": [
"tmp/**/*"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 899 B

View File

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F6E40C;}
</style>
<rect id="XMLID_1_" width="32" height="32"/>
<g id="XMLID_654_">
<path id="XMLID_656_" class="st0" d="M27.9,17.4c-1.1,0-2.1,0-3,0c-1.2,0-2.3,0-3.5,0c-0.5,0-0.7,0.2-0.6,0.7c0,2.1,0,4.3,0,6.4
c0,0.5-0.2,0.8-0.6,1c-2.5,1.4-4.9,2.8-7.3,4.3c-0.4,0.2-0.6,0.2-1,0c-2.4-1.4-4.9-2.9-7.3-4.3c-0.2-0.1-0.5-0.5-0.5-0.7
c0-3.2,0-6.4,0-9.6c0-0.1,0-0.1,0.1-0.3c0.3,0,0.5,0,0.8,0c1.9,0,3.7,0,5.6,0c0.6,0,0.7-0.2,0.7-0.7c0-2.1,0-4.2,0-6.4
c0-0.5,0.1-0.8,0.6-1.1c2.5-1.4,4.9-2.9,7.3-4.3c0.2-0.1,0.6-0.1,0.9,0c2.5,1.4,5,2.9,7.5,4.4c0.2,0.1,0.4,0.4,0.4,0.6
C27.9,10.6,27.9,13.9,27.9,17.4z M20.8,14.8c1.4,0,2.7,0,4,0c0.5,0,0.7-0.2,0.7-0.7c0-1.7,0-3.3,0-5c0-0.5-0.2-0.7-0.6-1
c-1.6-0.9-3.2-1.9-4.8-2.8c-0.2-0.1-0.7-0.1-0.9,0c-1.6,0.9-3.2,1.9-4.8,2.8c-0.4,0.2-0.6,0.5-0.6,1c0,3.2,0,6.3,0,9.5
c0,1.9,0,1.9-1.9,1.9c-0.4,0-0.6-0.1-0.6-0.6c0-0.6,0-1.3,0-1.9c0-0.5-0.2-0.6-0.6-0.6c-1.1,0-2.2,0-3.3,0c-0.5,0-0.7,0.2-0.7,0.7
c0,1.6,0,3.3,0,4.9c0,0.5,0.2,0.8,0.6,1c1.6,0.9,3.2,1.9,4.8,2.8c0.2,0.1,0.7,0.1,0.9,0c1.6-0.9,3.2-1.9,4.8-2.8
c0.4-0.2,0.6-0.5,0.6-1c0-3.1,0-6.1,0-9.2c0-1.9,0-1.9,1.9-1.9c0.5,0,0.7,0.2,0.7,0.7C20.8,13.3,20.8,14,20.8,14.8z"/>
<path id="XMLID_655_" class="st0" d="M18,2.1l-6.8,3.9V2.7c0-0.3,0.3-0.6,0.6-0.6H18z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -92,48 +92,6 @@ components:
- clientSecret
type: object
additionalProperties: false
IRecordOfAny:
properties: {}
type: object
additionalProperties: {}
LogLine:
properties:
line:
type: string
required:
- line
type: object
additionalProperties: false
HTTPHeaders:
properties: {}
type: object
additionalProperties:
type: string
ExecuteReturnJsonResponse:
properties:
status:
type: string
_webout:
anyOf:
-
type: string
-
$ref: '#/components/schemas/IRecordOfAny'
log:
items:
$ref: '#/components/schemas/LogLine'
type: array
message:
type: string
httpHeaders:
$ref: '#/components/schemas/HTTPHeaders'
required:
- status
- _webout
- log
- httpHeaders
type: object
additionalProperties: false
ExecuteSASCodePayload:
properties:
code:
@@ -161,8 +119,6 @@ components:
$ref: '#/components/schemas/FolderMember'
-
$ref: '#/components/schemas/ServiceMember'
-
$ref: '#/components/schemas/FileMember'
type: array
required:
- name
@@ -188,24 +144,6 @@ components:
- code
type: object
additionalProperties: false
MemberType.file:
enum:
- file
type: string
FileMember:
properties:
name:
type: string
type:
$ref: '#/components/schemas/MemberType.file'
code:
type: string
required:
- name
- type
- code
type: object
additionalProperties: false
FileTree:
properties:
members:
@@ -215,8 +153,6 @@ components:
$ref: '#/components/schemas/FolderMember'
-
$ref: '#/components/schemas/ServiceMember'
-
$ref: '#/components/schemas/FileMember'
type: array
required:
- members
@@ -228,8 +164,6 @@ components:
type: string
message:
type: string
streamServiceName:
type: string
example:
$ref: '#/components/schemas/FileTree'
required:
@@ -241,15 +175,24 @@ components:
properties:
appLoc:
type: string
streamWebFolder:
type: string
fileTree:
$ref: '#/components/schemas/FileTree'
required:
- appLoc
- fileTree
type: object
additionalProperties: false
GetFileResponse:
properties:
status:
type: string
fileContent:
type: string
message:
type: string
required:
- status
type: object
additionalProperties: false
UpdateFileResponse:
properties:
status:
@@ -260,6 +203,21 @@ components:
- status
type: object
additionalProperties: false
FilePayload:
properties:
filePath:
type: string
description: 'Path of the file'
example: /Public/somefolder/some.file
fileContent:
type: string
description: 'Contents of the file'
example: 'Contents of the File'
required:
- filePath
- fileContent
type: object
additionalProperties: false
TreeNode:
properties:
name:
@@ -410,6 +368,21 @@ components:
- description
type: object
additionalProperties: false
ExecuteReturnJsonResponse:
properties:
status:
type: string
_webout:
type: string
log:
type: string
message:
type: string
required:
- status
- _webout
type: object
additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
@@ -425,7 +398,7 @@ components:
bearerFormat: JWT
info:
title: api
version: 0.0.2
version: 0.0.1
description: 'Api of SASjs server'
contact:
name: '4GL Ltd'
@@ -547,7 +520,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReturnJsonResponse'
type: string
description: 'Execute SAS code.'
summary: 'Run SAS Code and returns log'
tags:
@@ -606,62 +579,28 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/DeployPayload'
/SASjsApi/drive/deploy/upload:
post:
operationId: DeployUpload
/SASjsApi/drive/file:
get:
operationId: GetFile
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/DeployResponse'
$ref: '#/components/schemas/GetFileResponse'
examples:
'Example 1':
value: {status: success, message: 'Files deployed successfully to @sasjs/server.'}
value: {status: success, fileContent: 'Contents of the File'}
'400':
description: 'Invalid Format'
description: 'Unable to get File'
content:
application/json:
schema:
$ref: '#/components/schemas/DeployResponse'
$ref: '#/components/schemas/GetFileResponse'
examples:
'Example 1':
value: {status: failure, message: 'Provided not supported data format.'}
'500':
description: 'Execution Error'
content:
application/json:
schema:
$ref: '#/components/schemas/DeployResponse'
examples:
'Example 1':
value: {status: failure, message: 'Deployment failed!'}
summary: 'Creates/updates files within SASjs Drive using uploaded JSON file.'
tags:
- Drive
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
required:
- file
/SASjsApi/drive/file:
get:
operationId: GetFile
responses:
'204':
description: 'No content'
value: {status: failure, message: 'File request failed.'}
summary: 'Get file from SASjs Drive'
tags:
- Drive
@@ -671,34 +610,7 @@ paths:
parameters:
-
in: query
name: _filePath
required: true
schema:
type: string
example: /Public/somefolder/some.file
delete:
operationId: DeleteFile
responses:
'200':
description: Ok
content:
application/json:
schema:
properties:
status: {type: string}
required:
- status
type: object
summary: 'Delete file from SASjs Drive'
tags:
- Drive
security:
-
bearerAuth: []
parameters:
-
in: query
name: _filePath
name: filePath
required: true
schema:
type: string
@@ -715,7 +627,7 @@ paths:
examples:
'Example 1':
value: {status: success}
'403':
'400':
description: 'File already exists'
content:
application/json:
@@ -724,36 +636,19 @@ paths:
examples:
'Example 1':
value: {status: failure, message: 'File request failed.'}
description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request."
summary: 'Create a file in SASjs Drive'
tags:
- Drive
security:
-
bearerAuth: []
parameters:
-
description: 'Location of SAS program'
in: query
name: _filePath
required: false
schema:
type: string
example: /Public/somefolder/some.file.sas
parameters: []
requestBody:
required: true
content:
multipart/form-data:
application/json:
schema:
type: object
properties:
file:
type: string
format: binary
filePath:
type: string
required:
- file
$ref: '#/components/schemas/FilePayload'
patch:
operationId: UpdateFile
responses:
@@ -766,8 +661,8 @@ paths:
examples:
'Example 1':
value: {status: success}
'403':
description: ""
'400':
description: 'Unable to get File'
content:
application/json:
schema:
@@ -775,66 +670,19 @@ paths:
examples:
'Example 1':
value: {status: failure, message: 'File request failed.'}
description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request."
summary: 'Modify a file in SASjs Drive'
tags:
- Drive
security:
-
bearerAuth: []
parameters:
-
description: 'Location of SAS program'
in: query
name: _filePath
required: false
schema:
type: string
example: /Public/somefolder/some.file.sas
parameters: []
requestBody:
required: true
content:
multipart/form-data:
application/json:
schema:
type: object
properties:
file:
type: string
format: binary
filePath:
type: string
required:
- file
/SASjsApi/drive/folder:
get:
operationId: GetFolder
responses:
'200':
description: Ok
content:
application/json:
schema:
properties:
folders: {items: {type: string}, type: array}
files: {items: {type: string}, type: array}
required:
- folders
- files
type: object
summary: 'Get folder contents from SASjs Drive'
tags:
- Drive
security:
-
bearerAuth: []
parameters:
-
in: query
name: _folderPath
required: false
schema:
type: string
example: /Public/somefolder
$ref: '#/components/schemas/FilePayload'
/SASjsApi/drive/filetree:
get:
operationId: GetFileTree
@@ -1187,11 +1035,9 @@ paths:
content:
application/json:
schema:
anyOf:
- {type: string}
- {type: string, format: byte}
description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. Setting _debug=131 will\ncause the log to be streamed in the output.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response headers can be adjusted using the mfs_httpheader() macro. Any\nfile type can be returned, including binary files such as zip or xls.\n\nIf _debug is >= 131, response headers will contain Content-Type: 'text/plain'\n\nThis behaviour differs for POST requests, in which case the response is\nalways JSON."
summary: 'Execute Stored Program, return raw _webout content.'
type: string
description: "Trigger a SAS program using it's location in the _program parameter.\nEnable debugging using the _debug parameter.\nAdditional URL parameters are turned into SAS macro variables.\nAny files provided are placed into the session and\ncorresponding _WEBIN_XXX variables are created."
summary: 'Execute Stored Program, return raw content'
tags:
- STP
security:
@@ -1199,7 +1045,6 @@ paths:
bearerAuth: []
parameters:
-
description: 'Location of SAS program'
in: query
name: _program
required: true
@@ -1215,10 +1060,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReturnJsonResponse'
examples:
'Example 1':
value: {status: success, _webout: 'webout content', log: [], httpHeaders: {Content-type: application/zip, Cache-Control: 'public, max-age=1000'}}
description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. In any case, the log is\nalways returned in the log object.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response will be a JSON object with the following root attributes: log,\nwebout, headers.\n\nThe webout will be a nested JSON object ONLY if the response-header\ncontains a content-type of application/json AND it is valid JSON.\nOtherwise it will be a stringified version of the webout content.\n\nResponse headers from the mfs_httpheader macro are simply listed in the\nheaders object, for POST requests they have no effect on the actual\nresponse header."
description: "Trigger a SAS program using it's location in the _program parameter.\nEnable debugging using the _debug parameter.\nAdditional URL parameters are turned into SAS macro variables.\nAny files provided are placed into the session and\ncorresponding _WEBIN_XXX variables are created."
summary: 'Execute Stored Program, return JSON'
tags:
- STP
@@ -1227,7 +1069,6 @@ paths:
bearerAuth: []
parameters:
-
description: 'Location of SAS program'
in: query
name: _program
required: false

View File

@@ -1,6 +1,5 @@
import path from 'path'
import {
CompileTree,
createFile,
loadDependenciesFile,
readFile,
@@ -18,12 +17,11 @@ const compiledSystemInit = async (systemInit: string) =>
programFolders: [],
macroFolders: [],
buildSourceFolder: '',
binaryFolders: [],
macroCorePath,
compileTree: new CompileTree('') // dummy compileTree
macroCorePath
}))
const createSysInitFile = async () => {
console.log('macroCorePath', macroCorePath)
const systemInitContent = await readFile(
path.join(__dirname, 'systemInit.sas')
)

View File

@@ -1,32 +0,0 @@
import path from 'path'
import {
asyncForEach,
copy,
createFile,
createFolder,
deleteFolder,
listFilesInFolder
} from '@sasjs/utils'
import { apiRoot, sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils'
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
export const copySASjsCore = async () => {
await deleteFolder(sasJSCoreMacros)
await createFolder(sasJSCoreMacros)
const foldersToCopy = ['base', 'ddl', 'fcmp', 'lua', 'server']
await asyncForEach(foldersToCopy, async (coreSubFolder) => {
const coreSubFolderPath = path.join(macroCorePath, coreSubFolder)
await copy(coreSubFolderPath, sasJSCoreMacros)
})
const fileNames = await listFilesInFolder(sasJSCoreMacros)
await createFile(sasJSCoreMacrosInfo, fileNames.join('\n'))
}
copySASjsCore()

View File

@@ -4,24 +4,10 @@
@details This program is inserted into every sasjs/server program invocation,
_before_ any user-provided content.
A number of useful CORE macros are also compiled below, so that they can be
available "out of the box".
<h4> SAS Macros </h4>
@li mcf_stpsrv_header.sas
@li mf_getuser.sas
@li mf_getvarlist.sas
@li mf_mkdir.sas
@li mf_nobs.sas
@li mf_uid.sas
@li mfs_httpheader.sas
@li mp_dirlist.sas
@li mp_ds2ddl.sas
@li mp_ds2md.sas
@li mp_getdbml.sas
@li mp_init.sas
@li mp_makedata.sas
@li mp_zip.sas
**/
%mcf_stpsrv_header(wrap=YES, insert_cmplib=YES)

View File

@@ -1,64 +1,36 @@
import path from 'path'
import express, { ErrorRequestHandler } from 'express'
import express from 'express'
import morgan from 'morgan'
import cookieParser from 'cookie-parser'
import dotenv from 'dotenv'
import cors from 'cors'
import {
connectDB,
copySASjsCore,
getWebBuildFolderPath,
loadAppStreamConfig,
setProcessVariables,
setupFolders
} from './utils'
import webRouter from './routes/web'
import apiRouter from './routes/api'
import { connectDB, getWebBuildFolderPath } from './utils'
dotenv.config()
const app = express()
const { MODE, CORS, WHITELIST } = process.env
const { MODE, CORS, PORT_WEB } = process.env
const whiteList = [
`http://localhost:${PORT_WEB ?? 3000}`,
'https://sas.analytium.co.uk:8343'
]
if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') {
const whiteList: string[] = []
WHITELIST?.split(' ')?.forEach((url) => {
if (url.startsWith('http'))
// removing trailing slash of URLs listing for CORS
whiteList.push(url.replace(/\/$/, ''))
})
console.log('All CORS Requests are enabled for:', whiteList)
console.log('All CORS Requests are enabled')
app.use(cors({ credentials: true, origin: whiteList }))
}
app.use(cookieParser())
app.use(express.json({ limit: '50mb' }))
app.use(morgan('tiny'))
app.use(express.json({ limit: '100mb' }))
app.use(express.static(path.join(__dirname, '../public')))
const onError: ErrorRequestHandler = (err, req, res, next) => {
console.error(err.stack)
res.status(500).send('Something broke!')
}
app.use('/', webRouter)
app.use('/SASjsApi', apiRouter)
app.use(express.json({ limit: '50mb' }))
export default setProcessVariables().then(async () => {
await setupFolders()
await copySASjsCore()
app.use(express.static(getWebBuildFolderPath()))
// loading these modules after setting up variables due to
// multer's usage of process var process.driveLoc
const { setupRoutes } = await import('./routes/setupRoutes')
setupRoutes(app)
await loadAppStreamConfig()
// 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(onError)
await connectDB()
return app
})
export default connectDB().then(() => app)

View File

@@ -1,9 +1,7 @@
import express from 'express'
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
import { ExecuteReturnJson, ExecutionController } from './internal'
import { ExecutionController } from './internal'
import { PreProgramVars } from '../types'
import { ExecuteReturnJsonResponse } from '.'
import { parseLogToArray } from '../utils'
interface ExecuteSASCodePayload {
/**
@@ -25,28 +23,22 @@ export class CodeController {
public async executeSASCode(
@Request() request: express.Request,
@Body() body: ExecuteSASCodePayload
): Promise<ExecuteReturnJsonResponse> {
): Promise<string> {
return executeSASCode(request, body)
}
}
const executeSASCode = async (req: any, { code }: ExecuteSASCodePayload) => {
try {
const { webout, log, httpHeaders } =
(await new ExecutionController().executeProgram(
code,
getPreProgramVariables(req),
{ ...req.query, _debug: 131 },
undefined,
true
)) as ExecuteReturnJson
const result = await new ExecutionController().executeProgram(
code,
getPreProgramVariables(req),
{ ...req.query, _debug: 131 },
undefined,
true
)
return {
status: 'success',
_webout: webout as string,
log: parseLogToArray(log),
httpHeaders
}
return result as string
} catch (err: any) {
throw {
code: 400,

View File

@@ -1,8 +1,5 @@
import path from 'path'
import express, { Express } from 'express'
import {
Security,
Request,
Route,
Tags,
Example,
@@ -11,39 +8,35 @@ import {
Response,
Query,
Get,
Patch,
UploadedFile,
FormField,
Delete,
Hidden
Patch
} from 'tsoa'
import {
fileExists,
moveFile,
createFolder,
deleteFile as deleteFileOnSystem,
folderExists,
listFilesInFolder,
listSubFoldersInFolder,
isFolder,
FileTree,
isFileTree
} from '@sasjs/utils'
import { fileExists, readFile, createFile } from '@sasjs/utils'
import { createFileTree, ExecutionController, getTreeExample } from './internal'
import { TreeNode } from '../types'
import { FileTree, isFileTree, TreeNode } from '../types'
import path from 'path'
import { getTmpFilesFolderPath } from '../utils'
interface DeployPayload {
appLoc: string
streamWebFolder?: string
appLoc?: string
fileTree: FileTree
}
interface FilePayload {
/**
* Path of the file
* @example "/Public/somefolder/some.file"
*/
filePath: string
/**
* Contents of the file
* @example "Contents of the File"
*/
fileContent: string
}
interface DeployResponse {
status: string
message: string
streamServiceName?: string
example?: FileTree
}
@@ -96,106 +89,57 @@ export class DriveController {
}
/**
* @summary Creates/updates files within SASjs Drive using uploaded JSON file.
*
*/
@Example<DeployResponse>(successDeployResponse)
@Response<DeployResponse>(400, 'Invalid Format', invalidDeployFormatResponse)
@Response<DeployResponse>(500, 'Execution Error', execDeployErrorResponse)
@Post('/deploy/upload')
public async deployUpload(
@UploadedFile() file: Express.Multer.File, // passing here for API docs
@Query() @Hidden() body?: DeployPayload // Hidden decorator has be optional
): Promise<DeployResponse> {
return deploy(body!)
}
/**
*
* @summary Get file from SASjs Drive
* @query _filePath Location of SAS program
* @example _filePath "/Public/somefolder/some.file"
* @query filePath Location of SAS program
* @example filePath "/Public/somefolder/some.file"
*/
@Example<GetFileResponse>({
status: 'success',
fileContent: 'Contents of the File'
})
@Response<GetFileResponse>(400, 'Unable to get File', {
status: 'failure',
message: 'File request failed.'
})
@Get('/file')
public async getFile(
@Request() request: express.Request,
@Query() _filePath: string
) {
return getFile(request, _filePath)
public async getFile(@Query() filePath: string): Promise<GetFileResponse> {
return getFile(filePath)
}
/**
*
* @summary Get folder contents from SASjs Drive
* @query _folderPath Location of SAS program
* @example _folderPath "/Public/somefolder"
*/
@Get('/folder')
public async getFolder(@Query() _folderPath?: string) {
return getFolder(_folderPath)
}
/**
*
* @summary Delete file from SASjs Drive
* @query _filePath Location of SAS program
* @example _filePath "/Public/somefolder/some.file"
*/
@Delete('/file')
public async deleteFile(@Query() _filePath: string) {
return deleteFile(_filePath)
}
/**
* It's optional to either provide `_filePath` in url as query parameter
* Or provide `filePath` in body as form field.
* But it's required to provide else API will respond with Bad Request.
*
* @summary Create a file in SASjs Drive
* @param _filePath Location of SAS program
* @example _filePath "/Public/somefolder/some.file.sas"
*
*/
@Example<UpdateFileResponse>({
status: 'success'
})
@Response<UpdateFileResponse>(403, 'File already exists', {
@Response<UpdateFileResponse>(400, 'File already exists', {
status: 'failure',
message: 'File request failed.'
})
@Post('/file')
public async saveFile(
@UploadedFile() file: Express.Multer.File,
@Query() _filePath?: string,
@FormField() filePath?: string
@Body() body: FilePayload
): Promise<UpdateFileResponse> {
return saveFile((_filePath ?? filePath)!, file)
return saveFile(body)
}
/**
* It's optional to either provide `_filePath` in url as query parameter
* Or provide `filePath` in body as form field.
* But it's required to provide else API will respond with Bad Request.
*
* @summary Modify a file in SASjs Drive
* @param _filePath Location of SAS program
* @example _filePath "/Public/somefolder/some.file.sas"
*
*/
@Example<UpdateFileResponse>({
status: 'success'
})
@Response<UpdateFileResponse>(403, `File doesn't exist`, {
@Response<UpdateFileResponse>(400, 'Unable to get File', {
status: 'failure',
message: 'File request failed.'
})
@Patch('/file')
public async updateFile(
@UploadedFile() file: Express.Multer.File,
@Query() _filePath?: string,
@FormField() filePath?: string
@Body() body: FilePayload
): Promise<UpdateFileResponse> {
return updateFile((_filePath ?? filePath)!, file)
return updateFile(body)
}
/**
@@ -209,151 +153,91 @@ export class DriveController {
}
const getFileTree = () => {
const tree = new ExecutionController().buildDirectoryTree()
const tree = new ExecutionController().buildDirectorytree()
return { status: 'success', tree }
}
const deploy = async (data: DeployPayload) => {
const driveFilesPath = getTmpFilesFolderPath()
const appLocParts = data.appLoc.replace(/^\//, '').split('/')
const appLocPath = path
.join(getTmpFilesFolderPath(), ...appLocParts)
.replace(new RegExp('/', 'g'), path.sep)
if (!appLocPath.includes(driveFilesPath)) {
throw new Error('appLoc cannot be outside drive.')
}
if (!isFileTree(data.fileTree)) {
throw { code: 400, ...invalidDeployFormatResponse }
}
await createFileTree(data.fileTree.members, appLocParts).catch((err) => {
await createFileTree(
data.fileTree.members,
data.appLoc ? data.appLoc.replace(/^\//, '').split('/') : []
).catch((err) => {
throw { code: 500, ...execDeployErrorResponse, ...err }
})
return successDeployResponse
}
const getFile = async (req: express.Request, filePath: string) => {
const driveFilesPath = getTmpFilesFolderPath()
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot get file outside drive.')
}
if (!(await fileExists(filePathFull))) {
throw new Error("File doesn't exist.")
}
const extension = path.extname(filePathFull).toLowerCase()
if (extension === '.sas') {
req.res?.setHeader('Content-type', 'text/plain')
}
req.res?.sendFile(path.resolve(filePathFull))
}
const getFolder = async (folderPath?: string) => {
const driveFilesPath = getTmpFilesFolderPath()
if (folderPath) {
const folderPathFull = path
.join(getTmpFilesFolderPath(), folderPath)
const getFile = async (filePath: string): Promise<GetFileResponse> => {
try {
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!folderPathFull.includes(driveFilesPath)) {
throw new Error('Cannot get folder outside drive.')
await validateFilePath(filePathFull)
const fileContent = await readFile(filePathFull)
return { status: 'success', fileContent: fileContent }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}
if (!(await folderExists(folderPathFull))) {
throw new Error("Folder doesn't exist.")
const saveFile = async (body: FilePayload): Promise<GetFileResponse> => {
const { filePath, fileContent } = body
try {
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (await fileExists(filePathFull)) {
throw 'DriveController: File already exists.'
}
await createFile(filePathFull, fileContent)
if (!(await isFolder(folderPathFull))) {
throw new Error('Not a Folder.')
return { status: 'success' }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
error: typeof err === 'object' ? err.toString() : err
}
const files: string[] = await listFilesInFolder(folderPathFull)
const folders: string[] = await listSubFoldersInFolder(folderPathFull)
return { files, folders }
}
const files: string[] = await listFilesInFolder(driveFilesPath)
const folders: string[] = await listSubFoldersInFolder(driveFilesPath)
return { files, folders }
}
const deleteFile = async (filePath: string) => {
const driveFilesPath = getTmpFilesFolderPath()
const updateFile = async (body: FilePayload): Promise<GetFileResponse> => {
const { filePath, fileContent } = body
try {
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
const filePathFull = path
.join(getTmpFilesFolderPath(), filePath)
.replace(new RegExp('/', 'g'), path.sep)
await validateFilePath(filePathFull)
await createFile(filePathFull, fileContent)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot delete file outside drive.')
return { status: 'success' }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
if (!(await fileExists(filePathFull))) {
throw new Error('File does not exist.')
}
await deleteFileOnSystem(filePathFull)
return { status: 'success' }
}
const saveFile = async (
filePath: string,
multerFile: Express.Multer.File
): Promise<GetFileResponse> => {
const driveFilesPath = getTmpFilesFolderPath()
const filePathFull = path
.join(driveFilesPath, filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot put file outside drive.')
const validateFilePath = async (filePath: string) => {
if (!(await fileExists(filePath))) {
throw 'DriveController: File does not exists.'
}
if (await fileExists(filePathFull)) {
throw new Error('File already exists.')
}
const folderPath = path.dirname(filePathFull)
await createFolder(folderPath)
await moveFile(multerFile.path, filePathFull)
return { status: 'success' }
}
const updateFile = async (
filePath: string,
multerFile: Express.Multer.File
): Promise<GetFileResponse> => {
const driveFilesPath = getTmpFilesFolderPath()
const filePathFull = path
.join(driveFilesPath, filePath)
.replace(new RegExp('/', 'g'), path.sep)
if (!filePathFull.includes(driveFilesPath)) {
throw new Error('Cannot modify file outside drive.')
}
if (!(await fileExists(filePathFull))) {
throw new Error(`File doesn't exist.`)
}
await moveFile(multerFile.path, filePathFull)
return { status: 'success' }
}

View File

@@ -1,46 +1,17 @@
import path from 'path'
import fs from 'fs'
import { getSessionController } from './'
import {
readFile,
fileExists,
createFile,
moveFile,
readFileBinary
} from '@sasjs/utils'
import { PreProgramVars, Session, TreeNode } from '../../types'
import {
extractHeaders,
generateFileUploadSasCode,
getTmpFilesFolderPath,
getTmpMacrosPath,
HTTPHeaders,
isDebugOn
} from '../../utils'
export interface ExecutionVars {
[key: string]: string | number | undefined
}
export interface ExecuteReturnRaw {
httpHeaders: HTTPHeaders
result: string | Buffer
}
export interface ExecuteReturnJson {
httpHeaders: HTTPHeaders
webout: string | Buffer
log?: string
}
import { readFile, fileExists, createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, TreeNode } from '../../types'
import { generateFileUploadSasCode, getTmpFilesFolderPath } from '../../utils'
export class ExecutionController {
async executeFile(
programPath: string,
preProgramVariables: PreProgramVars,
vars: ExecutionVars,
vars: { [key: string]: string | number | undefined },
otherArgs?: any,
returnJson?: boolean,
session?: Session
returnJson?: boolean
) {
if (!(await fileExists(programPath)))
throw 'ExecutionController: SAS file does not exist.'
@@ -52,32 +23,28 @@ export class ExecutionController {
preProgramVariables,
vars,
otherArgs,
returnJson,
session
returnJson
)
}
async executeProgram(
program: string,
preProgramVariables: PreProgramVars,
vars: ExecutionVars,
vars: { [key: string]: string | number | undefined },
otherArgs?: any,
returnJson?: boolean,
sessionByFileUpload?: Session
): Promise<ExecuteReturnRaw | ExecuteReturnJson> {
returnJson?: boolean
) {
const sessionController = getSessionController()
const session =
sessionByFileUpload ?? (await sessionController.getSession())
const session = await sessionController.getSession()
session.inUse = true
session.consumed = true
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 weboutPath = path.join(session.path, 'webout.txt')
await createFile(weboutPath, '')
const tokenFile = path.join(session.path, 'accessToken.txt')
await createFile(
tokenFile,
preProgramVariables?.accessToken ?? 'accessToken'
@@ -88,7 +55,6 @@ export class ExecutionController {
`${computed}%let ${key}=${vars[key]};\n`,
''
)
const preProgramVarStatments = `
%let _sasjs_tokenfile=${tokenFile};
%let _sasjs_username=${preProgramVariables?.username};
@@ -99,19 +65,17 @@ export class ExecutionController {
%let _metaperson=&_sasjs_displayname;
%let _metauser=&_sasjs_username;
%let sasjsprocessmode=Stored Program;
%let sasjs_stpsrv_header_loc=%sysfunc(pathname(work))/../stpsrv_header.txt;
%global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG;
%global SYSPROCESSMODE SYSTCPIPHOSTNAME;
%macro _sasjs_server_init();
%if "&SYSPROCESSMODE"="" %then %let SYSPROCESSMODE=&sasjsprocessmode;
%if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl;
%mend;
%_sasjs_server_init()
%sysmacdelete _sasjs_server_init;
`
program = `
options insert=(SASAUTOS="${getTmpMacrosPath()}");
/* runtime vars */
${varStatments}
filename _webout "${weboutPath}" mod;
@@ -123,7 +87,7 @@ ${preProgramVarStatments}
${program}`
// if no files are uploaded filesNamesMap will be undefined
if (otherArgs?.filesNamesMap) {
if (otherArgs && otherArgs.filesNamesMap) {
const uploadSasCode = await generateFileUploadSasCode(
otherArgs.filesNamesMap,
session.path
@@ -152,42 +116,30 @@ ${program}`
}
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
const headersContent = (await fileExists(headersPath))
? await readFile(headersPath)
: ''
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
const fileResponse: boolean =
httpHeaders.hasOwnProperty('content-type') &&
!returnJson && // not a POST Request
!isDebugOn(vars) // Debug is not enabled
const webout = (await fileExists(weboutPath))
? fileResponse
? await readFileBinary(weboutPath)
: await readFile(weboutPath)
? await readFile(weboutPath)
: ''
const debugValue =
typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug
// it should be deleted by scheduleSessionDestroy
session.inUse = false
if (returnJson) {
return {
httpHeaders,
webout,
log: isDebugOn(vars) || session.crashed ? log : undefined
log:
(debugValue && debugValue >= 131) || session.crashed ? log : undefined
}
}
return {
httpHeaders,
result:
isDebugOn(vars) || session.crashed
? `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
: webout
}
return (debugValue && debugValue >= 131) || session.crashed
? `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
: webout
}
buildDirectoryTree() {
buildDirectorytree() {
const root: TreeNode = {
name: 'files',
relativePath: '',

View File

@@ -1,6 +1,6 @@
import multer from 'multer'
import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.'
const multer = require('multer')
export class FileUploadController {
private storage = multer.diskStorage({
@@ -18,14 +18,12 @@ 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 = async (req: any, res: any, next: any) => {
let session
const sessionController = getSessionController()
session = await sessionController.getSession()
// marking consumed true, so that it's not available
// as readySession for any other request
session.consumed = true
session.inUse = true
req.sasSession = session

View File

@@ -12,7 +12,8 @@ import {
createFile,
fileExists,
generateTimestamp,
readFile
readFile,
moveFile
} from '@sasjs/utils'
const execFilePromise = promisify(execFile)
@@ -30,7 +31,7 @@ export class SessionController {
? readySessions[0]
: await this.createSession()
if (readySessions.length < 3) this.createSession()
if (readySessions.length < 2) this.createSession()
return session
}
@@ -40,7 +41,6 @@ export class SessionController {
const sessionFolder = path.join(getTmpSessionsFolderPath(), sessionId)
const creationTimeStamp = sessionId.split('-').pop() as string
// death time of session is 15 mins from creation
const deathTimeStamp = (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
@@ -67,10 +67,7 @@ export class SessionController {
// the autoexec file is executed on SAS startup
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
const contentForAutoExec = `/* compiled systemInit */
${compiledSystemInitContent}
/* autoexec */
${autoExecContent}`
const contentForAutoExec = `/* compiled systemInit */\n${compiledSystemInitContent}\n/* autoexec */\n${autoExecContent}`
await createFile(autoExecPath, contentForAutoExec)
// create empty code.sas as SAS will not start without a SYSIN
@@ -87,8 +84,6 @@ ${autoExecContent}`
codePath,
'-LOG',
path.join(session.path, 'log.log'),
'-PRINT',
path.join(session.path, 'output.lst'),
'-WORK',
session.path,
'-AUTOEXEC',
@@ -140,9 +135,7 @@ ${autoExecContent}`
private scheduleSessionDestroy(session: Session) {
setTimeout(async () => {
if (session.inUse) {
// adding 10 more minutes
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000
session.deathTimeStamp = newDeathTimeStamp.toString()
session.deathTimeStamp = session.deathTimeStamp + 1000 * 10
this.scheduleSessionDestroy(session)
} else {

View File

@@ -1,19 +1,11 @@
import path from 'path'
import { MemberType, FolderMember, ServiceMember, FileTree } from '../../types'
import { getTmpFilesFolderPath } from '../../utils/file'
import {
createFolder,
createFile,
asyncForEach,
FolderMember,
ServiceMember,
FileMember,
MemberType,
FileTree
} from '@sasjs/utils'
import { createFolder, createFile, asyncForEach } from '@sasjs/utils'
import path from 'path'
// REFACTOR: export FileTreeCpntroller
export const createFileTree = async (
members: (FolderMember | ServiceMember | FileMember)[],
members: (FolderMember | ServiceMember)[],
parentFolders: string[] = []
) => {
const destinationPath = path.join(
@@ -21,32 +13,25 @@ export const createFileTree = async (
path.join(...parentFolders)
)
await asyncForEach(
members,
async (member: FolderMember | ServiceMember | FileMember) => {
let name = member.name
await asyncForEach(members, async (member: FolderMember | ServiceMember) => {
let name = member.name
if (member.type === MemberType.service) name += '.sas'
if (member.type === MemberType.service) name += '.sas'
if (member.type === MemberType.folder) {
await createFolder(path.join(destinationPath, name)).catch((err) =>
Promise.reject({ error: err, failedToCreate: name })
)
if (member.type === MemberType.folder) {
await createFolder(path.join(destinationPath, name)).catch((err) =>
Promise.reject({ error: err, failedToCreate: name })
)
await createFileTree(member.members, [...parentFolders, name]).catch(
(err) => Promise.reject({ error: err, failedToCreate: name })
)
} else {
const encoding = member.type === MemberType.file ? 'base64' : undefined
await createFile(
path.join(destinationPath, name),
member.code,
encoding
).catch((err) => Promise.reject({ error: err, failedToCreate: name }))
}
await createFileTree(member.members, [...parentFolders, name]).catch(
(err) => Promise.reject({ error: err, failedToCreate: name })
)
} else {
await createFile(path.join(destinationPath, name), member.code).catch(
(err) => Promise.reject({ error: err, failedToCreate: name })
)
}
)
})
return Promise.resolve()
}

View File

@@ -1,31 +1,9 @@
import express from 'express'
import path from 'path'
import {
Request,
Security,
Route,
Tags,
Post,
Body,
Get,
Query,
Example
} from 'tsoa'
import {
ExecuteReturnJson,
ExecuteReturnRaw,
ExecutionController,
ExecutionVars
} from './internal'
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
import { ExecutionController } from './internal'
import { PreProgramVars } from '../types'
import {
getTmpFilesFolderPath,
HTTPHeaders,
isDebugOn,
LogLine,
makeFilesNamesMap,
parseLogToArray
} from '../utils'
import { getTmpFilesFolderPath, makeFilesNamesMap } from '../utils'
interface ExecuteReturnJsonPayload {
/**
@@ -34,16 +12,11 @@ interface ExecuteReturnJsonPayload {
*/
_program?: string
}
interface IRecordOfAny {
[key: string]: any
}
export interface ExecuteReturnJsonResponse {
interface ExecuteReturnJsonResponse {
status: string
_webout: string | IRecordOfAny
log: LogLine[]
_webout: string
log?: string
message?: string
httpHeaders: HTTPHeaders
}
@Security('bearerAuth')
@@ -51,69 +24,33 @@ export interface ExecuteReturnJsonResponse {
@Tags('STP')
export class STPController {
/**
* Trigger a SAS program using it's location in the _program URL parameter.
* Enable debugging using the _debug URL parameter. Setting _debug=131 will
* cause the log to be streamed in the output.
*
* Trigger a SAS program using it's location in the _program parameter.
* Enable debugging using the _debug parameter.
* Additional URL parameters are turned into SAS macro variables.
*
* Any files provided in the request body are placed into the SAS session with
* corresponding _WEBIN_XXX variables created.
*
* The response headers can be adjusted using the mfs_httpheader() macro. Any
* file type can be returned, including binary files such as zip or xls.
*
* If _debug is >= 131, response headers will contain Content-Type: 'text/plain'
*
* This behaviour differs for POST requests, in which case the response is
* always JSON.
*
* @summary Execute Stored Program, return raw _webout content.
* @param _program Location of SAS program
* Any files provided are placed into the session and
* corresponding _WEBIN_XXX variables are created.
* @summary Execute Stored Program, return raw content
* @query _program Location of SAS program
* @example _program "/Public/somefolder/some.file"
*/
@Get('/execute')
public async executeReturnRaw(
@Request() request: express.Request,
@Query() _program: string
): Promise<string | Buffer> {
): Promise<string> {
return executeReturnRaw(request, _program)
}
/**
* Trigger a SAS program using it's location in the _program URL parameter.
* Enable debugging using the _debug URL parameter. In any case, the log is
* always returned in the log object.
*
* Trigger a SAS program using it's location in the _program parameter.
* Enable debugging using the _debug parameter.
* Additional URL parameters are turned into SAS macro variables.
*
* Any files provided in the request body are placed into the SAS session with
* corresponding _WEBIN_XXX variables created.
*
* The response will be a JSON object with the following root attributes: log,
* webout, headers.
*
* The webout will be a nested JSON object ONLY if the response-header
* contains a content-type of application/json AND it is valid JSON.
* Otherwise it will be a stringified version of the webout content.
*
* Response headers from the mfs_httpheader macro are simply listed in the
* headers object, for POST requests they have no effect on the actual
* response header.
*
* Any files provided are placed into the session and
* corresponding _WEBIN_XXX variables are created.
* @summary Execute Stored Program, return JSON
* @param _program Location of SAS program
* @query _program Location of SAS program
* @example _program "/Public/somefolder/some.file"
*/
@Example<ExecuteReturnJsonResponse>({
status: 'success',
_webout: 'webout content',
log: [],
httpHeaders: {
'Content-type': 'application/zip',
'Cache-Control': 'public, max-age=1000'
}
})
@Post('/execute')
public async executeReturnJson(
@Request() request: express.Request,
@@ -128,34 +65,21 @@ export class STPController {
const executeReturnRaw = async (
req: express.Request,
_program: string
): Promise<string | Buffer> => {
const query = req.query as ExecutionVars
): Promise<string> => {
const query = req.query as { [key: string]: string | number | undefined }
const sasCodePath =
path
.join(getTmpFilesFolderPath(), _program)
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
try {
const { result, httpHeaders } =
(await new ExecutionController().executeFile(
sasCodePath,
getPreProgramVariables(req),
query
)) as ExecuteReturnRaw
const result = await new ExecutionController().executeFile(
sasCodePath,
getPreProgramVariables(req),
query
)
// Should over-ride response header for debug
// on GET request to see entire log rendering on browser.
if (isDebugOn(query)) {
httpHeaders['content-type'] = 'text/plain'
}
req.res?.set(httpHeaders)
if (result instanceof Buffer) {
;(req as any).sasHeaders = httpHeaders
}
return result
return result as string
} catch (err: any) {
throw {
code: 400,
@@ -178,28 +102,17 @@ const executeReturnJson = async (
const filesNamesMap = req.files?.length ? makeFilesNamesMap(req.files) : null
try {
const { webout, log, httpHeaders } =
(await new ExecutionController().executeFile(
sasCodePath,
getPreProgramVariables(req),
{ ...req.query, ...req.body },
{ filesNamesMap: filesNamesMap },
true,
req.sasSession
)) as ExecuteReturnJson
let weboutRes: string | IRecordOfAny = webout
if (httpHeaders['content-type']?.toLowerCase() === 'application/json') {
try {
weboutRes = JSON.parse(webout as string)
} catch (_) {}
}
const { webout, log } = (await new ExecutionController().executeFile(
sasCodePath,
getPreProgramVariables(req),
{ ...req.query, ...req.body },
{ filesNamesMap: filesNamesMap },
true
)) as { webout: string; log: string }
return {
status: 'success',
_webout: weboutRes,
log: parseLogToArray(log),
httpHeaders
_webout: webout,
log
}
} catch (err: any) {
throw {

View File

@@ -43,9 +43,7 @@ const authenticateToken = (
}
const authHeader = req.headers['authorization']
const token =
authHeader?.split(' ')[1] ??
(tokenType === 'accessToken' ? req.cookies.accessToken : '')
const token = authHeader?.split(' ')[1]
if (!token) return res.sendStatus(401)
jwt.verify(token, key, async (err: any, data: any) => {

View File

@@ -1,72 +0,0 @@
import path from 'path'
import { Request } from 'express'
import multer, { FileFilterCallback, Options } from 'multer'
import { blockFileRegex, getTmpUploadsPath } from '../utils'
const fieldNameSize = 300
const fileSize = 104857600 // 100 MB
const storage = multer.diskStorage({
destination: getTmpUploadsPath(),
filename: function (
_req: Request,
file: Express.Multer.File,
callback: (error: Error | null, filename: string) => void
) {
callback(
null,
file.fieldname + path.extname(file.originalname) + '-' + Date.now()
)
}
})
const limits: Options['limits'] = {
fieldNameSize,
fileSize
}
const fileFilter: Options['fileFilter'] = (
req: Request,
file: Express.Multer.File,
callback: FileFilterCallback
) => {
const fileExtension = path.extname(file.originalname)
const shouldBlockUpload = blockFileRegex.test(file.originalname)
if (shouldBlockUpload) {
return callback(
new Error(`File extension '${fileExtension}' not acceptable.`)
)
}
const uploadFileSize = parseInt(req.headers['content-length'] ?? '')
if (uploadFileSize > fileSize) {
return callback(
new Error(
`File size is over limit. File limit is: ${fileSize / 1024 / 1024} MB`
)
)
}
callback(null, true)
}
const options: Options = { storage, limits, fileFilter }
const multerInstance = multer(options)
export const multerSingle = (fileName: string, arg: any) => {
const [req, res, next] = arg
const upload = multerInstance.single(fileName)
upload(req, res, function (err) {
if (err instanceof multer.MulterError) {
return res.status(500).send(err.message)
} else if (err) {
return res.status(400).send(err.message)
}
// Everything went fine.
next()
})
}
export default multerInstance

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

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

View File

@@ -55,9 +55,8 @@ authRouter.post('/token', async (req, res) => {
const controller = new AuthController()
try {
const response = await controller.token(body)
const { accessToken } = response
res.cookie('accessToken', accessToken).send(response)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
}

View File

@@ -12,12 +12,6 @@ runRouter.post('/execute', async (req, res) => {
try {
const response = await controller.executeSASCode(req, body)
if (response instanceof Buffer) {
res.writeHead(200, (req as any).sasHeaders)
return res.end(response)
}
res.send(response)
} catch (err: any) {
const statusCode = err.code

View File

@@ -1,38 +1,13 @@
import express from 'express'
import { deleteFile, readFile } from '@sasjs/utils'
import { publishAppStream } from '../appStream'
import { multerSingle } from '../../middlewares/multer'
import { DriveController } from '../../controllers/'
import {
deployValidation,
fileBodyValidation,
fileParamValidation,
folderParamValidation
} from '../../utils'
const controller = new DriveController()
import { getFileDriveValidation, updateFileDriveValidation } from '../../utils'
const driveRouter = express.Router()
driveRouter.post('/deploy', async (req, res) => {
const { error, value: body } = deployValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new DriveController()
try {
const response = await controller.deploy(body)
if (body.streamWebFolder) {
const { streamServiceName } = await publishAppStream(
body.appLoc,
body.streamWebFolder,
body.streamServiceName,
body.streamLogo
)
response.streamServiceName = streamServiceName
}
const response = await controller.deploy(req.body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
@@ -43,149 +18,59 @@ driveRouter.post('/deploy', async (req, res) => {
}
})
driveRouter.post(
'/deploy/upload',
(...arg) => multerSingle('file', arg),
async (req, res) => {
if (!req.file) return res.status(400).send('"file" is not present.')
const fileContent = await readFile(req.file.path)
let jsonContent
try {
jsonContent = JSON.parse(fileContent)
} catch (err) {
deleteFile(req.file.path)
return res.status(400).send('File containing invalid JSON content.')
}
const { error, value: body } = deployValidation(jsonContent)
if (error) {
deleteFile(req.file.path)
return res.status(400).send(error.details[0].message)
}
try {
const response = await controller.deployUpload(req.file, body)
if (body.streamWebFolder) {
const { streamServiceName } = await publishAppStream(
body.appLoc,
body.streamWebFolder,
body.streamServiceName,
body.streamLogo
)
response.streamServiceName = streamServiceName
}
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
} finally {
deleteFile(req.file.path)
}
}
)
driveRouter.get('/file', async (req, res) => {
const { error: errQ, value: query } = fileParamValidation(req.query)
if (errQ) return res.status(400).send(errQ.details[0].message)
const { error, value: query } = getFileDriveValidation(req.query)
if (error) return res.status(400).send(error.details[0].message)
const controller = new DriveController()
try {
await controller.getFile(req, query._filePath)
} catch (err: any) {
res.status(403).send(err.toString())
}
})
driveRouter.get('/folder', async (req, res) => {
const { error: errQ, value: query } = folderParamValidation(req.query)
if (errQ) return res.status(400).send(errQ.details[0].message)
try {
const response = await controller.getFolder(query._folderPath)
const response = await controller.getFile(query.filePath)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
driveRouter.delete('/file', async (req, res) => {
const { error: errQ, value: query } = fileParamValidation(req.query)
if (errQ) return res.status(400).send(errQ.details[0].message)
driveRouter.post('/file', async (req, res) => {
const { error, value: body } = updateFileDriveValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
const controller = new DriveController()
try {
const response = await controller.deleteFile(query._filePath)
const response = await controller.saveFile(body)
res.send(response)
} catch (err: any) {
res.status(403).send(err.toString())
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
driveRouter.post(
'/file',
(...arg) => multerSingle('file', arg),
async (req, res) => {
const { error: errQ, value: query } = fileParamValidation(req.query)
const { error: errB, value: body } = fileBodyValidation(req.body)
driveRouter.patch('/file', async (req, res) => {
const { error, value: body } = updateFileDriveValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)
if (errQ && errB) {
if (req.file) await deleteFile(req.file.path)
return res.status(400).send(errQ.details[0].message)
}
const controller = new DriveController()
try {
const response = await controller.updateFile(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
if (!req.file) return res.status(400).send('"file" is not present.')
delete err.code
try {
const response = await controller.saveFile(
req.file,
query._filePath,
body.filePath
)
res.send(response)
} catch (err: any) {
await deleteFile(req.file.path)
res.status(403).send(err.toString())
}
res.status(statusCode).send(err)
}
)
driveRouter.patch(
'/file',
(...arg) => multerSingle('file', arg),
async (req, res) => {
const { error: errQ, value: query } = fileParamValidation(req.query)
const { error: errB, value: body } = fileBodyValidation(req.body)
if (errQ && errB) {
if (req.file) await deleteFile(req.file.path)
return res.status(400).send(errQ.details[0].message)
}
if (!req.file) return res.status(400).send('"file" is not present.')
try {
const response = await controller.updateFile(
req.file,
query._filePath,
body.filePath
)
res.send(response)
} catch (err: any) {
await deleteFile(req.file.path)
res.status(403).send(err.toString())
}
}
)
})
driveRouter.get('/fileTree', async (req, res) => {
const controller = new DriveController()
try {
const response = await controller.getFileTree()
res.send(response)

View File

@@ -1,5 +1,6 @@
import express from 'express'
import { SessionController } from '../../controllers'
import { authenticateAccessToken } from '../../middlewares'
const sessionRouter = express.Router()

View File

@@ -1,37 +1,15 @@
import path from 'path'
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import {
folderExists,
fileExists,
readFile,
deleteFolder,
generateTimestamp,
copy,
createFolder,
createFile,
ServiceMember,
FolderMember
} from '@sasjs/utils'
import * as fileUtilModules from '../../../utils/file'
const timestamp = generateTimestamp()
const tmpFolder = path.join(process.cwd(), `tmp-${timestamp}`)
jest
.spyOn(fileUtilModules, 'getTmpFolderPath')
.mockImplementation(() => tmpFolder)
jest
.spyOn(fileUtilModules, 'getTmpUploadsPath')
.mockImplementation(() => path.join(tmpFolder, 'uploads'))
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
import { generateAccessToken, saveTokensInDB } from '../../../utils/'
const { getTmpFilesFolderPath } = fileUtilModules
import { getTmpFilesFolderPath } from '../../../utils/file'
import { folderExists, fileExists, readFile, deleteFolder } from '@sasjs/utils'
import path from 'path'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { FolderMember, ServiceMember } from '../../../types'
let app: Express
appPromise.then((_app) => {
@@ -47,50 +25,45 @@ const user = {
isActive: true
}
describe('drive', () => {
describe('files', () => {
let con: Mongoose
let mongoServer: MongoMemoryServer
const controller = new UserController()
let accessToken: string
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create()
con = await mongoose.connect(mongoServer.getUri())
const dbUser = await controller.createUser(user)
accessToken = generateAccessToken({
clientId,
userId: dbUser.id
})
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
})
afterAll(async () => {
await con.connection.dropDatabase()
await con.connection.close()
await mongoServer.stop()
await deleteFolder(tmpFolder)
})
describe('deploy', () => {
let accessToken: string
let dbUser: any
beforeAll(async () => {
dbUser = await controller.createUser(user)
accessToken = generateAccessToken({
clientId,
userId: dbUser.id
})
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
})
const shouldFailAssertion = async (payload: any) => {
const res = await request(app)
.post('/SASjsApi/drive/deploy')
.auth(accessToken, { type: 'bearer' })
.send({ appLoc: '/Public', fileTree: payload })
.send(payload)
expect(res.statusCode).toEqual(400)
if (payload === undefined) {
expect(res.text).toEqual('"fileTree" is required')
} else {
expect(res.body).toEqual({
status: 'failure',
message: 'Provided not supported data format.',
example: getTreeExample()
})
}
expect(res.body).toEqual({
status: 'failure',
message: 'Provided not supported data format.',
example: getTreeExample()
})
}
it('should respond with payload example if valid payload was not provided', async () => {
@@ -149,11 +122,11 @@ describe('drive', () => {
})
})
it('should successfully deploy if valid payload was provided', async () => {
it('should respond with payload example if valid payload was not provided', async () => {
const res = await request(app)
.post('/SASjsApi/drive/deploy')
.auth(accessToken, { type: 'bearer' })
.send({ appLoc: '/public', fileTree: getTreeExample() })
.send({ fileTree: getTreeExample() })
expect(res.statusCode).toEqual(200)
expect(res.text).toEqual(
@@ -163,7 +136,6 @@ describe('drive', () => {
const testJobFolder = path.join(
getTmpFilesFolderPath(),
'public',
'jobs',
'extract'
)
@@ -172,504 +144,13 @@ describe('drive', () => {
const exampleService = getExampleService()
const testJobFile = path.join(testJobFolder, exampleService.name) + '.sas'
console.log(`[testJobFile]`, testJobFile)
await expect(fileExists(testJobFile)).resolves.toEqual(true)
await expect(readFile(testJobFile)).resolves.toEqual(exampleService.code)
await deleteFolder(path.join(getTmpFilesFolderPath(), 'public'))
})
})
describe('folder', () => {
describe('get', () => {
const getFolderApi = '/SASjsApi/drive/folder'
it('should get root SAS folder on drive', async () => {
const res = await request(app)
.get(getFolderApi)
.auth(accessToken, { type: 'bearer' })
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({ files: [], folders: [] })
})
it('should get a SAS folder on drive having _folderPath as query param', async () => {
const pathToDrive = fileUtilModules.getTmpFilesFolderPath()
const dirLevel1 = 'level1'
const dirLevel2 = 'level2'
const fileLevel1 = 'file1'
const fileLevel2 = 'file2'
await createFolder(path.join(pathToDrive, dirLevel1, dirLevel2))
await createFile(
path.join(pathToDrive, dirLevel1, fileLevel1),
'some file content'
)
await createFile(
path.join(pathToDrive, dirLevel1, dirLevel2, fileLevel2),
'some file content'
)
const res1 = await request(app)
.get(getFolderApi)
.query({ _folderPath: '/' })
.auth(accessToken, { type: 'bearer' })
expect(res1.statusCode).toEqual(200)
expect(res1.body).toEqual({ files: [], folders: [dirLevel1] })
const res2 = await request(app)
.get(getFolderApi)
.query({ _folderPath: dirLevel1 })
.auth(accessToken, { type: 'bearer' })
expect(res2.statusCode).toEqual(200)
expect(res2.body).toEqual({ files: [fileLevel1], folders: [dirLevel2] })
const res3 = await request(app)
.get(getFolderApi)
.query({ _folderPath: `${dirLevel1}/${dirLevel2}` })
.auth(accessToken, { type: 'bearer' })
expect(res3.statusCode).toEqual(200)
expect(res3.body).toEqual({ files: [fileLevel2], folders: [] })
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).get(getFolderApi).expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if folder is not present', async () => {
const res = await request(app)
.get(getFolderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: `/my/path/code-${generateTimestamp()}` })
.expect(403)
expect(res.text).toEqual(`Error: Folder doesn't exist.`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if folderPath outside Drive', async () => {
const res = await request(app)
.get(getFolderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: '/../path/code.sas' })
.expect(403)
expect(res.text).toEqual('Error: Cannot get folder outside drive.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if folderPath is of a file', async () => {
const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas')
const filePath = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
await copy(fileToCopyPath, pathToCopy)
const res = await request(app)
.get(getFolderApi)
.auth(accessToken, { type: 'bearer' })
.query({ _folderPath: filePath })
.expect(403)
expect(res.text).toEqual('Error: Not a Folder.')
expect(res.body).toEqual({})
})
})
})
describe('file', () => {
describe('create', () => {
it('should create a SAS file on drive having filePath as form field', async () => {
const pathToUpload = `/my/path/code-1.sas`
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', path.join(__dirname, 'files', 'sample.sas'))
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should create a SAS file on drive having _filePath as query param', async () => {
const pathToUpload = `/my/path/code-2.sas`
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: pathToUpload })
.attach('file', path.join(__dirname, 'files', 'sample.sas'))
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.post('/SASjsApi/drive/file')
.field('filePath', '/my/path/code.sas')
.attach('file', path.join(__dirname, 'files', 'sample.sas'))
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if file is already present', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = `/my/path/code-${generateTimestamp()}.sas`
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(403)
expect(res.text).toEqual('Error: File already exists.')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if filePath outside Drive', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/../path/code.sas'
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(403)
expect(res.text).toEqual('Error: Cannot put file outside drive.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if filePath is missing', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.attach('file', fileToAttachPath)
.expect(400)
expect(res.text).toEqual(`"_filePath" is required`)
expect(res.body).toEqual({})
})
it("should respond with Bad Request if filePath doesn't has correct extension", async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/my/path/code.exe'
const res = await request(app)
.post(`/SASjsApi/drive/file?_filePath=${pathToUpload}`)
.auth(accessToken, { type: 'bearer' })
// .field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(400)
expect(res.text).toEqual('Invalid file extension')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if file is missing', async () => {
const pathToUpload = '/my/path/code.sas'
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.expect(400)
expect(res.text).toEqual('"file" is not present.')
expect(res.body).toEqual({})
})
it("should respond with Bad Request if attached file doesn't has correct extension", async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.exe')
const pathToUpload = '/my/path/code.sas'
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(400)
expect(res.text).toEqual(`File extension '.exe' not acceptable.`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if attached file exceeds file limit', async () => {
const pathToUpload = '/my/path/code.sas'
const attachedFile = Buffer.from('.'.repeat(110 * 1024 * 1024)) // 110mb
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', attachedFile, 'another.sas')
.expect(400)
expect(res.text).toEqual(
'File size is over limit. File limit is: 100 MB'
)
expect(res.body).toEqual({})
})
})
describe('update', () => {
it('should update a SAS file on drive having filePath as form field', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should update a SAS file on drive having _filePath as query param', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
pathToUpload
)
await copy(fileToAttachPath, pathToCopy)
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({
status: 'success'
})
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app)
.patch('/SASjsApi/drive/file')
.field('filePath', '/my/path/code.sas')
.attach('file', path.join(__dirname, 'files', 'sample.sas'))
.expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if file is not present', async () => {
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', `/my/path/code-3.sas`)
.attach('file', path.join(__dirname, 'files', 'sample.sas'))
.expect(403)
expect(res.text).toEqual(`Error: File doesn't exist.`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if filePath outside Drive', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/../path/code.sas'
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(403)
expect(res.text).toEqual('Error: Cannot modify file outside drive.')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if filePath is missing', async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.attach('file', fileToAttachPath)
.expect(400)
expect(res.text).toEqual(`"_filePath" is required`)
expect(res.body).toEqual({})
})
it("should respond with Bad Request if filePath doesn't has correct extension", async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas')
const pathToUpload = '/my/path/code.exe'
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: pathToUpload })
.attach('file', fileToAttachPath)
.expect(400)
expect(res.text).toEqual('Invalid file extension')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if file is missing', async () => {
const pathToUpload = '/my/path/code.sas'
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.expect(400)
expect(res.text).toEqual('"file" is not present.')
expect(res.body).toEqual({})
})
it("should respond with Bad Request if attached file doesn't has correct extension", async () => {
const fileToAttachPath = path.join(__dirname, 'files', 'sample.exe')
const pathToUpload = '/my/path/code.sas'
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', fileToAttachPath)
.expect(400)
expect(res.text).toEqual(`File extension '.exe' not acceptable.`)
expect(res.body).toEqual({})
})
it('should respond with Bad Request if attached file exceeds file limit', async () => {
const pathToUpload = '/my/path/code.sas'
const attachedFile = Buffer.from('.'.repeat(110 * 1024 * 1024)) // 110mb
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.field('filePath', pathToUpload)
.attach('file', attachedFile, 'another.sas')
.expect(400)
expect(res.text).toEqual(
'File size is over limit. File limit is: 100 MB'
)
expect(res.body).toEqual({})
})
})
describe('get', () => {
it('should get a SAS file on drive having _filePath as query param', async () => {
const fileToCopyPath = path.join(__dirname, 'files', 'sample.sas')
const fileToCopyContent = await readFile(fileToCopyPath)
const filePath = '/my/path/code.sas'
const pathToCopy = path.join(
fileUtilModules.getTmpFilesFolderPath(),
filePath
)
await copy(fileToCopyPath, pathToCopy)
const res = await request(app)
.get('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: filePath })
expect(res.statusCode).toEqual(200)
expect(res.body).toEqual({})
expect(res.text).toEqual(fileToCopyContent)
})
it('should respond with Unauthorized if access token is not present', async () => {
const res = await request(app).get('/SASjsApi/drive/file').expect(401)
expect(res.text).toEqual('Unauthorized')
expect(res.body).toEqual({})
})
it('should respond with Forbidden if file is not present', async () => {
const res = await request(app)
.get('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: `/my/path/code-4.sas` })
.expect(403)
expect(res.text).toEqual(`Error: File doesn't exist.`)
expect(res.body).toEqual({})
})
it('should respond with Forbidden if filePath outside Drive', async () => {
const res = await request(app)
.get('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: '/../path/code.sas' })
.expect(403)
expect(res.text).toEqual('Error: Cannot get file outside drive.')
expect(res.body).toEqual({})
})
it("should respond with Bad Request if filePath doesn't has correct extension", async () => {
const res = await request(app)
.patch('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.query({ _filePath: '/my/path/code.exe' })
.expect(400)
expect(res.text).toEqual('Invalid file extension')
expect(res.body).toEqual({})
})
it('should respond with Bad Request if filePath is missing', async () => {
const res = await request(app)
.post('/SASjsApi/drive/file')
.auth(accessToken, { type: 'bearer' })
.expect(400)
expect(res.text).toEqual(`"_filePath" is required`)
expect(res.body).toEqual({})
})
await deleteFolder(getTmpFilesFolderPath())
})
})
})

View File

@@ -1 +0,0 @@
some code of sas

View File

@@ -1 +0,0 @@
some code of sas

View File

@@ -1,5 +1,5 @@
import express from 'express'
import { executeProgramRawValidation } from '../../utils'
import { executeProgramRawValidation, runSASValidation } from '../../utils'
import { STPController } from '../../controllers/'
import { FileUploadController } from '../../controllers/internal'
@@ -14,12 +14,6 @@ stpRouter.get('/execute', async (req, res) => {
try {
const response = await controller.executeReturnRaw(req, query._program)
if (response instanceof Buffer) {
res.writeHead(200, (req as any).sasHeaders)
return res.end(response)
}
res.send(response)
} catch (err: any) {
const statusCode = err.code
@@ -32,7 +26,7 @@ stpRouter.get('/execute', async (req, res) => {
stpRouter.post(
'/execute',
fileUploadController.preUploadMiddleware,
fileUploadController.preuploadMiddleware,
fileUploadController.getMulterUploadObject().any(),
async (req: any, res: any) => {
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
@@ -46,12 +40,6 @@ stpRouter.post(
body,
query?._program
)
if (response instanceof Buffer) {
res.writeHead(200, (req as any).sasHeaders)
return res.end(response)
}
res.send(response)
} catch (err: any) {
const statusCode = err.code

View File

@@ -1,44 +0,0 @@
import { AppStreamConfig } from '../../types'
import { script } from './script'
import { style } from './style'
const defaultAppLogo = '/sasjs-logo.svg'
const singleAppStreamHtml = (
streamServiceName: string,
appLoc: string,
logo?: string
) =>
` <a class="app" href="${streamServiceName}" title="${appLoc}">
<img
src="${logo ? streamServiceName + '/' + logo : defaultAppLogo}"
onerror="this.src = '${defaultAppLogo}';"
/>
${streamServiceName}
</a>`
export const appStreamHtml = (appStreamConfig: AppStreamConfig) => `
<html>
<head>
<base href="/AppStream/">
${style}
</head>
<body>
<h1>App Stream</h1>
<div class="app-container">
${Object.entries(appStreamConfig)
.map(([streamServiceName, entry]) =>
singleAppStreamHtml(streamServiceName, entry.appLoc, entry.streamLogo)
)
.join('')}
<a class="app" title="Upload build.json">
<input id="fileId" type="file" hidden />
<button id="uploadButton" style="margin-bottom: 5px; cursor: pointer">
<img src="/plus.png" />
</button>
<span id="uploadMessage">Upload New App</span>
</a>
</div>
${script}
</body>
</html>`

View File

@@ -1,65 +0,0 @@
import path from 'path'
import express from 'express'
import { folderExists } from '@sasjs/utils'
import { addEntryToAppStreamConfig, getTmpFilesFolderPath } from '../../utils'
import { appStreamHtml } from './appStreamHtml'
const router = express.Router()
router.get('/', async (_, res) => {
const content = appStreamHtml(process.appStreamConfig)
return res.send(content)
})
export const publishAppStream = async (
appLoc: string,
streamWebFolder: string,
streamServiceName?: string,
streamLogo?: string,
addEntryToFile: boolean = true
) => {
const driveFilesPath = getTmpFilesFolderPath()
const appLocParts = appLoc.replace(/^\//, '')?.split('/')
const appLocPath = path.join(driveFilesPath, ...appLocParts)
if (!appLocPath.includes(driveFilesPath)) {
throw new Error('appLoc cannot be outside drive.')
}
const pathToDeployment = path.join(appLocPath, 'services', streamWebFolder)
if (!pathToDeployment.includes(appLocPath)) {
throw new Error('streamWebFolder cannot be outside appLoc.')
}
if (await folderExists(pathToDeployment)) {
const appCount = process.appStreamConfig
? Object.keys(process.appStreamConfig).length
: 0
if (!streamServiceName) {
streamServiceName = `AppStreamName${appCount + 1}`
}
router.use(`/${streamServiceName}`, express.static(pathToDeployment))
addEntryToAppStreamConfig(
streamServiceName,
appLoc,
streamWebFolder,
streamLogo,
addEntryToFile
)
const sasJsPort = process.env.PORT || 5000
console.log(
'Serving Stream App: ',
`http://localhost:${sasJsPort}/AppStream/${streamServiceName}`
)
return { streamServiceName }
}
return {}
}
export default router

View File

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

View File

@@ -1,22 +0,0 @@
export const style = `<style>
* {
font-family: 'Roboto', sans-serif;
}
.app-container {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
}
.app-container .app {
width: 150px;
margin: 10px;
overflow: hidden;
text-align: center;
}
.app-container .app img{
width: 100%;
margin-bottom: 10px;
border-radius: 10px;
}
</style>`

View File

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

View File

@@ -1,28 +1,10 @@
import { createServer } from 'https'
import appPromise from './app'
import { getCertificates } from './utils'
appPromise.then(async (app) => {
const protocol = process.env.PROTOCOL || 'http'
const sasJsPort = process.env.PORT || 5000
console.log('PROTOCOL: ', protocol)
if (protocol !== 'https') {
app.listen(sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
)
})
} else {
const { key, cert } = await getCertificates()
const httpsServer = createServer({ key, cert }, app)
httpsServer.listen(sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
)
})
}
appPromise.then((app) => {
const sasJsPort = process.env.PORT ?? 5000
app.listen(sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
)
})
})

View File

@@ -1,7 +0,0 @@
export interface AppStreamConfig {
[key: string]: {
appLoc: string
streamWebFolder: string
streamLogo?: string
}
}

47
api/src/types/FileTree.ts Normal file
View File

@@ -0,0 +1,47 @@
export interface FileTree {
members: (FolderMember | ServiceMember)[]
}
export enum MemberType {
folder = 'folder',
service = 'service'
}
export interface FolderMember {
name: string
type: MemberType.folder
members: (FolderMember | ServiceMember)[]
}
export interface ServiceMember {
name: string
type: MemberType.service
code: string
}
export const isFileTree = (arg: any): arg is FileTree =>
arg &&
arg.members &&
Array.isArray(arg.members) &&
arg.members.filter(
(member: FolderMember | ServiceMember) =>
!isFolderMember(member) && !isServiceMember(member)
).length === 0
const isFolderMember = (arg: any): arg is FolderMember =>
arg &&
typeof arg.name === 'string' &&
arg.type === MemberType.folder &&
arg.members &&
Array.isArray(arg.members) &&
arg.members.filter(
(member: FolderMember | ServiceMember) =>
!isFolderMember(member) && !isServiceMember(member)
).length === 0
const isServiceMember = (arg: any): arg is ServiceMember =>
arg &&
typeof arg.name === 'string' &&
arg.type === MemberType.service &&
arg.code &&
typeof arg.code === 'string'

View File

@@ -3,6 +3,5 @@ declare namespace NodeJS {
sasLoc: string
driveLoc: string
sessionController?: import('../controllers/internal').SessionController
appStreamConfig: import('./').AppStreamConfig
}
}

View File

@@ -1,6 +1,6 @@
// TODO: uppercase types
export * from './AppStreamConfig'
export * from './Execution'
export * from './FileTree'
export * from './InfoJWT'
export * from './PreProgramVars'
export * from './Request'

View File

@@ -1,89 +0,0 @@
import { createFile, fileExists, readFile } from '@sasjs/utils'
import { publishAppStream } from '../routes/appStream'
import { AppStreamConfig } from '../types'
import { getTmpAppStreamConfigPath } from './file'
export const loadAppStreamConfig = async () => {
if (process.env.NODE_ENV === 'test') return
const appStreamConfigPath = getTmpAppStreamConfigPath()
const content = (await fileExists(appStreamConfigPath))
? await readFile(appStreamConfigPath)
: '{}'
let appStreamConfig: AppStreamConfig
try {
appStreamConfig = JSON.parse(content)
if (!isValidAppStreamConfig(appStreamConfig)) throw 'invalid type'
} catch (_) {
appStreamConfig = {}
}
process.appStreamConfig = {}
for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) {
const { appLoc, streamWebFolder, streamLogo } = entry
publishAppStream(
appLoc,
streamWebFolder,
streamServiceName,
streamLogo,
false
)
}
console.log('App Stream Config loaded!')
}
export const addEntryToAppStreamConfig = (
streamServiceName: string,
appLoc: string,
streamWebFolder: string,
streamLogo?: string,
addEntryToFile: boolean = true
) => {
if (streamServiceName && appLoc && streamWebFolder) {
process.appStreamConfig[streamServiceName] = {
appLoc,
streamWebFolder,
streamLogo
}
if (addEntryToFile) saveAppStreamConfig()
}
}
export const removeEntryFromAppStreamConfig = (streamServiceName: string) => {
if (streamServiceName) {
delete process.appStreamConfig[streamServiceName]
saveAppStreamConfig()
}
}
const saveAppStreamConfig = async () => {
const appStreamConfigPath = getTmpAppStreamConfigPath()
try {
await createFile(
appStreamConfigPath,
JSON.stringify(process.appStreamConfig, null, 2)
)
} catch (_) {}
}
const isValidAppStreamConfig = (config: any) => {
if (config) {
return !Object.entries(config).some(([streamServiceName, entry]) => {
const { appLoc, streamWebFolder, streamLogo } = entry as any
return (
typeof streamServiceName !== 'string' ||
typeof appLoc !== 'string' ||
typeof streamWebFolder !== 'string'
)
})
}
return false
}

View File

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

View File

@@ -1,34 +0,0 @@
import path from 'path'
import {
asyncForEach,
createFile,
createFolder,
deleteFolder,
readFile
} from '@sasjs/utils'
import { getTmpMacrosPath, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.'
export const copySASjsCore = async () => {
if (process.env.NODE_ENV === 'test') return
console.log('Copying Macros from container to drive(tmp).')
const macrosDrivePath = getTmpMacrosPath()
await deleteFolder(macrosDrivePath)
await createFolder(macrosDrivePath)
const macros = await readFile(sasJSCoreMacrosInfo)
await asyncForEach(macros.split('\n'), async (macroName) => {
const macroFileSourcePath = path.join(sasJSCoreMacros, macroName)
const macroContent = await readFile(macroFileSourcePath)
const macroFileDestPath = path.join(macrosDrivePath, macroName)
await createFile(macroFileDestPath, macroContent)
})
console.log('Macros Drive Path:', macrosDrivePath)
}

View File

@@ -1,25 +0,0 @@
const headerUtils = require('http-headers-validation')
export interface HTTPHeaders {
[key: string]: string
}
export const extractHeaders = (content?: string): HTTPHeaders => {
const headersObj: HTTPHeaders = {}
const headersArr = content
?.split('\n')
.map((line) => line.trim())
.filter((line) => !!line)
headersArr?.forEach((headerStr) => {
const [key, value] = headerStr.split(':').map((data) => data.trim())
if (value && headerUtils.validateHeader(key, value)) {
headersObj[key.toLowerCase()] = value
} else {
delete headersObj[key.toLowerCase()]
}
})
return headersObj
}

View File

@@ -8,21 +8,11 @@ export const sysInitCompiledPath = path.join(
'systemInitCompiled.sas'
)
export const sasJSCoreMacros = path.join(apiRoot, 'sasjscore')
export const sasJSCoreMacrosInfo = path.join(apiRoot, 'sasjscore', '.macrolist')
export const getWebBuildFolderPath = () =>
path.join(codebaseRoot, 'web', 'build')
export const getTmpFolderPath = () => process.driveLoc
export const getTmpAppStreamConfigPath = () =>
path.join(getTmpFolderPath(), 'appStreamConfig.json')
export const getTmpMacrosPath = () => path.join(getTmpFolderPath(), 'sasjscore')
export const getTmpUploadsPath = () => path.join(getTmpFolderPath(), 'uploads')
export const getTmpFilesFolderPath = () =>
path.join(getTmpFolderPath(), 'files')

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
import path from 'path'
import { fileExists, getString, readFile } from '@sasjs/utils'
export const getCertificates = async () => {
const { PRIVATE_KEY, FULL_CHAIN } = process.env
const keyPath = PRIVATE_KEY ?? (await getFileInput('Private Key (PEM)'))
const certPath = FULL_CHAIN ?? (await getFileInput('Full Chain (PEM)'))
console.log('keyPath: ', keyPath)
console.log('certPath: ', certPath)
const key = await readFile(keyPath)
const cert = await readFile(certPath)
return { key, cert }
}
const getFileInput = async (filename: string): Promise<string> => {
const validator = async (filePath: string) => {
if (!filePath) return `Path to ${filename} is required.`
if (!(await fileExists(path.join(process.cwd(), filePath)))) {
return 'No file found at provided path.'
}
return true
}
const targetName = await getString(
`Please enter path to ${filename} (relative path): `,
validator
)
return targetName
}

View File

@@ -1,19 +1,12 @@
export * from './appStreamConfig'
export * from './connectDB'
export * from './copySASjsCore'
export * from './extractHeaders'
export * from './file'
export * from './generateAccessToken'
export * from './generateAuthCode'
export * from './generateRefreshToken'
export * from './getCertificates'
export * from './getDesktopFields'
export * from './isDebugOn'
export * from './parseLogToArray'
export * from './removeTokensInDB'
export * from './saveTokensInDB'
export * from './setProcessVariables'
export * from './setupFolders'
export * from './sleep'
export * from './upload'
export * from './validation'
export * from './verifyTokenInDB'

View File

@@ -1,8 +0,0 @@
import { ExecutionVars } from '../controllers/internal'
export const isDebugOn = (vars: ExecutionVars) => {
const debugValue =
typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug
return !!(debugValue && debugValue >= 131)
}

View File

@@ -1,9 +0,0 @@
export interface LogLine {
line: string
}
export const parseLogToArray = (content?: string): LogLine[] => {
if (!content) return []
return content.split('\n').map((line) => ({ line: line }))
}

View File

@@ -1,31 +0,0 @@
import path from 'path'
import { getRealPath } from '@sasjs/utils'
import { configuration } from '../../package.json'
import { getDesktopFields } from '.'
export const setProcessVariables = async () => {
if (process.env.NODE_ENV === 'test') {
process.driveLoc = path.join(process.cwd(), 'tmp')
return
}
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
const { sasLoc, driveLoc } = await getDesktopFields()
process.sasLoc = sasLoc
process.driveLoc = driveLoc
} else {
const { SAS_PATH, DRIVE_PATH } = process.env
process.sasLoc = SAS_PATH ?? configuration.sasPath
process.driveLoc = getRealPath(
path.join(process.cwd(), DRIVE_PATH ?? 'tmp')
)
}
console.log('sasLoc: ', process.sasLoc)
console.log('sasDrive: ', process.driveLoc)
}

View File

@@ -1,7 +0,0 @@
import { createFolder } from '@sasjs/utils'
import { getTmpFilesFolderPath } from './file'
export const setupFolders = async () => {
const drivePath = getTmpFilesFolderPath()
await createFolder(drivePath)
}

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

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

View File

@@ -1,52 +0,0 @@
import { extractHeaders } from '..'
describe('extractHeaders', () => {
it('should return valid http headers', () => {
const headers = extractHeaders(`
Content-type: application/csv
Cache-Control: public, max-age=2000
Content-type: application/text
Cache-Control: public, max-age=1500
Content-type: application/zip
Cache-Control: public, max-age=1000
`)
expect(headers).toEqual({
'content-type': 'application/zip',
'cache-control': 'public, max-age=1000'
})
})
it('should not return http headers if last occurrence is blank', () => {
const headers = extractHeaders(`
Content-type: application/csv
Cache-Control: public, max-age=1000
Content-type: application/text
Content-type:
`)
expect(headers).toEqual({ 'cache-control': 'public, max-age=1000' })
})
it('should return only valid http headers', () => {
const headers = extractHeaders(`
Content-type[]: application/csv
Content//-type: application/text
Content()-type: application/zip
`)
expect(headers).toEqual({})
})
it('should return http headers if empty', () => {
const headers = extractHeaders('')
expect(headers).toEqual({})
})
it('should return http headers if not provided', () => {
const headers = extractHeaders()
expect(headers).toEqual({})
})
})

View File

@@ -1,33 +0,0 @@
import { parseLogToArray } from '..'
describe('parseLogToArray', () => {
it('should parse log to array type', () => {
const log = parseLogToArray(`
line 1 of log content
line 2 of log content
line 3 of log content
line 4 of log content
`)
expect(log).toEqual([
{ line: '' },
{ line: 'line 1 of log content' },
{ line: 'line 2 of log content' },
{ line: 'line 3 of log content' },
{ line: 'line 4 of log content' },
{ line: ' ' }
])
})
it('should parse log to array type if empty', () => {
const log = parseLogToArray('')
expect(log).toEqual([])
})
it('should parse log to array type if not provided', () => {
const log = parseLogToArray()
expect(log).toEqual([])
})
})

View File

@@ -1,21 +1,9 @@
import path from 'path'
import fs from 'fs'
import { getTmpSessionsFolderPath } from '.'
import { MulterFile } from '../types/Upload'
import { listFilesInFolder } from '@sasjs/utils'
interface FilenameMapSingle {
fieldName: string
originalName: string
}
interface FilenamesMap {
[key: string]: FilenameMapSingle
}
interface UploadedFiles extends FilenameMapSingle {
fileref: string
filepath: string
count: number
}
/**
* It will create an object that maps hashed file names to the original names
* @param files array of files to be mapped
@@ -24,13 +12,10 @@ interface UploadedFiles extends FilenameMapSingle {
export const makeFilesNamesMap = (files: MulterFile[]) => {
if (!files) return null
const filesNamesMap: FilenamesMap = {}
const filesNamesMap: { [key: string]: string } = {}
for (let file of files) {
filesNamesMap[file.filename] = {
fieldName: file.fieldname,
originalName: file.originalname
}
filesNamesMap[file.filename] = file.fieldname
}
return filesNamesMap
@@ -43,12 +28,17 @@ export const makeFilesNamesMap = (files: MulterFile[]) => {
* @returns generated sas code
*/
export const generateFileUploadSasCode = async (
filesNamesMap: FilenamesMap,
filesNamesMap: any,
sasSessionFolder: string
): Promise<string> => {
let uploadSasCode = ''
let fileCount = 0
const uploadedFiles: UploadedFiles[] = []
let uploadedFilesMap: {
fileref: string
filepath: string
filename: string
count: number
}[] = []
const sasSessionFolderList: string[] = await listFilesInFolder(
sasSessionFolder
@@ -60,32 +50,31 @@ export const generateFileUploadSasCode = async (
if (fileName.includes('req_file')) {
fileCount++
uploadedFiles.push({
uploadedFilesMap.push({
fileref: `_sjs${fileCountString}`,
filepath: `${sasSessionFolder}/${fileName}`,
originalName: filesNamesMap[fileName].originalName,
fieldName: filesNamesMap[fileName].fieldName,
filename: filesNamesMap[fileName],
count: fileCount
})
}
})
for (const uploadedFile of uploadedFiles) {
uploadSasCode += `\nfilename ${uploadedFile.fileref} "${uploadedFile.filepath}";`
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\nfilename ${uploadedMap.fileref} "${uploadedMap.filepath}";`
}
uploadSasCode += `\n%let _WEBIN_FILE_COUNT=${fileCount};`
for (const uploadedFile of uploadedFiles) {
uploadSasCode += `\n%let _WEBIN_FILENAME${uploadedFile.count}=${uploadedFile.originalName};`
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\n%let _WEBIN_FILENAME${uploadedMap.count}=${uploadedMap.filepath};`
}
for (const uploadedFile of uploadedFiles) {
uploadSasCode += `\n%let _WEBIN_FILEREF${uploadedFile.count}=${uploadedFile.fileref};`
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\n%let _WEBIN_FILEREF${uploadedMap.count}=${uploadedMap.fileref};`
}
for (const uploadedFile of uploadedFiles) {
uploadSasCode += `\n%let _WEBIN_NAME${uploadedFile.count}=${uploadedFile.fieldName};`
for (let uploadedMap of uploadedFilesMap) {
uploadSasCode += `\n%let _WEBIN_NAME${uploadedMap.count}=${uploadedMap.filename};`
}
if (fileCount > 0) {

View File

@@ -3,8 +3,6 @@ import Joi from 'joi'
const usernameSchema = Joi.string().alphanum().min(6).max(20)
const passwordSchema = Joi.string().min(6).max(1024)
export const blockFileRegex = /\.(exe|sh|htaccess)$/i
export const authorizeValidation = (data: any): Joi.ValidationResult =>
Joi.object({
username: usernameSchema.required(),
@@ -68,39 +66,15 @@ export const registerClientValidation = (data: any): Joi.ValidationResult =>
clientSecret: Joi.string().required()
}).validate(data)
export const deployValidation = (data: any): Joi.ValidationResult =>
export const getFileDriveValidation = (data: any): Joi.ValidationResult =>
Joi.object({
appLoc: Joi.string().pattern(/^\//).required().min(2),
streamServiceName: Joi.string(),
streamWebFolder: Joi.string(),
streamLogo: Joi.string(),
fileTree: Joi.any().required()
filePath: Joi.string().required()
}).validate(data)
const filePathSchema = Joi.string()
.custom((value, helpers) => {
if (blockFileRegex.test(value)) return helpers.error('string.pattern.base')
return value
})
.required()
.messages({
'string.pattern.base': `Invalid file extension`
})
export const fileBodyValidation = (data: any): Joi.ValidationResult =>
export const updateFileDriveValidation = (data: any): Joi.ValidationResult =>
Joi.object({
filePath: filePathSchema
}).validate(data)
export const fileParamValidation = (data: any): Joi.ValidationResult =>
Joi.object({
_filePath: filePathSchema
}).validate(data)
export const folderParamValidation = (data: any): Joi.ValidationResult =>
Joi.object({
_folderPath: Joi.string()
filePath: Joi.string().required(),
fileContent: Joi.string().required()
}).validate(data)
export const runSASValidation = (data: any): Joi.ValidationResult =>

View File

@@ -14,14 +14,14 @@ services:
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
AUTH_CODE_SECRET: ${AUTH_CODE_SECRET}
DB_CONNECT: mongodb://mongodb:27017/sasjs
SAS_PATH: /usr/server/sasexe/${SAS_EXEC_NAME}
SAS_PATH: /usr/server/sasexe
expose:
- ${PORT_API}
ports:
- ${PORT_API}:${PORT_API}
volumes:
- type: bind
source: ${SAS_EXEC_PATH}
source: ${SAS_EXEC}
target: /usr/server/sasexe
read_only: true
links:

1595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{
"name": "server",
"version": "0.0.51",
"version": "0.0.23",
"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 && cd ..",
"server:start": "cd api && npm run start",
"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}\"",

View File

@@ -1,22 +0,0 @@
### Get Auth Code
POST http://localhost:5000/SASjsApi/auth/authorize
Content-Type: application/json
{
"username": "secretuser",
"password": "secretpassword",
"client_id": "clientID1"
}
### Exchange AuthCode with Access/Refresh Tokens
POST http://localhost:5000/SASjsApi/auth/token
Content-Type: application/json
{
"client_id": "clientID1",
"client_secret": "clientID1secret",
"code": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDYxLCJleHAiOjE2MzU4MDQwOTF9.jV7DpBWG7XAGODs22zAW_kWOqVLZvOxmmYJGpSNQ-KM"
}
### Perform logout to deactivate access token instantly
DELETE http://localhost:5000/SASjsApi/auth/logout

View File

@@ -1,48 +0,0 @@
### Get contents of folder
GET http://localhost:5000/SASjsApi/drive/folder?_path=/Public/app/react-seed-app/services/web
###
POST http://localhost:5000/SASjsApi/drive/deploy
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDc2LCJleHAiOjE2MzU4OTA0NzZ9.Cx1F54ILgAUtnkit0Wg1K1YVO2RdNjOnTKdPhUtDm5I
### multipart upload to sas server file
POST http://localhost:5000/SASjsApi/drive/file
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="filePath"
/saad/files/new.sas
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="sample_new.sas"
Content-Type: application/octet-stream
< ./sample.sas
------WebKitFormBoundary7MA4YWxkTrZu0gW--
### multipart upload to sas server file text
POST http://localhost:5000/SASjsApi/drive/file
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW \n
Content-Disposition: form-data; name="filePath"
/saad/files/new2.sas
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="sample_new.sas"
Content-Type: text/plain
SOME CONTENTS OF SAS FILE IN REQUEST
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Users
"username": "username1",
"password": "some password",
"username": "username2",
"password": "some password",
Admins
"username": "secretuser",
"password": "secretpassword",

View File

@@ -1 +0,0 @@
some code of sas

View File

@@ -1,2 +0,0 @@
### Get current user's info via access token
GET http://localhost:5000/SASjsApi/session

View File

@@ -1,25 +0,0 @@
### testing upload file example
POST http://localhost:5000/SASjsApi/stp/execute/?_program=/Public/app/viya/services/editors/loadfile&table=DCCONFIG.MPE_X_TEST
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarynkYOqevUMKZrXeAy
------WebKitFormBoundarynkYOqevUMKZrXeAy
Content-Disposition: form-data; name="fileSome11"; filename="DCCONFIG.MPE_X_TEST.xlsx"
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
------WebKitFormBoundarynkYOqevUMKZrXeAy
Content-Disposition: form-data; name="fileSome22"; filename="DCCONFIG.MPE_X_TEST.xlsx.csv"
Content-Type: application/csv
_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM
,0,this is dummy data 321,Option 1,42,1960-02-12,1960-01-01 00:00:42,00:00:42,3,44
,1,more dummy data 123,Option 2,42,1960-02-12,1960-01-01 00:00:42,00:07:02,3,44
,1039,39 bottles of beer on the wall,Option 1,0.8716847965827607,1962-05-30,1960-01-01 00:05:21,00:01:30,89,6
,1045,45 bottles of beer on the wall,Option 1,0.7279699667021492,1960-03-24,1960-01-01 07:18:54,00:01:08,89,83
,1047,47 bottles of beer on the wall,Option 1,0.6224654082313484,1961-06-07,1960-01-01 09:45:23,00:01:33,76,98
,1048,48 bottles of beer on the wall,Option 1,0.0874847523344144,1962-03-01,1960-01-01 13:06:13,00:00:02,76,63
------WebKitFormBoundarynkYOqevUMKZrXeAy
Content-Disposition: form-data; name="_debug"
131
------WebKitFormBoundarynkYOqevUMKZrXeAy--

View File

@@ -1,10 +0,0 @@
### Create User
POST http://localhost:5000/SASjsApi/user
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InNlY3JldHVzZXIiLCJpc2FkbWluIjp0cnVlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODAzOTc3LCJleHAiOjE2MzU4OTAzNzd9.f-FLgLwryKvB5XrihdzaGZajO3d5E5OHEEuJI_03GRI
Content-Type: application/json
{
"displayname": "User 2",
"username": "username2",
"password": "some password"
}

View File

@@ -23,7 +23,7 @@ Content-Type: application/json
"client_secret": "newClientSecret"
}
###
POST http://localhost:5000/SASjsApi/auth/authorize
POST https://sas.analytium.co.uk:5002/SASjsApi/auth/authorize
Content-Type: application/json
{
@@ -45,41 +45,6 @@ Content-Type: application/json
###
DELETE http://localhost:5000/SASjsApi/auth/logout
###
GET http://localhost:5000/SASjsApi/session
### multipart upload to sas server file
POST http://localhost:5000/SASjsApi/drive/file
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="filePath"
/saad/files/new.sas
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="sample_new.sas"
Content-Type: application/octet-stream
< ./sample.sas
------WebKitFormBoundary7MA4YWxkTrZu0gW--
### multipart upload to sas server file text
POST http://localhost:5000/SASjsApi/drive/file
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW \n
Content-Disposition: form-data; name="filePath"
/saad/files/new2.sas
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="sample_new.sas"
Content-Type: text/plain
SOME CONTENTS OF SAS FILE IN REQUEST
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Users
"username": "username1",

View File

@@ -1,4 +0,0 @@
{
"presets": ["@babel/env", "@babel/react", "@babel/preset-typescript"],
"plugins": ["@babel/plugin-proposal-class-properties"]
}

View File

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

View File

@@ -1 +0,0 @@
v16.14.0

35413
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
"start": "npx webpack-dev-server --config webpack.dev.ts --hot",
"build": "npx webpack --config webpack.prod.ts"
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"dependencies": {
"@emotion/react": "^11.4.1",
@@ -20,43 +22,19 @@
"@types/jest": "^26.0.24",
"@types/node": "^12.20.28",
"@types/react": "^17.0.27",
"axios": "^0.24.0",
"@types/react-dom": "^17.0.9",
"axios": "^0.22.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^5.3.0"
"react-router-dom": "^5.3.0",
"react-scripts": "4.0.3",
"typescript": "^4.4.3"
},
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/node": "^7.16.0",
"@babel/plugin-proposal-class-properties": "^7.16.0",
"@babel/preset-env": "^7.16.4",
"@babel/preset-react": "^7.16.0",
"@babel/preset-typescript": "^7.16.0",
"@types/dotenv-webpack": "^7.0.3",
"@types/prismjs": "^1.16.6",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
"@types/react-router-dom": "^5.3.1",
"babel-loader": "^8.2.3",
"babel-plugin-prismjs": "^2.1.0",
"copy-webpack-plugin": "^10.0.0",
"css-loader": "^6.5.1",
"dotenv-webpack": "^7.1.0",
"eslint": "^8.5.0",
"eslint-config-react-app": "^7.0.0",
"eslint-webpack-plugin": "^3.1.1",
"file-loader": "^6.2.0",
"html-webpack-plugin": "5.5.0",
"path": "0.12.7",
"prettier": "^2.4.1",
"sass": "^1.44.0",
"sass-loader": "^12.3.0",
"style-loader": "^3.3.1",
"ts-loader": "^9.2.6",
"typescript": "^4.5.2",
"webpack": "5.64.3",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "4.7.4"
"prettier": "^2.4.1"
},
"eslintConfig": {
"extends": [

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="favicon.ico" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="SASjs Server Web Interface" />
@@ -10,7 +10,7 @@
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="manifest.json" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -5,13 +5,6 @@ import AppBar from '@mui/material/AppBar'
import Toolbar from '@mui/material/Toolbar'
import Tabs from '@mui/material/Tabs'
import Tab from '@mui/material/Tab'
import Button from '@mui/material/Button'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
const Header = (props: any) => {
const history = useHistory()
@@ -28,10 +21,10 @@ const Header = (props: any) => {
>
<Toolbar variant="dense">
<img
src="logo.png"
src="logo-white.png"
alt="logo"
style={{
width: '35px',
width: '50px',
cursor: 'pointer',
marginRight: '25px'
}}
@@ -59,28 +52,6 @@ const Header = (props: any) => {
component={Link}
/>
</Tabs>
<Button
href={`${baseUrl}/SASjsApi`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNewIcon />}
>
API Docs
</Button>
<Button
href={`${baseUrl}/AppStream`}
target="_blank"
rel="noreferrer"
variant="contained"
color="primary"
size="large"
endIcon={<OpenInNewIcon />}
>
App Stream
</Button>
</Toolbar>
</AppBar>
)

View File

@@ -8,10 +8,11 @@ const headers = {
Accept: 'application/json',
'Content-Type': 'application/json'
}
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const { NODE_ENV, REACT_APP_PORT_API } = process.env
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
NODE_ENV === 'development'
? `http://localhost:${REACT_APP_PORT_API ?? 5000}`
: ''
const getAuthCode = async (credentials: any) => {
return fetch(`${baseUrl}/SASjsApi/auth/authorize`, {
@@ -45,7 +46,7 @@ const Login = ({ setTokens, getCodeOnly }: any) => {
error = false
setErrorMessage('')
e.preventDefault()
let clientId = process.env.CLIENT_ID
let { REACT_APP_CLIENT_ID: clientId } = process.env
if (getCodeOnly) {
const params = new URLSearchParams(location.search)

View File

@@ -36,10 +36,11 @@ export default function useTokens() {
}
}
const NODE_ENV = process.env.NODE_ENV
const PORT_API = process.env.PORT_API
const { NODE_ENV, REACT_APP_PORT_API } = process.env
const baseUrl =
NODE_ENV === 'development' ? `http://localhost:${PORT_API ?? 5000}` : ''
NODE_ENV === 'development'
? `http://localhost:${REACT_APP_PORT_API ?? 5000}`
: ''
const isAbsoluteURLRegex = /^(?:\w+:)\/\//

View File

@@ -1,6 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import React, { useState } from 'react'
import CssBaseline from '@mui/material/CssBaseline'
import Box from '@mui/material/Box'
@@ -8,93 +6,13 @@ import Box from '@mui/material/Box'
import SideBar from './sideBar'
import Main from './main'
export interface TreeNode {
name: string
relativePath: string
absolutePath: string
children: Array<TreeNode>
}
const Drive = () => {
const location = useLocation()
const baseUrl = window.location.origin
const [selectedFilePath, setSelectedFilePath] = useState('')
const [directoryData, setDirectoryData] = useState<TreeNode | null>(null)
const setFilePathOnMount = useCallback(() => {
const queryParams = new URLSearchParams(location.search)
setSelectedFilePath(queryParams.get('filePath') ?? '')
}, [location.search])
useEffect(() => {
axios
.get(`/SASjsApi/drive/fileTree`)
.then((res: any) => {
if (res.data && res.data?.status === 'success') {
setDirectoryData(res.data.tree)
}
})
.catch((err) => {
console.log(err)
})
setFilePathOnMount()
}, [setFilePathOnMount])
const handleSelect = (node: TreeNode) => {
if (node.children.length) return
if (!node.name.includes('.')) return
window.history.pushState(
'',
'',
`${baseUrl}/#/SASjsDrive?filePath=${node.relativePath}`
)
setSelectedFilePath(node.relativePath)
}
const removeFileFromTree = (path: string) => {
if (directoryData) {
const newTree = JSON.parse(JSON.stringify(directoryData)) as TreeNode
findAndRemoveNode(newTree, newTree, path)
setDirectoryData(newTree)
}
}
const findAndRemoveNode = (
node: TreeNode,
parentNode: TreeNode,
path: string
) => {
if (node.relativePath === path) {
removeNodeFromParent(parentNode, path)
return true
}
if (Array.isArray(node.children)) {
for (let i = 0; i < node.children.length; i++) {
if (findAndRemoveNode(node.children[i], node, path)) return
}
}
}
const removeNodeFromParent = (parent: TreeNode, path: string) => {
const index = parent.children.findIndex(
(node) => node.relativePath === path
)
if (index !== -1) {
parent.children.splice(index, 1)
}
}
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<SideBar directoryData={directoryData} handleSelect={handleSelect} />
<Main
selectedFilePath={selectedFilePath}
removeFileFromTree={removeFileFromTree}
/>
<SideBar setSelectedFilePath={setSelectedFilePath} />
<Main selectedFilePath={selectedFilePath} />
</Box>
)
}

View File

@@ -11,12 +11,7 @@ import Button from '@mui/material/Button'
import Toolbar from '@mui/material/Toolbar'
import CircularProgress from '@mui/material/CircularProgress'
type Props = {
selectedFilePath: string
removeFileFromTree: (path: string) => void
}
const Main = (props: Props) => {
const Main = (props: any) => {
const baseUrl = window.location.origin
const [isLoading, setIsLoading] = useState(false)
@@ -28,9 +23,9 @@ const Main = (props: Props) => {
if (props.selectedFilePath) {
setIsLoading(true)
axios
.get(`/SASjsApi/drive/file?_filePath=${props.selectedFilePath}`)
.get(`/SASjsApi/drive/file?filePath=${props.selectedFilePath}`)
.then((res: any) => {
setFileContent(res.data)
setFileContent(res.data.fileContent)
})
.catch((err) => {
console.log(err)
@@ -41,41 +36,17 @@ const Main = (props: Props) => {
}
}, [props.selectedFilePath])
const handleDeleteBtnClick = () => {
setIsLoading(true)
const filePath = props.selectedFilePath
axios
.delete(`/SASjsApi/drive/file?_filePath=${filePath}`)
.then((res) => {
setFileContent('')
props.removeFileFromTree(filePath)
window.history.pushState('', '', `${baseUrl}/#/SASjsDrive`)
})
.catch((err) => {
console.log(err)
})
.finally(() => {
setIsLoading(false)
})
}
const handleEditSaveBtnClick = () => {
if (!editMode) {
setFileContentBeforeEdit(fileContent)
setEditMode(true)
} else {
setIsLoading(true)
const formData = new FormData()
const stringBlob = new Blob([fileContent], { type: 'text/plain' })
formData.append('file', stringBlob, 'filename.sas')
formData.append('filePath', props.selectedFilePath)
axios
.patch(`/SASjsApi/drive/file`, formData)
.patch(`/SASjsApi/drive/file`, {
filePath: props.selectedFilePath,
fileContent: fileContent
})
.then((res) => {
setEditMode(false)
})
@@ -137,13 +108,6 @@ const Main = (props: Props) => {
direction="row"
sx={{ justifyContent: 'center', marginTop: '20px' }}
>
<Button
variant="contained"
onClick={handleDeleteBtnClick}
disabled={isLoading || !props?.selectedFilePath}
>
Delete
</Button>
<Button
variant="contained"
onClick={handleEditSaveBtnClick}

View File

@@ -1,4 +1,6 @@
import React from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import axios from 'axios'
import { useLocation } from 'react-router-dom'
import { makeStyles } from '@mui/styles'
@@ -14,7 +16,12 @@ import TreeItem from '@mui/lab/TreeItem'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import { TreeNode } from '.'
interface TreeNode {
name: string
relativePath: string
absolutePath: string
children: Array<TreeNode>
}
const useStyles = makeStyles(() => ({
root: {
@@ -29,14 +36,44 @@ const useStyles = makeStyles(() => ({
const drawerWidth = 240
type Props = {
directoryData: TreeNode | null
handleSelect: (node: TreeNode) => void
}
const SideBar = ({ directoryData, handleSelect }: Props) => {
const SideBar = (props: any) => {
const location = useLocation()
const baseUrl = window.location.origin
const classes = useStyles()
const { setSelectedFilePath } = props
const [directoryData, setDirectoryData] = useState<TreeNode | null>(null)
const setFilePathOnMount = useCallback(() => {
const queryParams = new URLSearchParams(location.search)
setSelectedFilePath(queryParams.get('filePath'))
}, [location.search, setSelectedFilePath])
useEffect(() => {
axios
.get(`/SASjsApi/drive/fileTree`)
.then((res: any) => {
if (res.data && res.data?.status === 'success') {
setDirectoryData(res.data.tree)
}
})
.catch((err) => {
console.log(err)
})
setFilePathOnMount()
}, [setFilePathOnMount])
const handleSelect = (node: TreeNode) => {
if (!node.children.length) {
window.history.pushState(
'',
'',
`${baseUrl}/#/SASjsDrive?filePath=${node.relativePath}`
)
setSelectedFilePath(node.relativePath)
}
}
const renderTree = (nodes: TreeNode) => (
<TreeItem
classes={{ root: classes.root }}
@@ -68,15 +105,12 @@ const SideBar = ({ directoryData, handleSelect }: Props) => {
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
{directoryData && (
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
defaultExpanded={[directoryData.relativePath]}
>
{renderTree(directoryData)}
</TreeView>
)}
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
>
{directoryData && renderTree(directoryData)}
</TreeView>
</Box>
</Drawer>
)

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'
import axios from 'axios'
import Box from '@mui/material/Box'
import { Button, Paper, Stack, Tab, Tooltip } from '@mui/material'
import { Button, Paper, Stack, Tab } from '@mui/material'
import { makeStyles } from '@mui/styles'
import Editor, { OnMount } from '@monaco-editor/react'
import { useLocation } from 'react-router-dom'
@@ -15,17 +15,6 @@ const useStyles = makeStyles(() => ({
'&.Mui-selected': {
color: 'black'
}
},
subMenu: {
marginTop: '25px',
display: 'flex',
justifyContent: 'center'
},
runButton: {
display: 'flex',
alignItems: 'center',
padding: '5px 5px',
minWidth: 'unset'
}
}))
@@ -33,10 +22,8 @@ const Studio = () => {
const location = useLocation()
const [fileContent, setFileContent] = useState('')
const [log, setLog] = useState('')
const [ctrlPressed, setCtrlPressed] = useState(false)
const [webout, setWebout] = useState('')
const [tab, setTab] = React.useState('1')
const handleTabChange = (_e: any, newValue: string) => {
setTab(newValue)
}
@@ -59,36 +46,30 @@ const Studio = () => {
axios
.post(`/SASjsApi/code/execute`, { code })
.then((res: any) => {
const parsedLog = res?.data?.log
.map((logLine: any) => logLine.line)
.join('\n')
setLog(`<div><h2>SAS Log</h2><pre>${res?.data?.log}</pre></div>`)
setLog(parsedLog)
let weboutString: string
try {
weboutString = res.data.webout
.split('>>weboutBEGIN<<')[1]
.split('>>weboutEND<<')[0]
} catch (_) {
weboutString = res?.data?.webout ?? ''
}
setWebout(`${res.data?._webout}`)
let webout: string
try {
webout = JSON.stringify(JSON.parse(weboutString), null, 4)
} catch (_) {
webout = weboutString
}
setWebout(`<pre><code>${webout}</code></pre>`)
setTab('2')
// Scroll to bottom of log
window.scrollTo(0, document.body.scrollHeight)
})
.catch((err) => console.log(err))
}
const handleKeyDown = (event: any) => {
if (event.ctrlKey) {
if (event.key === 'v') {
setCtrlPressed(false)
}
if (event.key === 'Enter') runCode(getSelection() || fileContent)
if (!ctrlPressed) setCtrlPressed(true)
}
}
const handleKeyUp = (event: any) => {
if (!event.ctrlKey && ctrlPressed) setCtrlPressed(false)
}
useEffect(() => {
const content = localStorage.getItem('fileContent') ?? ''
setFileContent(content)
@@ -112,89 +93,73 @@ const Studio = () => {
}, [location.search])
const classes = useStyles()
return (
<Box
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
sx={{ width: '100%', typography: 'body1', marginTop: '50px' }}
>
<TabContext value={tab}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider'
}}
style={{ position: 'fixed', background: 'white', width: '100%' }}
>
<TabList onChange={handleTabChange} centered>
<Tab className={classes.root} label="Code" value="1" />
<Tab className={classes.root} label="Log" value="2" />
<Tooltip title="Displays content from the _webout fileref">
<Tab className={classes.root} label="Webout" value="3" />
</Tooltip>
</TabList>
</Box>
<TabPanel style={{ paddingBottom: 0 }} value="1">
<div className={classes.subMenu}>
<Tooltip title="CTRL+ENTER will also run SAS code">
<Button onClick={handleRunBtnClick} className={classes.runButton}>
<img
draggable="false"
style={{ width: '25px' }}
src="/running-sas.png"
></img>
<span style={{ fontSize: '12px' }}>RUN</span>
</Button>
</Tooltip>
</div>
{/* <Toolbar /> */}
<Paper
<>
<br />
<br />
<br />
<Box sx={{ width: '100%', typography: 'body1' }}>
<TabContext value={tab}>
<Box
sx={{
height: 'calc(100vh - 170px)',
padding: '10px',
overflow: 'auto',
position: 'relative'
borderBottom: 1,
borderColor: 'divider'
}}
elevation={3}
style={{ position: 'fixed', background: 'white', width: '100%' }}
>
<Editor
height="98%"
value={fileContent}
onMount={handleEditorDidMount}
options={{ readOnly: ctrlPressed }}
onChange={(val) => {
if (val) setFileContent(val)
}}
/>
<p
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: -10,
textAlign: 'center',
fontSize: '13px'
<TabList onChange={handleTabChange} centered>
<Tab className={classes.root} label="Code" value="1" />
<Tab className={classes.root} label="Log" value="2" />
<Tab className={classes.root} label="Webout" value="3" />
</TabList>
</Box>
<TabPanel value="1">
{/* <Toolbar /> */}
<Paper
sx={{
height: '70vh',
marginTop: '50px',
padding: '10px',
overflow: 'auto',
position: 'relative'
}}
elevation={3}
>
Press CTRL + ENTER to run SAS code
</p>
</Paper>
</TabPanel>
<TabPanel value="2">
<div style={{ marginTop: '50px' }}>
<h2>SAS Log</h2>
<pre>{log}</pre>
</div>
</TabPanel>
<TabPanel value="3">
<div style={{ marginTop: '50px' }}>
<pre>{webout}</pre>
</div>
</TabPanel>
</TabContext>
</Box>
<Editor
height="95%"
value={fileContent}
onMount={handleEditorDidMount}
onChange={(val) => {
if (val) setFileContent(val)
}}
/>
</Paper>
<Stack
spacing={3}
direction="row"
sx={{ justifyContent: 'center', marginTop: '20px' }}
>
<Button variant="contained" onClick={handleRunBtnClick}>
Run SAS Code
</Button>
</Stack>
</TabPanel>
<TabPanel value="2">
<div
id="sas_log"
style={{ marginTop: '50px' }}
dangerouslySetInnerHTML={{ __html: log }}
/>
</TabPanel>
<TabPanel value="3">
<div
style={{ marginTop: '50px' }}
dangerouslySetInnerHTML={{ __html: webout }}
/>
</TabPanel>
</TabContext>
</Box>
</>
)
}

View File

@@ -1,60 +0,0 @@
import path from 'path'
import { Configuration } from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import CopyPlugin from 'copy-webpack-plugin'
import dotenv from 'dotenv-webpack'
const config: Configuration = {
entry: path.join(__dirname, 'src', 'index.tsx'),
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx']
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: ['babel-loader']
},
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: [
{
loader: 'ts-loader',
options: {
compilerOptions: {
noEmit: false
}
}
}
]
},
{
test: /\.css$/,
exclude: ['/node_modules/'],
use: ['style-loader', 'css-loader']
},
{
test: /\.scss$/,
exclude: ['/node_modules/'],
use: ['style-loader', 'css-loader', 'sass-loader']
},
{
test: /\.(jpg|jpeg|png|gif|mp3|svg)$/,
use: ['file-loader']
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'src', 'index.html')
}),
new CopyPlugin({
patterns: [{ from: 'public' }]
}),
new dotenv()
]
}
export default config

View File

@@ -1,28 +0,0 @@
import path from 'path'
import { Configuration as WebpackConfiguration } from 'webpack'
import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server'
import { merge } from 'webpack-merge'
import common from './webpack.common'
interface Configuration extends WebpackConfiguration {
devServer?: WebpackDevServerConfiguration
}
const devConfig: Configuration = merge(common, {
mode: 'development',
output: {
path: path.join(__dirname, 'build'),
filename: 'index.bundle.js',
publicPath: '/'
},
devServer: {
static: {
directory: path.join(__dirname, 'build')
},
historyApiFallback: true,
port: 3000
}
})
export default devConfig

View File

@@ -1,19 +0,0 @@
import path from 'path'
import { Configuration } from 'webpack'
import { merge } from 'webpack-merge'
import common from './webpack.common'
const prodConfig: Configuration = merge(common, {
mode: 'production',
output: {
path: path.join(__dirname, 'build'),
filename: 'index.bundle.js',
publicPath: './'
},
performance: {
hints: false
}
})
export default prodConfig