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

Merge pull request #33 from sasjs/executables

Executables
This commit is contained in:
Muhammad Saad
2021-11-14 00:51:07 +05:00
committed by GitHub
37 changed files with 10840 additions and 14651 deletions

View File

@@ -50,6 +50,7 @@ jobs:
run: npm test
env:
CI: true
MODE: 'server'
ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}}
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}

1
.gitignore vendored
View File

@@ -7,4 +7,5 @@ sas/
tmp/
build/
certificates/
executables/
.env

View File

@@ -1,3 +1,4 @@
MODE=[server]
ACCESS_TOKEN_SECRET=<secret>
REFRESH_TOKEN_SECRET=<secret>
AUTH_CODE_SECRET=<secret>

1183
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,21 @@
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --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"
"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 public:copy && npm run web:copy && pkg .",
"public:copy": "cp -r ./public/ ./build/public/",
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/"
},
"bin": "./build/src/server.js",
"pkg": {
"assets": [
"./build/public/**/*",
"./web/build/**/*"
],
"targets": [
"node16-macos-x64"
],
"outputPath": "../executables"
},
"release": {
"branches": [
@@ -23,8 +37,9 @@
},
"author": "Analytium Ltd",
"dependencies": {
"@sasjs/utils": "^2.23.3",
"@sasjs/utils": "^2.33.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.17.1",
"joi": "^17.4.2",
"jsonwebtoken": "^8.5.1",
@@ -37,6 +52,7 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.12",
"@types/jest": "^26.0.24",
"@types/jsonwebtoken": "^8.5.5",
@@ -50,6 +66,7 @@
"jest": "^27.0.6",
"mongodb-memory-server": "^8.0.0",
"nodemon": "^2.0.7",
"pkg": "^5.4.1",
"prettier": "^2.3.1",
"rimraf": "^3.0.2",
"semantic-release": "^17.4.3",

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,93 @@ components:
requestBodies: {}
responses: {}
schemas:
AuthorizeResponse:
properties:
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- code
type: object
additionalProperties: false
AuthorizePayload:
properties:
username:
type: string
description: 'Username for user'
example: secretuser
password:
type: string
description: 'Password for user'
example: secretpassword
clientId:
type: string
description: 'Client ID'
example: clientID1
required:
- username
- password
- clientId
type: object
additionalProperties: false
TokenResponse:
properties:
accessToken:
type: string
description: 'Access Token'
example: someRandomCryptoString
refreshToken:
type: string
description: 'Refresh Token'
example: someRandomCryptoString
required:
- accessToken
- refreshToken
type: object
additionalProperties: false
TokenPayload:
properties:
clientId:
type: string
description: 'Client ID'
example: clientID1
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- clientId
- code
type: object
additionalProperties: false
InfoJWT:
properties:
clientId:
type: string
userId:
type: number
format: double
required:
- clientId
- userId
type: object
additionalProperties: false
ClientPayload:
properties:
clientId:
type: string
description: 'Client ID'
example: someFormattedClientID1234
clientSecret:
type: string
description: 'Client Secret'
example: someRandomCryptoString
required:
- clientId
- clientSecret
type: object
additionalProperties: false
MemberType.folder:
enum:
- folder
@@ -151,28 +238,6 @@ components:
- tree
type: object
additionalProperties: false
ExecuteReturnJsonResponse:
properties:
status:
type: string
log:
type: string
result:
type: string
message:
type: string
required:
- status
type: object
additionalProperties: false
ExecuteReturnJsonPayload:
properties:
_program:
type: string
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
additionalProperties: false
UserResponse:
properties:
id:
@@ -293,91 +358,26 @@ components:
- description
type: object
additionalProperties: false
ClientPayload:
ExecuteReturnJsonResponse:
properties:
clientId:
status:
type: string
description: 'Client ID'
example: someFormattedClientID1234
clientSecret:
log:
type: string
result:
type: string
message:
type: string
description: 'Client Secret'
example: someRandomCryptoString
required:
- clientId
- clientSecret
- status
type: object
additionalProperties: false
AuthorizeResponse:
ExecuteReturnJsonPayload:
properties:
code:
_program:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- code
type: object
additionalProperties: false
AuthorizePayload:
properties:
username:
type: string
description: 'Username for user'
example: secretuser
password:
type: string
description: 'Password for user'
example: secretpassword
clientId:
type: string
description: 'Client ID'
example: clientID1
required:
- username
- password
- clientId
type: object
additionalProperties: false
TokenResponse:
properties:
accessToken:
type: string
description: 'Access Token'
example: someRandomCryptoString
refreshToken:
type: string
description: 'Refresh Token'
example: someRandomCryptoString
required:
- accessToken
- refreshToken
type: object
additionalProperties: false
TokenPayload:
properties:
clientId:
type: string
description: 'Client ID'
example: clientID1
code:
type: string
description: 'Authorization code'
example: someRandomCryptoString
required:
- clientId
- code
type: object
additionalProperties: false
InfoJWT:
properties:
clientId:
type: string
userId:
type: number
format: double
required:
- clientId
- userId
description: 'Location of SAS program'
example: /Public/somefolder/some.file
type: object
additionalProperties: false
securitySchemes:
@@ -393,6 +393,113 @@ info:
name: 'Analytium Ltd'
openapi: 3.0.0
paths:
/SASjsApi/auth/authorize:
post:
operationId: Authorize
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizeResponse'
examples:
'Example 1':
value: {code: someRandomCryptoString}
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
tags:
- Auth
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizePayload'
/SASjsApi/auth/token:
post:
operationId: Token
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
'Example 1':
value: {accessToken: someRandomCryptoString, refreshToken: someRandomCryptoString}
summary: 'Accepts client/auth code and returns access/refresh tokens'
tags:
- Auth
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TokenPayload'
/SASjsApi/auth/refresh:
post:
operationId: Refresh
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
'Example 1':
value: {accessToken: someRandomCryptoString, refreshToken: someRandomCryptoString}
summary: 'Returns new access/refresh tokens'
tags:
- Auth
security:
-
bearerAuth: []
parameters: []
/SASjsApi/auth/logout:
post:
operationId: Logout
responses:
'204':
description: 'No content'
summary: 'Logout terminate access/refresh tokens and returns nothing'
tags:
- Auth
security:
-
bearerAuth: []
parameters: []
/SASjsApi/client:
post:
operationId: CreateClient
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/ClientPayload'
examples:
'Example 1':
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString}
summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.'
tags:
- Client
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ClientPayload'
/SASjsApi/drive/deploy:
post:
operationId: Deploy
@@ -473,6 +580,40 @@ paths:
schema:
type: string
example: /Public/somefolder/some.file
post:
operationId: SaveFile
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateFileResponse'
examples:
'Example 1':
value: {status: success}
'400':
description: 'File already exists'
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateFileResponse'
examples:
'Example 1':
value: {status: failure, message: 'File request failed.'}
summary: 'Create a file in SASjs Drive'
tags:
- Drive
security:
-
bearerAuth: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FilePayload'
patch:
operationId: UpdateFile
responses:
@@ -524,61 +665,6 @@ paths:
-
bearerAuth: []
parameters: []
/SASjsApi/client/execute:
get:
operationId: ExecuteReturnRaw
responses:
'200':
description: Ok
content:
application/json:
schema:
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:
-
bearerAuth: []
parameters:
-
in: query
name: _program
required: true
schema:
type: string
example: /Public/somefolder/some.file
post:
operationId: ExecuteReturnJson
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReturnJsonResponse'
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
security:
-
bearerAuth: []
parameters:
-
in: query
name: _program
required: false
schema:
type: string
example: /Public/somefolder/some.file
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReturnJsonPayload'
/SASjsApi/user:
get:
operationId: GetAllUsers
@@ -885,113 +971,61 @@ paths:
format: double
type: number
example: '6789'
/SASjsApi/client:
post:
operationId: CreateClient
/SASjsApi/stp/execute:
get:
operationId: ExecuteReturnRaw
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/ClientPayload'
examples:
'Example 1':
value: {clientId: someFormattedClientID1234, clientSecret: someRandomCryptoString}
summary: 'Create client with the following attributes: ClientId, ClientSecret. Admin only task.'
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:
- Client
- STP
security:
-
bearerAuth: []
parameters: []
parameters:
-
in: query
name: _program
required: true
schema:
type: string
example: /Public/somefolder/some.file
post:
operationId: ExecuteReturnJson
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/ExecuteReturnJsonResponse'
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
security:
-
bearerAuth: []
parameters:
-
in: query
name: _program
required: false
schema:
type: string
example: /Public/somefolder/some.file
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ClientPayload'
/SASjsApi/auth/authorize:
post:
operationId: Authorize
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizeResponse'
examples:
'Example 1':
value: {code: someRandomCryptoString}
summary: 'Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE'
tags:
- Auth
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AuthorizePayload'
/SASjsApi/auth/token:
post:
operationId: Token
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
'Example 1':
value: {accessToken: someRandomCryptoString, refreshToken: someRandomCryptoString}
summary: 'Accepts client/auth code and returns access/refresh tokens'
tags:
- Auth
security: []
parameters: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TokenPayload'
/SASjsApi/auth/refresh:
post:
operationId: Refresh
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
'Example 1':
value: {accessToken: someRandomCryptoString, refreshToken: someRandomCryptoString}
summary: 'Returns new access/refresh tokens'
tags:
- Auth
security:
-
bearerAuth: []
parameters: []
/SASjsApi/auth/logout:
post:
operationId: Logout
responses:
'204':
description: 'No content'
summary: 'Logout terminate access/refresh tokens and returns nothing'
tags:
- Auth
security:
-
bearerAuth: []
parameters: []
$ref: '#/components/schemas/ExecuteReturnJsonPayload'
servers:
-
url: /

View File

@@ -1,14 +1,27 @@
import path from 'path'
import express from 'express'
import morgan from 'morgan'
import dotenv from 'dotenv'
import cors from 'cors'
import webRouter from './routes/web'
import apiRouter from './routes/api'
import { getWebBuildFolderPath } from './utils'
import { connectDB } from './routes/api/auth'
dotenv.config()
const app = express()
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
console.log('All CORS Requests are enabled')
app.use(cors({ credentials: true, origin: 'http://localhost:3000' }))
}
app.use(express.json({ limit: '50mb' }))
app.use(morgan('tiny'))
app.use(express.static('public'))
app.use(express.static(path.join(__dirname, '../public')))
app.use('/', webRouter)
app.use('/SASjsApi', apiRouter)
@@ -16,4 +29,4 @@ app.use(express.json({ limit: '50mb' }))
app.use(express.static(getWebBuildFolderPath()))
export default app
export default connectDB().then(() => app)

View File

@@ -106,6 +106,24 @@ export class DriveController {
return getFile(filePath)
}
/**
* @summary Create a file in SASjs Drive
*
*/
@Example<UpdateFileResponse>({
status: 'success'
})
@Response<UpdateFileResponse>(400, 'File already exists', {
status: 'failure',
message: 'File request failed.'
})
@Post('/file')
public async saveFile(
@Body() body: FilePayload
): Promise<UpdateFileResponse> {
return saveFile(body)
}
/**
* @summary Modify a file in SASjs Drive
*
@@ -164,12 +182,35 @@ const getFile = async (filePath: string): Promise<GetFileResponse> => {
const fileContent = await readFile(filePathFull)
return { status: 'success', fileContent: fileContent }
} catch (err) {
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
...(typeof err === 'object' ? err : { details: err })
error: typeof err === 'object' ? err.toString() : err
}
}
}
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)
return { status: 'success' }
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
error: typeof err === 'object' ? err.toString() : err
}
}
}
@@ -185,12 +226,12 @@ const updateFile = async (body: FilePayload): Promise<GetFileResponse> => {
await createFile(filePathFull, fileContent)
return { status: 'success' }
} catch (err) {
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'File request failed.',
...(typeof err === 'object' ? err : { details: err })
error: typeof err === 'object' ? err.toString() : err
}
}
}

View File

@@ -1,50 +1,36 @@
import path from 'path'
import fs from 'fs'
import { getSessionController } from './'
import { readFile, fileExists, createFile } from '@sasjs/utils'
import { configuration } from '../../../package.json'
import { promisify } from 'util'
import { execFile } from 'child_process'
import { PreProgramVars, Session, TreeNode } from '../../types'
import { readFile, fileExists, createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, TreeNode } from '../../types'
import { generateFileUploadSasCode, getTmpFilesFolderPath } from '../../utils'
const execFilePromise = promisify(execFile)
export class ExecutionController {
async execute(
program = '',
preProgramVariables?: PreProgramVars,
autoExec?: string,
session?: Session,
vars?: any,
programPath: string,
preProgramVariables: PreProgramVars,
vars: { [key: string]: string | number | undefined },
otherArgs?: any,
returnJson?: boolean
) {
if (program) {
if (!(await fileExists(program))) {
throw 'ExecutionController: SAS file does not exist.'
}
if (!(await fileExists(programPath)))
throw 'ExecutionController: SAS file does not exist.'
program = await readFile(program)
let program = await readFile(programPath)
if (vars) {
Object.keys(vars).forEach(
(key: string) => (program = `%let ${key}=${vars[key]};\n${program}`)
)
}
}
Object.keys(vars).forEach(
(key: string) => (program = `%let ${key}=${vars[key]};\n${program}`)
)
const sessionController = getSessionController()
if (!session) {
session = await sessionController.getSession()
session.inUse = true
}
const session = await sessionController.getSession()
session.inUse = true
let log = path.join(session.path, 'log.log')
const logPath = path.join(session.path, 'log.log')
let webout = path.join(session.path, 'webout.txt')
await createFile(webout, '')
const weboutPath = path.join(session.path, 'webout.txt')
await createFile(weboutPath, '')
const tokenFile = path.join(session.path, 'accessToken.txt')
await createFile(
@@ -60,7 +46,7 @@ export class ExecutionController {
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
%let _sasjs_apipath=/SASjsApi/stp/execute;
%let sasjsprocessmode=Stored Program;
filename _webout "${webout}";
filename _webout "${weboutPath}";
${program}`
// if no files are uploaded filesNamesMap will be undefined
@@ -76,53 +62,40 @@ ${program}`
}
}
const code = path.join(session.path, 'code.sas')
if (!(await fileExists(code))) {
await createFile(code, program)
const codePath = path.join(session.path, 'code.sas')
// Creating this file in a RUNNING session will break out
// the autoexec loop and actually execute the program
// but - given it will take several milliseconds to create
// (which can mean SAS trying to run a partial program, or
// failing due to file lock) we first create the file THEN
// we rename it.
await createFile(codePath + '.bkp', program)
await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session array
while (!session.completed || !session.crashed) {
await delay(50)
}
let additionalArgs: string[] = []
if (autoExec) additionalArgs = ['-AUTOEXEC', autoExec]
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
const webout = (await fileExists(weboutPath))
? await readFile(weboutPath)
: ''
const { stdout, stderr } = await execFilePromise(configuration.sasPath, [
'-SYSIN',
code,
'-LOG',
log,
'-WORK',
session.path,
...additionalArgs,
process.platform === 'win32' ? '-nosplash' : ''
]).catch((err) => ({ stderr: err, stdout: '' }))
const debugValue =
typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug
if (await fileExists(log)) log = await readFile(log)
else log = ''
if (await fileExists(webout)) webout = await readFile(webout)
else webout = ''
const debug = Object.keys(vars).find(
(key: string) => key.toLowerCase() === '_debug'
)
let jsonResult
if ((debug && vars[debug] >= 131) || stderr) {
webout = `<html><body>
${webout}
<div style="text-align:left">
<hr /><h2>SAS Log</h2>
<pre>${log}</pre>
</div>
</body></html>`
} else if (returnJson) {
jsonResult = { result: webout, log: log }
let debugResponse: string | undefined
if ((debugValue && debugValue >= 131) || session.crashed) {
debugResponse = `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
}
session.inUse = false
sessionController.deleteSession(session)
return Promise.resolve(jsonResult || webout)
if (returnJson) return { result: debugResponse ?? webout, log }
return debugResponse ?? webout
}
buildDirectorytree() {
@@ -162,3 +135,5 @@ ${webout}
return root
}
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -1,22 +1,20 @@
import path from 'path'
import { Session } from '../../types'
import { configuration } from '../../../package.json'
import { promisify } from 'util'
import { execFile } from 'child_process'
import { getTmpSessionsFolderPath, generateUniqueFileName } from '../../utils'
import {
deleteFolder,
createFile,
fileExists,
deleteFile,
generateTimestamp
} from '@sasjs/utils'
import path from 'path'
import { ExecutionController } from './Execution'
const execFilePromise = promisify(execFile)
export class SessionController {
private sessions: Session[] = []
private executionController: ExecutionController
constructor() {
this.executionController = new ExecutionController()
}
public async getSession() {
const readySessions = this.sessions.filter((sess: Session) => sess.ready)
@@ -32,74 +30,87 @@ export class SessionController {
private async createSession() {
const sessionId = generateUniqueFileName(generateTimestamp())
const sessionFolder = path.join(await getTmpSessionsFolderPath(), sessionId)
const autoExecContent = `data _null_;
/* remove the dummy SYSIN */
length fname $8;
rc=filename(fname,getoption('SYSIN') );
if rc = 0 and fexist(fname) then rc=fdelete(fname);
rc=filename(fname);
/* now wait for the real SYSIN */
slept=0;
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
slept=slept+sleep(0.01,1);
end;
run;
EOL`
const autoExec = path.join(sessionFolder, 'autoexec.sas')
await createFile(autoExec, autoExecContent)
await createFile(path.join(sessionFolder, 'code.sas'), '')
const sessionFolder = path.join(getTmpSessionsFolderPath(), sessionId)
const creationTimeStamp = sessionId.split('-').pop() as string
const deathTimeStamp = (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
1000
).toString()
const session: Session = {
id: sessionId,
ready: false,
creationTimeStamp: creationTimeStamp,
deathTimeStamp: (
parseInt(creationTimeStamp) +
15 * 60 * 1000 -
1000
).toString(),
path: sessionFolder,
inUse: false
inUse: false,
completed: false,
creationTimeStamp,
deathTimeStamp,
path: sessionFolder
}
// we do not want to leave sessions running forever
// we clean them up after a predefined period, if unused
this.scheduleSessionDestroy(session)
this.executionController
.execute('', undefined, autoExec, session)
.catch(() => {})
// the autoexec file is executed on SAS startup
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
await createFile(autoExecPath, autoExecContent)
// create empty code.sas as SAS will not start without a SYSIN
const codePath = path.join(session.path, 'code.sas')
await createFile(codePath, '')
// trigger SAS but don't wait for completion - we need to
// update the session array to say that it is currently running
// however we also need a promise so that we can update the
// session array to say that it has (eventually) finished.
const sasLoc = process.sasLoc ?? configuration.sasPath
execFilePromise(sasLoc, [
'-SYSIN',
codePath,
'-LOG',
path.join(session.path, 'log.log'),
'-WORK',
session.path,
'-AUTOEXEC',
autoExecPath,
process.platform === 'win32' ? '-nosplash' : ''
])
.then(() => {
session.completed = true
console.log('session completed', session)
})
.catch((err) => {
session.completed = true
session.crashed = true
console.log('session crashed', session.id, err)
})
// we have a triggered session - add to array
this.sessions.push(session)
// SAS has been triggered but we can't use it until
// the autoexec deletes the code.sas file
await this.waitForSession(session)
return session
}
public async waitForSession(session: Session) {
if (await fileExists(path.join(session.path, 'code.sas'))) {
while (await fileExists(path.join(session.path, 'code.sas'))) {}
const codeFilePath = path.join(session.path, 'code.sas')
await deleteFile(path.join(session.path, 'log.log'))
while (await fileExists(codeFilePath)) {}
session.ready = true
return Promise.resolve(session)
} else {
session.ready = true
return Promise.resolve(session)
}
session.ready = true
return Promise.resolve(session)
}
public async deleteSession(session: Session) {
// remove the temporary files, to avoid buildup
await deleteFolder(session.path)
// remove the session from the session array
if (session.ready) {
this.sessions = this.sessions.filter(
(sess: Session) => sess.id !== session.id
@@ -127,3 +138,19 @@ export const getSessionController = (): SessionController => {
return process.sessionController
}
const autoExecContent = `
data _null_;
/* remove the dummy SYSIN */
length fname $8;
rc=filename(fname,getoption('SYSIN') );
if rc = 0 and fexist(fname) then rc=fdelete(fname);
rc=filename(fname);
/* now wait for the real SYSIN */
slept=0;
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
slept=slept+sleep(0.01,1);
end;
stop;
run;
`

View File

@@ -1,4 +1,4 @@
import express from 'express'
import express, { response } from 'express'
import path from 'path'
import {
Request,
@@ -14,6 +14,7 @@ import {
import { ExecutionController } from './internal'
import { PreProgramVars } from '../types'
import { getTmpFilesFolderPath, makeFilesNamesMap } from '../utils'
import { request } from 'https'
interface ExecuteReturnJsonPayload {
/**
@@ -30,7 +31,7 @@ interface ExecuteReturnJsonResponse {
}
@Security('bearerAuth')
@Route('SASjsApi/client')
@Route('SASjsApi/stp')
@Tags('STP')
export class STPController {
/**
@@ -75,6 +76,7 @@ const executeReturnRaw = async (
req: express.Request,
_program: string
): Promise<string> => {
const query = req.query as { [key: string]: string | number | undefined }
const sasCodePath =
path
.join(getTmpFilesFolderPath(), _program)
@@ -84,20 +86,16 @@ const executeReturnRaw = async (
const result = await new ExecutionController().execute(
sasCodePath,
getPreProgramVariables(req),
undefined,
undefined,
{
...req.query
}
query
)
return result as string
} catch (err) {
} catch (err: any) {
throw {
code: 400,
status: 'failure',
message: 'Job execution failed.',
...(typeof err === 'object' ? err : { details: err })
error: typeof err === 'object' ? err.toString() : err
}
}
}
@@ -117,8 +115,6 @@ const executeReturnJson = async (
const jsonResult: any = await new ExecutionController().execute(
sasCodePath,
getPreProgramVariables(req),
undefined,
req.sasSession,
{ ...req.query, ...req.body },
{ filesNamesMap: filesNamesMap },
true
@@ -128,11 +124,11 @@ const executeReturnJson = async (
result: jsonResult.result,
log: jsonResult.log
}
} catch (err) {
} catch (err: any) {
throw {
status: 'failure',
message: 'Job execution failed.',
...(typeof err === 'object' ? err : { details: err })
error: typeof err === 'object' ? err.toString() : err
}
}
}

View File

@@ -28,6 +28,20 @@ const authenticateToken = (
key: string,
tokenType: 'accessToken' | 'refreshToken' = 'accessToken'
) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
req.user = {
userId: '1234',
clientId: 'desktopModeClientId',
username: 'desktopModeUsername',
displayName: 'desktopModeDisplayName',
isAdmin: true,
isActive: true
}
req.accessToken = 'desktopModeAccessToken'
return next()
}
const authHeader = req.headers['authorization']
const token = authHeader?.split(' ')[1]
if (!token) return res.sendStatus(401)

View File

@@ -0,0 +1,7 @@
export const desktopRestrict = (req: any, res: any, next: any) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server')
return res.status(403).send('Not Allowed while in Desktop Mode.')
next()
}

View File

@@ -1,3 +1,4 @@
export * from './authenticateToken'
export * from './desktopRestrict'
export * from './verifyAdmin'
export * from './verifyAdminIfNeeded'

View File

@@ -1,4 +1,7 @@
export const verifyAdmin = (req: any, res: any, next: any) => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') return next()
const { user } = req
if (!user?.isAdmin) return res.status(401).send('Admin account required')
next()

View File

@@ -2,7 +2,7 @@ import path from 'path'
import { readFileSync } from 'fs'
import * as https from 'https'
import { configuration } from '../package.json'
import app from './app'
import appPromise from './app'
const keyPath = path.join('..', 'certificates', 'privkey.pem')
const certPath = path.join('..', 'certificates', 'fullchain.pem')
@@ -10,10 +10,12 @@ const certPath = path.join('..', 'certificates', 'fullchain.pem')
const key = readFileSync(keyPath)
const cert = readFileSync(certPath)
const httpsServer = https.createServer({ key, cert }, app)
appPromise.then((app) => {
const httpsServer = https.createServer({ key, cert }, app)
httpsServer.listen(configuration.sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at https://localhost:${configuration.sasJsPort}`
)
httpsServer.listen(configuration.sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at https://localhost:${configuration.sasJsPort}`
)
})
})

View File

@@ -9,7 +9,11 @@ import {
authenticateRefreshToken
} from '../../middlewares'
import { authorizeValidation, tokenValidation } from '../../utils'
import {
authorizeValidation,
getDesktopFields,
tokenValidation
} from '../../utils'
import { InfoJWT } from '../../types'
const authRouter = express.Router()
@@ -24,7 +28,19 @@ export const populateClients = async () => {
})
}
export const connectDB = () => {
export const connectDB = async () => {
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
console.log('Running in Destop Mode, no DB to connect.')
const { sasLoc, driveLoc } = await getDesktopFields()
process.sasLoc = sasLoc
process.driveLoc = driveLoc
return
}
// NOTE: when exporting app.js as agent for supertest
// we should exlcude connecting to the real database
if (process.env.NODE_ENV !== 'test') {

View File

@@ -35,6 +35,23 @@ driveRouter.get('/file', async (req, res) => {
}
})
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.saveFile(body)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
driveRouter.patch('/file', async (req, res) => {
const { error, value: body } = updateFileDriveValidation(req.body)
if (error) return res.status(400).send(error.details[0].message)

View File

@@ -1,27 +1,34 @@
import express from 'express'
import dotenv from 'dotenv'
import swaggerUi from 'swagger-ui-express'
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
import {
authenticateAccessToken,
desktopRestrict,
verifyAdmin
} from '../../middlewares'
import driveRouter from './drive'
import stpRouter from './stp'
import userRouter from './user'
import groupRouter from './group'
import clientRouter from './client'
import authRouter, { connectDB } from './auth'
dotenv.config()
connectDB()
import authRouter from './auth'
const router = express.Router()
router.use('/auth', authRouter)
router.use('/client', authenticateAccessToken, verifyAdmin, clientRouter)
router.use('/auth', desktopRestrict, authRouter)
router.use(
'/client',
desktopRestrict,
authenticateAccessToken,
verifyAdmin,
clientRouter
)
router.use('/drive', authenticateAccessToken, driveRouter)
router.use('/group', groupRouter)
router.use('/group', desktopRestrict, groupRouter)
router.use('/stp', authenticateAccessToken, stpRouter)
router.use('/user', userRouter)
router.use('/user', desktopRestrict, userRouter)
router.use(
'/',
swaggerUi.serve,

View File

@@ -1,7 +1,8 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import app from '../../../app'
import appPromise from '../../../app'
import {
UserController,
ClientController,
@@ -17,6 +18,11 @@ import {
verifyTokenInDB
} from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const clientSecret = 'someclientSecret'
const user = {

View File

@@ -1,10 +1,16 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import app from '../../../app'
import appPromise from '../../../app'
import { UserController, ClientController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const client = {
clientId: 'someclientID',
clientSecret: 'someclientSecret'

View File

@@ -1,7 +1,8 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import app from '../../../app'
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import { getTreeExample } from '../../../controllers/internal'
import { getTmpFilesFolderPath } from '../../../utils/file'
@@ -10,6 +11,11 @@ import path from 'path'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
import { FolderMember, ServiceMember } from '../../../types'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const user = {
displayName: 'Test User',

View File

@@ -1,10 +1,16 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import app from '../../../app'
import appPromise from '../../../app'
import { UserController, GroupController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const adminUser = {
displayName: 'Test Admin',

View File

@@ -1,10 +1,16 @@
import { Express } from 'express'
import mongoose, { Mongoose } from 'mongoose'
import { MongoMemoryServer } from 'mongodb-memory-server'
import request from 'supertest'
import app from '../../../app'
import appPromise from '../../../app'
import { UserController } from '../../../controllers/'
import { generateAccessToken, saveTokensInDB } from '../../../utils'
let app: Express
appPromise.then((_app) => {
app = _app
})
const clientId = 'someclientID'
const adminUser = {
displayName: 'Test Admin',

View File

@@ -1,3 +1,4 @@
import { readFile } from '@sasjs/utils'
import express from 'express'
import path from 'path'
import { getWebBuildFolderPath } from '../../utils'
@@ -5,7 +6,24 @@ import { getWebBuildFolderPath } from '../../utils'
const webRouter = express.Router()
webRouter.get('/', async (_, res) => {
res.sendFile(path.join(getWebBuildFolderPath(), 'index.html'))
const indexHtmlPath = path.join(getWebBuildFolderPath(), 'index.html')
const { MODE } = process.env
if (MODE?.trim() !== 'server') {
const content = await readFile(indexHtmlPath)
const codeToInject = `
<script>
localStorage.setItem('accessToken', JSON.stringify('accessToken'))
localStorage.setItem('refreshToken', JSON.stringify('refreshToken'))
</script>`
const injectedContent = content.replace('</head>', `${codeToInject}</head>`)
res.setHeader('Content-Type', 'text/html')
return res.send(injectedContent)
}
res.sendFile(indexHtmlPath)
})
export default webRouter

View File

@@ -1,8 +1,10 @@
import app from './app'
import appPromise from './app'
import { configuration } from '../package.json'
app.listen(configuration.sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at http://localhost:${configuration.sasJsPort}`
)
appPromise.then((app) => {
app.listen(configuration.sasJsPort, () => {
console.log(
`⚡️[server]: Server is running at http://localhost:${configuration.sasJsPort}`
)
})
})

View File

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

View File

@@ -5,4 +5,6 @@ export interface Session {
deathTimeStamp: string
path: string
inUse: boolean
completed: boolean
crashed?: boolean
}

View File

@@ -5,7 +5,7 @@ export const getWebBuildFolderPath = () =>
getRealPath(path.join(__dirname, '..', '..', '..', 'web', 'build'))
export const getTmpFolderPath = () =>
getRealPath(path.join(__dirname, '..', '..', 'tmp'))
process.driveLoc ?? getRealPath(path.join(process.cwd(), 'tmp'))
export const getTmpFilesFolderPath = () =>
path.join(getTmpFolderPath(), 'files')

View File

@@ -0,0 +1,61 @@
import path from 'path'
import { getString } from '@sasjs/utils/input'
import { createFolder, fileExists, folderExists } from '@sasjs/utils'
const isWindows = () => process.platform === 'win32'
export const getDesktopFields = async () => {
const sasLoc = await getSASLocation()
const driveLoc = await getDriveLocation()
return { sasLoc, driveLoc }
}
const getDriveLocation = async (): Promise<string> => {
const validator = async (filePath: string) => {
if (!filePath) return 'Path to files/drive is required.'
const drivePath = path.join(process.cwd(), filePath)
if (!(await folderExists(drivePath))) {
await createFolder(drivePath)
await createFolder(path.join(drivePath, 'files'))
}
return true
}
const defaultLocation = isWindows() ? '.\\tmp\\' : './tmp/'
const targetName = await getString(
'Please enter path to file/drive (relative to executable): ',
validator,
defaultLocation
)
return targetName
}
const getSASLocation = async (): Promise<string> => {
const validator = async (filePath: string) => {
if (!filePath) return 'Path to SAS executable is required.'
if (!(await fileExists(filePath))) {
return 'No file found at provided path.'
}
return true
}
const defaultLocation = isWindows()
? 'C:\\Program Files\\SASHome\\SASFoundation\\9.4\\sas.exe'
: '/opt/sas/sas9/SASHome/SASFoundation/9.4/sasexe/sas'
const targetName = await getString(
'Please enter path to SAS executable (absolute path): ',
validator,
defaultLocation
)
return targetName
}

View File

@@ -2,6 +2,7 @@ export * from './file'
export * from './generateAccessToken'
export * from './generateAuthCode'
export * from './generateRefreshToken'
export * from './getDesktopFields'
export * from './removeTokensInDB'
export * from './saveTokensInDB'
export * from './sleep'

View File

@@ -79,7 +79,7 @@ export const updateFileDriveValidation = (data: any): Joi.ValidationResult =>
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
Joi.object({
_program: Joi.string().required
_program: Joi.string().required()
})
.pattern(/\w\d/, Joi.string())
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
.validate(data)

14205
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,21 +3,23 @@ import PropTypes from 'prop-types'
import { CssBaseline, Box, TextField, Button } from '@mui/material'
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json'
}
const baseUrl =
process.env.NODE_ENV === 'development' ? 'http://localhost:5000' : undefined
const getAuthCode = async (credentials: any) => {
return fetch('/SASjsApi/auth/authorize', {
return fetch(`${baseUrl}/SASjsApi/auth/authorize`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify(credentials)
}).then((data) => data.json())
}
const getTokens = async (payload: any) => {
return fetch('/SASjsApi/auth/token', {
return fetch(`${baseUrl}/SASjsApi/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify(payload)
}).then((data) => data.json())
}

View File

@@ -43,15 +43,18 @@ export default function useTokens() {
}
}
// const baseUrl = 'http://localhost:5000'
// const isAbsoluteURLRegex = /^(?:\w+:)\/\//
const baseUrl =
process.env.NODE_ENV === 'development' ? 'http://localhost:5000' : undefined
const isAbsoluteURLRegex = /^(?:\w+:)\/\//
const setAxiosRequestHeader = (accessToken: string) => {
axios.interceptors.request.use(function (config: any) {
// if (!isAbsoluteURLRegex.test(config.url)) {
// config.url = baseUrl + config.url
// }
config.headers.Authorization = `Bearer ${accessToken}`
axios.interceptors.request.use(function (config) {
if (baseUrl && !isAbsoluteURLRegex.test(config.url as string)) {
config.url = baseUrl + config.url
}
config.headers!['Authorization'] = `Bearer ${accessToken}`
config.withCredentials = true
return config
})

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -20,7 +16,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"include": ["src"]
}