mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d0fdbb02 | ||
|
|
2c34395110 | ||
|
|
534e4e5bf3 | ||
|
|
6146372eba | ||
|
|
aaa469a142 | ||
|
|
4fd5bf948e | ||
|
|
99f91fbce2 | ||
|
|
b0fb858c49 | ||
|
|
83959ef99e | ||
|
|
08087495d3 | ||
|
|
3f68474839 | ||
|
|
f26886f84d | ||
|
|
ddd50eac8e | ||
|
|
bba3e8d272 | ||
|
|
30944bfa18 | ||
|
|
8822de95df | ||
|
|
02a242fe4b | ||
|
|
1beac914db | ||
|
|
a45b42107e | ||
| 3d89b753f0 | |||
| fb77d99177 | |||
| fa627aabf9 | |||
|
|
fd2629862f | ||
|
|
75291f9397 | ||
|
|
99fb5f4b2b | ||
|
|
5dc3deeb11 | ||
|
|
6b708fcad3 | ||
|
|
bc0ff84d8d | ||
|
|
1ff6965dd2 | ||
|
|
d6fa877941 | ||
|
|
940f705f5d | ||
|
|
7a6e6c8bec | ||
|
|
67d200d817 | ||
|
|
a0c27ea8d3 | ||
|
|
3d583ff21d | ||
|
|
7072e282b1 | ||
|
|
145ac45036 | ||
|
|
698180ab7e | ||
|
|
0f4e38d51d | ||
|
|
e76283daa4 | ||
|
|
6ab42ca486 |
67
CHANGELOG.md
67
CHANGELOG.md
@@ -2,6 +2,73 @@
|
||||
|
||||
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.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)
|
||||
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
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.
|
||||
110
README.md
110
README.md
@@ -1,54 +1,21 @@
|
||||
# SASjs Server
|
||||
|
||||
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or it could even run locally on your desktop. It provides the following functionality:
|
||||
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or locally on your desktop. It provides:
|
||||
|
||||
- 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).
|
||||
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 authentiation, and a database)
|
||||
SASjs Server is available in two modes - Desktop (without authentication) and Server (with authentication, and a database)
|
||||
|
||||
## Installation
|
||||
|
||||
## Configuration
|
||||
Installation can be made programmatically using command line, or by manually downloading and running the executable.
|
||||
|
||||
When launching the app, it will make use of specific environment variables. These can be set in the following places:
|
||||
|
||||
- Configured globally in /etc/environment file
|
||||
- Export in terminal or shell script (`export VAR=VALUE`)
|
||||
- Prepend in command
|
||||
- Enter in the `.env` file alongside the executable
|
||||
|
||||
Example variables:
|
||||
|
||||
```
|
||||
MODE=[desktop|server] default considered as desktop
|
||||
CORS=[disable|enable] default considered as disable
|
||||
PROTOCOL=[http|https] default considered as http
|
||||
PORT=[5000] default value is 5000
|
||||
PORT_WEB=[port for sasjs web component(react)] default value is 3000
|
||||
SAS_PATH=/path/to/sas/executable.exe
|
||||
DRIVE_PATH=./tmp
|
||||
PROTOCOL=[http|https] default considered as http. Use pems below if htttps.
|
||||
PRIVATE_KEY=privkey.pem
|
||||
FULL_CHAIN=fullchain.pem
|
||||
```
|
||||
|
||||
## Desktop Version
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Download the relevant package from the [releases](https://github.com/sasjs/server/releases) page
|
||||
|
||||
Next, trigger by double clicking (windows) or executing from commandline.
|
||||
|
||||
You are presented with two prompts (if not set as ENV vars):
|
||||
|
||||
- Location of your `sas.exe` / `sas.sh` executable
|
||||
- Path to a filesystem location for Stored Programs and temporary files
|
||||
|
||||
## Programmatic Installation
|
||||
### Programmatic
|
||||
|
||||
Fetch the relevant package from github using `curl`, eg as follows (for linux):
|
||||
|
||||
@@ -59,6 +26,58 @@ unzip linux.zip
|
||||
|
||||
The app can then be launched with `./api-linux` and prompts followed (if ENV vars not set).
|
||||
|
||||
### Manual
|
||||
|
||||
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.
|
||||
|
||||
You are presented with two prompts (if not set as ENV vars):
|
||||
|
||||
- Location of your `sas.exe` / `sas.sh` executable
|
||||
- Path to a filesystem location for Stored Programs and temporary files
|
||||
|
||||
## ENV Var configuration
|
||||
|
||||
When launching the app, it will make use of specific environment variables. These can be set in the following places:
|
||||
|
||||
- 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
|
||||
|
||||
Example contents of a `.env` file:
|
||||
|
||||
```
|
||||
MODE=desktop # options: [desktop|server] default: `desktop`
|
||||
CORS=disable # options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
|
||||
WHITELIST= # options: <http://localhost:3000 https://abc.com ...> space separated urls
|
||||
PROTOCOL=http # options: [http|https] default: http
|
||||
PORT=5000 # default: 5000
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Persisting the Session
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -69,7 +88,7 @@ export DRIVE_PATH=./tmp
|
||||
pm2 start api-linux
|
||||
```
|
||||
|
||||
To get the logs (and some usefull commands):
|
||||
To get the logs (and some useful commands):
|
||||
|
||||
```bash
|
||||
pm2 [list|ls|status]
|
||||
@@ -91,11 +110,10 @@ Instead of `app_name` you can pass:
|
||||
- `all` to act on all processes
|
||||
- `id` to act on a specific process id
|
||||
|
||||
|
||||
## Server Version
|
||||
|
||||
The following credentials can be used for the initial connection to SASjs/server. It is recommended to change these on first use.
|
||||
The following credentials can be used for the initial connection to SASjs/server. It is recommended to change these on first use.
|
||||
|
||||
* CLIENTID: `clientID1`
|
||||
* USERNAME: `secretuser`
|
||||
* PASSWORD: `secretpassword`
|
||||
- CLIENTID: `clientID1`
|
||||
- USERNAME: `secretuser`
|
||||
- PASSWORD: `secretpassword`
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
MODE=[desktop|server] default considered as desktop
|
||||
CORS=[disable|enable] default considered as disable
|
||||
CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE
|
||||
WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
|
||||
PROTOCOL=[http|https] default considered as http
|
||||
PRIVATE_KEY=privkey.pem
|
||||
FULL_CHAIN=fullchain.pem
|
||||
PORT=[5000] default value is 5000
|
||||
PORT_WEB=[port for sasjs web component(react)] default value is 3000
|
||||
ACCESS_TOKEN_SECRET=<secret>
|
||||
REFRESH_TOKEN_SECRET=<secret>
|
||||
AUTH_CODE_SECRET=<secret>
|
||||
|
||||
1
api/.nvmrc
Normal file
1
api/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
v16.14.0
|
||||
832
api/package-lock.json
generated
832
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,14 +9,14 @@
|
||||
"prebuild": "npm run initial",
|
||||
"start": "nodemon ./src/server.ts",
|
||||
"build": "rimraf build && tsc",
|
||||
"postbuild": "npm run copy:files",
|
||||
"swagger": "tsoa spec",
|
||||
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
||||
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
|
||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
|
||||
"exe": "npm run build && npm run exe:copy && pkg .",
|
||||
"exe:copy": "npm run public:copy && npm run sasjsbuild:copy && npm run sasjscore:copy && npm run web:copy",
|
||||
"exe": "npm run build && pkg .",
|
||||
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sasjscore: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/",
|
||||
@@ -46,8 +46,9 @@
|
||||
"author": "4GL Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/core": "4.9.0",
|
||||
"@sasjs/utils": "2.34.1",
|
||||
"@sasjs/utils": "2.36.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"joi": "^17.4.2",
|
||||
@@ -56,11 +57,11 @@
|
||||
"mongoose-sequence": "^5.3.1",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.3",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"tsoa": "3.14.1"
|
||||
"swagger-ui-express": "^4.1.6"
|
||||
},
|
||||
"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",
|
||||
@@ -76,12 +77,13 @@
|
||||
"jest": "^27.0.6",
|
||||
"mongodb-memory-server": "^8.0.0",
|
||||
"nodemon": "^2.0.7",
|
||||
"pkg": "^5.4.1",
|
||||
"pkg": "5.5.2",
|
||||
"prettier": "^2.3.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsoa": "3.14.1",
|
||||
"typescript": "^4.3.2"
|
||||
},
|
||||
"configuration": {
|
||||
|
||||
@@ -172,12 +172,20 @@ components:
|
||||
enum:
|
||||
- service
|
||||
type: string
|
||||
MemberType.file:
|
||||
enum:
|
||||
- file
|
||||
type: string
|
||||
ServiceMember:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/MemberType.service'
|
||||
anyOf:
|
||||
-
|
||||
$ref: '#/components/schemas/MemberType.service'
|
||||
-
|
||||
$ref: '#/components/schemas/MemberType.file'
|
||||
code:
|
||||
type: string
|
||||
required:
|
||||
@@ -220,6 +228,7 @@ components:
|
||||
fileTree:
|
||||
$ref: '#/components/schemas/FileTree'
|
||||
required:
|
||||
- appLoc
|
||||
- fileTree
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -233,21 +242,6 @@ 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:
|
||||
@@ -600,6 +594,7 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: 'No content'
|
||||
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: 'Get file from SASjs Drive'
|
||||
tags:
|
||||
- Drive
|
||||
@@ -609,11 +604,57 @@ paths:
|
||||
parameters:
|
||||
-
|
||||
in: query
|
||||
name: filePath
|
||||
required: true
|
||||
name: _filePath
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
example: /Public/somefolder/some.file
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
filePath:
|
||||
type: string
|
||||
delete:
|
||||
operationId: DeleteFile
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
status: {type: string}
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
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: 'Delete file from SASjs Drive'
|
||||
tags:
|
||||
- Drive
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
parameters:
|
||||
-
|
||||
in: query
|
||||
name: _filePath
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
example: /Public/somefolder/some.file
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
filePath:
|
||||
type: string
|
||||
post:
|
||||
operationId: SaveFile
|
||||
responses:
|
||||
@@ -626,7 +667,7 @@ paths:
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {status: success}
|
||||
'400':
|
||||
'403':
|
||||
description: 'File already exists'
|
||||
content:
|
||||
application/json:
|
||||
@@ -635,7 +676,7 @@ 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 provided else API will respond with Bad Request."
|
||||
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
|
||||
@@ -677,8 +718,8 @@ paths:
|
||||
examples:
|
||||
'Example 1':
|
||||
value: {status: success}
|
||||
'400':
|
||||
description: 'Unable to get File'
|
||||
'403':
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -686,19 +727,36 @@ 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: []
|
||||
parameters:
|
||||
-
|
||||
description: 'Location of SAS program'
|
||||
in: query
|
||||
name: _filePath
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
example: /Public/somefolder/some.file.sas
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FilePayload'
|
||||
type: object
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
filePath:
|
||||
type: string
|
||||
required:
|
||||
- file
|
||||
/SASjsApi/drive/filetree:
|
||||
get:
|
||||
operationId: GetFileTree
|
||||
@@ -1054,7 +1112,7 @@ paths:
|
||||
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\nThis behaviour differs for POST requests, in which case the reponse is\nalways JSON."
|
||||
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.'
|
||||
tags:
|
||||
- STP
|
||||
|
||||
@@ -17,6 +17,7 @@ const compiledSystemInit = async (systemInit: string) =>
|
||||
programFolders: [],
|
||||
macroFolders: [],
|
||||
buildSourceFolder: '',
|
||||
binaryFolders: [],
|
||||
macroCorePath
|
||||
}))
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ export const copySASjsCore = async () => {
|
||||
await deleteFolder(sasJSCoreMacros)
|
||||
await createFolder(sasJSCoreMacros)
|
||||
|
||||
console.log('Copying SASjs Core Macros...')
|
||||
|
||||
const foldersToCopy = ['base', 'ddl', 'fcmp', 'lua', 'server']
|
||||
|
||||
await asyncForEach(foldersToCopy, async (coreSubFolder) => {
|
||||
@@ -18,8 +16,6 @@ export const copySASjsCore = async () => {
|
||||
|
||||
await copy(coreSubFolderPath, sasJSCoreMacros)
|
||||
})
|
||||
|
||||
console.log('Macros available at: ', sasJSCoreMacros)
|
||||
}
|
||||
|
||||
copySASjsCore()
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
import path from 'path'
|
||||
import express, { ErrorRequestHandler } from 'express'
|
||||
import morgan from 'morgan'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import dotenv from 'dotenv'
|
||||
import cors from 'cors'
|
||||
|
||||
import { connectDB, getWebBuildFolderPath, setProcessVariables } from './utils'
|
||||
import {
|
||||
connectDB,
|
||||
getWebBuildFolderPath,
|
||||
sasJSCoreMacros,
|
||||
setProcessVariables
|
||||
} from './utils'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app = express()
|
||||
|
||||
const { MODE, CORS, PORT_WEB } = process.env
|
||||
const whiteList = [
|
||||
`http://localhost:${PORT_WEB ?? 3000}`,
|
||||
'https://sas.analytium.co.uk:8343'
|
||||
]
|
||||
const { MODE, CORS, WHITELIST } = process.env
|
||||
|
||||
if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') {
|
||||
console.log('All CORS Requests are enabled')
|
||||
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)
|
||||
app.use(cors({ credentials: true, origin: whiteList }))
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
app.use(cookieParser())
|
||||
app.use(morgan('tiny'))
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
app.use(express.static(path.join(__dirname, '../public')))
|
||||
app.use(express.static(getWebBuildFolderPath()))
|
||||
|
||||
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||
console.error(err.stack)
|
||||
@@ -37,6 +46,12 @@ export default setProcessVariables().then(async () => {
|
||||
const { setupRoutes } = await import('./routes/setupRoutes')
|
||||
setupRoutes(app)
|
||||
|
||||
// should be served after setting up web route
|
||||
// index.html needs to be injected with some js script.
|
||||
app.use(express.static(getWebBuildFolderPath()))
|
||||
|
||||
console.log('sasJSCoreMacros', sasJSCoreMacros)
|
||||
|
||||
app.use(onError)
|
||||
|
||||
await connectDB()
|
||||
|
||||
@@ -13,30 +13,24 @@ import {
|
||||
Get,
|
||||
Patch,
|
||||
UploadedFile,
|
||||
FormField
|
||||
FormField,
|
||||
Delete
|
||||
} from 'tsoa'
|
||||
import { fileExists, createFile, moveFile, createFolder } from '@sasjs/utils'
|
||||
import {
|
||||
fileExists,
|
||||
moveFile,
|
||||
createFolder,
|
||||
deleteFile as deleteFileOnSystem
|
||||
} from '@sasjs/utils'
|
||||
import { createFileTree, ExecutionController, getTreeExample } from './internal'
|
||||
|
||||
import { FileTree, isFileTree, TreeNode } from '../types'
|
||||
import { getTmpFilesFolderPath } from '../utils'
|
||||
|
||||
interface DeployPayload {
|
||||
appLoc?: 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
|
||||
@@ -93,22 +87,45 @@ export class DriveController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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"
|
||||
*/
|
||||
@Get('/file')
|
||||
public async getFile(
|
||||
@Request() request: express.Request,
|
||||
@Query() filePath: string
|
||||
|
||||
@Query() _filePath?: string,
|
||||
@FormField() filePath?: string
|
||||
) {
|
||||
return getFile(request, filePath)
|
||||
return getFile(request, (_filePath ?? 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 provided else API will respond with Bad Request.
|
||||
* But it's required to provide else API will respond with Bad Request.
|
||||
*
|
||||
* @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,
|
||||
@FormField() filePath?: string
|
||||
) {
|
||||
return deleteFile((_filePath ?? 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
|
||||
@@ -118,7 +135,7 @@ export class DriveController {
|
||||
@Example<UpdateFileResponse>({
|
||||
status: 'success'
|
||||
})
|
||||
@Response<UpdateFileResponse>(400, 'File already exists', {
|
||||
@Response<UpdateFileResponse>(403, 'File already exists', {
|
||||
status: 'failure',
|
||||
message: 'File request failed.'
|
||||
})
|
||||
@@ -132,21 +149,29 @@ export class DriveController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>(400, 'Unable to get File', {
|
||||
@Response<UpdateFileResponse>(403, `File doesn't exist`, {
|
||||
status: 'failure',
|
||||
message: 'File request failed.'
|
||||
})
|
||||
@Patch('/file')
|
||||
public async updateFile(
|
||||
@Body() body: FilePayload
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Query() _filePath?: string,
|
||||
@FormField() filePath?: string
|
||||
): Promise<UpdateFileResponse> {
|
||||
return updateFile(body)
|
||||
return updateFile((_filePath ?? filePath)!, file)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +196,7 @@ const deploy = async (data: DeployPayload) => {
|
||||
|
||||
await createFileTree(
|
||||
data.fileTree.members,
|
||||
data.appLoc ? data.appLoc.replace(/^\//, '').split('/') : []
|
||||
data.appLoc.replace(/^\//, '').split('/')
|
||||
).catch((err) => {
|
||||
throw { code: 500, ...execDeployErrorResponse, ...err }
|
||||
})
|
||||
@@ -194,7 +219,32 @@ const getFile = async (req: express.Request, filePath: string) => {
|
||||
throw new Error('File does not exist.')
|
||||
}
|
||||
|
||||
req.res?.download(filePathFull)
|
||||
const extension = path.extname(filePathFull).toLowerCase()
|
||||
if (extension === '.sas') {
|
||||
req.res?.setHeader('Content-type', 'text/plain')
|
||||
}
|
||||
|
||||
req.res?.sendFile(path.resolve(filePathFull))
|
||||
}
|
||||
|
||||
const deleteFile = async (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 delete file outside drive.')
|
||||
}
|
||||
|
||||
if (!(await fileExists(filePathFull))) {
|
||||
throw new Error('File does not exist.')
|
||||
}
|
||||
|
||||
await deleteFileOnSystem(filePathFull)
|
||||
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
const saveFile = async (
|
||||
@@ -222,25 +272,27 @@ const saveFile = async (
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
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 updateFile = async (
|
||||
filePath: string,
|
||||
multerFile: Express.Multer.File
|
||||
): Promise<GetFileResponse> => {
|
||||
const driveFilesPath = getTmpFilesFolderPath()
|
||||
|
||||
await validateFilePath(filePathFull)
|
||||
await createFile(filePathFull, fileContent)
|
||||
const filePathFull = path
|
||||
.join(driveFilesPath, filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
return { status: 'success' }
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'File request failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
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' }
|
||||
}
|
||||
|
||||
const validateFilePath = async (filePath: string) => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
generateFileUploadSasCode,
|
||||
getTmpFilesFolderPath,
|
||||
HTTPHeaders,
|
||||
isDebugOn,
|
||||
sasJSCoreMacros
|
||||
} from '../../utils'
|
||||
|
||||
@@ -118,7 +119,7 @@ ${preProgramVarStatments}
|
||||
${program}`
|
||||
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs && otherArgs.filesNamesMap) {
|
||||
if (otherArgs?.filesNamesMap) {
|
||||
const uploadSasCode = await generateFileUploadSasCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
@@ -160,9 +161,6 @@ ${program}`
|
||||
: await readFile(weboutPath)
|
||||
: ''
|
||||
|
||||
const debugValue =
|
||||
typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug
|
||||
|
||||
// it should be deleted by scheduleSessionDestroy
|
||||
session.inUse = false
|
||||
|
||||
@@ -170,8 +168,7 @@ ${program}`
|
||||
return {
|
||||
httpHeaders,
|
||||
webout,
|
||||
log:
|
||||
(debugValue && debugValue >= 131) || session.crashed ? log : undefined
|
||||
log: isDebugOn(vars) || session.crashed ? log : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +176,7 @@ ${program}`
|
||||
httpHeaders,
|
||||
result: fileResponse
|
||||
? webout
|
||||
: (debugValue && debugValue >= 131) || session.crashed
|
||||
: isDebugOn(vars) || session.crashed
|
||||
? `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
|
||||
: webout
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'path'
|
||||
import { MemberType, FolderMember, ServiceMember, FileTree } from '../../types'
|
||||
import { getTmpFilesFolderPath } from '../../utils/file'
|
||||
import { createFolder, createFile, asyncForEach } from '@sasjs/utils'
|
||||
import path from 'path'
|
||||
|
||||
// REFACTOR: export FileTreeCpntroller
|
||||
export const createFileTree = async (
|
||||
@@ -27,9 +27,13 @@ export const createFileTree = async (
|
||||
(err) => Promise.reject({ error: err, failedToCreate: name })
|
||||
)
|
||||
} else {
|
||||
await createFile(path.join(destinationPath, name), member.code).catch(
|
||||
(err) => Promise.reject({ error: err, failedToCreate: name })
|
||||
)
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PreProgramVars } from '../types'
|
||||
import {
|
||||
getTmpFilesFolderPath,
|
||||
HTTPHeaders,
|
||||
isDebugOn,
|
||||
LogLine,
|
||||
makeFilesNamesMap,
|
||||
parseLogToArray
|
||||
@@ -62,7 +63,9 @@ export class STPController {
|
||||
* 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.
|
||||
*
|
||||
* This behaviour differs for POST requests, in which case the reponse is
|
||||
* 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.
|
||||
@@ -140,6 +143,13 @@ const executeReturnRaw = async (
|
||||
query
|
||||
)) as ExecuteReturnRaw
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -43,7 +43,9 @@ const authenticateToken = (
|
||||
}
|
||||
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader?.split(' ')[1]
|
||||
const token =
|
||||
authHeader?.split(' ')[1] ??
|
||||
(tokenType === 'accessToken' ? req.cookies.accessToken : '')
|
||||
if (!token) return res.sendStatus(401)
|
||||
|
||||
jwt.verify(token, key, async (err: any, data: any) => {
|
||||
|
||||
@@ -55,8 +55,9 @@ authRouter.post('/token', async (req, res) => {
|
||||
const controller = new AuthController()
|
||||
try {
|
||||
const response = await controller.token(body)
|
||||
const { accessToken } = response
|
||||
|
||||
res.send(response)
|
||||
res.cookie('accessToken', accessToken).send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import express from 'express'
|
||||
import { deleteFile } from '@sasjs/utils'
|
||||
|
||||
import { publishAppStream } from '../appStream'
|
||||
|
||||
import { multerSingle } from '../../middlewares/multer'
|
||||
import { DriveController } from '../../controllers/'
|
||||
import {
|
||||
getFileDriveValidation,
|
||||
updateFileDriveValidation,
|
||||
uploadFileBodyValidation,
|
||||
uploadFileParamValidation
|
||||
deployValidation,
|
||||
fileBodyValidation,
|
||||
fileParamValidation
|
||||
} from '../../utils'
|
||||
|
||||
const controller = new DriveController()
|
||||
@@ -15,8 +16,16 @@ const controller = new DriveController()
|
||||
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)
|
||||
|
||||
try {
|
||||
const response = await controller.deploy(req.body)
|
||||
const response = await controller.deploy(body)
|
||||
|
||||
const appLoc = body.appLoc.replace(/^\//, '')?.split('/')
|
||||
|
||||
publishAppStream(appLoc)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
@@ -28,11 +37,27 @@ driveRouter.post('/deploy', async (req, res) => {
|
||||
})
|
||||
|
||||
driveRouter.get('/file', async (req, res) => {
|
||||
const { error, value: query } = getFileDriveValidation(req.query)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
const { error: errQ, value: query } = fileParamValidation(req.query)
|
||||
const { error: errB, value: body } = fileBodyValidation(req.body)
|
||||
|
||||
if (errQ && errB) return res.status(400).send(errQ.details[0].message)
|
||||
|
||||
try {
|
||||
await controller.getFile(req, query.filePath)
|
||||
await controller.getFile(req, query._filePath, body.filePath)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.delete('/file', async (req, res) => {
|
||||
const { error: errQ, value: query } = fileParamValidation(req.query)
|
||||
const { error: errB, value: body } = fileBodyValidation(req.body)
|
||||
|
||||
if (errQ && errB) return res.status(400).send(errQ.details[0].message)
|
||||
|
||||
try {
|
||||
const response = await controller.deleteFile(query._filePath, body.filePath)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
@@ -42,12 +67,12 @@ driveRouter.post(
|
||||
'/file',
|
||||
(...arg) => multerSingle('file', arg),
|
||||
async (req, res) => {
|
||||
const { error: errQ, value: query } = uploadFileParamValidation(req.query)
|
||||
const { error: errB, value: body } = uploadFileBodyValidation(req.body)
|
||||
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(errB.details[0].message)
|
||||
return res.status(400).send(errQ.details[0].message)
|
||||
}
|
||||
|
||||
if (!req.file) return res.status(400).send('"file" is not present.')
|
||||
@@ -66,21 +91,33 @@ driveRouter.post(
|
||||
}
|
||||
)
|
||||
|
||||
driveRouter.patch('/file', async (req, res) => {
|
||||
const { error, value: body } = updateFileDriveValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
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)
|
||||
|
||||
try {
|
||||
const response = await controller.updateFile(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
if (errQ && errB) {
|
||||
if (req.file) await deleteFile(req.file.path)
|
||||
return res.status(400).send(errQ.details[0].message)
|
||||
}
|
||||
|
||||
delete err.code
|
||||
if (!req.file) return res.status(400).send('"file" is not present.')
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
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) => {
|
||||
try {
|
||||
|
||||
@@ -74,14 +74,19 @@ describe('files', () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/drive/deploy')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(payload)
|
||||
.send({ appLoc: '/Public', fileTree: payload })
|
||||
|
||||
expect(res.statusCode).toEqual(400)
|
||||
expect(res.body).toEqual({
|
||||
status: 'failure',
|
||||
message: 'Provided not supported data format.',
|
||||
example: getTreeExample()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('should respond with payload example if valid payload was not provided', async () => {
|
||||
@@ -140,11 +145,11 @@ describe('files', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with payload example if valid payload was not provided', async () => {
|
||||
it('should successfully deploy if valid payload was provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/drive/deploy')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ fileTree: getTreeExample() })
|
||||
.send({ appLoc: '/public', fileTree: getTreeExample() })
|
||||
|
||||
expect(res.statusCode).toEqual(200)
|
||||
expect(res.text).toEqual(
|
||||
@@ -154,6 +159,7 @@ describe('files', () => {
|
||||
|
||||
const testJobFolder = path.join(
|
||||
getTmpFilesFolderPath(),
|
||||
'public',
|
||||
'jobs',
|
||||
'extract'
|
||||
)
|
||||
@@ -254,7 +260,7 @@ describe('files', () => {
|
||||
.attach('file', fileToAttachPath)
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"filePath" is required`)
|
||||
expect(res.text).toEqual(`"_filePath" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
@@ -263,9 +269,9 @@ describe('files', () => {
|
||||
const pathToUpload = '/my/path/code.oth'
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/drive/file')
|
||||
.post(`/SASjsApi/drive/file?_filePath=${pathToUpload}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.field('filePath', pathToUpload)
|
||||
// .field('filePath', pathToUpload)
|
||||
.attach('file', fileToAttachPath)
|
||||
.expect(400)
|
||||
|
||||
@@ -321,6 +327,166 @@ describe('files', () => {
|
||||
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-${generateTimestamp()}.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.oth'
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/drive/file?_filePath=${pathToUpload}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
// .field('filePath', pathToUpload)
|
||||
.attach('file', fileToAttachPath)
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual('Valid extensions for filePath: .sas')
|
||||
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.oth')
|
||||
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 '.oth' not acceptable. Valid extension(s): .sas`
|
||||
)
|
||||
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(20 * 1024 * 1024))
|
||||
|
||||
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: 10 MB'
|
||||
)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
26
api/src/routes/appStream/index.ts
Normal file
26
api/src/routes/appStream/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import path from 'path'
|
||||
import express from 'express'
|
||||
import { folderExists } from '@sasjs/utils'
|
||||
|
||||
import { getTmpFilesFolderPath } from '../../utils'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
export const publishAppStream = async (appLoc: string[]) => {
|
||||
const appLocUrl = encodeURI(appLoc.join('/'))
|
||||
const appLocPath = appLoc.join(path.sep)
|
||||
|
||||
const pathToDeployment = path.join(
|
||||
getTmpFilesFolderPath(),
|
||||
appLocPath,
|
||||
'services',
|
||||
'webv'
|
||||
)
|
||||
|
||||
if (await folderExists(pathToDeployment)) {
|
||||
router.use(`/${appLocUrl}`, express.static(pathToDeployment))
|
||||
console.log('Serving Stream App: ', appLocUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -2,8 +2,15 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ export interface FileTree {
|
||||
|
||||
export enum MemberType {
|
||||
folder = 'folder',
|
||||
service = 'service'
|
||||
service = 'service',
|
||||
file = 'file'
|
||||
}
|
||||
|
||||
export interface FolderMember {
|
||||
@@ -15,7 +16,7 @@ export interface FolderMember {
|
||||
|
||||
export interface ServiceMember {
|
||||
name: string
|
||||
type: MemberType.service
|
||||
type: MemberType.service | MemberType.file
|
||||
code: string
|
||||
}
|
||||
|
||||
@@ -36,7 +37,9 @@ const isFolderMember = (arg: any): arg is FolderMember =>
|
||||
Array.isArray(arg.members) &&
|
||||
arg.members.filter(
|
||||
(member: FolderMember | ServiceMember) =>
|
||||
!isFolderMember(member) && !isServiceMember(member)
|
||||
!isFolderMember(member) &&
|
||||
!isServiceMember(member) &&
|
||||
!isFileMember(member)
|
||||
).length === 0
|
||||
|
||||
const isServiceMember = (arg: any): arg is ServiceMember =>
|
||||
@@ -45,3 +48,10 @@ const isServiceMember = (arg: any): arg is ServiceMember =>
|
||||
arg.type === MemberType.service &&
|
||||
arg.code &&
|
||||
typeof arg.code === 'string'
|
||||
|
||||
const isFileMember = (arg: any): arg is ServiceMember =>
|
||||
arg &&
|
||||
typeof arg.name === 'string' &&
|
||||
arg.type === MemberType.file &&
|
||||
arg.code &&
|
||||
typeof arg.code === 'string'
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './file'
|
||||
export * from './generateAccessToken'
|
||||
export * from './generateAuthCode'
|
||||
export * from './generateRefreshToken'
|
||||
export * from './isDebugOn'
|
||||
export * from './getCertificates'
|
||||
export * from './getDesktopFields'
|
||||
export * from './parseLogToArray'
|
||||
|
||||
8
api/src/utils/isDebugOn.ts
Normal file
8
api/src/utils/isDebugOn.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const makeFilesNamesMap = (files: MulterFile[]) => {
|
||||
const filesNamesMap: { [key: string]: string } = {}
|
||||
|
||||
for (let file of files) {
|
||||
filesNamesMap[file.filename] = file.fieldname
|
||||
filesNamesMap[file.filename] = file.originalname
|
||||
}
|
||||
|
||||
return filesNamesMap
|
||||
@@ -66,7 +66,7 @@ export const generateFileUploadSasCode = async (
|
||||
uploadSasCode += `\n%let _WEBIN_FILE_COUNT=${fileCount};`
|
||||
|
||||
for (let uploadedMap of uploadedFilesMap) {
|
||||
uploadSasCode += `\n%let _WEBIN_FILENAME${uploadedMap.count}=${uploadedMap.filepath};`
|
||||
uploadSasCode += `\n%let _WEBIN_FILENAME${uploadedMap.count}=${uploadedMap.filename};`
|
||||
}
|
||||
|
||||
for (let uploadedMap of uploadedFilesMap) {
|
||||
@@ -74,7 +74,7 @@ export const generateFileUploadSasCode = async (
|
||||
}
|
||||
|
||||
for (let uploadedMap of uploadedFilesMap) {
|
||||
uploadSasCode += `\n%let _WEBIN_NAME${uploadedMap.count}=${uploadedMap.filename};`
|
||||
uploadSasCode += `\n%let _WEBIN_NAME${uploadedMap.count}=${uploadedMap.filepath};`
|
||||
}
|
||||
|
||||
if (fileCount > 0) {
|
||||
|
||||
@@ -66,25 +66,20 @@ export const registerClientValidation = (data: any): Joi.ValidationResult =>
|
||||
clientSecret: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
export const getFileDriveValidation = (data: any): Joi.ValidationResult =>
|
||||
export const deployValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
filePath: Joi.string().required()
|
||||
appLoc: Joi.string().pattern(/^\//).required().min(2),
|
||||
fileTree: Joi.any().required()
|
||||
}).validate(data)
|
||||
|
||||
export const updateFileDriveValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
filePath: Joi.string().required(),
|
||||
fileContent: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
export const uploadFileBodyValidation = (data: any): Joi.ValidationResult =>
|
||||
export const fileBodyValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
filePath: Joi.string().pattern(/.sas$/).required().messages({
|
||||
'string.pattern.base': `Valid extensions for filePath: .sas`
|
||||
})
|
||||
}).validate(data)
|
||||
|
||||
export const uploadFileParamValidation = (data: any): Joi.ValidationResult =>
|
||||
export const fileParamValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_filePath: Joi.string().pattern(/.sas$/).required().messages({
|
||||
'string.pattern.base': `Valid extensions for filePath: .sas`
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.30",
|
||||
"version": "0.0.35",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "0.0.30",
|
||||
"version": "0.0.35",
|
||||
"devDependencies": {
|
||||
"prettier": "^2.3.1",
|
||||
"standard-version": "^9.3.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.30",
|
||||
"version": "0.0.35",
|
||||
"description": "NodeJS wrapper for calling the SAS binary executable",
|
||||
"repository": "https://github.com/sasjs/server",
|
||||
"scripts": {
|
||||
|
||||
27
restClient/stp.rest
Normal file
27
restClient/stp.rest
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
|
||||
### 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="file"; filename="DCCONFIG.MPE_X_TEST.xlsx"
|
||||
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
|
||||
|
||||
------WebKitFormBoundarynkYOqevUMKZrXeAy
|
||||
Content-Disposition: form-data; name="file"; 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--
|
||||
1
web/.nvmrc
Normal file
1
web/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
v16.14.0
|
||||
17
web/package-lock.json
generated
17
web/package-lock.json
generated
@@ -9215,11 +9215,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz",
|
||||
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==",
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
|
||||
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
@@ -18181,9 +18184,9 @@
|
||||
}
|
||||
},
|
||||
"prismjs": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz",
|
||||
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==",
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
|
||||
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
|
||||
@@ -5,6 +5,13 @@ 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()
|
||||
@@ -52,6 +59,18 @@ const Header = (props: any) => {
|
||||
component={Link}
|
||||
/>
|
||||
</Tabs>
|
||||
<Button
|
||||
href={`${baseUrl}/SASjsApi`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
style={{ marginLeft: '50px' }}
|
||||
>
|
||||
API Docs
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import Box from '@mui/material/Box'
|
||||
@@ -6,13 +8,93 @@ 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 setSelectedFilePath={setSelectedFilePath} />
|
||||
<Main selectedFilePath={selectedFilePath} />
|
||||
<SideBar directoryData={directoryData} handleSelect={handleSelect} />
|
||||
<Main
|
||||
selectedFilePath={selectedFilePath}
|
||||
removeFileFromTree={removeFileFromTree}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ import Button from '@mui/material/Button'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
const Main = (props: any) => {
|
||||
type Props = {
|
||||
selectedFilePath: string
|
||||
removeFileFromTree: (path: string) => void
|
||||
}
|
||||
|
||||
const Main = (props: Props) => {
|
||||
const baseUrl = window.location.origin
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -23,9 +28,9 @@ const Main = (props: any) => {
|
||||
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.fileContent)
|
||||
setFileContent(res.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
@@ -36,17 +41,41 @@ const Main = (props: any) => {
|
||||
}
|
||||
}, [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`, {
|
||||
filePath: props.selectedFilePath,
|
||||
fileContent: fileContent
|
||||
})
|
||||
.patch(`/SASjsApi/drive/file`, formData)
|
||||
.then((res) => {
|
||||
setEditMode(false)
|
||||
})
|
||||
@@ -108,6 +137,13 @@ const Main = (props: any) => {
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'center', marginTop: '20px' }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleDeleteBtnClick}
|
||||
disabled={isLoading || !props?.selectedFilePath}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleEditSaveBtnClick}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import React from 'react'
|
||||
|
||||
import { makeStyles } from '@mui/styles'
|
||||
|
||||
@@ -16,12 +14,7 @@ import TreeItem from '@mui/lab/TreeItem'
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||
|
||||
interface TreeNode {
|
||||
name: string
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
children: Array<TreeNode>
|
||||
}
|
||||
import { TreeNode } from '.'
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
root: {
|
||||
@@ -36,44 +29,14 @@ const useStyles = makeStyles(() => ({
|
||||
|
||||
const drawerWidth = 240
|
||||
|
||||
const SideBar = (props: any) => {
|
||||
const location = useLocation()
|
||||
const baseUrl = window.location.origin
|
||||
type Props = {
|
||||
directoryData: TreeNode | null
|
||||
handleSelect: (node: TreeNode) => void
|
||||
}
|
||||
|
||||
const SideBar = ({ directoryData, handleSelect }: Props) => {
|
||||
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 }}
|
||||
@@ -105,12 +68,15 @@ const SideBar = (props: any) => {
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<TreeView
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
>
|
||||
{directoryData && renderTree(directoryData)}
|
||||
</TreeView>
|
||||
{directoryData && (
|
||||
<TreeView
|
||||
defaultCollapseIcon={<ExpandMoreIcon />}
|
||||
defaultExpandIcon={<ChevronRightIcon />}
|
||||
defaultExpanded={[directoryData.relativePath]}
|
||||
>
|
||||
{renderTree(directoryData)}
|
||||
</TreeView>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user