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

Compare commits

...

30 Commits

Author SHA1 Message Date
semantic-release-bot
471c28eaa2 chore(release): 0.39.2 [skip ci]
## [0.39.2](https://github.com/sasjs/server/compare/v0.39.1...v0.39.2) (2025-09-25)

### Bug Fixes

* addressing test fail ([e51b204](e51b20421a))
* packages missmatch ([379ea60](379ea604bc))
* type libs ([6d123c3](6d123c3e23))
* typescript errors ([631e956](631e95604b))
* typescript errors ([198cd79](198cd79354))
2025-09-25 16:57:01 +00:00
Allan Bowe
584ffe9e0e Merge pull request #383 from sasjs/npm_update_20250919
Npm update 20250919
2025-09-25 17:53:46 +01:00
M
e51b20421a fix: addressing test fail 2025-09-25 13:49:32 +02:00
M
631e95604b fix: typescript errors 2025-09-25 13:40:10 +02:00
M
198cd79354 fix: typescript errors 2025-09-25 13:34:55 +02:00
M
379ea604bc fix: packages missmatch 2025-09-25 13:12:23 +02:00
M
9ffa403bcb chore: package-lock 2025-09-25 13:06:06 +02:00
M
6d123c3e23 fix: type libs 2025-09-25 13:03:47 +02:00
M
dda1aadc67 chore(git): Merge branch 'main' into npm_update_20250919 2025-09-25 12:48:10 +02:00
M
d47cf15cdb ci: ubuntu 22 2025-09-25 12:46:19 +02:00
Trevor Moody
d0c7968d66 build: updated package dependencies for /web 2025-09-19 18:24:58 +01:00
Trevor Moody
a5c99971cc build: server/api dependency update 2025-09-19 14:06:50 +01:00
semantic-release-bot
c422e7f02e chore(release): 0.39.1 [skip ci]
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)

### Bug Fixes

* extra bit of sleep for file recognition ([f4768bf](f4768bffd3)), closes [#381](https://github.com/sasjs/server/issues/381)
2025-03-13 10:59:10 +00:00
Allan Bowe
02a993611c Merge pull request #382 from sasjs/381-add-slight-delay-to-enable-file-detection
fix: extra bit of sleep for file recognition
2025-03-13 10:56:13 +00:00
aca2fff4ac chore(workflow): run the build workflow on ubuntu 20.04 2025-03-13 15:50:23 +05:00
af1a386b13 chore(workflow): install openssl 1.1 in actions 2025-03-13 15:43:20 +05:00
Allan Bowe
f5018ce1df chore: prettier fix 2025-03-12 17:33:02 +00:00
Allan Bowe
3529232f1f chore: whitespace removal 2025-03-12 17:29:02 +00:00
Allan Bowe
f4768bffd3 fix: extra bit of sleep for file recognition
closes #381
2025-03-12 17:27:57 +00:00
semantic-release-bot
c261745f1d chore(release): 0.39.0 [skip ci]
# [0.39.0](https://github.com/sasjs/server/compare/v0.38.0...v0.39.0) (2024-10-31)

### Bug Fixes

* **api:** fixed condition in processProgram ([48a9a4d](48a9a4dd0e))

### Features

* **api:** added session state endpoint ([6b6546c](6b6546c7ad))
2024-10-31 12:54:02 +00:00
Yury Shkoda
d6e527ecf2 Merge pull request #379 from sasjs/issue-378
Issue 378
2024-10-31 15:51:13 +03:00
Yury
bc2cff1d0d chore(api): updated trigger endpoints description 2024-10-31 15:30:32 +03:00
Yury
66aa9b5891 chore(api): updated trigger endpoints description 2024-10-31 15:20:35 +03:00
Yury
ca17e7c192 chore(api): updated endpoint description 2024-10-31 14:08:56 +03:00
Yury
73df102422 chore(api): updated endpoint description 2024-10-31 12:27:56 +03:00
Yury
48a9a4dd0e fix(api): fixed condition in processProgram 2024-10-31 11:17:20 +03:00
Yury
4f6f735f5b chore(lint): fixed code style issue 2024-10-31 10:08:34 +03:00
Yury
6b6546c7ad feat(api): added session state endpoint 2024-10-30 17:42:50 +03:00
Yury
f94ddc0352 refactor(session): implemented session state 2024-10-30 15:33:06 +03:00
Yury
03670cf0d6 chore(swagger): fixed code/stp trigger examples 2024-10-30 15:25:03 +03:00
29 changed files with 11415 additions and 23552 deletions

View File

@@ -5,7 +5,7 @@ on:
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
@@ -28,7 +28,7 @@ jobs:
run: npm run lint-web run: npm run lint-web
build-api: build-api:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:
@@ -66,7 +66,7 @@ jobs:
CI: true CI: true
build-web: build-web:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:

View File

@@ -7,7 +7,7 @@ on:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
strategy: strategy:
matrix: matrix:

View File

@@ -1,5 +1,3 @@
{ {
"cSpell.words": [ "cSpell.words": ["autoexec", "initialising"]
"autoexec"
]
} }

View File

@@ -1,3 +1,33 @@
## [0.39.2](https://github.com/sasjs/server/compare/v0.39.1...v0.39.2) (2025-09-25)
### Bug Fixes
* addressing test fail ([e51b204](https://github.com/sasjs/server/commit/e51b20421adc1598ea267c79b1fb4dbc085f97b9))
* packages missmatch ([379ea60](https://github.com/sasjs/server/commit/379ea604bcb5686b5299fae6a32f759c45b275ea))
* type libs ([6d123c3](https://github.com/sasjs/server/commit/6d123c3e23628c1d703eaa13142c77f0da970a55))
* typescript errors ([631e956](https://github.com/sasjs/server/commit/631e95604b64b1a96f2abade659348618f3b00b2))
* typescript errors ([198cd79](https://github.com/sasjs/server/commit/198cd79354254511c21ac1acfbf7b6bcfdab2af7))
## [0.39.1](https://github.com/sasjs/server/compare/v0.39.0...v0.39.1) (2025-03-13)
### Bug Fixes
* extra bit of sleep for file recognition ([f4768bf](https://github.com/sasjs/server/commit/f4768bffd3dbb2fe243966572ba74002024d96e1)), closes [#381](https://github.com/sasjs/server/issues/381)
# [0.39.0](https://github.com/sasjs/server/compare/v0.38.0...v0.39.0) (2024-10-31)
### Bug Fixes
* **api:** fixed condition in processProgram ([48a9a4d](https://github.com/sasjs/server/commit/48a9a4dd0e31f84209635382be4ec4bb2c3a9c0c))
### Features
* **api:** added session state endpoint ([6b6546c](https://github.com/sasjs/server/commit/6b6546c7ad0833347f8dc4cdba6ad19132f7aaef))
# [0.38.0](https://github.com/sasjs/server/compare/v0.37.0...v0.38.0) (2024-10-30) # [0.38.0](https://github.com/sasjs/server/compare/v0.37.0...v0.38.0) (2024-10-30)

20466
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,24 +49,24 @@
"author": "4GL Ltd", "author": "4GL Ltd",
"dependencies": { "dependencies": {
"@sasjs/core": "^4.40.1", "@sasjs/core": "^4.40.1",
"@sasjs/utils": "3.2.0", "@sasjs/utils": "^3.5.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"connect-mongo": "^4.6.0", "connect-mongo": "^5.1.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.17.1", "express": "^4.21.2",
"express-session": "^1.17.2", "express-session": "^1.18.2",
"helmet": "^5.0.2", "helmet": "^5.0.2",
"joi": "^17.4.2", "joi": "^17.4.2",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^9.0.2",
"ldapjs": "2.3.3", "ldapjs": "2.3.3",
"mongoose": "^6.0.12", "mongoose": "^6.13.8",
"morgan": "^1.10.0", "morgan": "^1.10.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"rate-limiter-flexible": "2.4.1", "rate-limiter-flexible": "2.4.1",
"rotating-file-stream": "^3.0.4", "rotating-file-stream": "^3.0.4",
"swagger-ui-express": "4.3.0", "swagger-ui-express": "4.3.0",
"unzipper": "^0.10.11", "unzipper": "^0.12.3",
"url": "^0.10.3" "url": "^0.10.3"
}, },
"devDependencies": { "devDependencies": {
@@ -76,32 +76,32 @@
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/express": "^4.17.12", "@types/express": "^4.17.12",
"@types/express-session": "^1.17.4", "@types/express-session": "^1.17.4",
"@types/jest": "^26.0.24", "@types/jest": "^29.5.0",
"@types/jsonwebtoken": "^8.5.5", "@types/jsonwebtoken": "^8.5.5",
"@types/ldapjs": "^2.2.4", "@types/ldapjs": "^2.2.4",
"@types/morgan": "^1.9.3", "@types/morgan": "^1.9.3",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^15.12.2", "@types/node": "^20.0.0",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@types/swagger-ui-express": "^4.1.3", "@types/swagger-ui-express": "^4.1.3",
"@types/unzipper": "^0.10.5", "@types/unzipper": "^0.10.5",
"adm-zip": "^0.5.9", "adm-zip": "^0.5.9",
"axios": "0.27.2", "axios": "^1.12.2",
"csrf": "^3.1.0", "csrf": "^3.1.0",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"http-headers-validation": "^0.0.1", "http-headers-validation": "^0.0.1",
"jest": "^27.0.6", "jest": "^29.7.0",
"mongodb-memory-server": "8.11.4", "mongodb-memory-server": "8.11.4",
"nodejs-file-downloader": "4.10.2", "nodejs-file-downloader": "4.10.2",
"nodemon": "^2.0.7", "nodemon": "^3.0.0",
"pkg": "5.6.0", "pkg": "5.6.0",
"prettier": "^2.3.1", "prettier": "^3.0.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"supertest": "^6.1.3", "supertest": "^6.1.3",
"ts-jest": "^27.0.3", "ts-jest": "^29.1.0",
"ts-node": "^10.0.0", "ts-node": "^10.0.0",
"tsoa": "3.14.1", "tsoa": "3.14.1",
"typescript": "^4.3.2" "typescript": "^5.0.0"
}, },
"nodemonConfig": { "nodemonConfig": {
"ignore": [ "ignore": [

View File

@@ -113,8 +113,8 @@ components:
properties: properties:
sessionId: sessionId:
type: string type: string
description: "The SessionId is the name of the temporary folder used to store the outputs.\nFor SAS, this would be the SASWORK folder. Can be used to poll job status.\nThis session ID should be used to poll job status." description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' example: 20241028074744-54132-1730101664824
required: required:
- sessionId - sessionId
type: object type: object
@@ -585,6 +585,14 @@ components:
- needsToUpdatePassword - needsToUpdatePassword
type: object type: object
additionalProperties: false additionalProperties: false
SessionState:
enum:
- initialising
- pending
- running
- completed
- failed
type: string
ExecutePostRequestPayload: ExecutePostRequestPayload:
properties: properties:
_program: _program:
@@ -597,8 +605,8 @@ components:
properties: properties:
sessionId: sessionId:
type: string type: string
description: "The SessionId is the name of the temporary folder used to store the outputs.\nFor SAS, this would be the SASWORK folder. Can be used to poll program status.\nThis session ID should be used to poll program status." description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' example: 20241028074744-54132-1730101664824
required: required:
- sessionId - sessionId
type: object type: object
@@ -1841,6 +1849,30 @@ paths:
- -
bearerAuth: [] bearerAuth: []
parameters: [] parameters: []
'/SASjsApi/session/{sessionId}/state':
get:
operationId: SessionState
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/SessionState'
description: "The polling endpoint is currently implemented for single-server deployments only.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\nIf your site requires this, please reach out to SASjs Support."
summary: 'Get session state (initialising, pending, running, completed, failed).'
tags:
- Session
security:
-
bearerAuth: []
parameters:
-
in: path
name: sessionId
required: true
schema:
type: string
/SASjsApi/stp/execute: /SASjsApi/stp/execute:
get: get:
operationId: ExecuteGetRequest operationId: ExecuteGetRequest

View File

@@ -234,9 +234,10 @@ const verifyAuthCode = async (
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => { jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
if (err) return resolve(undefined) if (err) return resolve(undefined)
const payload = data as InfoJWT
const clientInfo: InfoJWT = { const clientInfo: InfoJWT = {
clientId: data?.clientId, clientId: payload?.clientId,
userId: data?.userId userId: payload?.userId
} }
if (clientInfo.clientId === clientId) { if (clientInfo.clientId === clientId) {
return resolve(clientInfo) return resolve(clientInfo)

View File

@@ -42,10 +42,12 @@ interface TriggerCodePayload {
interface TriggerCodeResponse { interface TriggerCodeResponse {
/** /**
* The SessionId is the name of the temporary folder used to store the outputs. * `sessionId` is the ID of the session and the name of the temporary folder
* For SAS, this would be the SASWORK folder. Can be used to poll job status. * used to store code outputs.<br><br>
* This session ID should be used to poll job status. * For SAS, this would be the location of the SASWORK folder.<br><br>
* @example "{ sessionId: '20241028074744-54132-1730101664824' }" * `sessionId` can be used to poll session state using the
* GET /SASjsApi/session/{sessionId}/state endpoint.
* @example "20241028074744-54132-1730101664824"
*/ */
sessionId: string sessionId: string
} }

View File

@@ -2,7 +2,7 @@ import path from 'path'
import fs from 'fs' import fs from 'fs'
import { getSessionController, processProgram } from './' import { getSessionController, processProgram } from './'
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils' import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
import { PreProgramVars, Session, TreeNode } from '../../types' import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
import { import {
extractHeaders, extractHeaders,
getFilesFolder, getFilesFolder,
@@ -75,8 +75,7 @@ export class ExecutionController {
const session = const session =
sessionByFileUpload ?? (await sessionController.getSession()) sessionByFileUpload ?? (await sessionController.getSession())
session.inUse = true session.state = SessionState.running
session.consumed = true
const logPath = path.join(session.path, 'log.log') const logPath = path.join(session.path, 'log.log')
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
@@ -121,7 +120,7 @@ export class ExecutionController {
: '' : ''
// it should be deleted by scheduleSessionDestroy // it should be deleted by scheduleSessionDestroy
session.inUse = false session.state = SessionState.completed
const resultParts = [] const resultParts = []
@@ -145,7 +144,9 @@ export class ExecutionController {
return { return {
httpHeaders, httpHeaders,
result: result:
isDebugOn(vars) || session.crashed ? resultParts.join(`\n`) : webout isDebugOn(vars) || session.failureReason
? resultParts.join(`\n`)
: webout
} }
} }

View File

@@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express'
import multer from 'multer' import multer from 'multer'
import { uuidv4 } from '@sasjs/utils' import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.' import { getSessionController } from '.'
import { import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
executeProgramRawValidation, import { SessionState } from '../../types'
getRunTimeAndFilePath,
RunTimeType
} from '../../utils'
export class FileUploadController { export class FileUploadController {
private storage = multer.diskStorage({ private storage = multer.diskStorage({
@@ -56,9 +53,8 @@ export class FileUploadController {
} }
const session = await sessionController.getSession() const session = await sessionController.getSession()
// marking consumed true, so that it's not available // change session state to 'running', so that it's not available for any other request
// as readySession for any other request session.state = SessionState.running
session.consumed = true
req.sasjsSession = session req.sasjsSession = session

View File

@@ -1,5 +1,5 @@
import path from 'path' import path from 'path'
import { Session } from '../../types' import { Session, SessionState } from '../../types'
import { promisify } from 'util' import { promisify } from 'util'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { import {
@@ -23,7 +23,9 @@ export class SessionController {
protected sessions: Session[] = [] protected sessions: Session[] = []
protected getReadySessions = (): Session[] => protected getReadySessions = (): Session[] =>
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed) this.sessions.filter(
(session: Session) => session.state === SessionState.pending
)
protected async createSession(): Promise<Session> { protected async createSession(): Promise<Session> {
const sessionId = generateUniqueFileName(generateTimestamp()) const sessionId = generateUniqueFileName(generateTimestamp())
@@ -39,19 +41,18 @@ export class SessionController {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: true, state: SessionState.pending,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder
} }
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'content-type: text/html; charset=utf-8') await createFile(headersPath, 'content-type: text/html; charset=utf-8')
this.sessions.push(session) this.sessions.push(session)
return session return session
} }
@@ -66,6 +67,10 @@ export class SessionController {
return session return session
} }
public getSessionById(id: string) {
return this.sessions.find((session) => session.id === id)
}
} }
export class SASSessionController extends SessionController { export class SASSessionController extends SessionController {
@@ -83,10 +88,7 @@ export class SASSessionController extends SessionController {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: false, state: SessionState.initialising,
inUse: false,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder
@@ -144,13 +146,20 @@ ${autoExecContent}`
process.sasLoc!.endsWith('sas.exe') ? session.path : '' process.sasLoc!.endsWith('sas.exe') ? session.path : ''
]) ])
.then(() => { .then(() => {
session.completed = true session.state = SessionState.completed
process.logger.info('session completed', session) process.logger.info('session completed', session)
}) })
.catch((err) => { .catch((err) => {
session.completed = true session.state = SessionState.failed
session.crashed = err.toString()
process.logger.error('session crashed', session.id, session.crashed) session.failureReason = err.toString()
process.logger.error(
'session crashed',
session.id,
session.failureReason
)
}) })
// we have a triggered session - add to array // we have a triggered session - add to array
@@ -167,15 +176,19 @@ ${autoExecContent}`
const codeFilePath = path.join(session.path, 'code.sas') const codeFilePath = path.join(session.path, 'code.sas')
// TODO: don't wait forever // TODO: don't wait forever
while ((await fileExists(codeFilePath)) && !session.crashed) {} while (
(await fileExists(codeFilePath)) &&
session.state !== SessionState.failed
) {}
if (session.crashed) if (session.state === SessionState.failed) {
process.logger.error( process.logger.error(
'session crashed! while waiting to be ready', 'session crashed! while waiting to be ready',
session.crashed session.failureReason
) )
} else {
session.ready = true session.state = SessionState.pending
}
} }
private async deleteSession(session: Session) { private async deleteSession(session: Session) {
@@ -191,7 +204,7 @@ ${autoExecContent}`
private scheduleSessionDestroy(session: Session) { private scheduleSessionDestroy(session: Session) {
setTimeout( setTimeout(
async () => { async () => {
if (session.inUse) { if (session.state === SessionState.running) {
// adding 10 more minutes // adding 10 more minutes
const newDeathTimeStamp = const newDeathTimeStamp =
parseInt(session.deathTimeStamp) + 10 * 60 * 1000 parseInt(session.deathTimeStamp) + 10 * 60 * 1000
@@ -202,7 +215,7 @@ ${autoExecContent}`
const { expiresAfterMins } = session const { expiresAfterMins } = session
// delay session destroy if expiresAfterMins present // delay session destroy if expiresAfterMins present
if (expiresAfterMins && !expiresAfterMins.used) { if (expiresAfterMins && session.state !== SessionState.completed) {
// calculate session death time using expiresAfterMins // calculate session death time using expiresAfterMins
const newDeathTimeStamp = const newDeathTimeStamp =
parseInt(session.deathTimeStamp) + parseInt(session.deathTimeStamp) +
@@ -247,9 +260,16 @@ data _null_;
rc=filename(fname,getoption('SYSIN') ); rc=filename(fname,getoption('SYSIN') );
if rc = 0 and fexist(fname) then rc=fdelete(fname); if rc = 0 and fexist(fname) then rc=fdelete(fname);
rc=filename(fname); rc=filename(fname);
/* now wait for the real SYSIN */ /* now wait for the real SYSIN (location of code.sas) */
slept=0; slept=0;fname='';
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) ); do until (slept>(60*15));
rc=filename(fname,getoption('SYSIN'));
if rc = 0 and fexist(fname) then do;
putlog fname=;
rc=filename(fname);
rc=sleep(0.01,1); /* wait just a little more */
stop;
end;
slept=slept+sleep(0.01,1); slept=slept+sleep(0.01,1);
end; end;
stop; stop;

View File

@@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { once } from 'stream' import { once } from 'stream'
import { createFile, moveFile } from '@sasjs/utils' import { createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types' import { PreProgramVars, Session, SessionState } from '../../types'
import { RunTimeType } from '../../utils' import { RunTimeType } from '../../utils'
import { import {
ExecutionVars, ExecutionVars,
@@ -49,7 +49,7 @@ export const processProgram = async (
await moveFile(codePath + '.bkp', codePath) await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session status // we now need to poll the session status
while (!session.completed) { while (session.state !== SessionState.completed) {
await delay(50) await delay(50)
} }
} else { } else {
@@ -114,13 +114,20 @@ export const processProgram = async (
await execFilePromise(executablePath, [codePath], writeStream) await execFilePromise(executablePath, [codePath], writeStream)
.then(() => { .then(() => {
session.completed = true session.state = SessionState.completed
process.logger.info('session completed', session) process.logger.info('session completed', session)
}) })
.catch((err) => { .catch((err) => {
session.completed = true session.state = SessionState.failed
session.crashed = err.toString()
process.logger.error('session crashed', session.id, session.crashed) session.failureReason = err.toString()
process.logger.error(
'session crashed',
session.id,
session.failureReason
)
}) })
// copy the code file to log and end write stream // copy the code file to log and end write stream

View File

@@ -1,6 +1,8 @@
import express from 'express' import express from 'express'
import { Request, Security, Route, Tags, Example, Get } from 'tsoa' import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
import { UserResponse } from './user' import { UserResponse } from './user'
import { getSessionController } from './internal'
import { SessionState } from '../types'
interface SessionResponse extends UserResponse { interface SessionResponse extends UserResponse {
needsToUpdatePassword: boolean needsToUpdatePassword: boolean
@@ -26,6 +28,18 @@ export class SessionController {
): Promise<SessionResponse> { ): Promise<SessionResponse> {
return session(request) return session(request)
} }
/**
* The polling endpoint is currently implemented for single-server deployments only.<br>
* Load balanced / grid topologies will be supported in a future release.<br>
* If your site requires this, please reach out to SASjs Support.
* @summary Get session state (initialising, pending, running, completed, failed).
* @example completed
*/
@Get('/:sessionId/state')
public async sessionState(sessionId: string): Promise<SessionState> {
return sessionState(sessionId)
}
} }
const session = (req: express.Request) => ({ const session = (req: express.Request) => ({
@@ -35,3 +49,23 @@ const session = (req: express.Request) => ({
isAdmin: req.user!.isAdmin, isAdmin: req.user!.isAdmin,
needsToUpdatePassword: req.user!.needsToUpdatePassword needsToUpdatePassword: req.user!.needsToUpdatePassword
}) })
const sessionState = (sessionId: string): SessionState => {
for (let runTime of process.runTimes) {
// get session controller for each available runTime
const sessionController = getSessionController(runTime)
// get session by sessionId
const session = sessionController.getSessionById(sessionId)
// return session state if session was found
if (session) {
return session.state
}
}
throw {
code: 404,
message: `Session with ID '${sessionId}' was not found.`
}
}

View File

@@ -40,10 +40,12 @@ interface TriggerProgramPayload {
interface TriggerProgramResponse { interface TriggerProgramResponse {
/** /**
* The SessionId is the name of the temporary folder used to store the outputs. * `sessionId` is the ID of the session and the name of the temporary folder
* For SAS, this would be the SASWORK folder. Can be used to poll program status. * used to store program outputs.<br><br>
* This session ID should be used to poll program status. * For SAS, this would be the location of the SASWORK folder.<br><br>
* @example "{ sessionId: '20241028074744-54132-1730101664824' }" * `sessionId` can be used to poll session state using the
* GET /SASjsApi/session/{sessionId}/state endpoint.
* @example "20241028074744-54132-1730101664824"
*/ */
sessionId: string sessionId: string
} }

View File

@@ -106,7 +106,10 @@ const login = async (
const rateLimiter = RateLimiter.getInstance() const rateLimiter = RateLimiter.getInstance()
if (!validPass) { if (!validPass) {
const retrySecs = await rateLimiter.consume(req.ip, user?.username) const retrySecs = await rateLimiter.consume(
req.ip || 'unknown',
user?.username
)
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs) if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
} }
@@ -114,7 +117,7 @@ const login = async (
if (!validPass) throw errors.invalidPassword if (!validPass) throw errors.invalidPassword
// Reset on successful authorization // Reset on successful authorization
rateLimiter.resetOnSuccess(req.ip, user.username) rateLimiter.resetOnSuccess(req.ip || 'unknown', user.username)
req.session.loggedIn = true req.session.loggedIn = true
req.session.user = { req.session.user = {

View File

@@ -37,10 +37,10 @@ export const authenticateAccessToken: RequestHandler = async (
if (user.isActive) { if (user.isActive) {
req.user = user req.user = user
return csrfProtection(req, res, nextFunction) return csrfProtection(req, res, nextFunction)
} else return res.sendStatus(401) } else return res.status(401).send('Unauthorized')
} }
} }
return res.sendStatus(401) return res.status(401).send('Unauthorized')
} }
await authenticateToken( await authenticateToken(
@@ -118,6 +118,6 @@ const authenticateToken = async (
return next() return next()
} }
res.sendStatus(401) res.status(401).send('Unauthorized')
} }
} }

View File

@@ -3,7 +3,7 @@ import { convertSecondsToHms } from '@sasjs/utils'
import { RateLimiter } from '../utils' import { RateLimiter } from '../utils'
export const bruteForceProtection: RequestHandler = async (req, res, next) => { export const bruteForceProtection: RequestHandler = async (req, res, next) => {
const ip = req.ip const ip = req.ip || 'unknown'
const username = req.body.username const username = req.body.username
const rateLimiter = RateLimiter.getInstance() const rateLimiter = RateLimiter.getInstance()

View File

@@ -76,7 +76,7 @@ groupSchema.post('save', function (group: IGroup, next: Function) {
}) })
// pre remove hook to remove all references of group from users // pre remove hook to remove all references of group from users
groupSchema.pre('remove', async function () { groupSchema.pre('remove', async function (this: IGroupDocument) {
const userIds = this.users const userIds = this.users
await Promise.all( await Promise.all(
userIds.map(async (userId) => { userIds.map(async (userId) => {

View File

@@ -1,16 +1,37 @@
import express from 'express' import express from 'express'
import { SessionController } from '../../controllers' import { SessionController } from '../../controllers'
import { sessionIdValidation } from '../../utils'
const sessionRouter = express.Router() const sessionRouter = express.Router()
const controller = new SessionController()
sessionRouter.get('/', async (req, res) => { sessionRouter.get('/', async (req, res) => {
const controller = new SessionController()
try { try {
const response = await controller.session(req) const response = await controller.session(req)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) res.status(403).send(err.toString())
} }
}) })
sessionRouter.get('/:sessionId/state', async (req, res) => {
const { error, value: params } = sessionIdValidation(req.params)
if (error) return res.status(400).send(error.details[0].message)
try {
const response = await controller.sessionState(params.sessionId)
res.status(200)
res.send(response)
} catch (err: any) {
const statusCode = err.code
delete err.code
res.status(statusCode).send(err)
}
})
export default sessionRouter export default sessionRouter

View File

@@ -25,7 +25,7 @@ import {
SASSessionController SASSessionController
} from '../../../controllers/internal' } from '../../../controllers/internal'
import * as ProcessProgramModule from '../../../controllers/internal/processProgram' import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
import { Session } from '../../../types' import { Session, SessionState } from '../../../types'
const clientId = 'someclientID' const clientId = 'someclientID'
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: true, state: SessionState.pending,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder

View File

@@ -277,7 +277,10 @@ const performLogin = async (
.set('x-xsrf-token', csrfToken) .set('x-xsrf-token', csrfToken)
.send(credentials) .send(credentials)
return { authCookies: header['set-cookie'].join() } return {
authCookies:
(header['set-cookie'] as unknown as string[] | undefined)?.join() || ''
}
} }
const extractCSRF = (text: string) => const extractCSRF = (text: string) =>

View File

@@ -1,12 +1,16 @@
export enum SessionState {
initialising = 'initialising', // session is initialising and not ready to be used yet
pending = 'pending', // session is ready to be used
running = 'running', // session is in use
completed = 'completed', // session is completed and can be destroyed
failed = 'failed' // session failed
}
export interface Session { export interface Session {
id: string id: string
ready: boolean state: SessionState
creationTimeStamp: string creationTimeStamp: string
deathTimeStamp: string deathTimeStamp: string
path: string path: string
inUse: boolean
consumed: boolean
completed: boolean
crashed?: string
expiresAfterMins?: { mins: number; used: boolean } expiresAfterMins?: { mins: number; used: boolean }
failureReason?: string
} }

View File

@@ -1,5 +1,6 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import User from '../model/User' import User from '../model/User'
import { InfoJWT } from '../types/InfoJWT'
const isValidToken = async ( const isValidToken = async (
token: string, token: string,
@@ -11,7 +12,8 @@ const isValidToken = async (
jwt.verify(token, key, (err, decoded) => { jwt.verify(token, key, (err, decoded) => {
if (err) return reject(false) if (err) return reject(false)
if (decoded?.userId === userId && decoded?.clientId === clientId) { const payload = decoded as InfoJWT
if (payload?.userId === userId && payload?.clientId === clientId) {
return resolve(true) return resolve(true)
} }

View File

@@ -201,3 +201,8 @@ export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
}) })
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number())) .pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
.validate(data) .validate(data)
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
Joi.object({
sessionId: Joi.string().required()
}).validate(data)

12864
package-lock.json generated

File diff suppressed because it is too large Load Diff

1263
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,8 @@
"@types/jest": "^26.0.24", "@types/jest": "^26.0.24",
"@types/node": "^12.20.28", "@types/node": "^12.20.28",
"@types/react": "^17.0.27", "@types/react": "^17.0.27",
"axios": "^0.24.0", "axios": "^1.12.2",
"monaco-editor": "^0.33.0", "monaco-editor": "^0.33.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0", "react-copy-to-clipboard": "^5.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@@ -54,6 +53,7 @@
"eslint-webpack-plugin": "^3.1.1", "eslint-webpack-plugin": "^3.1.1",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"html-webpack-plugin": "5.5.0", "html-webpack-plugin": "5.5.0",
"monaco-editor-webpack-plugin": "^7.0.1",
"path": "0.12.7", "path": "0.12.7",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"sass": "^1.44.0", "sass": "^1.44.0",

View File

@@ -1,15 +1,15 @@
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', font-family:
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
sans-serif; 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', font-family:
monospace; source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
} }
.container { .container {