├── .nvmrc ├── api ├── .nvmrc ├── mocks │ └── sas9 │ │ └── generic │ │ ├── logged-in │ │ ├── logged-out │ │ ├── public-access-denied │ │ ├── sas-stored-process │ │ └── login ├── src │ ├── routes │ │ ├── api │ │ │ ├── spec │ │ │ │ ├── files │ │ │ │ │ ├── sample.exe │ │ │ │ │ └── sample.sas │ │ │ │ └── info.spec.ts │ │ │ ├── info.ts │ │ │ ├── authConfig.ts │ │ │ ├── session.ts │ │ │ ├── client.ts │ │ │ ├── code.ts │ │ │ ├── auth.ts │ │ │ ├── index.ts │ │ │ ├── permission.ts │ │ │ └── stp.ts │ │ ├── setupRoutes.ts │ │ ├── web │ │ │ ├── index.ts │ │ │ ├── sasviya-web.ts │ │ │ └── web.ts │ │ └── appStream │ │ │ ├── style.ts │ │ │ ├── appStreamHtml.ts │ │ │ └── index.ts │ ├── types │ │ ├── system │ │ │ ├── global.d.ts │ │ │ ├── express-session.d.ts │ │ │ ├── express.d.ts │ │ │ └── process.d.ts │ │ ├── InfoJWT.ts │ │ ├── Execution.ts │ │ ├── AppStreamConfig.ts │ │ ├── TreeNode.ts │ │ ├── PreProgramVars.ts │ │ ├── Upload.ts │ │ ├── RequestUser.ts │ │ ├── index.ts │ │ └── Session.ts │ ├── app-modules │ │ ├── index.ts │ │ ├── configureCors.ts │ │ ├── configureSecurity.ts │ │ ├── configureLogger.ts │ │ └── configureExpressSession.ts │ ├── utils │ │ ├── extractName.ts │ │ ├── generateAuthCode.ts │ │ ├── parseLogToArray.ts │ │ ├── instantiateLogger.ts │ │ ├── isDebugOn.ts │ │ ├── setupFolders.ts │ │ ├── generateAccessToken.ts │ │ ├── generateRefreshToken.ts │ │ ├── connectDB.ts │ │ ├── desktopAutoExec.ts │ │ ├── getSequenceNextValue.ts │ │ ├── setupUserAutoExec.ts │ │ ├── getServerUrl.ts │ │ ├── removeTokensInDB.ts │ │ ├── createWeboutSasFile.ts │ │ ├── saveTokensInDB.ts │ │ ├── extractHeaders.ts │ │ ├── specs │ │ │ ├── parseLogToArray.spec.ts │ │ │ └── extractHeaders.spec.ts │ │ ├── isPublicRoute.ts │ │ ├── parseHelmetConfig.ts │ │ ├── copySASjsCore.ts │ │ ├── getPreProgramVariables.ts │ │ ├── getAuthorizedRoutes.ts │ │ ├── getRunTimeAndFilePath.ts │ │ ├── index.ts │ │ ├── zipped.ts │ │ ├── getCertificates.ts │ │ ├── verifyTokenInDB.ts │ │ ├── getTokensFromDB.ts │ │ ├── file.ts │ │ └── appStreamConfig.ts │ ├── middlewares │ │ ├── index.ts │ │ ├── verifyAdmin.ts │ │ ├── bruteForceProtection.ts │ │ ├── verifyAdminIfNeeded.ts │ │ ├── desktop.ts │ │ ├── csrfProtection.ts │ │ ├── multer.ts │ │ └── authorize.ts │ ├── model │ │ ├── Counter.ts │ │ ├── Configuration.ts │ │ ├── Client.ts │ │ └── Permission.ts │ ├── controllers │ │ ├── internal │ │ │ ├── index.ts │ │ │ ├── createRProgram.ts │ │ │ ├── deploy.ts │ │ │ ├── createPythonProgram.ts │ │ │ ├── createJSProgram.ts │ │ │ ├── FileUploadController.ts │ │ │ └── createSASProgram.ts │ │ ├── index.ts │ │ ├── info.ts │ │ ├── session.ts │ │ └── client.ts │ └── server.ts ├── public │ ├── plus.png │ ├── app-streams-script.js │ ├── SASjsApi │ │ └── swagger-ui-init.js │ └── sasjs-logo.svg ├── .dockerignore ├── csp.config.example.json ├── .vscode │ └── launch.json ├── tsconfig.json ├── jest.config.js ├── scripts │ ├── systemInit.sas │ ├── copySASjsCore.ts │ ├── compileSysInit.ts │ └── downloadMacros.ts ├── .env.example └── tsoa.json ├── web ├── .nvmrc ├── .dockerignore ├── .env.example ├── src │ ├── react-app-env.d.ts │ ├── utils │ │ ├── index.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useModal.tsx │ │ │ ├── useSnackbar.tsx │ │ │ ├── useStateWithCallback.ts │ │ │ └── usePrompt.ts │ │ ├── types.ts │ │ └── helper.ts │ ├── types │ │ └── declaration.d.ts │ ├── setupTests.ts │ ├── theme │ │ ├── index.js │ │ └── palette.js │ ├── containers │ │ ├── Settings │ │ │ ├── internal │ │ │ │ ├── helper.tsx │ │ │ │ ├── hooks │ │ │ │ │ ├── usePermissionResponseModal.tsx │ │ │ │ │ ├── useDeletePermissionModal.tsx │ │ │ │ │ ├── useUpdatePermissionModal.tsx │ │ │ │ │ └── usePermission.ts │ │ │ │ └── components │ │ │ │ │ ├── addPermission.tsx │ │ │ │ │ ├── displayGroup.tsx │ │ │ │ │ ├── updatePermissionModal.tsx │ │ │ │ │ └── filterPermissions.tsx │ │ │ ├── permission.tsx │ │ │ └── index.tsx │ │ ├── Studio │ │ │ └── internal │ │ │ │ ├── helper.ts │ │ │ │ └── components │ │ │ │ ├── log │ │ │ │ ├── logTabWithIcons.tsx │ │ │ │ └── log.module.css │ │ │ │ └── runMenu.tsx │ │ └── AuthCode │ │ │ └── index.tsx │ ├── index.css │ ├── index.tsx │ ├── components │ │ ├── username.tsx │ │ ├── dialogTitle.tsx │ │ ├── home.tsx │ │ ├── modal.tsx │ │ ├── deleteConfirmationModal.tsx │ │ ├── snackbar.tsx │ │ └── filePathInputModal.tsx │ ├── index.html │ └── App.tsx ├── public │ ├── logo.png │ ├── favicon.ico │ ├── robots.txt │ ├── running-sas.png │ ├── running-sas-white.png │ └── manifest.json ├── .babelrc ├── Dockerfile ├── webpack.prod.ts ├── tsconfig.json ├── webpack.dev.ts ├── webpack.common.ts ├── README.md └── package.json ├── restClient ├── sample.sas ├── session.rest ├── users.rest ├── auth.rest ├── stp.rest └── drive.rest ├── .vscode ├── settings.json ├── launch.json └── tasks.json ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── build.yml ├── mongo-seed ├── clients │ ├── clients.json │ └── Dockerfile └── users │ ├── Dockerfile │ └── users.json ├── .prettierrc.json ├── .gitignore ├── DockerfileApi ├── .env.example ├── DockerfileProd ├── docker-compose.debug.yml ├── .gitpod.yml ├── PULL_REQUEST_TEMPLATE.md ├── .dockerignore ├── .git-hooks └── commit-msg ├── .releaserc ├── LICENSE ├── docker-compose.prod.yml ├── package.json ├── docker-compose.yml └── .all-contributorsrc /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.15.1 -------------------------------------------------------------------------------- /api/.nvmrc: -------------------------------------------------------------------------------- 1 | v16.15.1 -------------------------------------------------------------------------------- /web/.nvmrc: -------------------------------------------------------------------------------- 1 | v16.15.1 -------------------------------------------------------------------------------- /restClient/sample.sas: -------------------------------------------------------------------------------- 1 | some code of sas -------------------------------------------------------------------------------- /api/mocks/sas9/generic/logged-in: -------------------------------------------------------------------------------- 1 | You have signed in. -------------------------------------------------------------------------------- /api/mocks/sas9/generic/logged-out: -------------------------------------------------------------------------------- 1 | You have signed out. -------------------------------------------------------------------------------- /api/src/routes/api/spec/files/sample.exe: -------------------------------------------------------------------------------- 1 | some code of sas -------------------------------------------------------------------------------- /api/src/routes/api/spec/files/sample.sas: -------------------------------------------------------------------------------- 1 | some code of sas -------------------------------------------------------------------------------- /api/src/types/system/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended' 2 | -------------------------------------------------------------------------------- /web/.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /api/mocks/sas9/generic/public-access-denied: -------------------------------------------------------------------------------- 1 | Public access has been denied. -------------------------------------------------------------------------------- /api/mocks/sas9/generic/sas-stored-process: -------------------------------------------------------------------------------- 1 | "title": "Log Off SAS Demo User" -------------------------------------------------------------------------------- /web/.env.example: -------------------------------------------------------------------------------- 1 | PORT_API=[place sasjs server port] default value is 5000 -------------------------------------------------------------------------------- /web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["autoexec", "initialising"] 3 | } 4 | -------------------------------------------------------------------------------- /api/public/plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/server/main/api/public/plus.png -------------------------------------------------------------------------------- /web/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/server/main/web/public/logo.png -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | node_modules 4 | public 5 | web 6 | Dockerfile 7 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/server/main/web/public/favicon.ico -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [sasjs] 4 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/public/running-sas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/server/main/web/public/running-sas.png -------------------------------------------------------------------------------- /api/src/types/InfoJWT.ts: -------------------------------------------------------------------------------- 1 | export interface InfoJWT { 2 | clientId: string 3 | userId: number 4 | } 5 | -------------------------------------------------------------------------------- /web/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './log' 2 | export * from './types' 3 | export * from './helper' 4 | -------------------------------------------------------------------------------- /web/public/running-sas-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/server/main/web/public/running-sas-white.png -------------------------------------------------------------------------------- /mongo-seed/clients/clients.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "clientId": "clientID1", 4 | "clientSecret": "clientSecret" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /api/src/types/Execution.ts: -------------------------------------------------------------------------------- 1 | export interface ExecutionResult { 2 | webout?: string 3 | log?: string 4 | logPath?: string 5 | } 6 | -------------------------------------------------------------------------------- /web/src/types/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.css' { 2 | const classes: { [key: string]: string } 3 | export default classes 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "endOfLine": "auto" 7 | } -------------------------------------------------------------------------------- /web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react", "@babel/preset-typescript"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /web/src/utils/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useModal' 2 | export * from './usePrompt' 3 | export * from './useStateWithCallback' 4 | export * from './useSnackbar' 5 | -------------------------------------------------------------------------------- /api/csp.config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "img-src": ["'self'", "data:"], 3 | "script-src": ["'self'", "'unsafe-inline'"], 4 | "script-src-attr": ["'self'", "'unsafe-inline'"] 5 | } -------------------------------------------------------------------------------- /api/src/app-modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './configureCors' 2 | export * from './configureExpressSession' 3 | export * from './configureLogger' 4 | export * from './configureSecurity' 5 | -------------------------------------------------------------------------------- /api/src/types/AppStreamConfig.ts: -------------------------------------------------------------------------------- 1 | export interface AppStreamConfig { 2 | [key: string]: { 3 | appLoc: string 4 | streamWebFolder: string 5 | streamLogo?: string 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /api/src/types/TreeNode.ts: -------------------------------------------------------------------------------- 1 | export interface TreeNode { 2 | name: string 3 | relativePath: string 4 | absolutePath: string 5 | isFolder: boolean 6 | children: Array 7 | } 8 | -------------------------------------------------------------------------------- /mongo-seed/users/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mongo 2 | 3 | COPY ./users.json /users.json 4 | CMD mongoimport --host mongodb --db sasjs --collection users --type json --file /users.json --jsonArray 5 | -------------------------------------------------------------------------------- /api/src/types/PreProgramVars.ts: -------------------------------------------------------------------------------- 1 | export interface PreProgramVars { 2 | username: string 3 | userId: number 4 | displayName: string 5 | serverUrl: string 6 | httpHeaders: string[] 7 | } 8 | -------------------------------------------------------------------------------- /mongo-seed/clients/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mongo 2 | 3 | COPY ./clients.json /clients.json 4 | CMD mongoimport --host mongodb --db sasjs --collection clients --type json --file /clients.json --jsonArray 5 | -------------------------------------------------------------------------------- /api/src/utils/extractName.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | export const extractName = (filePath: string) => { 4 | const extension = path.extname(filePath) 5 | return path.basename(filePath, extension) 6 | } 7 | -------------------------------------------------------------------------------- /restClient/session.rest: -------------------------------------------------------------------------------- 1 | ### Get current user's info via session ID 2 | GET http://localhost:5000/SASjsApi/session 3 | cookie: connect.sid=s:G2DeFdKuWhnmTOsTHmTWrxAXPx2P6TLD.JyNLxfACC1w3NlFQFfL5chyxtrqbPYmS6iButRc1goE -------------------------------------------------------------------------------- /api/src/types/system/express-session.d.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | declare module 'express-session' { 3 | interface SessionData { 4 | loggedIn: boolean 5 | user: import('../').RequestUser 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /api/src/types/system/express.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | accessToken?: string 4 | user?: import('../').RequestUser 5 | sasjsSession?: import('../').Session 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | WORKDIR /usr/server/web 3 | COPY ["package.json","package-lock.json", "./"] 4 | RUN npm ci 5 | COPY . . 6 | # RUN chown -R node /usr/server/api 7 | # USER node 8 | CMD ["npm","start"] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | dist/ 3 | node_modules/ 4 | .DS_Store 5 | .env* 6 | sas/ 7 | sasjs_root/ 8 | tmp/ 9 | build/ 10 | sasjsbuild/ 11 | sasjscore/ 12 | certificates/ 13 | executables/ 14 | .env 15 | api/csp.config.json 16 | -------------------------------------------------------------------------------- /api/src/types/Upload.ts: -------------------------------------------------------------------------------- 1 | export interface MulterFile { 2 | fieldname: string 3 | originalname: string 4 | encoding: string 5 | mimetype: string 6 | destination: string 7 | filename: string 8 | path: string 9 | size: number 10 | } 11 | -------------------------------------------------------------------------------- /api/src/utils/generateAuthCode.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { InfoJWT } from '../types' 3 | 4 | export const generateAuthCode = (data: InfoJWT) => 5 | jwt.sign(data, process.secrets.AUTH_CODE_SECRET, { 6 | expiresIn: '30s' 7 | }) 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Docker Node.js Launch", 5 | "type": "docker", 6 | "request": "launch", 7 | "preLaunchTask": "docker-run: debug", 8 | "platform": "node" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /api/src/types/RequestUser.ts: -------------------------------------------------------------------------------- 1 | export interface RequestUser { 2 | userId: number 3 | clientId: string 4 | username: string 5 | displayName: string 6 | isAdmin: boolean 7 | isActive: boolean 8 | needsToUpdatePassword: boolean 9 | autoExec?: string 10 | } 11 | -------------------------------------------------------------------------------- /api/src/types/index.ts: -------------------------------------------------------------------------------- 1 | // TODO: uppercase types 2 | export * from './AppStreamConfig' 3 | export * from './Execution' 4 | export * from './InfoJWT' 5 | export * from './PreProgramVars' 6 | export * from './Session' 7 | export * from './TreeNode' 8 | export * from './RequestUser' 9 | -------------------------------------------------------------------------------- /api/src/utils/parseLogToArray.ts: -------------------------------------------------------------------------------- 1 | export interface LogLine { 2 | line: string 3 | } 4 | 5 | export const parseLogToArray = (content?: string): LogLine[] => { 6 | if (!content) return [] 7 | 8 | return content.split('\n').map((line) => ({ line: line })) 9 | } 10 | -------------------------------------------------------------------------------- /web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | -------------------------------------------------------------------------------- /api/src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authenticateToken' 2 | export * from './authorize' 3 | export * from './csrfProtection' 4 | export * from './desktop' 5 | export * from './verifyAdmin' 6 | export * from './verifyAdminIfNeeded' 7 | export * from './bruteForceProtection' 8 | -------------------------------------------------------------------------------- /DockerfileApi: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | WORKDIR /usr/server/api 3 | COPY ["package.json","package-lock.json", "./"] 4 | RUN npm ci 5 | COPY ./api . 6 | COPY ./certificates ../certificates 7 | # RUN chown -R node /usr/server/api 8 | # USER node 9 | CMD ["npm","start"] 10 | -------------------------------------------------------------------------------- /mongo-seed/users/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "displayName": "Super Admin", 5 | "username": "secretuser", 6 | "password": "$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO", 7 | "isAdmin": true, 8 | "isActive": true 9 | } 10 | ] 11 | -------------------------------------------------------------------------------- /api/src/utils/instantiateLogger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel, Logger } from '@sasjs/utils/logger' 2 | 3 | export const instantiateLogger = () => { 4 | const logLevel = (process.env.LOG_LEVEL || LogLevel.Info) as LogLevel 5 | const logger = new Logger(logLevel) 6 | process.logger = logger 7 | } 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SAS_EXEC_PATH= 2 | SAS_EXEC_NAME= 3 | PORT_API= 4 | PORT_WEB= 5 | ACCESS_TOKEN_SECRET= 6 | REFRESH_TOKEN_SECRET= 7 | AUTH_CODE_SECRET= -------------------------------------------------------------------------------- /api/src/utils/isDebugOn.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionVars } from '../controllers/internal' 2 | 3 | export const isDebugOn = (vars: ExecutionVars) => { 4 | const debugValue = 5 | typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug 6 | 7 | return !!(debugValue && debugValue >= 131) 8 | } 9 | -------------------------------------------------------------------------------- /api/src/utils/setupFolders.ts: -------------------------------------------------------------------------------- 1 | import { createFolder } from '@sasjs/utils' 2 | import { getFilesFolder, getPackagesFolder } from './file' 3 | 4 | export const setupFilesFolder = async () => await createFolder(getFilesFolder()) 5 | 6 | export const setupPackagesFolder = async () => 7 | await createFolder(getPackagesFolder()) 8 | -------------------------------------------------------------------------------- /api/src/model/Counter.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose' 2 | 3 | const CounterSchema = new Schema({ 4 | id: { 5 | type: String, 6 | required: true, 7 | unique: true 8 | }, 9 | seq: { 10 | type: Number, 11 | required: true 12 | } 13 | }) 14 | 15 | export default mongoose.model('Counter', CounterSchema) 16 | -------------------------------------------------------------------------------- /api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Launch via NPM", 6 | "request": "launch", 7 | "runtimeArgs": ["run-script", "start"], 8 | "runtimeExecutable": "npm", 9 | "skipFiles": ["/**"], 10 | "type": "pwa-node" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /DockerfileProd: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | RUN npm install -g @sasjs/cli 3 | WORKDIR /usr/server/ 4 | COPY . . 5 | RUN cd web && npm ci --silent 6 | RUN cd web && REACT_APP_CLIENT_ID=clientID1 npm run build 7 | RUN cd api && npm ci --silent 8 | # RUN chown -R node /usr/server/api 9 | # USER node 10 | WORKDIR /usr/server/api 11 | CMD ["npm","run","start:prod"] 12 | -------------------------------------------------------------------------------- /api/src/controllers/internal/index.ts: -------------------------------------------------------------------------------- 1 | export * from './deploy' 2 | export * from './Session' 3 | export * from './Execution' 4 | export * from './FileUploadController' 5 | export * from './createSASProgram' 6 | export * from './createJSProgram' 7 | export * from './createPythonProgram' 8 | export * from './createRProgram' 9 | export * from './processProgram' 10 | -------------------------------------------------------------------------------- /api/src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth' 2 | export * from './authConfig' 3 | export * from './client' 4 | export * from './code' 5 | export * from './drive' 6 | export * from './group' 7 | export * from './info' 8 | export * from './permission' 9 | export * from './session' 10 | export * from './stp' 11 | export * from './user' 12 | export * from './web' 13 | -------------------------------------------------------------------------------- /docker-compose.debug.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | server: 5 | image: server 6 | build: 7 | context: . 8 | dockerfile: ./Dockerfile 9 | environment: 10 | NODE_ENV: development 11 | ports: 12 | - 3000:3000 13 | - 9229:9229 14 | command: ["node", "--inspect=0.0.0.0:9229", "./src/server.ts"] 15 | -------------------------------------------------------------------------------- /api/src/utils/generateAccessToken.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { InfoJWT } from '../types' 3 | import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client' 4 | 5 | export const generateAccessToken = (data: InfoJWT, expiry?: number) => 6 | jwt.sign(data, process.secrets.ACCESS_TOKEN_SECRET, { 7 | expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY 8 | }) 9 | -------------------------------------------------------------------------------- /api/src/utils/generateRefreshToken.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { InfoJWT } from '../types' 3 | import { NUMBER_OF_SECONDS_IN_A_DAY } from '../model/Client' 4 | 5 | export const generateRefreshToken = (data: InfoJWT, expiry?: number) => 6 | jwt.sign(data, process.secrets.REFRESH_TOKEN_SECRET, { 7 | expiresIn: expiry ? expiry : NUMBER_OF_SECONDS_IN_A_DAY 8 | }) 9 | -------------------------------------------------------------------------------- /api/src/utils/connectDB.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { seedDB } from './seedDB' 3 | 4 | export const connectDB = async () => { 5 | try { 6 | await mongoose.connect(process.env.DB_CONNECT as string) 7 | } catch (err) { 8 | throw new Error('Unable to connect to DB!') 9 | } 10 | 11 | process.logger.success('Connected to DB!') 12 | return seedDB() 13 | } 14 | -------------------------------------------------------------------------------- /api/src/utils/desktopAutoExec.ts: -------------------------------------------------------------------------------- 1 | import { createFile, readFile } from '@sasjs/utils' 2 | import { getDesktopUserAutoExecPath } from './file' 3 | 4 | export const getUserAutoExec = async (): Promise => 5 | readFile(getDesktopUserAutoExecPath()) 6 | 7 | export const updateUserAutoExec = async (autoExecContent: string) => 8 | createFile(getDesktopUserAutoExecPath(), autoExecContent) 9 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | tasks: 6 | - init: npm install 7 | vscode: 8 | extensions: 9 | - dbaeumer.vscode-eslint 10 | - sasjs.sasjs-for-vscode 11 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "rootDir": "./", 6 | "outDir": "./build", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true 12 | }, 13 | "ts-node": { 14 | "files": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "SASjs Server Web", 3 | "name": "SASjs Server Web Interface", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /api/src/middlewares/verifyAdmin.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express' 2 | import { ModeType } from '../utils' 3 | 4 | export const verifyAdmin: RequestHandler = (req, res, next) => { 5 | const { MODE } = process.env 6 | if (MODE === ModeType.Desktop) return next() 7 | 8 | const { user } = req 9 | if (!user?.isAdmin) return res.status(401).send('Admin account required') 10 | next() 11 | } 12 | -------------------------------------------------------------------------------- /api/src/utils/getSequenceNextValue.ts: -------------------------------------------------------------------------------- 1 | import Counter from '../model/Counter' 2 | 3 | export const getSequenceNextValue = async (seqName: string) => { 4 | const seqDoc = await Counter.findOne({ id: seqName }) 5 | if (!seqDoc) { 6 | await Counter.create({ id: seqName, seq: 1 }) 7 | return 1 8 | } 9 | 10 | seqDoc.seq += 1 11 | 12 | await seqDoc.save() 13 | 14 | return seqDoc.seq 15 | } 16 | -------------------------------------------------------------------------------- /api/src/utils/setupUserAutoExec.ts: -------------------------------------------------------------------------------- 1 | import { createFile, fileExists } from '@sasjs/utils' 2 | import { getDesktopUserAutoExecPath } from './file' 3 | import { ModeType } from './verifyEnvVariables' 4 | 5 | export const setupUserAutoExec = async () => { 6 | if (process.env.MODE === ModeType.Desktop) { 7 | if (!(await fileExists(getDesktopUserAutoExecPath()))) { 8 | await createFile(getDesktopUserAutoExecPath(), '') 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /api/src/utils/getServerUrl.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import url from 'url' 3 | 4 | export const getFullUrl = (req: express.Request) => 5 | url.format({ 6 | protocol: req.protocol, 7 | host: req.get('host'), 8 | pathname: req.originalUrl 9 | }) 10 | 11 | export const getServerUrl = (req: express.Request) => 12 | url.format({ 13 | protocol: req.protocol, 14 | host: req.get('x-forwarded-host') || req.get('host') 15 | }) 16 | -------------------------------------------------------------------------------- /web/src/theme/index.js: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles' 2 | import palette from './palette' 3 | 4 | export const theme = createTheme({ 5 | palette, 6 | components: { 7 | MuiTab: { 8 | styleOverrides: { 9 | root: { 10 | fontSize: '21px', 11 | color: palette.white, 12 | '&.Mui-selected': { 13 | color: palette.secondary.main 14 | } 15 | } 16 | } 17 | } 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /api/src/utils/removeTokensInDB.ts: -------------------------------------------------------------------------------- 1 | import User from '../model/User' 2 | 3 | export const removeTokensInDB = async (userId: number, clientId: string) => { 4 | const user = await User.findOne({ id: userId }) 5 | if (!user) return 6 | 7 | const tokenObjIndex = user.tokens.findIndex( 8 | (tokenObj: any) => tokenObj.clientId === clientId 9 | ) 10 | 11 | if (tokenObjIndex > -1) { 12 | user.tokens.splice(tokenObjIndex, 1) 13 | await user.save() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /restClient/users.rest: -------------------------------------------------------------------------------- 1 | ### Create User 2 | POST http://localhost:5000/SASjsApi/user 3 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InNlY3JldHVzZXIiLCJpc2FkbWluIjp0cnVlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODAzOTc3LCJleHAiOjE2MzU4OTAzNzd9.f-FLgLwryKvB5XrihdzaGZajO3d5E5OHEEuJI_03GRI 4 | Content-Type: application/json 5 | 6 | { 7 | "displayname": "User 2", 8 | "username": "username2", 9 | "password": "some password" 10 | } 11 | -------------------------------------------------------------------------------- /api/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest/presets/js-with-ts', 3 | testEnvironment: 'node', 4 | // FIXME: improve test coverage and uncomment below lines 5 | // coverageThreshold: { 6 | // global: { 7 | // branches: 80, 8 | // functions: 80, 9 | // lines: 80, 10 | // statements: -10 11 | // } 12 | // }, 13 | collectCoverageFrom: ['src/**/{!(index),}.ts'], 14 | testPathIgnorePatterns: ['/node_modules/', '/build/'] 15 | } 16 | -------------------------------------------------------------------------------- /web/webpack.prod.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Configuration } from 'webpack' 3 | import { merge } from 'webpack-merge' 4 | 5 | import common from './webpack.common' 6 | 7 | const prodConfig: Configuration = merge(common, { 8 | mode: 'production', 9 | output: { 10 | path: path.join(__dirname, 'build'), 11 | filename: 'index.bundle.js', 12 | publicPath: './' 13 | }, 14 | performance: { 15 | hints: false 16 | } 17 | }) 18 | 19 | export default prodConfig 20 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Issue 2 | 3 | Link any related issue(s) in this section. 4 | 5 | ## Intent 6 | 7 | What this PR intends to achieve. 8 | 9 | ## Implementation 10 | 11 | What code changes have been made to achieve the intent. 12 | 13 | ## Checks 14 | 15 | - [ ] Code is formatted correctly (`npm run lint:fix`). 16 | - [ ] Any new functionality has been unit tested. 17 | - [ ] All unit tests are passing (`npm test`). 18 | - [ ] All CI checks are green. 19 | - [ ] Reviewer is assigned. 20 | -------------------------------------------------------------------------------- /api/scripts/systemInit.sas: -------------------------------------------------------------------------------- 1 | /** 2 | @file 3 | @brief The systemInit program 4 | @details This program is inserted into every sasjs/server program invocation, 5 | _before_ any user-provided content. 6 | 7 | A number of useful CORE macros are also compiled below, so that they can be 8 | available by default for Stored Programs. 9 | 10 | Note that the full CORE library is available to sessions in SASjs Studio. 11 | 12 |

SAS Macros

13 | @li mfs_httpheader.sas 14 | @li ms_webout.sas 15 | **/ 16 | 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | !.env 5 | **/.git 6 | **/.gitignore 7 | **/.project 8 | **/.settings 9 | **/.toolstarget 10 | **/.vs 11 | **/.vscode 12 | **/*.*proj.user 13 | **/*.dbmdl 14 | **/*.jfm 15 | **/charts 16 | **/docker-compose* 17 | **/compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | api/build 25 | api/coverage 26 | api/build 27 | api/node_modules 28 | api/public 29 | api/web 30 | web/build 31 | web/node_modules 32 | README.md 33 | -------------------------------------------------------------------------------- /api/src/routes/setupRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | 3 | import webRouter from './web' 4 | import apiRouter from './api' 5 | import appStreamRouter from './appStream' 6 | 7 | import { csrfProtection } from '../middlewares' 8 | 9 | export const setupRoutes = (app: Express) => { 10 | app.use('/SASjsApi', apiRouter) 11 | 12 | app.use('/AppStream', csrfProtection, function (req, res, next) { 13 | // this needs to be a function to hook on 14 | // whatever the current router is 15 | appStreamRouter(req, res, next) 16 | }) 17 | 18 | app.use('/', webRouter) 19 | } 20 | -------------------------------------------------------------------------------- /web/src/utils/hooks/useModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import Modal from '../../components/modal' 3 | 4 | export const useModal = () => { 5 | const [openModal, setOpenModal] = useState(false) 6 | const [modalTitle, setModalTitle] = useState('') 7 | const [modalPayload, setModalPayload] = useState('') 8 | 9 | const Dialog = () => ( 10 | 16 | ) 17 | 18 | return { Dialog, setOpenModal, setModalTitle, setModalPayload } 19 | } 20 | -------------------------------------------------------------------------------- /api/src/types/Session.ts: -------------------------------------------------------------------------------- 1 | export enum SessionState { 2 | initialising = 'initialising', // session is initialising and not ready to be used yet 3 | pending = 'pending', // session is ready to be used 4 | running = 'running', // session is in use 5 | completed = 'completed', // session is completed and can be destroyed 6 | failed = 'failed' // session failed 7 | } 8 | export interface Session { 9 | id: string 10 | state: SessionState 11 | creationTimeStamp: string 12 | deathTimeStamp: string 13 | path: string 14 | expiresAfterMins?: { mins: number; used: boolean } 15 | failureReason?: string 16 | } 17 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/helper.tsx: -------------------------------------------------------------------------------- 1 | import { PermissionResponse } from '../../../utils/types' 2 | import { PrincipalType } from './hooks/usePermission' 3 | import DisplayGroup from './components/displayGroup' 4 | 5 | export const displayPrincipal = (permission: PermissionResponse) => { 6 | if (permission.user) return permission.user.username 7 | if (permission.group) return 8 | } 9 | 10 | export const displayPrincipalType = (permission: PermissionResponse) => { 11 | if (permission.user) return PrincipalType.User 12 | if (permission.group) return PrincipalType.Group 13 | } 14 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "plugins": [{ "name": "typescript-plugin-css-modules" }] 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /api/src/routes/api/spec/info.spec.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | import request from 'supertest' 3 | import appPromise from '../../../app' 4 | 5 | describe('Info', () => { 6 | let app: Express 7 | 8 | beforeAll(async () => { 9 | app = await appPromise 10 | }) 11 | 12 | it('should should return configured information of the server instance', async () => { 13 | const res = await request(app).get('/SASjsApi/info').expect(200) 14 | 15 | expect(res.body.mode).toEqual('server') 16 | expect(res.body.cors).toEqual('disable') 17 | expect(res.body.whiteList).toEqual([]) 18 | expect(res.body.protocol).toEqual('http') 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /api/src/middlewares/bruteForceProtection.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express' 2 | import { convertSecondsToHms } from '@sasjs/utils' 3 | import { RateLimiter } from '../utils' 4 | 5 | export const bruteForceProtection: RequestHandler = async (req, res, next) => { 6 | const ip = req.ip || 'unknown' 7 | const username = req.body.username 8 | 9 | const rateLimiter = RateLimiter.getInstance() 10 | 11 | const retrySecs = await rateLimiter.check(ip, username) 12 | 13 | if (retrySecs > 0) { 14 | res 15 | .status(429) 16 | .send(`Too Many Requests! Retry after ${convertSecondsToHms(retrySecs)}`) 17 | 18 | return 19 | } 20 | 21 | next() 22 | } 23 | -------------------------------------------------------------------------------- /api/src/types/system/process.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface Process { 3 | sasLoc?: string 4 | nodeLoc?: string 5 | pythonLoc?: string 6 | rLoc?: string 7 | driveLoc: string 8 | sasjsRoot: string 9 | logsLoc: string 10 | logsUUID: string 11 | sessionController?: import('../../controllers/internal').SessionController 12 | sasSessionController?: import('../../controllers/internal').SASSessionController 13 | appStreamConfig: import('../').AppStreamConfig 14 | logger: import('@sasjs/utils/logger').Logger 15 | runTimes: import('../../utils').RunTimeType[] 16 | secrets: import('../../model/Configuration').ConfigurationType 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/src/utils/createWeboutSasFile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { createFile } from '@sasjs/utils' 3 | import { getMacrosFolder } from './file' 4 | 5 | const fileContent = `%macro webout(action,ds,dslabel=,fmt=,missing=NULL,showmeta=NO,maxobs=MAX); 6 | %ms_webout(&action,ds=&ds,dslabel=&dslabel,fmt=&fmt 7 | ,missing=&missing 8 | ,showmeta=&showmeta 9 | ,maxobs=&maxobs 10 | ) 11 | %mend;` 12 | 13 | export const createWeboutSasFile = async () => { 14 | const macrosDrivePath = getMacrosFolder() 15 | process.logger.log(`Creating webout.sas at ${macrosDrivePath}`) 16 | const filePath = path.join(macrosDrivePath, 'webout.sas') 17 | await createFile(filePath, fileContent) 18 | } 19 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 4 | -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 5 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: 12 | source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 13 | } 14 | 15 | .container { 16 | margin: 50px 10px 0 10px; 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | } 21 | 22 | .permissions-page { 23 | display: flex; 24 | flex-direction: column; 25 | padding: '5px 10px'; 26 | margin-top: '10px'; 27 | } 28 | -------------------------------------------------------------------------------- /api/src/routes/web/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import sas9WebRouter from './sas9-web' 3 | import sasViyaWebRouter from './sasviya-web' 4 | import webRouter from './web' 5 | import { MOCK_SERVERTYPEType } from '../../utils' 6 | import { csrfProtection } from '../../middlewares' 7 | 8 | const router = express.Router() 9 | 10 | const { MOCK_SERVERTYPE } = process.env 11 | 12 | switch (MOCK_SERVERTYPE) { 13 | case MOCK_SERVERTYPEType.SAS9: { 14 | router.use('/', sas9WebRouter) 15 | break 16 | } 17 | case MOCK_SERVERTYPEType.SASVIYA: { 18 | router.use('/', sasViyaWebRouter) 19 | break 20 | } 21 | default: { 22 | router.use('/', csrfProtection, webRouter) 23 | } 24 | } 25 | 26 | export default router 27 | -------------------------------------------------------------------------------- /.git-hooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | RED="\033[1;31m" 3 | GREEN="\033[1;32m" 4 | 5 | # Get the commit message (the parameter we're given is just the path to the 6 | # temporary file which holds the message). 7 | commit_message=$(cat "$1") 8 | 9 | if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z \-]+\))?!?: .+$") then 10 | echo "${GREEN} ✔ Commit message meets Conventional Commit standards" 11 | exit 0 12 | fi 13 | 14 | echo "${RED}❌ Commit message does not meet the Conventional Commit standard!" 15 | echo "An example of a valid message is:" 16 | echo " feat(login): add the 'remember me' button" 17 | echo "ℹ More details at: https://www.conventionalcommits.org/en/v1.0.0/#summary" 18 | exit 1 -------------------------------------------------------------------------------- /api/src/app-modules/configureCors.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | import cors from 'cors' 3 | import { CorsType } from '../utils' 4 | 5 | export const configureCors = (app: Express) => { 6 | const { CORS, WHITELIST } = process.env 7 | 8 | if (CORS === CorsType.ENABLED) { 9 | const whiteList: string[] = [] 10 | WHITELIST?.split(' ') 11 | ?.filter((url) => !!url) 12 | .forEach((url) => { 13 | if (url.startsWith('http')) 14 | // removing trailing slash of URLs listing for CORS 15 | whiteList.push(url.replace(/\/$/, '')) 16 | }) 17 | 18 | process.logger.info('All CORS Requests are enabled for:', whiteList) 19 | app.use(cors({ credentials: true, origin: whiteList })) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/src/middlewares/verifyAdminIfNeeded.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express' 2 | 3 | // This middleware checks if a non-admin user trying to 4 | // access information of other user 5 | export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => { 6 | const { user } = req 7 | 8 | if (!user?.isAdmin) { 9 | let adminAccountRequired: boolean = true 10 | 11 | if (req.params.userId) { 12 | adminAccountRequired = user?.userId !== parseInt(req.params.userId) 13 | } else if (req.params.username) { 14 | adminAccountRequired = user?.username !== req.params.username 15 | } 16 | 17 | if (adminAccountRequired) 18 | return res.status(401).send('Admin account required') 19 | } 20 | 21 | next() 22 | } 23 | -------------------------------------------------------------------------------- /api/src/utils/saveTokensInDB.ts: -------------------------------------------------------------------------------- 1 | import User from '../model/User' 2 | 3 | export const saveTokensInDB = async ( 4 | userId: number, 5 | clientId: string, 6 | accessToken: string, 7 | refreshToken: string 8 | ) => { 9 | const user = await User.findOne({ id: userId }) 10 | if (!user) return 11 | 12 | const currentTokenObj = user.tokens.find( 13 | (tokenObj: any) => tokenObj.clientId === clientId 14 | ) 15 | if (currentTokenObj) { 16 | currentTokenObj.accessToken = accessToken 17 | currentTokenObj.refreshToken = refreshToken 18 | } else { 19 | user.tokens.push({ 20 | clientId: clientId, 21 | accessToken: accessToken, 22 | refreshToken: refreshToken 23 | }) 24 | } 25 | await user.save() 26 | } 27 | -------------------------------------------------------------------------------- /api/src/routes/api/info.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { InfoController } from '../../controllers' 3 | 4 | const infoRouter = express.Router() 5 | 6 | infoRouter.get('/', async (req, res) => { 7 | const controller = new InfoController() 8 | try { 9 | const response = controller.info() 10 | res.send(response) 11 | } catch (err: any) { 12 | res.status(403).send(err.toString()) 13 | } 14 | }) 15 | 16 | infoRouter.get('/authorizedRoutes', async (req, res) => { 17 | const controller = new InfoController() 18 | try { 19 | const response = controller.authorizedRoutes() 20 | res.send(response) 21 | } catch (err: any) { 22 | res.status(403).send(err.toString()) 23 | } 24 | }) 25 | 26 | export default infoRouter 27 | -------------------------------------------------------------------------------- /api/src/utils/extractHeaders.ts: -------------------------------------------------------------------------------- 1 | const headerUtils = require('http-headers-validation') 2 | 3 | export interface HTTPHeaders { 4 | [key: string]: string 5 | } 6 | 7 | export const extractHeaders = (content?: string): HTTPHeaders => { 8 | const headersObj: HTTPHeaders = {} 9 | const headersArr = content 10 | ?.split('\n') 11 | .map((line) => line.trim()) 12 | .filter((line) => !!line) 13 | 14 | headersArr?.forEach((headerStr) => { 15 | const [key, value] = headerStr.split(':').map((data) => data.trim()) 16 | 17 | if (value && headerUtils.validateHeader(key, value)) { 18 | headersObj[key.toLowerCase()] = value 19 | } else { 20 | delete headersObj[key.toLowerCase()] 21 | } 22 | }) 23 | 24 | return headersObj 25 | } 26 | -------------------------------------------------------------------------------- /web/src/utils/hooks/useSnackbar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import BootstrapSnackbar, { AlertSeverityType } from '../../components/snackbar' 3 | 4 | export const useSnackbar = () => { 5 | const [openSnackbar, setOpenSnackbar] = useState(false) 6 | const [snackbarMessage, setSnackbarMessage] = useState('') 7 | const [snackbarSeverity, setSnackbarSeverity] = useState( 8 | AlertSeverityType.Success 9 | ) 10 | 11 | const Snackbar = () => ( 12 | 18 | ) 19 | 20 | return { Snackbar, setOpenSnackbar, setSnackbarMessage, setSnackbarSeverity } 21 | } 22 | -------------------------------------------------------------------------------- /web/src/utils/hooks/useStateWithCallback.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react' 2 | 3 | export const useStateWithCallback = ( 4 | initialValue: T 5 | ): [T, (newValue: T, callback?: () => void) => void] => { 6 | const callbackRef = useRef(null) 7 | 8 | const [value, setValue] = useState(initialValue) 9 | 10 | useEffect(() => { 11 | if (typeof callbackRef.current === 'function') { 12 | callbackRef.current() 13 | 14 | callbackRef.current = null 15 | } 16 | }, [value]) 17 | 18 | const setValueWithCallback = (newValue: T, callback?: () => void) => { 19 | callbackRef.current = callback 20 | 21 | setValue(newValue) 22 | } 23 | 24 | return [value, setValueWithCallback] 25 | } 26 | 27 | export default useStateWithCallback 28 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | import AppContextProvider from './context/appContext' 6 | 7 | import axios from 'axios' 8 | 9 | const NODE_ENV = process.env.NODE_ENV 10 | const PORT_API = process.env.PORT_API 11 | const baseUrl = 12 | NODE_ENV === 'development' 13 | ? `http://localhost:${PORT_API ?? 5000}` 14 | : window.location.origin + window.location.pathname 15 | 16 | axios.defaults = Object.assign(axios.defaults, { 17 | withCredentials: true, 18 | baseURL: baseUrl 19 | }) 20 | 21 | ReactDOM.render( 22 | 23 | 24 | 25 | 26 | , 27 | document.getElementById('root') 28 | ) 29 | -------------------------------------------------------------------------------- /api/src/routes/api/authConfig.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { AuthConfigController } from '../../controllers' 3 | const authConfigRouter = express.Router() 4 | 5 | authConfigRouter.get('/', async (req, res) => { 6 | const controller = new AuthConfigController() 7 | try { 8 | const response = controller.getDetail() 9 | res.send(response) 10 | } catch (err: any) { 11 | res.status(500).send(err.toString()) 12 | } 13 | }) 14 | 15 | authConfigRouter.post('/synchroniseWithLDAP', async (req, res) => { 16 | const controller = new AuthConfigController() 17 | try { 18 | const response = await controller.synchroniseWithLDAP() 19 | res.send(response) 20 | } catch (err: any) { 21 | res.status(500).send(err.toString()) 22 | } 23 | }) 24 | 25 | export default authConfigRouter 26 | -------------------------------------------------------------------------------- /web/webpack.dev.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Configuration as WebpackConfiguration } from 'webpack' 3 | import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server' 4 | import { merge } from 'webpack-merge' 5 | 6 | import common from './webpack.common' 7 | 8 | interface Configuration extends WebpackConfiguration { 9 | devServer?: WebpackDevServerConfiguration 10 | } 11 | 12 | const devConfig: Configuration = merge(common, { 13 | mode: 'development', 14 | output: { 15 | path: path.join(__dirname, 'build'), 16 | filename: 'index.bundle.js', 17 | publicPath: '/' 18 | }, 19 | devServer: { 20 | static: { 21 | directory: path.join(__dirname, 'build') 22 | }, 23 | historyApiFallback: true, 24 | port: 3000 25 | } 26 | }) 27 | 28 | export default devConfig 29 | -------------------------------------------------------------------------------- /restClient/auth.rest: -------------------------------------------------------------------------------- 1 | ### Get Auth Code 2 | POST http://localhost:5000/SASjsApi/auth/authorize 3 | Content-Type: application/json 4 | 5 | { 6 | "username": "secretuser", 7 | "password": "secretpassword", 8 | "client_id": "clientID1" 9 | } 10 | 11 | ### Exchange AuthCode with Access/Refresh Tokens 12 | POST http://localhost:5000/SASjsApi/auth/token 13 | Content-Type: application/json 14 | 15 | { 16 | "client_id": "clientID1", 17 | "client_secret": "clientID1secret", 18 | "code": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDYxLCJleHAiOjE2MzU4MDQwOTF9.jV7DpBWG7XAGODs22zAW_kWOqVLZvOxmmYJGpSNQ-KM" 19 | } 20 | 21 | ### Perform logout to deactivate access token instantly 22 | DELETE http://localhost:5000/SASjsApi/auth/logout 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "docker-build", 6 | "label": "docker-build", 7 | "platform": "node", 8 | "dockerBuild": { 9 | "dockerfile": "${workspaceFolder}/Dockerfile", 10 | "context": "${workspaceFolder}", 11 | "pull": true 12 | } 13 | }, 14 | { 15 | "type": "docker-run", 16 | "label": "docker-run: release", 17 | "dependsOn": ["docker-build"], 18 | "platform": "node" 19 | }, 20 | { 21 | "type": "docker-run", 22 | "label": "docker-run: debug", 23 | "dependsOn": ["docker-build"], 24 | "dockerRun": { 25 | "env": { 26 | "DEBUG": "*", 27 | "NODE_ENV": "development" 28 | } 29 | }, 30 | "node": { 31 | "enableDebugging": true 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /api/src/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'https' 2 | 3 | import appPromise from './app' 4 | import { getCertificates } from './utils' 5 | 6 | appPromise.then(async (app) => { 7 | const protocol = process.env.PROTOCOL || 'http' 8 | const sasJsPort = process.env.PORT || 5000 9 | 10 | process.logger.info('PROTOCOL: ', protocol) 11 | 12 | if (protocol !== 'https') { 13 | app.listen(sasJsPort, () => { 14 | process.logger.info( 15 | `⚡️[server]: Server is running at http://localhost:${sasJsPort}` 16 | ) 17 | }) 18 | } else { 19 | const { key, cert, ca } = await getCertificates() 20 | 21 | const httpsServer = createServer({ key, cert, ca }, app) 22 | httpsServer.listen(sasJsPort, () => { 23 | process.logger.info( 24 | `⚡️[server]: Server is running at https://localhost:${sasJsPort}` 25 | ) 26 | }) 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /api/src/app-modules/configureSecurity.ts: -------------------------------------------------------------------------------- 1 | import { Express } from 'express' 2 | import { getEnvCSPDirectives } from '../utils/parseHelmetConfig' 3 | import { HelmetCoepType, ProtocolType } from '../utils' 4 | import helmet from 'helmet' 5 | 6 | export const configureSecurity = (app: Express) => { 7 | const { PROTOCOL, HELMET_CSP_CONFIG_PATH, HELMET_COEP } = process.env 8 | 9 | const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives( 10 | HELMET_CSP_CONFIG_PATH 11 | ) 12 | if (PROTOCOL === ProtocolType.HTTP) 13 | cspConfigJson['upgrade-insecure-requests'] = null 14 | 15 | app.use( 16 | helmet({ 17 | contentSecurityPolicy: { 18 | directives: { 19 | ...helmet.contentSecurityPolicy.getDefaultDirectives(), 20 | ...cspConfigJson 21 | } 22 | }, 23 | crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE 24 | }) 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /api/src/utils/specs/parseLogToArray.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseLogToArray } from '../parseLogToArray' 2 | 3 | describe('parseLogToArray', () => { 4 | it('should parse log to array type', () => { 5 | const log = parseLogToArray(` 6 | line 1 of log content 7 | line 2 of log content 8 | line 3 of log content 9 | line 4 of log content 10 | `) 11 | 12 | expect(log).toEqual([ 13 | { line: '' }, 14 | { line: 'line 1 of log content' }, 15 | { line: 'line 2 of log content' }, 16 | { line: 'line 3 of log content' }, 17 | { line: 'line 4 of log content' }, 18 | { line: ' ' } 19 | ]) 20 | }) 21 | 22 | it('should parse log to array type if empty', () => { 23 | const log = parseLogToArray('') 24 | 25 | expect(log).toEqual([]) 26 | }) 27 | 28 | it('should parse log to array type if not provided', () => { 29 | const log = parseLogToArray() 30 | 31 | expect(log).toEqual([]) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /api/scripts/copySASjsCore.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { 3 | asyncForEach, 4 | copy, 5 | createFile, 6 | createFolder, 7 | deleteFolder, 8 | listFilesInFolder 9 | } from '@sasjs/utils' 10 | 11 | import { 12 | apiRoot, 13 | sasJSCoreMacros, 14 | sasJSCoreMacrosInfo 15 | } from '../src/utils/file' 16 | 17 | const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core') 18 | 19 | export const copySASjsCore = async () => { 20 | await deleteFolder(sasJSCoreMacros) 21 | await createFolder(sasJSCoreMacros) 22 | 23 | const foldersToCopy = ['base', 'ddl', 'fcmp', 'lua', 'server'] 24 | 25 | await asyncForEach(foldersToCopy, async (coreSubFolder) => { 26 | const coreSubFolderPath = path.join(macroCorePath, coreSubFolder) 27 | 28 | await copy(coreSubFolderPath, sasJSCoreMacros) 29 | }) 30 | 31 | const fileNames = await listFilesInFolder(sasJSCoreMacros) 32 | 33 | await createFile(sasJSCoreMacrosInfo, fileNames.join('\n')) 34 | } 35 | 36 | copySASjsCore() 37 | -------------------------------------------------------------------------------- /web/src/utils/hooks/usePrompt.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useContext } from 'react' 2 | import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom' 3 | import { History, Blocker, Transition } from 'history' 4 | 5 | const useBlocker = (blocker: Blocker, when = true) => { 6 | const navigator = useContext(NavigationContext).navigator as History 7 | 8 | useEffect(() => { 9 | if (!when) return 10 | 11 | const unblock = navigator.block((tx: Transition) => { 12 | const autoUnblockingTx = { 13 | ...tx, 14 | retry() { 15 | unblock() 16 | tx.retry() 17 | } 18 | } 19 | 20 | blocker(autoUnblockingTx) 21 | }) 22 | 23 | return unblock 24 | }, [navigator, blocker, when]) 25 | } 26 | 27 | export const usePrompt = (message: string, when = true) => { 28 | const blocker = useCallback( 29 | (tx) => { 30 | if (window.confirm(message)) tx.retry() 31 | }, 32 | [message] 33 | ) 34 | 35 | useBlocker(blocker, when) 36 | } 37 | -------------------------------------------------------------------------------- /api/src/utils/isPublicRoute.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import { getPath } from './getAuthorizedRoutes' 3 | import Group, { PUBLIC_GROUP_NAME } from '../model/Group' 4 | import Permission from '../model/Permission' 5 | import { PermissionSettingForRoute } from '../controllers' 6 | import { RequestUser } from '../types' 7 | 8 | export const isPublicRoute = async (req: Request): Promise => { 9 | const group = await Group.findOne({ name: PUBLIC_GROUP_NAME }) 10 | if (group) { 11 | const path = getPath(req) 12 | 13 | const groupPermission = await Permission.findOne({ 14 | path, 15 | group: group?._id 16 | }) 17 | if (groupPermission?.setting === PermissionSettingForRoute.grant) 18 | return true 19 | } 20 | 21 | return false 22 | } 23 | 24 | export const publicUser: RequestUser = { 25 | userId: 0, 26 | clientId: 'public_app', 27 | username: 'publicUser', 28 | displayName: 'Public User', 29 | isAdmin: false, 30 | isActive: true, 31 | needsToUpdatePassword: false 32 | } 33 | -------------------------------------------------------------------------------- /web/src/components/username.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Typography, IconButton } from '@mui/material' 3 | import AccountCircle from '@mui/icons-material/AccountCircle' 4 | 5 | const Username = (props: any) => { 6 | return ( 7 | 14 | {props.avatarContent ? ( 15 | user-avatar 20 | ) : ( 21 | 22 | )} 23 | 31 | {props.username} 32 | 33 | 34 | ) 35 | } 36 | 37 | export default Username 38 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/changelog", 9 | [ 10 | "@semantic-release/git", 11 | { 12 | "assets": [ 13 | "CHANGELOG.md" 14 | ] 15 | } 16 | ], 17 | [ 18 | "@semantic-release/github", 19 | { 20 | "assets": [ 21 | { 22 | "path": "./executables/linux.zip", 23 | "label": "Linux Executable Binary" 24 | }, 25 | { 26 | "path": "./executables/macos.zip", 27 | "label": "Macos Executable Binary" 28 | }, 29 | { 30 | "path": "./executables/windows.zip", 31 | "label": "Windows Executable Binary" 32 | } 33 | ] 34 | } 35 | ], 36 | [ 37 | "@semantic-release/exec", 38 | { 39 | "publishCmd": "echo 'publish command'" 40 | } 41 | ] 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /api/src/utils/parseHelmetConfig.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | 4 | export const getEnvCSPDirectives = ( 5 | HELMET_CSP_CONFIG_PATH: string | undefined 6 | ) => { 7 | let cspConfigJson = { 8 | 'img-src': ["'self'", 'data:'], 9 | 'script-src': ["'self'", "'unsafe-inline'"], 10 | 'script-src-attr': ["'self'", "'unsafe-inline'"] 11 | } 12 | 13 | if ( 14 | typeof HELMET_CSP_CONFIG_PATH === 'string' && 15 | HELMET_CSP_CONFIG_PATH.length > 0 16 | ) { 17 | const cspConfigPath = path.join(process.cwd(), HELMET_CSP_CONFIG_PATH) 18 | 19 | try { 20 | let file = fs.readFileSync(cspConfigPath).toString() 21 | 22 | try { 23 | cspConfigJson = JSON.parse(file) 24 | } catch (e) { 25 | process.logger.error( 26 | 'Parsing Content Security Policy JSON config failed. Make sure it is valid json' 27 | ) 28 | } 29 | } catch (e) { 30 | process.logger.error('Error reading HELMET CSP config file', e) 31 | } 32 | } 33 | 34 | return cspConfigJson 35 | } 36 | -------------------------------------------------------------------------------- /api/src/routes/api/session.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { SessionController } from '../../controllers' 3 | import { sessionIdValidation } from '../../utils' 4 | 5 | const sessionRouter = express.Router() 6 | 7 | const controller = new SessionController() 8 | 9 | sessionRouter.get('/', async (req, res) => { 10 | try { 11 | const response = await controller.session(req) 12 | 13 | res.send(response) 14 | } catch (err: any) { 15 | res.status(403).send(err.toString()) 16 | } 17 | }) 18 | 19 | sessionRouter.get('/:sessionId/state', async (req, res) => { 20 | const { error, value: params } = sessionIdValidation(req.params) 21 | if (error) return res.status(400).send(error.details[0].message) 22 | 23 | try { 24 | const response = await controller.sessionState(params.sessionId) 25 | 26 | res.status(200) 27 | res.send(response) 28 | } catch (err: any) { 29 | const statusCode = err.code 30 | 31 | delete err.code 32 | 33 | res.status(statusCode).send(err) 34 | } 35 | }) 36 | 37 | export default sessionRouter 38 | -------------------------------------------------------------------------------- /api/scripts/compileSysInit.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { 3 | CompileTree, 4 | createFile, 5 | loadDependenciesFile, 6 | readFile, 7 | SASJsFileType 8 | } from '@sasjs/utils' 9 | import { apiRoot, sysInitCompiledPath } from '../src/utils/file' 10 | 11 | const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core') 12 | 13 | const compiledSystemInit = async (systemInit: string) => 14 | 'options ls=max ps=max;\n' + 15 | (await loadDependenciesFile({ 16 | fileContent: systemInit, 17 | type: SASJsFileType.job, 18 | programFolders: [], 19 | macroFolders: [], 20 | buildSourceFolder: '', 21 | binaryFolders: [], 22 | macroCorePath, 23 | compileTree: new CompileTree('') // dummy compileTree 24 | })) 25 | 26 | const createSysInitFile = async () => { 27 | const systemInitContent = await readFile( 28 | path.join(__dirname, 'systemInit.sas') 29 | ) 30 | 31 | await createFile( 32 | path.join(sysInitCompiledPath), 33 | await compiledSystemInit(systemInitContent) 34 | ) 35 | } 36 | 37 | createSysInitFile() 38 | -------------------------------------------------------------------------------- /web/src/components/dialogTitle.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react' 2 | 3 | import DialogTitle from '@mui/material/DialogTitle' 4 | import IconButton from '@mui/material/IconButton' 5 | import CloseIcon from '@mui/icons-material/Close' 6 | 7 | export interface DialogTitleProps { 8 | id: string 9 | children?: React.ReactNode 10 | handleOpen: Dispatch> 11 | } 12 | 13 | export const BootstrapDialogTitle = (props: DialogTitleProps) => { 14 | const { children, handleOpen, ...other } = props 15 | 16 | return ( 17 | 18 | {children} 19 | {handleOpen ? ( 20 | handleOpen(false)} 23 | sx={{ 24 | position: 'absolute', 25 | right: 8, 26 | top: 8, 27 | color: (theme) => theme.palette.grey[500] 28 | }} 29 | > 30 | 31 | 32 | ) : null} 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /api/src/app-modules/configureLogger.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Express } from 'express' 3 | import morgan from 'morgan' 4 | import { createStream } from 'rotating-file-stream' 5 | import { generateTimestamp } from '@sasjs/utils' 6 | import { getLogFolder } from '../utils' 7 | 8 | export const configureLogger = (app: Express) => { 9 | const { LOG_FORMAT_MORGAN } = process.env 10 | 11 | let options 12 | if ( 13 | process.env.NODE_ENV !== 'development' && 14 | process.env.NODE_ENV !== 'test' 15 | ) { 16 | const timestamp = generateTimestamp() 17 | const filename = `${timestamp}.log` 18 | const logsFolder = getLogFolder() 19 | 20 | // create a rotating write stream 21 | var accessLogStream = createStream(filename, { 22 | interval: '1d', // rotate daily 23 | path: logsFolder 24 | }) 25 | 26 | process.logger.info('Writing Logs to :', path.join(logsFolder, filename)) 27 | 28 | options = { stream: accessLogStream } 29 | } 30 | 31 | // setup the logger 32 | app.use(morgan(LOG_FORMAT_MORGAN as string, options)) 33 | } 34 | -------------------------------------------------------------------------------- /api/src/utils/copySASjsCore.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { 3 | asyncForEach, 4 | createFile, 5 | createFolder, 6 | deleteFolder, 7 | readFile 8 | } from '@sasjs/utils' 9 | 10 | import { getMacrosFolder, sasJSCoreMacros, sasJSCoreMacrosInfo } from '.' 11 | 12 | export const copySASjsCore = async () => { 13 | if (process.env.NODE_ENV === 'test') return 14 | 15 | process.logger.log('Copying Macros from container to drive.') 16 | 17 | const macrosDrivePath = getMacrosFolder() 18 | 19 | await deleteFolder(macrosDrivePath) 20 | await createFolder(macrosDrivePath) 21 | 22 | const macros = await readFile(sasJSCoreMacrosInfo) 23 | 24 | await asyncForEach(macros.split('\n'), async (macroName) => { 25 | const macroFileSourcePath = path.join(sasJSCoreMacros, macroName) 26 | const macroContent = await readFile(macroFileSourcePath) 27 | 28 | const macroFileDestPath = path.join(macrosDrivePath, macroName) 29 | 30 | await createFile(macroFileDestPath, macroContent) 31 | }) 32 | 33 | process.logger.info('Macros Drive Path:', macrosDrivePath) 34 | } 35 | -------------------------------------------------------------------------------- /api/src/routes/web/sasviya-web.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { generateCSRFToken } from '../../middlewares' 3 | import { WebController } from '../../controllers/web' 4 | 5 | const sasViyaWebRouter = express.Router() 6 | const controller = new WebController() 7 | 8 | sasViyaWebRouter.get('/', async (req, res) => { 9 | let response 10 | try { 11 | response = await controller.home() 12 | } catch (_) { 13 | response = 'Web Build is not present' 14 | } finally { 15 | const codeToInject = `` 16 | const injectedContent = response?.replace( 17 | '', 18 | `${codeToInject}` 19 | ) 20 | 21 | return res.send(injectedContent) 22 | } 23 | }) 24 | 25 | sasViyaWebRouter.post('/SASJobExecution/', async (req, res) => { 26 | try { 27 | res.send({ test: 'test' }) 28 | } catch (err: any) { 29 | res.status(403).send(err.toString()) 30 | } 31 | }) 32 | 33 | export default sasViyaWebRouter 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 SASjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/src/routes/api/client.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { ClientController } from '../../controllers' 3 | import { registerClientValidation } from '../../utils' 4 | import { authenticateAccessToken, verifyAdmin } from '../../middlewares' 5 | 6 | const clientRouter = express.Router() 7 | 8 | clientRouter.post('/', async (req, res) => { 9 | const { error, value: body } = registerClientValidation(req.body) 10 | if (error) return res.status(400).send(error.details[0].message) 11 | 12 | const controller = new ClientController() 13 | try { 14 | const response = await controller.createClient(body) 15 | res.send(response) 16 | } catch (err: any) { 17 | res.status(403).send(err.toString()) 18 | } 19 | }) 20 | 21 | clientRouter.get( 22 | '/', 23 | authenticateAccessToken, 24 | verifyAdmin, 25 | async (req, res) => { 26 | const controller = new ClientController() 27 | try { 28 | const response = await controller.getAllClients() 29 | res.send(response) 30 | } catch (err: any) { 31 | res.status(403).send(err.toString()) 32 | } 33 | } 34 | ) 35 | 36 | export default clientRouter 37 | -------------------------------------------------------------------------------- /api/src/utils/getPreProgramVariables.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import { PreProgramVars } from '../types' 3 | 4 | export const getPreProgramVariables = (req: Request): PreProgramVars => { 5 | const host = req.get('host') 6 | const protocol = req.protocol + '://' 7 | const { user, accessToken } = req 8 | const csrfToken = req.headers['x-xsrf-token'] || req.cookies['XSRF-TOKEN'] 9 | const sessionId = req.cookies['connect.sid'] 10 | 11 | const httpHeaders: string[] = [] 12 | 13 | if (accessToken) httpHeaders.push(`Authorization: Bearer ${accessToken}`) 14 | if (csrfToken) httpHeaders.push(`x-xsrf-token: ${csrfToken}`) 15 | 16 | const cookies: string[] = [] 17 | if (sessionId) cookies.push(`connect.sid=${sessionId}`) 18 | 19 | if (cookies.length) httpHeaders.push(`cookie: ${cookies.join('; ')}`) 20 | 21 | //In desktop mode when mocking mode is enabled, user was undefined. 22 | //So this is workaround. 23 | return { 24 | username: user ? user.username : 'demo', 25 | userId: user ? user.userId : 0, 26 | displayName: user ? user.displayName : 'demo', 27 | serverUrl: protocol + host, 28 | httpHeaders 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web/src/containers/Studio/internal/helper.ts: -------------------------------------------------------------------------------- 1 | import { RunTimeType } from '../../../context/appContext' 2 | 3 | export const getLanguageFromExtension = (extension: string) => { 4 | if (extension === 'js') return 'javascript' 5 | 6 | if (extension === 'ts') return 'typescript' 7 | 8 | if (extension === 'md' || extension === 'mdx') return 'markdown' 9 | 10 | return extension 11 | } 12 | 13 | export const getSelection = (editor: any) => { 14 | const selection = editor?.getModel().getValueInRange(editor?.getSelection()) 15 | return selection ?? '' 16 | } 17 | 18 | export const programPathInjection = ( 19 | code: string, 20 | path: string, 21 | runtime: RunTimeType 22 | ) => { 23 | if (path) { 24 | if (runtime === RunTimeType.JS) { 25 | return `const _PROGRAM = '${path}';\n${code}` 26 | } 27 | if (runtime === RunTimeType.PY) { 28 | return `_PROGRAM = '${path}';\n${code}` 29 | } 30 | if (runtime === RunTimeType.R) { 31 | return `._PROGRAM = '${path}';\n${code}` 32 | } 33 | if (runtime === RunTimeType.SAS) { 34 | return `%let _program = ${path};\n${code}` 35 | } 36 | } 37 | 38 | return code 39 | } 40 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | sasjs_server_prod: 5 | image: sasjs_server_prod 6 | build: 7 | context: . 8 | dockerfile: DockerfileProd 9 | environment: 10 | MODE: server 11 | CORS: disable 12 | PORT: ${PORT_API} 13 | ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} 14 | REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} 15 | AUTH_CODE_SECRET: ${AUTH_CODE_SECRET} 16 | DB_CONNECT: mongodb://mongodb:27017/sasjs 17 | SAS_PATH: /usr/server/sasexe/${SAS_EXEC_NAME} 18 | expose: 19 | - ${PORT_API} 20 | ports: 21 | - ${PORT_API}:${PORT_API} 22 | volumes: 23 | - type: bind 24 | source: ${SAS_EXEC_PATH} 25 | target: /usr/server/sasexe 26 | read_only: true 27 | links: 28 | - mongodb 29 | 30 | mongodb: 31 | image: mongo:latest 32 | ports: 33 | - 27017:27017 34 | volumes: 35 | - data:/data/db 36 | mongo-seed-users: 37 | build: ./mongo-seed/users 38 | links: 39 | - mongodb 40 | mongo-seed-clients: 41 | build: ./mongo-seed/clients 42 | links: 43 | - mongodb 44 | 45 | volumes: 46 | data: 47 | -------------------------------------------------------------------------------- /api/src/model/Configuration.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose' 2 | 3 | export interface ConfigurationType { 4 | /** 5 | * SecretOrPrivateKey to sign Access Token 6 | * @example "someRandomCryptoString" 7 | */ 8 | ACCESS_TOKEN_SECRET: string 9 | /** 10 | * SecretOrPrivateKey to sign Refresh Token 11 | * @example "someRandomCryptoString" 12 | */ 13 | REFRESH_TOKEN_SECRET: string 14 | /** 15 | * SecretOrPrivateKey to sign Auth Code 16 | * @example "someRandomCryptoString" 17 | */ 18 | AUTH_CODE_SECRET: string 19 | /** 20 | * Secret used to sign the session cookie 21 | * @example "someRandomCryptoString" 22 | */ 23 | SESSION_SECRET: string 24 | } 25 | 26 | const ConfigurationSchema = new Schema({ 27 | ACCESS_TOKEN_SECRET: { 28 | type: String, 29 | required: true 30 | }, 31 | REFRESH_TOKEN_SECRET: { 32 | type: String, 33 | required: true 34 | }, 35 | AUTH_CODE_SECRET: { 36 | type: String, 37 | required: true 38 | }, 39 | SESSION_SECRET: { 40 | type: String, 41 | required: true 42 | } 43 | }) 44 | 45 | export default mongoose.model('Configuration', ConfigurationSchema) 46 | -------------------------------------------------------------------------------- /api/src/middlewares/desktop.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler, Request } from 'express' 2 | import { userInfo } from 'os' 3 | import { RequestUser } from '../types' 4 | import { ModeType } from '../utils' 5 | 6 | const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1 7 | 8 | const allowedInDesktopMode: { [key: string]: RegExp[] } = { 9 | GET: [regexUser], 10 | PATCH: [regexUser] 11 | } 12 | 13 | const reqAllowedInDesktopMode = (request: Request): boolean => { 14 | const { method, originalUrl: url } = request 15 | 16 | return !!allowedInDesktopMode[method]?.find((urlRegex) => urlRegex.test(url)) 17 | } 18 | 19 | export const desktopRestrict: RequestHandler = (req, res, next) => { 20 | const { MODE } = process.env 21 | 22 | if (MODE === ModeType.Desktop) { 23 | if (!reqAllowedInDesktopMode(req)) 24 | return res.status(403).send('Not Allowed while in Desktop Mode.') 25 | } 26 | 27 | next() 28 | } 29 | 30 | export const desktopUser: RequestUser = { 31 | userId: 12345, 32 | clientId: 'desktop_app', 33 | username: userInfo().username, 34 | displayName: userInfo().username, 35 | isAdmin: true, 36 | isActive: true, 37 | needsToUpdatePassword: false 38 | } 39 | -------------------------------------------------------------------------------- /api/scripts/downloadMacros.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import Downloader from 'nodejs-file-downloader' 3 | import { createFile, listFilesInFolder } from '@sasjs/utils' 4 | 5 | import { sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils/file' 6 | 7 | export const downloadMacros = async () => { 8 | const url = 9 | 'https://api.github.com/repos/yabwon/SAS_PACKAGES/contents/SPF/Macros' 10 | 11 | console.info(`Downloading macros from ${url}`) 12 | 13 | await axios 14 | .get(url) 15 | .then(async (res) => { 16 | await downloadFiles(res.data) 17 | }) 18 | .catch((err) => { 19 | throw new Error(err) 20 | }) 21 | } 22 | 23 | const downloadFiles = async function (fileList: any) { 24 | for (const file of fileList) { 25 | const downloader = new Downloader({ 26 | url: file.download_url, 27 | directory: sasJSCoreMacros, 28 | fileName: file.path.replace(/^SPF\/Macros/, ''), 29 | cloneFiles: false 30 | }) 31 | await downloader.download() 32 | } 33 | 34 | const fileNames = await listFilesInFolder(sasJSCoreMacros) 35 | 36 | await createFile(sasJSCoreMacrosInfo, fileNames.join('\n')) 37 | } 38 | 39 | downloadMacros() 40 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/hooks/usePermissionResponseModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import PermissionResponseModal, { 3 | PermissionResponsePayload 4 | } from '../components/permissionResponseModal' 5 | 6 | const usePermissionResponseModal = () => { 7 | const [openPermissionResponseModal, setOpenPermissionResponseModal] = 8 | useState(false) 9 | const [permissionResponsePayload, setPermissionResponsePayload] = 10 | useState({ 11 | permissionType: '', 12 | principalType: '', 13 | principal: '', 14 | permissionSetting: '', 15 | existingPermissions: [], 16 | newAddedPermissions: [], 17 | updatedPermissions: [], 18 | errorPaths: [] 19 | }) 20 | 21 | const PermissionResponseDialog = () => ( 22 | 27 | ) 28 | 29 | return { 30 | PermissionResponseDialog, 31 | setOpenPermissionResponseModal, 32 | setPermissionResponsePayload 33 | } 34 | } 35 | 36 | export default usePermissionResponseModal 37 | -------------------------------------------------------------------------------- /api/src/utils/getAuthorizedRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | 3 | export const TopLevelRoutes = ['/AppStream', '/SASjsApi'] 4 | 5 | const StaticAuthorizedRoutes = [ 6 | '/SASjsApi/code/execute', 7 | '/SASjsApi/stp/execute', 8 | '/SASjsApi/drive/deploy', 9 | '/SASjsApi/drive/deploy/upload', 10 | '/SASjsApi/drive/file', 11 | '/SASjsApi/drive/folder', 12 | '/SASjsApi/drive/fileTree', 13 | '/SASjsApi/drive/rename' 14 | ] 15 | 16 | export const getAuthorizedRoutes = () => { 17 | const streamingApps = Object.keys(process.appStreamConfig) 18 | const streamingAppsRoutes = streamingApps.map((app) => `/AppStream/${app}`) 19 | return [...TopLevelRoutes, ...StaticAuthorizedRoutes, ...streamingAppsRoutes] 20 | } 21 | 22 | export const getPath = (req: Request) => { 23 | const { baseUrl, path: reqPath } = req 24 | 25 | if (baseUrl === '/AppStream') { 26 | const appStream = reqPath.split('/')[1] 27 | 28 | // removing trailing slash of URLs 29 | return (baseUrl + '/' + appStream).replace(/\/$/, '') 30 | } 31 | 32 | return (baseUrl + reqPath).replace(/\/$/, '') 33 | } 34 | 35 | export const isAuthorizingRoute = (req: Request): boolean => 36 | getAuthorizedRoutes().includes(getPath(req)) 37 | -------------------------------------------------------------------------------- /web/src/components/home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import CssBaseline from '@mui/material/CssBaseline' 4 | import Box from '@mui/material/Box' 5 | 6 | const Home = () => { 7 | return ( 8 | 9 | 10 |

Welcome to SASjs Server!

11 |

12 | SASjs Server provides a REST interface for executing Stored Programs and 13 | ad hoc code (studio) against SAS and JS executables. The source is 14 | available on{' '} 15 | 20 | {' '} 21 | github 22 | {' '} 23 | and contributions are welcomed. 24 |

25 |

26 | SASjs Server is maintained by the SAS Apps team -{' '} 27 | 32 | {' '} 33 | contact us 34 | {' '} 35 | if you'd like help with SAS DevOps or SAS Application development!{' '} 36 |

37 |
38 | ) 39 | } 40 | 41 | export default Home 42 | -------------------------------------------------------------------------------- /web/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export interface UserResponse { 2 | id: number 3 | username: string 4 | displayName: string 5 | isAdmin: boolean 6 | } 7 | 8 | export interface GroupResponse { 9 | groupId: number 10 | name: string 11 | description: string 12 | } 13 | 14 | export interface GroupDetailsResponse extends GroupResponse { 15 | isActive: boolean 16 | users: UserResponse[] 17 | } 18 | 19 | export interface PermissionResponse { 20 | permissionId: number 21 | path: string 22 | type: string 23 | setting: string 24 | user?: UserResponse 25 | group?: GroupDetailsResponse 26 | } 27 | 28 | export interface RegisterPermissionPayload { 29 | path: string 30 | type: string 31 | setting: string 32 | principalType: string 33 | principalId: number 34 | } 35 | 36 | export interface TreeNode { 37 | name: string 38 | relativePath: string 39 | isFolder: boolean 40 | children: Array 41 | } 42 | 43 | export interface LogInstance { 44 | body: string 45 | line: number 46 | type: 'error' | 'warning' 47 | id: number 48 | ref?: any 49 | } 50 | 51 | export interface LogObject { 52 | body: string 53 | errors?: LogInstance[] 54 | warnings?: LogInstance[] 55 | linesCount: number 56 | } 57 | -------------------------------------------------------------------------------- /api/src/model/Client.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose' 2 | 3 | export const NUMBER_OF_SECONDS_IN_A_DAY = 86400 4 | export interface ClientPayload { 5 | /** 6 | * Client ID 7 | * @example "someFormattedClientID1234" 8 | */ 9 | clientId: string 10 | /** 11 | * Client Secret 12 | * @example "someRandomCryptoString" 13 | */ 14 | clientSecret: string 15 | /** 16 | * Number of seconds after which access token will expire. Default is 86400 (1 day) 17 | * @example 86400 18 | */ 19 | accessTokenExpiration?: number 20 | /** 21 | * Number of seconds after which access token will expire. Default is 2592000 (30 days) 22 | * @example 2592000 23 | */ 24 | refreshTokenExpiration?: number 25 | } 26 | 27 | const ClientSchema = new Schema({ 28 | clientId: { 29 | type: String, 30 | required: true 31 | }, 32 | clientSecret: { 33 | type: String, 34 | required: true 35 | }, 36 | accessTokenExpiration: { 37 | type: Number, 38 | default: NUMBER_OF_SECONDS_IN_A_DAY 39 | }, 40 | refreshTokenExpiration: { 41 | type: Number, 42 | default: NUMBER_OF_SECONDS_IN_A_DAY * 30 43 | } 44 | }) 45 | 46 | export default mongoose.model('Client', ClientSchema) 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.76", 4 | "description": "NodeJS wrapper for calling the SAS binary executable", 5 | "repository": "https://github.com/sasjs/server", 6 | "scripts": { 7 | "server": "npm run server:prepare && npm run server:start", 8 | "server:prepare": "cd web && npm ci && npm run build && cd ../api && npm ci && npm run build && cd ..", 9 | "server:start": "cd api && npm run start:prod", 10 | "lint-api:fix": "npx prettier --write \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", 11 | "lint-api": "npx prettier --check \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", 12 | "lint-web:fix": "npx prettier --write \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", 13 | "lint-web": "npx prettier --check \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", 14 | "lint": "npm run lint-api && npm run lint-web", 15 | "lint:fix": "npm run lint-api:fix && npm run lint-web:fix" 16 | }, 17 | "devDependencies": { 18 | "@semantic-release/changelog": "^6.0.1", 19 | "@semantic-release/exec": "^6.0.3", 20 | "@semantic-release/git": "^10.0.1", 21 | "@semantic-release/github": "^8.0.4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/components/addPermission.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IconButton, Tooltip } from '@mui/material' 3 | import { Add } from '@mui/icons-material' 4 | import { RegisterPermissionPayload } from '../../../../utils/types' 5 | import AddPermissionModal from './addPermissionModal' 6 | 7 | type Props = { 8 | openModal: boolean 9 | setOpenModal: React.Dispatch> 10 | addPermission: ( 11 | permissionsToAdd: RegisterPermissionPayload[], 12 | permissionType: string, 13 | principalType: string, 14 | principal: string, 15 | permissionSetting: string 16 | ) => Promise 17 | } 18 | 19 | const AddPermission = ({ openModal, setOpenModal, addPermission }: Props) => { 20 | return ( 21 | <> 22 | 27 | setOpenModal(true)}> 28 | 29 | 30 | 31 | 36 | 37 | ) 38 | } 39 | 40 | export default AddPermission 41 | -------------------------------------------------------------------------------- /api/src/utils/getRunTimeAndFilePath.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileExists } from '@sasjs/utils' 3 | import { getFilesFolder } from './file' 4 | import { RunTimeType } from '.' 5 | 6 | export const getRunTimeAndFilePath = async (programPath: string) => { 7 | const ext = path.extname(programPath).toLowerCase() 8 | // If programPath (_program) is provided with a ".sas", ".js", ".py" or ".r" extension 9 | // we should use that extension to determine the appropriate runTime 10 | if (ext && Object.values(RunTimeType).includes(ext.slice(1) as RunTimeType)) { 11 | const runTime = ext.slice(1) 12 | 13 | const codePath = path 14 | .join(getFilesFolder(), programPath) 15 | .replace(new RegExp('/', 'g'), path.sep) 16 | 17 | if (await fileExists(codePath)) { 18 | return { codePath, runTime: runTime as RunTimeType } 19 | } 20 | } else { 21 | for (const runTime of process.runTimes) { 22 | const codePath = 23 | path 24 | .join(getFilesFolder(), programPath) 25 | .replace(new RegExp('/', 'g'), path.sep) + 26 | '.' + 27 | runTime 28 | 29 | if (await fileExists(codePath)) return { codePath, runTime } 30 | } 31 | } 32 | throw `The Program at (${programPath}) does not exist.` 33 | } 34 | -------------------------------------------------------------------------------- /web/src/components/modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Typography, Dialog, DialogContent } from '@mui/material' 4 | import { styled } from '@mui/material/styles' 5 | 6 | import { BootstrapDialogTitle } from './dialogTitle' 7 | 8 | export const BootstrapDialog = styled(Dialog)(({ theme }) => ({ 9 | '& .MuiDialogContent-root': { 10 | padding: theme.spacing(2) 11 | }, 12 | '& .MuiDialogActions-root': { 13 | padding: theme.spacing(1) 14 | } 15 | })) 16 | 17 | type ModalProps = { 18 | open: boolean 19 | setOpen: React.Dispatch> 20 | title: string 21 | payload: string 22 | } 23 | 24 | const Modal = (props: ModalProps) => { 25 | const { open, setOpen, title, payload } = props 26 | 27 | return ( 28 |
29 | setOpen(false)} open={open}> 30 | 31 | {title} 32 | 33 | 34 | 35 | {payload} 36 | 37 | 38 | 39 |
40 | ) 41 | } 42 | 43 | export default Modal 44 | -------------------------------------------------------------------------------- /api/src/middlewares/csrfProtection.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express' 2 | import csrf from 'csrf' 3 | 4 | const csrfTokens = new csrf() 5 | const secret = csrfTokens.secretSync() 6 | 7 | export const generateCSRFToken = () => csrfTokens.create(secret) 8 | 9 | export const csrfProtection: RequestHandler = (req, res, next) => { 10 | if (req.method === 'GET') return next() 11 | 12 | // Reads the token from the following locations, in order: 13 | // req.body.csrf_token - typically generated by the body-parser module. 14 | // req.query.csrf_token - a built-in from Express.js to read from the URL query string. 15 | // req.headers['csrf-token'] - the CSRF-Token HTTP request header. 16 | // req.headers['xsrf-token'] - the XSRF-Token HTTP request header. 17 | // req.headers['x-csrf-token'] - the X-CSRF-Token HTTP request header. 18 | // req.headers['x-xsrf-token'] - the X-XSRF-Token HTTP request header. 19 | 20 | const token = 21 | req.body?.csrf_token || 22 | req.query?.csrf_token || 23 | req.headers['csrf-token'] || 24 | req.headers['xsrf-token'] || 25 | req.headers['x-csrf-token'] || 26 | req.headers['x-xsrf-token'] 27 | 28 | if (!csrfTokens.verify(secret, token)) { 29 | return res.status(400).send('Invalid CSRF token!') 30 | } 31 | next() 32 | } 33 | -------------------------------------------------------------------------------- /web/src/theme/palette.js: -------------------------------------------------------------------------------- 1 | import { colors } from '@mui/material' 2 | 3 | const white = '#FFFFFF' 4 | const black = '#000000' 5 | const yellow = '#F6E30F' 6 | 7 | const palette = { 8 | black, 9 | white, 10 | primary: { 11 | contrastText: white, 12 | main: black 13 | }, 14 | secondary: { 15 | contrastText: white, 16 | main: yellow 17 | }, 18 | success: { 19 | contrastText: white, 20 | dark: colors.green[900], 21 | main: colors.green[600], 22 | light: colors.green[400] 23 | }, 24 | info: { 25 | contrastText: white, 26 | dark: colors.blue[900], 27 | main: colors.blue[600], 28 | light: colors.blue[400] 29 | }, 30 | warning: { 31 | contrastText: white, 32 | dark: colors.orange[900], 33 | main: colors.orange[600], 34 | light: colors.orange[400] 35 | }, 36 | error: { 37 | contrastText: white, 38 | dark: colors.red[900], 39 | main: colors.red[600], 40 | light: colors.red[400] 41 | }, 42 | text: { 43 | primary: colors.blueGrey[900], 44 | secondary: colors.blueGrey[600], 45 | link: colors.blue[600] 46 | }, 47 | background: { 48 | default: '#F4F6F8', 49 | paper: white 50 | }, 51 | icon: colors.blueGrey[600], 52 | divider: colors.grey[200] 53 | } 54 | 55 | export default palette 56 | -------------------------------------------------------------------------------- /web/src/containers/Studio/internal/components/log/logTabWithIcons.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorOutline, Warning } from '@mui/icons-material' 2 | import FileDownloadIcon from '@mui/icons-material/FileDownload' 3 | import { 4 | LogObject, 5 | download, 6 | clearErrorsAndWarningsHtmlWrapping 7 | } from '../../../../../utils' 8 | import Tooltip from '@mui/material/Tooltip' 9 | import classes from './log.module.css' 10 | 11 | interface LogTabProps { 12 | log: LogObject 13 | } 14 | 15 | const LogTabWithIcons = (props: LogTabProps) => { 16 | const { errors, warnings, body } = props.log 17 | 18 | return ( 19 |
20 | log 21 | {errors && errors.length !== 0 && ( 22 | 23 | )} 24 | {warnings && warnings.length !== 0 && ( 25 | 26 | )} 27 | { 30 | download(evt, clearErrorsAndWarningsHtmlWrapping(body)) 31 | }} 32 | > 33 | 36 | 37 |
38 | ) 39 | } 40 | 41 | export default LogTabWithIcons 42 | -------------------------------------------------------------------------------- /api/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './appStreamConfig' 2 | export * from './connectDB' 3 | export * from './copySASjsCore' 4 | export * from './createWeboutSasFile' 5 | export * from './desktopAutoExec' 6 | export * from './extractHeaders' 7 | export * from './extractName' 8 | export * from './file' 9 | export * from './generateAccessToken' 10 | export * from './generateAuthCode' 11 | export * from './generateRefreshToken' 12 | export * from './getAuthorizedRoutes' 13 | export * from './getCertificates' 14 | export * from './getDesktopFields' 15 | export * from './getPreProgramVariables' 16 | export * from './getRunTimeAndFilePath' 17 | export * from './getSequenceNextValue' 18 | export * from './getServerUrl' 19 | export * from './getTokensFromDB' 20 | export * from './instantiateLogger' 21 | export * from './isDebugOn' 22 | export * from './isPublicRoute' 23 | export * from './ldapClient' 24 | export * from './parseLogToArray' 25 | export * from './rateLimiter' 26 | export * from './removeTokensInDB' 27 | export * from './saveTokensInDB' 28 | export * from './seedDB' 29 | export * from './setProcessVariables' 30 | export * from './setupFolders' 31 | export * from './setupUserAutoExec' 32 | export * from './upload' 33 | export * from './validation' 34 | export * from './verifyEnvVariables' 35 | export * from './verifyTokenInDB' 36 | export * from './zipped' 37 | -------------------------------------------------------------------------------- /api/src/utils/zipped.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import unZipper from 'unzipper' 3 | import { extractName } from './extractName' 4 | import { createReadStream } from './file' 5 | 6 | export const isZipFile = ( 7 | file: Express.Multer.File 8 | ): { error?: string; value?: Express.Multer.File } => { 9 | const fileExtension = path.extname(file.originalname) 10 | if (fileExtension.toUpperCase() !== '.ZIP') 11 | return { error: `"file" has invalid extension ${fileExtension}` } 12 | 13 | const allowedMimetypes = ['application/zip', 'application/x-zip-compressed'] 14 | 15 | if (!allowedMimetypes.includes(file.mimetype)) 16 | return { error: `"file" has invalid type ${file.mimetype}` } 17 | 18 | return { value: file } 19 | } 20 | 21 | export const extractJSONFromZip = async (zipFile: Express.Multer.File) => { 22 | let fileContent: string = '' 23 | 24 | const fileInZip = extractName(zipFile.originalname) 25 | const zip = (await createReadStream(zipFile.path)).pipe( 26 | unZipper.Parse({ forceStream: true }) 27 | ) 28 | 29 | for await (const entry of zip) { 30 | const fileName = entry.path as string 31 | // grab the first json found in .zip 32 | if (fileName.toUpperCase().endsWith('.JSON')) { 33 | fileContent = await entry.buffer() 34 | break 35 | } else { 36 | entry.autodrain() 37 | } 38 | } 39 | 40 | return fileContent 41 | } 42 | -------------------------------------------------------------------------------- /api/public/app-streams-script.js: -------------------------------------------------------------------------------- 1 | const inputElement = document.getElementById('fileId') 2 | 3 | document.getElementById('uploadButton').addEventListener('click', function () { 4 | inputElement.click() 5 | }) 6 | 7 | inputElement.addEventListener( 8 | 'change', 9 | function () { 10 | const fileList = this.files /* now you can work with the file list */ 11 | 12 | updateFileUploadMessage('Requesting ...') 13 | 14 | const file = fileList[0] 15 | const formData = new FormData() 16 | 17 | formData.append('file', file) 18 | 19 | axios 20 | .post('/SASjsApi/drive/deploy/upload', formData) 21 | .then((res) => res.data) 22 | .then((data) => { 23 | return ( 24 | data.message + 25 | '\nstreamServiceName: ' + 26 | data.streamServiceName + 27 | '\nrefreshing page once alert box closes.' 28 | ) 29 | }) 30 | .then((message) => { 31 | alert(message) 32 | location.reload() 33 | }) 34 | .catch((error) => { 35 | alert(error.response.data) 36 | resetFileUpload() 37 | updateFileUploadMessage('Upload New App') 38 | }) 39 | }, 40 | false 41 | ) 42 | 43 | function updateFileUploadMessage(message) { 44 | document.getElementById('uploadMessage').innerHTML = message 45 | } 46 | 47 | function resetFileUpload() { 48 | inputElement.value = null 49 | } 50 | -------------------------------------------------------------------------------- /api/mocks/sas9/generic/login: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 | -------------------------------------------------------------------------------- /api/src/routes/api/code.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { runCodeValidation, triggerCodeValidation } from '../../utils' 3 | import { CodeController } from '../../controllers/' 4 | 5 | const runRouter = express.Router() 6 | 7 | const controller = new CodeController() 8 | 9 | runRouter.post('/execute', async (req, res) => { 10 | const { error, value: body } = runCodeValidation(req.body) 11 | if (error) return res.status(400).send(error.details[0].message) 12 | 13 | try { 14 | const response = await controller.executeCode(req, body) 15 | 16 | if (response instanceof Buffer) { 17 | res.writeHead(200, (req as any).sasHeaders) 18 | return res.end(response) 19 | } 20 | 21 | res.send(response) 22 | } catch (err: any) { 23 | const statusCode = err.code 24 | 25 | delete err.code 26 | 27 | res.status(statusCode).send(err) 28 | } 29 | }) 30 | 31 | runRouter.post('/trigger', async (req, res) => { 32 | const { error, value: body } = triggerCodeValidation(req.body) 33 | if (error) return res.status(400).send(error.details[0].message) 34 | 35 | try { 36 | const response = await controller.triggerCode(req, body) 37 | 38 | res.status(200) 39 | res.send(response) 40 | } catch (err: any) { 41 | const statusCode = err.code 42 | 43 | delete err.code 44 | 45 | res.status(statusCode).send(err) 46 | } 47 | }) 48 | 49 | export default runRouter 50 | -------------------------------------------------------------------------------- /api/src/utils/getCertificates.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { fileExists, getString, readFile } from '@sasjs/utils' 3 | 4 | export const getCertificates = async () => { 5 | const { PRIVATE_KEY, CERT_CHAIN, CA_ROOT } = process.env 6 | 7 | let ca 8 | 9 | const keyPath = PRIVATE_KEY ?? (await getFileInput('Private Key (PEM)')) 10 | const certPath = CERT_CHAIN ?? (await getFileInput('Certificate Chain (PEM)')) 11 | const caPath = CA_ROOT 12 | 13 | process.logger.info('keyPath: ', keyPath) 14 | process.logger.info('certPath: ', certPath) 15 | if (caPath) process.logger.info('caPath: ', caPath) 16 | 17 | const key = await readFile(keyPath) 18 | const cert = await readFile(certPath) 19 | if (caPath) ca = await readFile(caPath) 20 | 21 | return { key, cert, ca } 22 | } 23 | 24 | const getFileInput = async ( 25 | filename: string, 26 | required: boolean = true 27 | ): Promise => { 28 | const validator = async (filePath: string) => { 29 | if (!required) return true 30 | 31 | if (!filePath) return `Path to ${filename} is required.` 32 | 33 | if (!(await fileExists(path.join(process.cwd(), filePath)))) { 34 | return 'No file found at provided path.' 35 | } 36 | 37 | return true 38 | } 39 | 40 | const targetName = await getString( 41 | `Please enter path to ${filename} (relative path): `, 42 | validator 43 | ) 44 | 45 | return targetName 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: SASjs Server Executable Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-22.04 11 | 12 | strategy: 13 | matrix: 14 | node-version: [lts/*] 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Install Dependencies WEB 26 | working-directory: ./web 27 | run: npm ci 28 | 29 | - name: Build WEB 30 | working-directory: ./web 31 | run: npm run build 32 | env: 33 | CI: true 34 | 35 | - name: Install Dependencies API 36 | working-directory: ./api 37 | run: npm ci 38 | 39 | - name: Build Executables 40 | working-directory: ./api 41 | run: npm run exe 42 | env: 43 | CI: true 44 | 45 | - name: Compress Executables 46 | working-directory: ./executables 47 | run: | 48 | zip linux.zip api-linux 49 | zip macos.zip api-macos 50 | zip windows.zip api-win.exe 51 | 52 | - name: Install Semantic Release and plugins 53 | run: | 54 | npm i 55 | npm i -g semantic-release 56 | 57 | - name: Release 58 | run: | 59 | GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} semantic-release 60 | -------------------------------------------------------------------------------- /api/src/utils/verifyTokenInDB.ts: -------------------------------------------------------------------------------- 1 | import User from '../model/User' 2 | import { RequestUser } from '../types' 3 | 4 | export const fetchLatestAutoExec = async ( 5 | reqUser: RequestUser 6 | ): Promise => { 7 | const dbUser = await User.findOne({ id: reqUser.userId }) 8 | 9 | if (!dbUser) return undefined 10 | 11 | return { 12 | userId: reqUser.userId, 13 | clientId: reqUser.clientId, 14 | username: dbUser.username, 15 | displayName: dbUser.displayName, 16 | isAdmin: dbUser.isAdmin, 17 | isActive: dbUser.isActive, 18 | needsToUpdatePassword: dbUser.needsToUpdatePassword, 19 | autoExec: dbUser.autoExec 20 | } 21 | } 22 | 23 | export const verifyTokenInDB = async ( 24 | userId: number, 25 | clientId: string, 26 | token: string, 27 | tokenType: 'accessToken' | 'refreshToken' 28 | ): Promise => { 29 | const dbUser = await User.findOne({ id: userId }) 30 | 31 | if (!dbUser) return undefined 32 | 33 | const currentTokenObj = dbUser.tokens.find( 34 | (tokenObj: any) => tokenObj.clientId === clientId 35 | ) 36 | 37 | return currentTokenObj?.[tokenType] === token 38 | ? { 39 | userId: dbUser.id, 40 | clientId, 41 | username: dbUser.username, 42 | displayName: dbUser.displayName, 43 | isAdmin: dbUser.isAdmin, 44 | isActive: dbUser.isActive, 45 | needsToUpdatePassword: dbUser.needsToUpdatePassword, 46 | autoExec: dbUser.autoExec 47 | } 48 | : undefined 49 | } 50 | -------------------------------------------------------------------------------- /web/src/components/deleteConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { 4 | Button, 5 | Dialog, 6 | DialogContent, 7 | DialogActions, 8 | Typography 9 | } from '@mui/material' 10 | import { styled } from '@mui/material/styles' 11 | 12 | const BootstrapDialog = styled(Dialog)(({ theme }) => ({ 13 | '& .MuiDialogContent-root': { 14 | padding: theme.spacing(2) 15 | }, 16 | '& .MuiDialogActions-root': { 17 | padding: theme.spacing(1) 18 | } 19 | })) 20 | 21 | type DeleteConfirmationModalProps = { 22 | open: boolean 23 | setOpen: React.Dispatch> 24 | message: string 25 | _delete: () => void 26 | } 27 | 28 | const DeleteConfirmationModal = ({ 29 | open, 30 | setOpen, 31 | message, 32 | _delete 33 | }: DeleteConfirmationModalProps) => { 34 | const handleDeleteClick = (event: React.MouseEvent) => { 35 | event.stopPropagation() 36 | _delete() 37 | } 38 | 39 | const handleClose = (event: any) => { 40 | event.stopPropagation() 41 | setOpen(false) 42 | } 43 | 44 | return ( 45 | 46 | 47 | {message} 48 | 49 | 50 | 51 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export default DeleteConfirmationModal 60 | -------------------------------------------------------------------------------- /restClient/stp.rest: -------------------------------------------------------------------------------- 1 | ### testing upload file example 2 | POST http://localhost:5000/SASjsApi/stp/execute/?_program=/Public/app/viya/services/editors/loadfile&table=DCCONFIG.MPE_X_TEST 3 | Content-Type: multipart/form-data; boundary=----WebKitFormBoundarynkYOqevUMKZrXeAy 4 | 5 | ------WebKitFormBoundarynkYOqevUMKZrXeAy 6 | Content-Disposition: form-data; name="fileSome11"; filename="DCCONFIG.MPE_X_TEST.xlsx" 7 | Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 8 | 9 | 10 | ------WebKitFormBoundarynkYOqevUMKZrXeAy 11 | Content-Disposition: form-data; name="fileSome22"; filename="DCCONFIG.MPE_X_TEST.xlsx.csv" 12 | Content-Type: application/csv 13 | 14 | _____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM 15 | ,0,this is dummy data 321,Option 1,42,1960-02-12,1960-01-01 00:00:42,00:00:42,3,44 16 | ,1,more dummy data 123,Option 2,42,1960-02-12,1960-01-01 00:00:42,00:07:02,3,44 17 | ,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 18 | ,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 19 | ,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 20 | ,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 21 | ------WebKitFormBoundarynkYOqevUMKZrXeAy 22 | Content-Disposition: form-data; name="_debug" 23 | 24 | 131 25 | ------WebKitFormBoundarynkYOqevUMKZrXeAy-- 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | sasjs_server_api: 5 | image: sasjs_server_api 6 | build: 7 | context: . 8 | dockerfile: DockerfileApi 9 | environment: 10 | MODE: 'server' 11 | CORS: ${CORS} 12 | PORT: ${PORT_API} 13 | PORT_WEB: ${PORT_WEB} 14 | ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} 15 | REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} 16 | AUTH_CODE_SECRET: ${AUTH_CODE_SECRET} 17 | DB_CONNECT: mongodb://mongodb:27017/sasjs 18 | SAS_PATH: /usr/server/sasexe 19 | expose: 20 | - ${PORT_API} 21 | ports: 22 | - ${PORT_API}:${PORT_API} 23 | volumes: 24 | - ./api:/usr/server/api 25 | - type: bind 26 | source: ${SAS_EXEC} 27 | target: /usr/server/sasexe 28 | read_only: true 29 | links: 30 | - mongodb 31 | 32 | sasjs_server_web: 33 | image: sasjs_server_web 34 | build: ./web 35 | environment: 36 | REACT_APP_PORT_API: ${PORT_API} 37 | PORT: ${PORT_WEB} 38 | expose: 39 | - ${PORT_WEB} 40 | ports: 41 | - ${PORT_WEB}:${PORT_WEB} 42 | volumes: 43 | - ./web:/usr/server/web 44 | 45 | mongodb: 46 | image: mongo:5.0.4 47 | ports: 48 | - 27017:27017 49 | volumes: 50 | - data:/data/db 51 | mongo-seed-users: 52 | build: ./mongo-seed/users 53 | links: 54 | - mongodb 55 | mongo-seed-clients: 56 | build: ./mongo-seed/clients 57 | links: 58 | - mongodb 59 | 60 | volumes: 61 | data: 62 | -------------------------------------------------------------------------------- /api/public/SASjsApi/swagger-ui-init.js: -------------------------------------------------------------------------------- 1 | window.onload = function () { 2 | // Build a system 3 | var url = window.location.search.match(/url=([^&]+)/) 4 | if (url && url.length > 1) { 5 | url = decodeURIComponent(url[1]) 6 | } else { 7 | url = window.location.origin 8 | } 9 | var options = { 10 | customOptions: { 11 | url: '/swagger.yaml', 12 | requestInterceptor: function (request) { 13 | request.credentials = 'include' 14 | var cookie = document.cookie 15 | var startIndex = cookie.indexOf('XSRF-TOKEN') 16 | var csrf = cookie.slice(startIndex + 11).split('; ')[0] 17 | request.headers['X-XSRF-TOKEN'] = csrf 18 | return request 19 | } 20 | } 21 | } 22 | url = options.swaggerUrl || url 23 | var urls = options.swaggerUrls 24 | var customOptions = options.customOptions 25 | var spec1 = options.swaggerDoc 26 | var swaggerOptions = { 27 | spec: spec1, 28 | url: url, 29 | urls: urls, 30 | dom_id: '#swagger-ui', 31 | deepLinking: true, 32 | presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], 33 | plugins: [SwaggerUIBundle.plugins.DownloadUrl], 34 | layout: 'StandaloneLayout' 35 | } 36 | for (var attrname in customOptions) { 37 | swaggerOptions[attrname] = customOptions[attrname] 38 | } 39 | var ui = SwaggerUIBundle(swaggerOptions) 40 | 41 | if (customOptions.oauth) { 42 | ui.initOAuth(customOptions.oauth) 43 | } 44 | 45 | if (customOptions.authAction) { 46 | ui.authActions.authorize(customOptions.authAction) 47 | } 48 | 49 | window.ui = ui 50 | } 51 | -------------------------------------------------------------------------------- /web/src/components/snackbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react' 2 | import Snackbar from '@mui/material/Snackbar' 3 | import MuiAlert, { AlertProps } from '@mui/material/Alert' 4 | import Slide, { SlideProps } from '@mui/material/Slide' 5 | 6 | const Alert = React.forwardRef( 7 | function Alert(props, ref) { 8 | return 9 | } 10 | ) 11 | 12 | const Transition = (props: SlideProps) => { 13 | return 14 | } 15 | 16 | export enum AlertSeverityType { 17 | Success = 'success', 18 | Warning = 'warning', 19 | Info = 'info', 20 | Error = 'error' 21 | } 22 | 23 | type BootstrapSnackbarProps = { 24 | open: boolean 25 | setOpen: Dispatch> 26 | message: string 27 | severity: AlertSeverityType 28 | } 29 | 30 | const BootstrapSnackbar = ({ 31 | open, 32 | setOpen, 33 | message, 34 | severity 35 | }: BootstrapSnackbarProps) => { 36 | const handleClose = ( 37 | event: React.SyntheticEvent | Event, 38 | reason?: string 39 | ) => { 40 | if (reason === 'clickaway') { 41 | return 42 | } 43 | 44 | setOpen(false) 45 | } 46 | 47 | return ( 48 | 54 | 55 | {message} 56 | 57 | 58 | ) 59 | } 60 | 61 | export default BootstrapSnackbar 62 | -------------------------------------------------------------------------------- /api/src/routes/appStream/style.ts: -------------------------------------------------------------------------------- 1 | export const style = `` 77 | -------------------------------------------------------------------------------- /api/src/utils/specs/extractHeaders.spec.ts: -------------------------------------------------------------------------------- 1 | import { extractHeaders } from '../extractHeaders' 2 | 3 | describe('extractHeaders', () => { 4 | it('should return valid http headers', () => { 5 | const headers = extractHeaders(` 6 | Content-type: application/csv 7 | Cache-Control: public, max-age=2000 8 | Content-type: application/text 9 | Cache-Control: public, max-age=1500 10 | Content-type: application/zip 11 | Cache-Control: public, max-age=1000 12 | `) 13 | 14 | expect(headers).toEqual({ 15 | 'content-type': 'application/zip', 16 | 'cache-control': 'public, max-age=1000' 17 | }) 18 | }) 19 | 20 | it('should not return http headers if last occurrence is blank', () => { 21 | const headers = extractHeaders(` 22 | Content-type: application/csv 23 | Cache-Control: public, max-age=1000 24 | Content-type: application/text 25 | Content-type: 26 | `) 27 | 28 | expect(headers).toEqual({ 'cache-control': 'public, max-age=1000' }) 29 | }) 30 | 31 | it('should return only valid http headers', () => { 32 | const headers = extractHeaders(` 33 | Content-type[]: application/csv 34 | Content//-type: application/text 35 | Content()-type: application/zip 36 | `) 37 | 38 | expect(headers).toEqual({}) 39 | }) 40 | 41 | it('should return http headers if empty', () => { 42 | const headers = extractHeaders('') 43 | 44 | expect(headers).toEqual({}) 45 | }) 46 | 47 | it('should return http headers if not provided', () => { 48 | const headers = extractHeaders() 49 | 50 | expect(headers).toEqual({}) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /api/src/utils/getTokensFromDB.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import User from '../model/User' 3 | import { InfoJWT } from '../types/InfoJWT' 4 | 5 | const isValidToken = async ( 6 | token: string, 7 | key: string, 8 | userId: number, 9 | clientId: string 10 | ) => { 11 | const promise = new Promise((resolve, reject) => 12 | jwt.verify(token, key, (err, decoded) => { 13 | if (err) return reject(false) 14 | 15 | const payload = decoded as InfoJWT 16 | if (payload?.userId === userId && payload?.clientId === clientId) { 17 | return resolve(true) 18 | } 19 | 20 | return reject(false) 21 | }) 22 | ) 23 | 24 | return await promise.then(() => true).catch(() => false) 25 | } 26 | 27 | export const getTokensFromDB = async (userId: number, clientId: string) => { 28 | const user = await User.findOne({ id: userId }) 29 | if (!user) return 30 | 31 | const currentTokenObj = user.tokens.find( 32 | (tokenObj: any) => tokenObj.clientId === clientId 33 | ) 34 | 35 | if (currentTokenObj) { 36 | const accessToken = currentTokenObj.accessToken 37 | const refreshToken = currentTokenObj.refreshToken 38 | 39 | const isValidAccessToken = await isValidToken( 40 | accessToken, 41 | process.secrets.ACCESS_TOKEN_SECRET, 42 | userId, 43 | clientId 44 | ) 45 | 46 | const isValidRefreshToken = await isValidToken( 47 | refreshToken, 48 | process.secrets.REFRESH_TOKEN_SECRET, 49 | userId, 50 | clientId 51 | ) 52 | 53 | if (isValidAccessToken && isValidRefreshToken) { 54 | return { accessToken, refreshToken } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /api/src/routes/appStream/appStreamHtml.ts: -------------------------------------------------------------------------------- 1 | import { AppStreamConfig } from '../../types' 2 | import { style } from './style' 3 | 4 | const defaultAppLogo = '/sasjs-logo.svg' 5 | 6 | const singleAppStreamHtml = ( 7 | streamServiceName: string, 8 | appLoc: string, 9 | logo?: string 10 | ) => 11 | ` 12 | 16 | ${streamServiceName} 17 | ` 18 | 19 | export const appStreamHtml = (appStreamConfig: AppStreamConfig) => ` 20 | 21 | 22 | 23 | ${style} 24 | 25 | 26 |
27 | 28 |

App Stream

29 |
30 |
31 | ${Object.entries(appStreamConfig) 32 | .map(([streamServiceName, entry]) => 33 | singleAppStreamHtml( 34 | streamServiceName, 35 | entry.appLoc, 36 | entry.streamLogo 37 | ) 38 | ) 39 | .join('')} 40 | 41 | 42 | 43 | 46 | Upload New App 47 | 48 |
49 | 50 | 51 | 52 | ` 53 | -------------------------------------------------------------------------------- /api/.env.example: -------------------------------------------------------------------------------- 1 | MODE=[desktop|server] default considered as desktop 2 | CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE 3 | ALLOWED_DOMAIN= 4 | WHITELIST= 5 | 6 | PROTOCOL=[http|https] default considered as http 7 | PRIVATE_KEY=privkey.pem 8 | CERT_CHAIN=certificate.pem 9 | CA_ROOT=fullchain.pem 10 | 11 | PORT=[5000] default value is 5000 12 | 13 | HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used 14 | HELMET_COEP=[true|false] if omitted HELMET default will be used 15 | 16 | DB_CONNECT=mongodb+srv://:@/?retryWrites=true&w=majority 17 | DB_TYPE=[mongodb|cosmos_mongodb] default considered as mongodb 18 | 19 | AUTH_PROVIDERS=[ldap] 20 | 21 | LDAP_URL= 22 | LDAP_BIND_DN= 23 | LDAP_BIND_PASSWORD = 24 | LDAP_USERS_BASE_DN = 25 | LDAP_GROUPS_BASE_DN = 26 | 27 | #default value is 100 28 | MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100 29 | 30 | #default value is 10 31 | MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10 32 | 33 | ADMIN_USERNAME=secretuser 34 | ADMIN_PASSWORD_INITIAL=secretpassword 35 | ADMIN_PASSWORD_RESET=NO 36 | 37 | RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas 38 | SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas 39 | NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node 40 | PYTHON_PATH=/usr/bin/python 41 | R_PATH=/usr/bin/Rscript 42 | 43 | SASJS_ROOT=./sasjs_root 44 | DRIVE_LOCATION=./sasjs_root/drive 45 | 46 | LOG_FORMAT_MORGAN=common 47 | LOG_LOCATION=./sasjs_root/logs -------------------------------------------------------------------------------- /api/public/sasjs-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /api/src/controllers/info.ts: -------------------------------------------------------------------------------- 1 | import { Route, Tags, Example, Get } from 'tsoa' 2 | import { getAuthorizedRoutes } from '../utils' 3 | export interface AuthorizedRoutesResponse { 4 | paths: string[] 5 | } 6 | 7 | export interface InfoResponse { 8 | mode: string 9 | cors: string 10 | whiteList: string[] 11 | protocol: string 12 | runTimes: string[] 13 | } 14 | 15 | @Route('SASjsApi/info') 16 | @Tags('Info') 17 | export class InfoController { 18 | /** 19 | * @summary Get server info (mode, cors, whiteList, protocol). 20 | * 21 | */ 22 | @Example({ 23 | mode: 'desktop', 24 | cors: 'enable', 25 | whiteList: ['http://example.com', 'http://example2.com'], 26 | protocol: 'http', 27 | runTimes: ['sas', 'js'] 28 | }) 29 | @Get('/') 30 | public info(): InfoResponse { 31 | const response = { 32 | mode: process.env.MODE ?? 'desktop', 33 | cors: 34 | process.env.CORS || 35 | (process.env.MODE === 'server' ? 'disable' : 'enable'), 36 | whiteList: 37 | process.env.WHITELIST?.split(' ')?.filter((url) => !!url) ?? [], 38 | protocol: process.env.PROTOCOL ?? 'http', 39 | runTimes: process.runTimes 40 | } 41 | return response 42 | } 43 | 44 | /** 45 | * @summary Get the list of available routes to which permissions can be applied. Used to populate the dialog in the URI Permissions feature. 46 | * 47 | */ 48 | @Example({ 49 | paths: ['/AppStream', '/SASjsApi/stp/execute'] 50 | }) 51 | @Get('/authorizedRoutes') 52 | public authorizedRoutes(): AuthorizedRoutesResponse { 53 | const response = { 54 | paths: getAuthorizedRoutes() 55 | } 56 | return response 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /web/src/containers/Studio/internal/components/log/log.module.css: -------------------------------------------------------------------------------- 1 | .ChunkHeader { 2 | color: #444; 3 | cursor: pointer; 4 | padding: 18px; 5 | width: 100%; 6 | text-align: left; 7 | border: none; 8 | outline: none; 9 | transition: 0.4s; 10 | box-shadow: 11 | rgba(0, 0, 0, 0.2) 0px 2px 1px -1px, 12 | rgba(0, 0, 0, 0.14) 0px 1px 1px 0px, 13 | rgba(0, 0, 0, 0.12) 0px 1px 3px 0px; 14 | } 15 | 16 | .ChunkDetails { 17 | display: flex; 18 | flex-direction: row; 19 | gap: 6px; 20 | align-items: center; 21 | } 22 | 23 | .ChunkExpandIcon { 24 | margin-left: auto; 25 | } 26 | 27 | .ChunkBody { 28 | background-color: white; 29 | overflow: hidden; 30 | } 31 | 32 | .ChunksContainer { 33 | display: flex; 34 | flex-direction: column; 35 | gap: 10px; 36 | } 37 | 38 | .LogContainer { 39 | background-color: #fbfbfb; 40 | border: 1px solid #e2e2e2; 41 | border-radius: 3px; 42 | min-height: 50px; 43 | padding: 10px; 44 | box-sizing: border-box; 45 | white-space: pre-wrap; 46 | font-family: Monaco, Courier, monospace; 47 | position: relative; 48 | width: 100%; 49 | } 50 | 51 | .LogWrapper { 52 | overflow-y: auto; 53 | max-height: calc(100vh - 130px); 54 | } 55 | 56 | .LogBody { 57 | overflow: auto; 58 | height: calc(100vh - 220px); 59 | } 60 | 61 | .TreeContainer { 62 | background-color: white; 63 | padding-top: 10px; 64 | padding-bottom: 10px; 65 | } 66 | 67 | .TabContainer { 68 | display: flex; 69 | flex-direction: row; 70 | gap: 6px; 71 | align-items: center; 72 | } 73 | 74 | .TabDownloadIcon { 75 | margin-left: 20px; 76 | } 77 | 78 | .HighlightedLine { 79 | background-color: #f6e30599; 80 | } 81 | 82 | .Icon { 83 | font-size: 20px !important; 84 | } 85 | 86 | .GreenIcon { 87 | color: green; 88 | } 89 | -------------------------------------------------------------------------------- /web/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 23 | SASjs Server Web Interface 24 | 25 | 26 | 27 |
28 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /api/src/app-modules/configureExpressSession.ts: -------------------------------------------------------------------------------- 1 | import { Express, CookieOptions } from 'express' 2 | import mongoose from 'mongoose' 3 | import session from 'express-session' 4 | import MongoStore from 'connect-mongo' 5 | 6 | import { DatabaseType, ModeType, ProtocolType } from '../utils' 7 | 8 | export const configureExpressSession = (app: Express) => { 9 | const { MODE, DB_TYPE } = process.env 10 | 11 | if (MODE === ModeType.Server) { 12 | let store: MongoStore | undefined 13 | 14 | if (process.env.NODE_ENV !== 'test') { 15 | if (DB_TYPE === DatabaseType.COSMOS_MONGODB) { 16 | // COSMOS DB requires specific connection options (compatibility mode) 17 | // See: https://www.npmjs.com/package/connect-mongo#set-the-compatibility-mode 18 | store = MongoStore.create({ 19 | client: mongoose.connection!.getClient() as any, 20 | autoRemove: 'interval' 21 | }) 22 | } else { 23 | store = MongoStore.create({ 24 | client: mongoose.connection!.getClient() as any 25 | }) 26 | } 27 | } 28 | 29 | const { PROTOCOL, ALLOWED_DOMAIN } = process.env 30 | const cookieOptions: CookieOptions = { 31 | secure: PROTOCOL === ProtocolType.HTTPS, 32 | httpOnly: true, 33 | sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined, 34 | maxAge: 24 * 60 * 60 * 1000, // 24 hours 35 | domain: ALLOWED_DOMAIN?.trim() || undefined 36 | } 37 | 38 | app.use( 39 | session({ 40 | secret: process.secrets.SESSION_SECRET, 41 | saveUninitialized: false, // don't create session until something stored 42 | resave: false, //don't save session if unmodified 43 | store, 44 | cookie: cookieOptions 45 | }) 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /api/tsoa.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryFile": "src/app.ts", 3 | "noImplicitAdditionalProperties": "throw-on-extras", 4 | "spec": { 5 | "outputDirectory": "public", 6 | "securityDefinitions": { 7 | "bearerAuth": { 8 | "type": "http", 9 | "scheme": "bearer", 10 | "bearerFormat": "JWT" 11 | } 12 | }, 13 | "tags": [ 14 | { 15 | "name": "Auth", 16 | "description": "Operations about auth" 17 | }, 18 | { 19 | "name": "Auth_Config", 20 | "description": "Operations about external auth providers" 21 | }, 22 | { 23 | "name": "Client", 24 | "description": "Operations about clients" 25 | }, 26 | { 27 | "name": "Code", 28 | "description": "Execution of code (various runtimes are supported)" 29 | }, 30 | { 31 | "name": "Drive", 32 | "description": "Operations on SASjs Drive" 33 | }, 34 | { 35 | "name": "Group", 36 | "description": "Operations on groups and group memberships" 37 | }, 38 | { 39 | "name": "Info", 40 | "description": "Get Server Information" 41 | }, 42 | { 43 | "name": "Permission", 44 | "description": "Operations about permissions" 45 | }, 46 | { 47 | "name": "Session", 48 | "description": "Get Session information" 49 | }, 50 | { 51 | "name": "STP", 52 | "description": "Execution of Stored Programs" 53 | }, 54 | { 55 | "name": "User", 56 | "description": "Operations with users" 57 | }, 58 | { 59 | "name": "Web", 60 | "description": "Operations on Web" 61 | } 62 | ], 63 | "yaml": true, 64 | "specVersion": 3 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /restClient/drive.rest: -------------------------------------------------------------------------------- 1 | ### Get contents of folder 2 | GET http://localhost:5000/SASjsApi/drive/folder?_path=/Public/app/react-seed-app/services/web 3 | 4 | ### 5 | POST http://localhost:5000/SASjsApi/drive/deploy 6 | Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDc2LCJleHAiOjE2MzU4OTA0NzZ9.Cx1F54ILgAUtnkit0Wg1K1YVO2RdNjOnTKdPhUtDm5I 7 | 8 | ### multipart upload to sas server file 9 | POST http://localhost:5000/SASjsApi/drive/file 10 | Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW 11 | 12 | ------WebKitFormBoundary7MA4YWxkTrZu0gW 13 | Content-Disposition: form-data; name="filePath" 14 | 15 | /saad/files/new.sas 16 | ------WebKitFormBoundary7MA4YWxkTrZu0gW 17 | Content-Disposition: form-data; name="file"; filename="sample_new.sas" 18 | Content-Type: application/octet-stream 19 | 20 | < ./sample.sas 21 | ------WebKitFormBoundary7MA4YWxkTrZu0gW-- 22 | 23 | ### multipart upload to sas server file text 24 | POST http://localhost:5000/SASjsApi/drive/file 25 | Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW 26 | 27 | ------WebKitFormBoundary7MA4YWxkTrZu0gW \n 28 | Content-Disposition: form-data; name="filePath" 29 | 30 | /saad/files/new2.sas 31 | ------WebKitFormBoundary7MA4YWxkTrZu0gW 32 | Content-Disposition: form-data; name="file"; filename="sample_new.sas" 33 | Content-Type: text/plain 34 | 35 | SOME CONTENTS OF SAS FILE IN REQUEST 36 | 37 | ------WebKitFormBoundary7MA4YWxkTrZu0gW-- 38 | 39 | 40 | Users 41 | "username": "username1", 42 | "password": "some password", 43 | 44 | "username": "username2", 45 | "password": "some password", 46 | Admins 47 | "username": "secretuser", 48 | "password": "secretpassword", -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/components/displayGroup.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Typography, Popover } from '@mui/material' 3 | import { GroupDetailsResponse } from '../../../../utils/types' 4 | 5 | type DisplayGroupProps = { 6 | group: GroupDetailsResponse 7 | } 8 | 9 | const DisplayGroup = ({ group }: DisplayGroupProps) => { 10 | const [anchorEl, setAnchorEl] = useState(null) 11 | 12 | const handlePopoverOpen = (event: React.MouseEvent) => { 13 | setAnchorEl(event.currentTarget) 14 | } 15 | 16 | const handlePopoverClose = () => { 17 | setAnchorEl(null) 18 | } 19 | 20 | const open = Boolean(anchorEl) 21 | 22 | return ( 23 |
24 | 30 | {group.name} 31 | 32 | 50 | 51 | Group Members 52 | 53 | {group.users.map((user, index) => ( 54 | 55 | {user.username} 56 | 57 | ))} 58 | 59 |
60 | ) 61 | } 62 | 63 | export default DisplayGroup 64 | -------------------------------------------------------------------------------- /web/src/utils/helper.ts: -------------------------------------------------------------------------------- 1 | import { PermissionResponse, RegisterPermissionPayload } from './types' 2 | 3 | export const findExistingPermission = ( 4 | existingPermissions: PermissionResponse[], 5 | newPermission: RegisterPermissionPayload 6 | ) => { 7 | for (const permission of existingPermissions) { 8 | if ( 9 | permission.user?.id === newPermission.principalId && 10 | hasSameCombination(permission, newPermission) 11 | ) 12 | return permission 13 | 14 | if ( 15 | permission.group?.groupId === newPermission.principalId && 16 | hasSameCombination(permission, newPermission) 17 | ) 18 | return permission 19 | } 20 | 21 | return null 22 | } 23 | 24 | export const findUpdatingPermission = ( 25 | existingPermissions: PermissionResponse[], 26 | newPermission: RegisterPermissionPayload 27 | ) => { 28 | for (const permission of existingPermissions) { 29 | if ( 30 | permission.user?.id === newPermission.principalId && 31 | hasDifferentSetting(permission, newPermission) 32 | ) 33 | return permission 34 | 35 | if ( 36 | permission.group?.groupId === newPermission.principalId && 37 | hasDifferentSetting(permission, newPermission) 38 | ) 39 | return permission 40 | } 41 | 42 | return null 43 | } 44 | 45 | const hasSameCombination = ( 46 | existingPermission: PermissionResponse, 47 | newPermission: RegisterPermissionPayload 48 | ) => 49 | existingPermission.path === newPermission.path && 50 | existingPermission.type === newPermission.type && 51 | existingPermission.setting === newPermission.setting 52 | 53 | const hasDifferentSetting = ( 54 | existingPermission: PermissionResponse, 55 | newPermission: RegisterPermissionPayload 56 | ) => 57 | existingPermission.path === newPermission.path && 58 | existingPermission.type === newPermission.type && 59 | existingPermission.setting !== newPermission.setting 60 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { Route, HashRouter, Routes } from 'react-router-dom' 3 | import { ThemeProvider } from '@mui/material/styles' 4 | import { theme } from './theme' 5 | 6 | import Login from './components/login' 7 | import Header from './components/header' 8 | import Home from './components/home' 9 | import Studio from './containers/Studio' 10 | import Settings from './containers/Settings' 11 | import UpdatePassword from './components/updatePassword' 12 | 13 | import { AppContext } from './context/appContext' 14 | import AuthCode from './containers/AuthCode' 15 | import { ToastContainer } from 'react-toastify' 16 | 17 | function App() { 18 | const appContext = useContext(AppContext) 19 | 20 | if (!appContext.loggedIn) { 21 | return ( 22 | 23 | 24 |
25 | 26 | } /> 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | if (appContext.needsToUpdatePassword) { 34 | return ( 35 | 36 | 37 |
38 | 39 | } /> 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | 47 | return ( 48 | 49 | 50 |
51 | 52 | } /> 53 | } /> 54 | } /> 55 | } /> 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | 63 | export default App 64 | -------------------------------------------------------------------------------- /web/src/containers/Settings/permission.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Paper, Grid, CircularProgress } from '@mui/material' 2 | import { styled } from '@mui/material/styles' 3 | import PermissionTable from './internal/components/permissionTable' 4 | import usePermission from './internal/hooks/usePermission' 5 | 6 | const BootstrapGridItem = styled(Grid)({ 7 | '&.MuiGrid-item': { 8 | maxWidth: '100%' 9 | } 10 | }) 11 | 12 | const Permission = () => { 13 | const { 14 | filterApplied, 15 | filteredPermissions, 16 | isAdmin, 17 | isLoading, 18 | permissions, 19 | AddPermissionButton, 20 | UpdatePermissionDialog, 21 | DeletePermissionDialog, 22 | FilterPermissionsButton, 23 | handleDeletePermissionClick, 24 | handleUpdatePermissionClick, 25 | PermissionResponseDialog, 26 | Dialog, 27 | Snackbar 28 | } = usePermission() 29 | 30 | return isLoading ? ( 31 | 34 | ) : ( 35 | 36 | 37 | 38 | 39 | 40 | {isAdmin && } 41 | 42 | 43 | 44 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | 60 | export default Permission 61 | -------------------------------------------------------------------------------- /api/src/utils/file.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { homedir } from 'os' 3 | import fs from 'fs-extra' 4 | 5 | export const apiRoot = path.join(__dirname, '..', '..') 6 | export const codebaseRoot = path.join(apiRoot, '..') 7 | export const sysInitCompiledPath = path.join( 8 | apiRoot, 9 | 'sasjsbuild', 10 | 'systemInitCompiled.sas' 11 | ) 12 | 13 | export const sasJSCoreMacros = path.join(apiRoot, 'sas', 'sasautos') 14 | export const sasJSCoreMacrosInfo = path.join(sasJSCoreMacros, '.macrolist') 15 | 16 | export const getWebBuildFolder = () => path.join(codebaseRoot, 'web', 'build') 17 | 18 | export const getSasjsHomeFolder = () => path.join(homedir(), '.sasjs-server') 19 | 20 | export const getDesktopUserAutoExecPath = () => 21 | path.join(getSasjsHomeFolder(), 'user-autoexec.sas') 22 | 23 | export const getSasjsRootFolder = () => process.sasjsRoot 24 | 25 | export const getSasjsDriveFolder = () => process.driveLoc 26 | 27 | export const getLogFolder = () => process.logsLoc 28 | 29 | export const getAppStreamConfigPath = () => 30 | path.join(getSasjsDriveFolder(), 'appStreamConfig.json') 31 | 32 | export const getMacrosFolder = () => 33 | path.join(getSasjsDriveFolder(), 'sas', 'sasautos') 34 | 35 | export const getPackagesFolder = () => 36 | path.join(getSasjsDriveFolder(), 'sas', 'sas_packages') 37 | 38 | export const getUploadsFolder = () => path.join(getSasjsRootFolder(), 'uploads') 39 | 40 | export const getFilesFolder = () => path.join(getSasjsDriveFolder(), 'files') 41 | 42 | export const getWeboutFolder = () => path.join(getSasjsRootFolder(), 'webouts') 43 | 44 | export const getSessionsFolder = () => 45 | path.join(getSasjsRootFolder(), 'sessions') 46 | 47 | export const generateUniqueFileName = (fileName: string, extension = '') => 48 | [ 49 | fileName, 50 | '-', 51 | Math.round(Math.random() * 100000), 52 | '-', 53 | new Date().getTime(), 54 | extension 55 | ].join('') 56 | 57 | export const createReadStream = async (filePath: string) => 58 | fs.createReadStream(filePath) 59 | -------------------------------------------------------------------------------- /api/src/routes/api/auth.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | 3 | import { AuthController } from '../../controllers/' 4 | 5 | import { 6 | authenticateAccessToken, 7 | authenticateRefreshToken 8 | } from '../../middlewares' 9 | 10 | import { tokenValidation, updatePasswordValidation } from '../../utils' 11 | import { InfoJWT } from '../../types' 12 | 13 | const authRouter = express.Router() 14 | const controller = new AuthController() 15 | 16 | authRouter.patch( 17 | '/updatePassword', 18 | authenticateAccessToken, 19 | async (req, res) => { 20 | const { error, value: body } = updatePasswordValidation(req.body) 21 | if (error) return res.status(400).send(error.details[0].message) 22 | 23 | try { 24 | await controller.updatePassword(req, body) 25 | res.sendStatus(204) 26 | } catch (err: any) { 27 | res.status(err.code).send(err.message) 28 | } 29 | } 30 | ) 31 | 32 | authRouter.post('/token', async (req, res) => { 33 | const { error, value: body } = tokenValidation(req.body) 34 | if (error) return res.status(400).send(error.details[0].message) 35 | 36 | try { 37 | const response = await controller.token(body) 38 | 39 | res.send(response) 40 | } catch (err: any) { 41 | res.status(403).send(err.toString()) 42 | } 43 | }) 44 | 45 | authRouter.post('/refresh', authenticateRefreshToken, async (req, res) => { 46 | const userInfo: InfoJWT = { 47 | userId: req.user!.userId!, 48 | clientId: req.user!.clientId! 49 | } 50 | 51 | try { 52 | const response = await controller.refresh(userInfo) 53 | 54 | res.send(response) 55 | } catch (err: any) { 56 | res.status(403).send(err.toString()) 57 | } 58 | }) 59 | 60 | authRouter.delete('/logout', authenticateAccessToken, async (req, res) => { 61 | const userInfo: InfoJWT = { 62 | userId: req.user!.userId!, 63 | clientId: req.user!.clientId! 64 | } 65 | 66 | try { 67 | await controller.logout(userInfo) 68 | } catch (e) {} 69 | 70 | res.sendStatus(204) 71 | }) 72 | 73 | export default authRouter 74 | -------------------------------------------------------------------------------- /web/webpack.common.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin' 3 | import { Configuration } from 'webpack' 4 | import HtmlWebpackPlugin from 'html-webpack-plugin' 5 | import CopyPlugin from 'copy-webpack-plugin' 6 | import dotenv from 'dotenv-webpack' 7 | 8 | const config: Configuration = { 9 | entry: path.join(__dirname, 'src', 'index.tsx'), 10 | resolve: { 11 | extensions: ['.tsx', '.ts', '.js', '.jsx'] 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.(js|jsx)$/, 17 | exclude: /node_modules/, 18 | use: ['babel-loader'] 19 | }, 20 | { 21 | test: /\.(ts|tsx)$/, 22 | exclude: /node_modules/, 23 | use: [ 24 | { 25 | loader: 'ts-loader', 26 | options: { 27 | compilerOptions: { 28 | noEmit: false 29 | } 30 | } 31 | } 32 | ] 33 | }, 34 | { 35 | test: /\.css$/, 36 | exclude: ['/node_modules/', /\.module\.css$/], 37 | use: ['style-loader', 'css-loader'] 38 | }, 39 | { 40 | test: /\.module\.css$/i, 41 | use: [ 42 | 'style-loader', 43 | { 44 | loader: 'css-loader', 45 | options: { 46 | modules: { 47 | localIdentName: '[local]--[hash:base64:5]' 48 | } 49 | } 50 | } 51 | ] 52 | }, 53 | { 54 | test: /\.scss$/, 55 | exclude: ['/node_modules/'], 56 | use: ['style-loader', 'css-loader', 'sass-loader'] 57 | }, 58 | { 59 | test: /\.(jpg|jpeg|png|gif|mp3|svg)$/, 60 | use: ['file-loader'] 61 | } 62 | ] 63 | }, 64 | plugins: [ 65 | new HtmlWebpackPlugin({ 66 | template: path.join(__dirname, 'src', 'index.html') 67 | }), 68 | new CopyPlugin({ 69 | patterns: [{ from: 'public' }] 70 | }), 71 | new dotenv(), 72 | new MonacoWebpackPlugin() 73 | ] 74 | } 75 | 76 | export default config 77 | -------------------------------------------------------------------------------- /api/src/controllers/internal/createRProgram.ts: -------------------------------------------------------------------------------- 1 | import { escapeWinSlashes } from '@sasjs/utils' 2 | import { PreProgramVars, Session } from '../../types' 3 | import { generateFileUploadRCode } from '../../utils' 4 | import { ExecutionVars } from '.' 5 | 6 | export const createRProgram = async ( 7 | program: string, 8 | preProgramVariables: PreProgramVars, 9 | vars: ExecutionVars, 10 | session: Session, 11 | weboutPath: string, 12 | headersPath: string, 13 | tokenFile: string, 14 | otherArgs?: any 15 | ) => { 16 | const varStatments = Object.keys(vars).reduce( 17 | (computed: string, key: string) => `${computed}.${key} <- '${vars[key]}'\n`, 18 | '' 19 | ) 20 | 21 | const preProgramVarStatments = ` 22 | ._SASJS_SESSION_PATH <- '${escapeWinSlashes(session.path)}'; 23 | ._WEBOUT <- '${escapeWinSlashes(weboutPath)}'; 24 | ._SASJS_WEBOUT_HEADERS <- '${escapeWinSlashes(headersPath)}'; 25 | ._SASJS_TOKENFILE <- '${escapeWinSlashes(tokenFile)}'; 26 | ._SASJS_USERNAME <- '${preProgramVariables?.username}'; 27 | ._SASJS_USERID <- '${preProgramVariables?.userId}'; 28 | ._SASJS_DISPLAYNAME <- '${preProgramVariables?.displayName}'; 29 | ._METAPERSON <- ._SASJS_DISPLAYNAME; 30 | ._METAUSER <- ._SASJS_USERNAME; 31 | SASJSPROCESSMODE <- 'Stored Program'; 32 | ` 33 | 34 | const requiredModules = `` 35 | 36 | program = ` 37 | # runtime vars 38 | ${varStatments} 39 | 40 | # dynamic user-provided vars 41 | ${preProgramVarStatments} 42 | 43 | # change working directory to session folder 44 | setwd(._SASJS_SESSION_PATH) 45 | 46 | # actual job code 47 | ${program} 48 | 49 | ` 50 | // if no files are uploaded filesNamesMap will be undefined 51 | if (otherArgs?.filesNamesMap) { 52 | const uploadRCode = await generateFileUploadRCode( 53 | otherArgs.filesNamesMap, 54 | session.path 55 | ) 56 | 57 | // If any files are uploaded, the program needs to be updated with some 58 | // dynamically generated variables (pointers) for ease of ingestion 59 | if (uploadRCode.length > 0) { 60 | program = `${uploadRCode}\n` + program 61 | } 62 | } 63 | return requiredModules + program 64 | } 65 | -------------------------------------------------------------------------------- /api/src/middlewares/multer.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { Request } from 'express' 3 | import multer, { FileFilterCallback, Options } from 'multer' 4 | import { blockFileRegex, getUploadsFolder } from '../utils' 5 | 6 | const fieldNameSize = 300 7 | const fileSize = 104857600 // 100 MB 8 | 9 | const storage = multer.diskStorage({ 10 | destination: getUploadsFolder(), 11 | filename: function ( 12 | _req: Request, 13 | file: Express.Multer.File, 14 | callback: (error: Error | null, filename: string) => void 15 | ) { 16 | callback( 17 | null, 18 | file.fieldname + path.extname(file.originalname) + '-' + Date.now() 19 | ) 20 | } 21 | }) 22 | 23 | const limits: Options['limits'] = { 24 | fieldNameSize, 25 | fileSize 26 | } 27 | 28 | const fileFilter: Options['fileFilter'] = ( 29 | req: Request, 30 | file: Express.Multer.File, 31 | callback: FileFilterCallback 32 | ) => { 33 | const fileExtension = path.extname(file.originalname) 34 | const shouldBlockUpload = blockFileRegex.test(file.originalname) 35 | if (shouldBlockUpload) { 36 | return callback( 37 | new Error(`File extension '${fileExtension}' not acceptable.`) 38 | ) 39 | } 40 | 41 | const uploadFileSize = parseInt(req.headers['content-length'] ?? '') 42 | if (uploadFileSize > fileSize) { 43 | return callback( 44 | new Error( 45 | `File size is over limit. File limit is: ${fileSize / 1024 / 1024} MB` 46 | ) 47 | ) 48 | } 49 | 50 | callback(null, true) 51 | } 52 | 53 | const options: Options = { storage, limits, fileFilter } 54 | 55 | const multerInstance = multer(options) 56 | 57 | export const multerSingle = (fileName: string, arg: any) => { 58 | const [req, res, next] = arg 59 | const upload = multerInstance.single(fileName) 60 | 61 | upload(req, res, function (err) { 62 | if (err instanceof multer.MulterError) { 63 | return res.status(500).send(err.message) 64 | } else if (err) { 65 | return res.status(400).send(err.message) 66 | } 67 | // Everything went fine. 68 | next() 69 | }) 70 | } 71 | 72 | export default multerInstance 73 | -------------------------------------------------------------------------------- /api/src/controllers/internal/deploy.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { getFilesFolder } from '../../utils/file' 3 | import { 4 | createFolder, 5 | createFile, 6 | asyncForEach, 7 | FolderMember, 8 | ServiceMember, 9 | FileMember, 10 | MemberType, 11 | FileTree 12 | } from '@sasjs/utils' 13 | 14 | // REFACTOR: export FileTreeCpntroller 15 | export const createFileTree = async ( 16 | members: (FolderMember | ServiceMember | FileMember)[], 17 | parentFolders: string[] = [] 18 | ) => { 19 | const destinationPath = path.join( 20 | getFilesFolder(), 21 | path.join(...parentFolders) 22 | ) 23 | 24 | await asyncForEach( 25 | members, 26 | async (member: FolderMember | ServiceMember | FileMember) => { 27 | let name = member.name 28 | 29 | if (member.type === MemberType.service) name += '.sas' 30 | 31 | if (member.type === MemberType.folder) { 32 | await createFolder(path.join(destinationPath, name)).catch((err) => 33 | Promise.reject({ error: err, failedToCreate: name }) 34 | ) 35 | 36 | await createFileTree(member.members, [...parentFolders, name]).catch( 37 | (err) => Promise.reject({ error: err, failedToCreate: name }) 38 | ) 39 | } else { 40 | const encoding = member.type === MemberType.file ? 'base64' : undefined 41 | 42 | await createFile( 43 | path.join(destinationPath, name), 44 | member.code, 45 | encoding 46 | ).catch((err) => Promise.reject({ error: err, failedToCreate: name })) 47 | } 48 | } 49 | ) 50 | 51 | return Promise.resolve() 52 | } 53 | 54 | export const getTreeExample = (): FileTree => ({ 55 | members: [ 56 | { 57 | name: 'jobs', 58 | type: MemberType.folder, 59 | members: [ 60 | { 61 | name: 'extract', 62 | type: MemberType.folder, 63 | members: [ 64 | { 65 | name: 'makedata1', 66 | type: MemberType.service, 67 | code: '%put Hello World!;' 68 | } 69 | ] 70 | } 71 | ] 72 | } 73 | ] 74 | }) 75 | -------------------------------------------------------------------------------- /api/src/routes/api/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | 3 | import swaggerUi from 'swagger-ui-express' 4 | 5 | import { 6 | authenticateAccessToken, 7 | desktopRestrict, 8 | verifyAdmin 9 | } from '../../middlewares' 10 | 11 | import infoRouter from './info' 12 | import driveRouter from './drive' 13 | import stpRouter from './stp' 14 | import codeRouter from './code' 15 | import userRouter from './user' 16 | import groupRouter from './group' 17 | import clientRouter from './client' 18 | import authRouter from './auth' 19 | import sessionRouter from './session' 20 | import permissionRouter from './permission' 21 | import authConfigRouter from './authConfig' 22 | 23 | const router = express.Router() 24 | 25 | router.use('/info', infoRouter) 26 | router.use('/session', authenticateAccessToken, sessionRouter) 27 | router.use('/auth', desktopRestrict, authRouter) 28 | router.use( 29 | '/client', 30 | desktopRestrict, 31 | authenticateAccessToken, 32 | verifyAdmin, 33 | clientRouter 34 | ) 35 | router.use('/drive', authenticateAccessToken, driveRouter) 36 | router.use('/group', desktopRestrict, groupRouter) 37 | router.use('/stp', authenticateAccessToken, stpRouter) 38 | router.use('/code', authenticateAccessToken, codeRouter) 39 | router.use('/user', desktopRestrict, userRouter) 40 | router.use( 41 | '/permission', 42 | desktopRestrict, 43 | authenticateAccessToken, 44 | permissionRouter 45 | ) 46 | 47 | router.use( 48 | '/authConfig', 49 | desktopRestrict, 50 | authenticateAccessToken, 51 | verifyAdmin, 52 | authConfigRouter 53 | ) 54 | 55 | router.use( 56 | '/', 57 | swaggerUi.serve, 58 | swaggerUi.setup(undefined, { 59 | swaggerOptions: { 60 | url: '/swagger.yaml', 61 | requestInterceptor: (request: any) => { 62 | request.credentials = 'include' 63 | 64 | const cookie = document.cookie 65 | const startIndex = cookie.indexOf('XSRF-TOKEN') 66 | const csrf = cookie.slice(startIndex + 11).split('; ')[0] 67 | request.headers['X-XSRF-TOKEN'] = csrf 68 | return request 69 | } 70 | } 71 | }) 72 | ) 73 | 74 | export default router 75 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/hooks/useDeletePermissionModal.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useState, useContext } from 'react' 3 | import { PermissionsContext } from '../../../../context/permissionsContext' 4 | import { AlertSeverityType } from '../../../../components/snackbar' 5 | import DeleteConfirmationModal from '../../../../components/deleteConfirmationModal' 6 | 7 | const useDeletePermissionModal = () => { 8 | const { 9 | selectedPermission, 10 | setSelectedPermission, 11 | fetchPermissions, 12 | setIsLoading, 13 | setSnackbarMessage, 14 | setSnackbarSeverity, 15 | setOpenSnackbar, 16 | setModalTitle, 17 | setModalPayload, 18 | setOpenModal 19 | } = useContext(PermissionsContext) 20 | const [deleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = 21 | useState(false) 22 | 23 | const deletePermission = () => { 24 | setDeleteConfirmationModalOpen(false) 25 | setIsLoading(true) 26 | axios 27 | .delete(`/SASjsApi/permission/${selectedPermission?.permissionId}`) 28 | .then((res: any) => { 29 | fetchPermissions() 30 | setSnackbarMessage('Permission deleted!') 31 | setSnackbarSeverity(AlertSeverityType.Success) 32 | setOpenSnackbar(true) 33 | }) 34 | .catch((err) => { 35 | setModalTitle('Abort') 36 | setModalPayload( 37 | typeof err.response.data === 'object' 38 | ? JSON.stringify(err.response.data) 39 | : err.response.data 40 | ) 41 | setOpenModal(true) 42 | }) 43 | .finally(() => { 44 | setIsLoading(false) 45 | setSelectedPermission(undefined) 46 | }) 47 | } 48 | 49 | const DeletePermissionDialog = () => ( 50 | 56 | ) 57 | 58 | return { DeletePermissionDialog, setDeleteConfirmationModalOpen } 59 | } 60 | 61 | export default useDeletePermissionModal 62 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/hooks/useUpdatePermissionModal.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useState, useContext } from 'react' 3 | import UpdatePermissionModal from '../components/updatePermissionModal' 4 | import { PermissionsContext } from '../../../../context/permissionsContext' 5 | import { AlertSeverityType } from '../../../../components/snackbar' 6 | 7 | const useUpdatePermissionModal = () => { 8 | const { 9 | selectedPermission, 10 | setSelectedPermission, 11 | fetchPermissions, 12 | setIsLoading, 13 | setSnackbarMessage, 14 | setSnackbarSeverity, 15 | setOpenSnackbar, 16 | setModalTitle, 17 | setModalPayload, 18 | setOpenModal 19 | } = useContext(PermissionsContext) 20 | const [updatePermissionModalOpen, setUpdatePermissionModalOpen] = 21 | useState(false) 22 | 23 | const updatePermission = (setting: string) => { 24 | setUpdatePermissionModalOpen(false) 25 | setIsLoading(true) 26 | axios 27 | .patch(`/SASjsApi/permission/${selectedPermission?.permissionId}`, { 28 | setting 29 | }) 30 | .then((res: any) => { 31 | fetchPermissions() 32 | setSnackbarMessage('Permission updated!') 33 | setSnackbarSeverity(AlertSeverityType.Success) 34 | setOpenSnackbar(true) 35 | }) 36 | .catch((err) => { 37 | setModalTitle('Abort') 38 | setModalPayload( 39 | typeof err.response.data === 'object' 40 | ? JSON.stringify(err.response.data) 41 | : err.response.data 42 | ) 43 | setOpenModal(true) 44 | }) 45 | .finally(() => { 46 | setIsLoading(false) 47 | setSelectedPermission(undefined) 48 | }) 49 | } 50 | 51 | const UpdatePermissionDialog = () => ( 52 | 58 | ) 59 | 60 | return { UpdatePermissionDialog, setUpdatePermissionModalOpen } 61 | } 62 | 63 | export default useUpdatePermissionModal 64 | -------------------------------------------------------------------------------- /api/src/controllers/internal/createPythonProgram.ts: -------------------------------------------------------------------------------- 1 | import { escapeWinSlashes } from '@sasjs/utils' 2 | import { PreProgramVars, Session } from '../../types' 3 | import { generateFileUploadPythonCode } from '../../utils' 4 | import { ExecutionVars } from './' 5 | 6 | export const createPythonProgram = async ( 7 | program: string, 8 | preProgramVariables: PreProgramVars, 9 | vars: ExecutionVars, 10 | session: Session, 11 | weboutPath: string, 12 | headersPath: string, 13 | tokenFile: string, 14 | otherArgs?: any 15 | ) => { 16 | const varStatments = Object.keys(vars).reduce( 17 | (computed: string, key: string) => `${computed}${key} = '${vars[key]}';\n`, 18 | '' 19 | ) 20 | 21 | const preProgramVarStatments = ` 22 | _SASJS_SESSION_PATH = '${escapeWinSlashes(session.path)}'; 23 | _WEBOUT = '${escapeWinSlashes(weboutPath)}'; 24 | _SASJS_WEBOUT_HEADERS = '${escapeWinSlashes(headersPath)}'; 25 | _SASJS_TOKENFILE = '${escapeWinSlashes(tokenFile)}'; 26 | _SASJS_USERNAME = '${preProgramVariables?.username}'; 27 | _SASJS_USERID = '${preProgramVariables?.userId}'; 28 | _SASJS_DISPLAYNAME = '${preProgramVariables?.displayName}'; 29 | _METAPERSON = _SASJS_DISPLAYNAME; 30 | _METAUSER = _SASJS_USERNAME; 31 | SASJSPROCESSMODE = 'Stored Program'; 32 | ` 33 | 34 | const requiredModules = `import os` 35 | 36 | program = ` 37 | # runtime vars 38 | ${varStatments} 39 | 40 | # dynamic user-provided vars 41 | ${preProgramVarStatments} 42 | 43 | # change working directory to session folder 44 | os.chdir(_SASJS_SESSION_PATH) 45 | 46 | # actual job code 47 | ${program} 48 | 49 | ` 50 | // if no files are uploaded filesNamesMap will be undefined 51 | if (otherArgs?.filesNamesMap) { 52 | const uploadPythonCode = await generateFileUploadPythonCode( 53 | otherArgs.filesNamesMap, 54 | session.path 55 | ) 56 | 57 | // If any files are uploaded, the program needs to be updated with some 58 | // dynamically generated variables (pointers) for ease of ingestion 59 | if (uploadPythonCode.length > 0) { 60 | program = `${uploadPythonCode}\n` + program 61 | } 62 | } 63 | return requiredModules + program 64 | } 65 | -------------------------------------------------------------------------------- /api/src/routes/api/permission.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { PermissionController } from '../../controllers/' 3 | import { verifyAdmin } from '../../middlewares' 4 | import { 5 | registerPermissionValidation, 6 | updatePermissionValidation 7 | } from '../../utils' 8 | 9 | const permissionRouter = express.Router() 10 | const controller = new PermissionController() 11 | 12 | permissionRouter.get('/', async (req, res) => { 13 | try { 14 | const response = await controller.getAllPermissions(req) 15 | res.send(response) 16 | } catch (err: any) { 17 | const statusCode = err.code 18 | delete err.code 19 | res.status(statusCode).send(err.message) 20 | } 21 | }) 22 | 23 | permissionRouter.post('/', verifyAdmin, async (req, res) => { 24 | const { error, value: body } = registerPermissionValidation(req.body) 25 | if (error) return res.status(400).send(error.details[0].message) 26 | 27 | try { 28 | const response = await controller.createPermission(body) 29 | res.send(response) 30 | } catch (err: any) { 31 | const statusCode = err.code 32 | delete err.code 33 | res.status(statusCode).send(err.message) 34 | } 35 | }) 36 | 37 | permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => { 38 | const { permissionId } = req.params 39 | 40 | const { error, value: body } = updatePermissionValidation(req.body) 41 | if (error) return res.status(400).send(error.details[0].message) 42 | 43 | try { 44 | const response = await controller.updatePermission(permissionId, body) 45 | res.send(response) 46 | } catch (err: any) { 47 | const statusCode = err.code 48 | delete err.code 49 | res.status(statusCode).send(err.message) 50 | } 51 | }) 52 | 53 | permissionRouter.delete( 54 | '/:permissionId', 55 | verifyAdmin, 56 | async (req: any, res) => { 57 | const { permissionId } = req.params 58 | 59 | try { 60 | await controller.deletePermission(permissionId) 61 | res.status(200).send('Permission Deleted!') 62 | } catch (err: any) { 63 | const statusCode = err.code 64 | delete err.code 65 | res.status(statusCode).send(err.message) 66 | } 67 | } 68 | ) 69 | export default permissionRouter 70 | -------------------------------------------------------------------------------- /api/src/controllers/internal/createJSProgram.ts: -------------------------------------------------------------------------------- 1 | import { escapeWinSlashes } from '@sasjs/utils' 2 | import { PreProgramVars, Session } from '../../types' 3 | import { generateFileUploadJSCode } from '../../utils' 4 | import { ExecutionVars } from './' 5 | 6 | export const createJSProgram = async ( 7 | program: string, 8 | preProgramVariables: PreProgramVars, 9 | vars: ExecutionVars, 10 | session: Session, 11 | weboutPath: string, 12 | headersPath: string, 13 | tokenFile: string, 14 | otherArgs?: any 15 | ) => { 16 | const varStatments = Object.keys(vars).reduce( 17 | (computed: string, key: string) => 18 | `${computed}const ${key} = \`${vars[key]}\`;\n`, 19 | '' 20 | ) 21 | 22 | const preProgramVarStatments = ` 23 | let _webout = ''; 24 | const weboutPath = '${escapeWinSlashes(weboutPath)}'; 25 | const _SASJS_TOKENFILE = '${escapeWinSlashes(tokenFile)}'; 26 | const _SASJS_WEBOUT_HEADERS = '${escapeWinSlashes(headersPath)}'; 27 | const _SASJS_USERNAME = '${preProgramVariables?.username}'; 28 | const _SASJS_USERID = '${preProgramVariables?.userId}'; 29 | const _SASJS_DISPLAYNAME = '${preProgramVariables?.displayName}'; 30 | const _METAPERSON = _SASJS_DISPLAYNAME; 31 | const _METAUSER = _SASJS_USERNAME; 32 | const SASJSPROCESSMODE = 'Stored Program'; 33 | ` 34 | 35 | const requiredModules = `const fs = require('fs')` 36 | 37 | program = ` 38 | /* runtime vars */ 39 | ${varStatments} 40 | 41 | /* dynamic user-provided vars */ 42 | ${preProgramVarStatments} 43 | 44 | /* actual job code */ 45 | ${program} 46 | 47 | /* write webout file only if webout exists*/ 48 | if (_webout) { 49 | fs.writeFile(weboutPath, _webout, function (err) { 50 | if (err) throw err; 51 | }) 52 | } 53 | ` 54 | // if no files are uploaded filesNamesMap will be undefined 55 | if (otherArgs?.filesNamesMap) { 56 | const uploadJsCode = await generateFileUploadJSCode( 57 | otherArgs.filesNamesMap, 58 | session.path 59 | ) 60 | 61 | // If any files are uploaded, the program needs to be updated with some 62 | // dynamically generated variables (pointers) for ease of ingestion 63 | if (uploadJsCode.length > 0) { 64 | program = `${uploadJsCode}\n` + program 65 | } 66 | } 67 | return requiredModules + program 68 | } 69 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/hooks/usePermission.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react' 2 | import { AppContext } from '../../../../context/appContext' 3 | import { PermissionsContext } from '../../../../context/permissionsContext' 4 | import { PermissionResponse } from '../../../../utils/types' 5 | import useAddPermission from './useAddPermission' 6 | import useUpdatePermissionModal from './useUpdatePermissionModal' 7 | import useDeletePermissionModal from './useDeletePermissionModal' 8 | import useFilterPermissions from './useFilterPermissions' 9 | 10 | export enum PrincipalType { 11 | User = 'User', 12 | Group = 'Group' 13 | } 14 | 15 | const usePermission = () => { 16 | const { isAdmin } = useContext(AppContext) 17 | const { 18 | filterApplied, 19 | filteredPermissions, 20 | isLoading, 21 | permissions, 22 | Dialog, 23 | Snackbar, 24 | PermissionResponseDialog, 25 | fetchPermissions, 26 | setSelectedPermission 27 | } = useContext(PermissionsContext) 28 | 29 | const { AddPermissionButton } = useAddPermission() 30 | 31 | const { UpdatePermissionDialog, setUpdatePermissionModalOpen } = 32 | useUpdatePermissionModal() 33 | 34 | const { DeletePermissionDialog, setDeleteConfirmationModalOpen } = 35 | useDeletePermissionModal() 36 | 37 | const { FilterPermissionsButton } = useFilterPermissions() 38 | 39 | useEffect(() => { 40 | if (fetchPermissions) fetchPermissions() 41 | }, [fetchPermissions]) 42 | 43 | const handleUpdatePermissionClick = (permission: PermissionResponse) => { 44 | setSelectedPermission(permission) 45 | setUpdatePermissionModalOpen(true) 46 | } 47 | 48 | const handleDeletePermissionClick = (permission: PermissionResponse) => { 49 | setSelectedPermission(permission) 50 | setDeleteConfirmationModalOpen(true) 51 | } 52 | 53 | return { 54 | filterApplied, 55 | filteredPermissions, 56 | isAdmin, 57 | isLoading, 58 | permissions, 59 | AddPermissionButton, 60 | UpdatePermissionDialog, 61 | DeletePermissionDialog, 62 | FilterPermissionsButton, 63 | handleDeletePermissionClick, 64 | handleUpdatePermissionClick, 65 | PermissionResponseDialog, 66 | Dialog, 67 | Snackbar 68 | } 69 | } 70 | 71 | export default usePermission 72 | -------------------------------------------------------------------------------- /api/src/controllers/session.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { Request, Security, Route, Tags, Example, Get } from 'tsoa' 3 | import { UserResponse } from './user' 4 | import { getSessionController } from './internal' 5 | import { SessionState } from '../types' 6 | 7 | interface SessionResponse extends UserResponse { 8 | needsToUpdatePassword: boolean 9 | } 10 | 11 | @Security('bearerAuth') 12 | @Route('SASjsApi/session') 13 | @Tags('Session') 14 | export class SessionController { 15 | /** 16 | * @summary Get session info (username). 17 | * 18 | */ 19 | @Example({ 20 | id: 123, 21 | username: 'johnusername', 22 | displayName: 'John', 23 | isAdmin: false 24 | }) 25 | @Get('/') 26 | public async session( 27 | @Request() request: express.Request 28 | ): Promise { 29 | return session(request) 30 | } 31 | 32 | /** 33 | * The polling endpoint is currently implemented for single-server deployments only.
34 | * Load balanced / grid topologies will be supported in a future release.
35 | * If your site requires this, please reach out to SASjs Support. 36 | * @summary Get session state (initialising, pending, running, completed, failed). 37 | * @example completed 38 | */ 39 | @Get('/:sessionId/state') 40 | public async sessionState(sessionId: string): Promise { 41 | return sessionState(sessionId) 42 | } 43 | } 44 | 45 | const session = (req: express.Request) => ({ 46 | id: req.user!.userId, 47 | username: req.user!.username, 48 | displayName: req.user!.displayName, 49 | isAdmin: req.user!.isAdmin, 50 | needsToUpdatePassword: req.user!.needsToUpdatePassword 51 | }) 52 | 53 | const sessionState = (sessionId: string): SessionState => { 54 | for (let runTime of process.runTimes) { 55 | // get session controller for each available runTime 56 | const sessionController = getSessionController(runTime) 57 | 58 | // get session by sessionId 59 | const session = sessionController.getSessionById(sessionId) 60 | 61 | // return session state if session was found 62 | if (session) { 63 | return session.state 64 | } 65 | } 66 | 67 | throw { 68 | code: 404, 69 | message: `Session with ID '${sessionId}' was not found.` 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/src/model/Permission.ts: -------------------------------------------------------------------------------- 1 | import { Schema, model, Document, Model } from 'mongoose' 2 | import { PermissionDetailsResponse } from '../controllers' 3 | import { getSequenceNextValue } from '../utils' 4 | 5 | interface GetPermissionBy { 6 | user?: Schema.Types.ObjectId 7 | group?: Schema.Types.ObjectId 8 | } 9 | 10 | interface IPermissionDocument extends Document { 11 | path: string 12 | type: string 13 | setting: string 14 | permissionId: number 15 | user: Schema.Types.ObjectId 16 | group: Schema.Types.ObjectId 17 | } 18 | 19 | interface IPermission extends IPermissionDocument {} 20 | 21 | interface IPermissionModel extends Model { 22 | get(getBy: GetPermissionBy): Promise 23 | } 24 | 25 | const permissionSchema = new Schema({ 26 | permissionId: { 27 | type: Number, 28 | unique: true 29 | }, 30 | path: { 31 | type: String, 32 | required: true 33 | }, 34 | type: { 35 | type: String, 36 | required: true 37 | }, 38 | setting: { 39 | type: String, 40 | required: true 41 | }, 42 | user: { type: Schema.Types.ObjectId, ref: 'User' }, 43 | group: { type: Schema.Types.ObjectId, ref: 'Group' } 44 | }) 45 | 46 | // Hooks 47 | permissionSchema.pre('save', async function () { 48 | if (this.isNew) { 49 | this.permissionId = await getSequenceNextValue('permissionId') 50 | } 51 | }) 52 | 53 | // Static Methods 54 | permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise< 55 | PermissionDetailsResponse[] 56 | > { 57 | return (await this.find(getBy) 58 | .select({ 59 | _id: 0, 60 | permissionId: 1, 61 | path: 1, 62 | type: 1, 63 | setting: 1 64 | }) 65 | .populate({ path: 'user', select: 'id username displayName isAdmin -_id' }) 66 | .populate({ 67 | path: 'group', 68 | select: 'groupId name description -_id', 69 | populate: { 70 | path: 'users', 71 | select: 'id username displayName isAdmin -_id', 72 | options: { limit: 15 } 73 | } 74 | })) as unknown as PermissionDetailsResponse[] 75 | }) 76 | 77 | export const Permission: IPermissionModel = model< 78 | IPermission, 79 | IPermissionModel 80 | >('Permission', permissionSchema) 81 | 82 | export default Permission 83 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/components/updatePermissionModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, Dispatch, SetStateAction, useEffect } from 'react' 2 | import { 3 | Button, 4 | Grid, 5 | DialogContent, 6 | DialogActions, 7 | TextField 8 | } from '@mui/material' 9 | 10 | import Autocomplete from '@mui/material/Autocomplete' 11 | 12 | import { BootstrapDialog } from '../../../../components/modal' 13 | import { BootstrapDialogTitle } from '../../../../components/dialogTitle' 14 | 15 | import { PermissionResponse } from '../../../../utils/types' 16 | 17 | type UpdatePermissionModalProps = { 18 | open: boolean 19 | handleOpen: Dispatch> 20 | permission: PermissionResponse | undefined 21 | updatePermission: (setting: string) => void 22 | } 23 | 24 | const UpdatePermissionModal = ({ 25 | open, 26 | handleOpen, 27 | permission, 28 | updatePermission 29 | }: UpdatePermissionModalProps) => { 30 | const [permissionSetting, setPermissionSetting] = useState('Grant') 31 | 32 | useEffect(() => { 33 | if (permission) setPermissionSetting(permission.setting) 34 | }, [permission]) 35 | 36 | return ( 37 | handleOpen(false)} open={open}> 38 | 42 | Update Permission 43 | 44 | 45 | 46 | 47 | 53 | setPermissionSetting(newValue) 54 | } 55 | renderInput={(params) => ( 56 | 57 | )} 58 | /> 59 | 60 | 61 | 62 | 63 | 70 | 71 | 72 | ) 73 | } 74 | 75 | export default UpdatePermissionModal 76 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "server", 3 | "projectOwner": "sasjs", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "saadjutt01", 15 | "name": "Saad Jutt", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/8914650?v=4", 17 | "profile": "https://github.com/saadjutt01", 18 | "contributions": [ 19 | "code", 20 | "test" 21 | ] 22 | }, 23 | { 24 | "login": "sabhas", 25 | "name": "Sabir Hassan", 26 | "avatar_url": "https://avatars.githubusercontent.com/u/82647447?v=4", 27 | "profile": "https://github.com/sabhas", 28 | "contributions": [ 29 | "code", 30 | "test" 31 | ] 32 | }, 33 | { 34 | "login": "YuryShkoda", 35 | "name": "Yury Shkoda", 36 | "avatar_url": "https://avatars.githubusercontent.com/u/25773492?v=4", 37 | "profile": "https://www.erudicat.com/", 38 | "contributions": [ 39 | "code", 40 | "test" 41 | ] 42 | }, 43 | { 44 | "login": "medjedovicm", 45 | "name": "Mihajlo Medjedovic", 46 | "avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4", 47 | "profile": "https://github.com/medjedovicm", 48 | "contributions": [ 49 | "code", 50 | "test" 51 | ] 52 | }, 53 | { 54 | "login": "allanbowe", 55 | "name": "Allan Bowe", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/4420615?v=4", 57 | "profile": "https://4gl.io/", 58 | "contributions": [ 59 | "code", 60 | "doc" 61 | ] 62 | }, 63 | { 64 | "login": "VladislavParhomchik", 65 | "name": "Vladislav Parhomchik", 66 | "avatar_url": "https://avatars.githubusercontent.com/u/83717836?v=4", 67 | "profile": "https://github.com/VladislavParhomchik", 68 | "contributions": [ 69 | "test" 70 | ] 71 | }, 72 | { 73 | "login": "kknapen", 74 | "name": "Koen Knapen", 75 | "avatar_url": "https://avatars.githubusercontent.com/u/78609432?v=4", 76 | "profile": "https://github.com/kknapen", 77 | "contributions": [ 78 | "userTesting" 79 | ] 80 | } 81 | ], 82 | "contributorsPerLine": 7, 83 | "skipCi": true 84 | } 85 | -------------------------------------------------------------------------------- /web/src/containers/AuthCode/index.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { CopyToClipboard } from 'react-copy-to-clipboard' 3 | import React, { useEffect, useState } from 'react' 4 | import { toast } from 'react-toastify' 5 | import 'react-toastify/dist/ReactToastify.css' 6 | import { useLocation } from 'react-router-dom' 7 | 8 | import { CssBaseline, Box, Typography, Button } from '@mui/material' 9 | 10 | const getAuthCode = async (credentials: any) => 11 | axios.post('/SASLogon/authorize', credentials).then((res) => res.data) 12 | 13 | const AuthCode = () => { 14 | const location = useLocation() 15 | const [displayCode, setDisplayCode] = useState('') 16 | const [errorMessage, setErrorMessage] = useState('') 17 | 18 | useEffect(() => { 19 | requestAuthCode() 20 | }, []) 21 | 22 | const requestAuthCode = async () => { 23 | setErrorMessage('') 24 | 25 | const params = new URLSearchParams(location.search) 26 | 27 | const responseType = params.get('response_type') 28 | if (responseType !== 'code') 29 | return setErrorMessage('response type is not support') 30 | 31 | const clientId = params.get('client_id') 32 | if (!clientId) return setErrorMessage('clientId is not provided') 33 | 34 | setErrorMessage('Fetching auth code... ') 35 | const { code } = await getAuthCode({ 36 | clientId 37 | }) 38 | .then((res) => { 39 | setErrorMessage('') 40 | return res 41 | }) 42 | .catch((err: any) => { 43 | setErrorMessage(err.response.data) 44 | return { code: null } 45 | }) 46 | return setDisplayCode(code) 47 | } 48 | 49 | return ( 50 | 51 | 52 |
53 |

Authorization Code

54 | {displayCode && ( 55 | 56 | {displayCode} 57 | 58 | )} 59 | {errorMessage && {errorMessage}} 60 | 61 |
62 | 63 | 66 | toast.info('Code copied to ClipBoard', { 67 | theme: 'dark', 68 | position: toast.POSITION.BOTTOM_RIGHT 69 | }) 70 | } 71 | > 72 | 73 | 74 |
75 | ) 76 | } 77 | 78 | export default AuthCode 79 | -------------------------------------------------------------------------------- /web/src/containers/Settings/internal/components/filterPermissions.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, useState } from 'react' 2 | import { IconButton, Tooltip } from '@mui/material' 3 | import { FilterList } from '@mui/icons-material' 4 | import { PermissionResponse } from '../../../../utils/types' 5 | import PermissionFilterModal from './permissionFilterModal' 6 | import { PrincipalType } from '../hooks/usePermission' 7 | 8 | type Props = { 9 | open: boolean 10 | handleOpen: Dispatch> 11 | permissions: PermissionResponse[] 12 | applyFilter: ( 13 | pathFilter: string[], 14 | principalFilter: string[], 15 | principalTypeFilter: PrincipalType[], 16 | settingFilter: string[] 17 | ) => void 18 | resetFilter: () => void 19 | } 20 | 21 | const FilterPermissions = ({ 22 | open, 23 | handleOpen, 24 | permissions, 25 | applyFilter, 26 | resetFilter 27 | }: Props) => { 28 | const [pathFilter, setPathFilter] = useState([]) 29 | const [principalFilter, setPrincipalFilter] = useState([]) 30 | const [principalTypeFilter, setPrincipalTypeFilter] = useState< 31 | PrincipalType[] 32 | >([]) 33 | const [settingFilter, setSettingFilter] = useState([]) 34 | const handleApplyFilter = () => { 35 | applyFilter(pathFilter, principalFilter, principalTypeFilter, settingFilter) 36 | } 37 | 38 | const handleResetFilter = () => { 39 | setPathFilter([]) 40 | setPrincipalFilter([]) 41 | setPrincipalFilter([]) 42 | setSettingFilter([]) 43 | resetFilter() 44 | } 45 | 46 | return ( 47 | <> 48 | 49 | handleOpen(true)}> 50 | 51 | 52 | 53 | 68 | 69 | ) 70 | } 71 | 72 | export default FilterPermissions 73 | -------------------------------------------------------------------------------- /api/src/controllers/internal/FileUploadController.ts: -------------------------------------------------------------------------------- 1 | import { Request, RequestHandler } from 'express' 2 | import multer from 'multer' 3 | import { uuidv4 } from '@sasjs/utils' 4 | import { getSessionController } from '.' 5 | import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils' 6 | import { SessionState } from '../../types' 7 | 8 | export class FileUploadController { 9 | private storage = multer.diskStorage({ 10 | destination: function (req: Request, file: any, cb: any) { 11 | //Sending the intercepted files to the sessions subfolder 12 | cb(null, req.sasjsSession?.path) 13 | }, 14 | filename: function (req: Request, file: any, cb: any) { 15 | //req_file prefix + unique hash added to sas request files 16 | cb(null, `req_file_${uuidv4().replace(/-/gm, '')}`) 17 | } 18 | }) 19 | 20 | private upload = multer({ storage: this.storage }) 21 | 22 | //It will intercept request and generate unique uuid to be used as a subfolder name 23 | //that will store the files uploaded 24 | public preUploadMiddleware: RequestHandler = async (req, res, next) => { 25 | const { error: errQ, value: query } = executeProgramRawValidation(req.query) 26 | const { error: errB, value: body } = executeProgramRawValidation(req.body) 27 | 28 | if (errQ && errB) return res.status(400).send(errB.details[0].message) 29 | 30 | const programPath = (query?._program ?? body?._program) as string 31 | 32 | let runTime 33 | 34 | try { 35 | ;({ runTime } = await getRunTimeAndFilePath(programPath)) 36 | } catch (err: any) { 37 | return res.status(400).send({ 38 | status: 'failure', 39 | message: 'Job execution failed', 40 | error: typeof err === 'object' ? err.toString() : err 41 | }) 42 | } 43 | 44 | let sessionController 45 | try { 46 | sessionController = getSessionController(runTime) 47 | } catch (err: any) { 48 | return res.status(400).send({ 49 | status: 'failure', 50 | message: err.message, 51 | error: typeof err === 'object' ? err.toString() : err 52 | }) 53 | } 54 | 55 | const session = await sessionController.getSession() 56 | // change session state to 'running', so that it's not available for any other request 57 | session.state = SessionState.running 58 | 59 | req.sasjsSession = session 60 | 61 | next() 62 | } 63 | 64 | public getMulterUploadObject() { 65 | return this.upload 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /api/src/controllers/internal/createSASProgram.ts: -------------------------------------------------------------------------------- 1 | import { PreProgramVars, Session } from '../../types' 2 | import { generateFileUploadSasCode, getMacrosFolder } from '../../utils' 3 | import { ExecutionVars } from './' 4 | 5 | export const createSASProgram = async ( 6 | program: string, 7 | preProgramVariables: PreProgramVars, 8 | vars: ExecutionVars, 9 | session: Session, 10 | weboutPath: string, 11 | headersPath: string, 12 | tokenFile: string, 13 | otherArgs?: any 14 | ) => { 15 | const varStatments = Object.keys(vars).reduce( 16 | (computed: string, key: string) => `${computed}%let ${key}=${vars[key]};\n`, 17 | '' 18 | ) 19 | 20 | const preProgramVarStatments = ` 21 | %let _sasjs_tokenfile=${tokenFile}; 22 | %let _sasjs_username=${preProgramVariables?.username}; 23 | %let _sasjs_userid=${preProgramVariables?.userId}; 24 | %let _sasjs_displayname=${preProgramVariables?.displayName}; 25 | %let _sasjs_apiserverurl=${preProgramVariables?.serverUrl}; 26 | %let _sasjs_apipath=/SASjsApi/stp/execute; 27 | %let _sasjs_webout_headers=${headersPath}; 28 | %let _metaperson=&_sasjs_displayname; 29 | %let _metauser=&_sasjs_username; 30 | 31 | /* the below is here for compatibility and will be removed in a future release */ 32 | %let sasjs_stpsrv_header_loc=&_sasjs_webout_headers; 33 | 34 | %let sasjsprocessmode=Stored Program; 35 | 36 | %global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG; 37 | %macro _sasjs_server_init(); 38 | %if "&SYSPROCESSMODE"="" %then %let SYSPROCESSMODE=&sasjsprocessmode; 39 | %if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl; 40 | %mend; 41 | %_sasjs_server_init() 42 | 43 | ` 44 | 45 | program = ` 46 | options insert=(SASAUTOS="${getMacrosFolder()}"); 47 | 48 | /* runtime vars */ 49 | ${varStatments} 50 | filename _webout "${weboutPath}" mod; 51 | 52 | /* dynamic user-provided vars */ 53 | ${preProgramVarStatments} 54 | 55 | /* user autoexec starts */ 56 | ${otherArgs?.userAutoExec ?? ''} 57 | /* user autoexec ends */ 58 | 59 | /* actual job code */ 60 | ${program}` 61 | 62 | // if no files are uploaded filesNamesMap will be undefined 63 | if (otherArgs?.filesNamesMap) { 64 | const uploadSasCode = await generateFileUploadSasCode( 65 | otherArgs.filesNamesMap, 66 | session.path 67 | ) 68 | 69 | // If any files are uploaded, the program needs to be updated with some 70 | // dynamically generated variables (pointers) for ease of ingestion 71 | if (uploadSasCode.length > 0) { 72 | program = `${uploadSasCode}` + program 73 | } 74 | } 75 | return program 76 | } 77 | -------------------------------------------------------------------------------- /api/src/routes/web/web.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { generateCSRFToken } from '../../middlewares' 3 | import { WebController } from '../../controllers/web' 4 | import { 5 | authenticateAccessToken, 6 | bruteForceProtection, 7 | desktopRestrict 8 | } from '../../middlewares' 9 | import { authorizeValidation, loginWebValidation } from '../../utils' 10 | 11 | const webRouter = express.Router() 12 | const controller = new WebController() 13 | 14 | webRouter.get('/', async (req, res) => { 15 | let response 16 | try { 17 | response = await controller.home() 18 | } catch (_) { 19 | response = 'Web Build is not present' 20 | } finally { 21 | const { ALLOWED_DOMAIN } = process.env 22 | const allowedDomain = ALLOWED_DOMAIN?.trim() 23 | const domain = allowedDomain ? ` Domain=${allowedDomain};` : '' 24 | const codeToInject = `` 25 | const injectedContent = response?.replace( 26 | '', 27 | `${codeToInject}` 28 | ) 29 | 30 | return res.send(injectedContent) 31 | } 32 | }) 33 | 34 | webRouter.post( 35 | '/SASLogon/login', 36 | desktopRestrict, 37 | bruteForceProtection, 38 | async (req, res) => { 39 | const { error, value: body } = loginWebValidation(req.body) 40 | if (error) return res.status(400).send(error.details[0].message) 41 | 42 | try { 43 | const response = await controller.login(req, body) 44 | res.send(response) 45 | } catch (err: any) { 46 | if (err instanceof Error) { 47 | res.status(500).send(err.toString()) 48 | } else { 49 | res.status(err.code).send(err.message) 50 | } 51 | } 52 | } 53 | ) 54 | 55 | webRouter.post( 56 | '/SASLogon/authorize', 57 | desktopRestrict, 58 | authenticateAccessToken, 59 | async (req, res) => { 60 | const { error, value: body } = authorizeValidation(req.body) 61 | if (error) return res.status(400).send(error.details[0].message) 62 | 63 | try { 64 | const response = await controller.authorize(req, body) 65 | res.send(response) 66 | } catch (err: any) { 67 | res.status(403).send(err.toString()) 68 | } 69 | } 70 | ) 71 | 72 | webRouter.get('/SASLogon/logout', desktopRestrict, async (req, res) => { 73 | try { 74 | await controller.logout(req) 75 | res.status(200).send('OK!') 76 | } catch (err: any) { 77 | res.status(403).send(err.toString()) 78 | } 79 | }) 80 | 81 | export default webRouter 82 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: SASjs Server Build 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-22.04 9 | 10 | strategy: 11 | matrix: 12 | node-version: [lts/*] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | 21 | - name: Install Dependencies 22 | run: npm ci 23 | 24 | - name: Check Api Code Style 25 | run: npm run lint-api 26 | 27 | - name: Check Web Code Style 28 | run: npm run lint-web 29 | 30 | build-api: 31 | runs-on: ubuntu-22.04 32 | 33 | strategy: 34 | matrix: 35 | node-version: [lts/*] 36 | 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Use Node.js ${{ matrix.node-version }} 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: ${{ matrix.node-version }} 43 | 44 | - name: Install Dependencies 45 | working-directory: ./api 46 | run: npm ci 47 | 48 | - name: Run Unit Tests 49 | working-directory: ./api 50 | run: npm test 51 | env: 52 | CI: true 53 | MODE: 'server' 54 | ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}} 55 | REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}} 56 | AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}} 57 | SESSION_SECRET: ${{secrets.SESSION_SECRET}} 58 | RUN_TIMES: 'sas,js' 59 | SAS_PATH: '/some/path/to/sas' 60 | NODE_PATH: '/some/path/to/node' 61 | 62 | - name: Build Package 63 | working-directory: ./api 64 | run: npm run build 65 | env: 66 | CI: true 67 | 68 | build-web: 69 | runs-on: ubuntu-22.04 70 | 71 | strategy: 72 | matrix: 73 | node-version: [lts/*] 74 | 75 | steps: 76 | - uses: actions/checkout@v2 77 | - name: Use Node.js ${{ matrix.node-version }} 78 | uses: actions/setup-node@v2 79 | with: 80 | node-version: ${{ matrix.node-version }} 81 | 82 | - name: Install Dependencies 83 | working-directory: ./web 84 | run: npm ci 85 | 86 | # TODO: Uncomment next step when unit tests provided 87 | # - name: Run Unit Tests 88 | # working-directory: ./web 89 | # run: npm test 90 | 91 | - name: Build Package 92 | working-directory: ./web 93 | run: npm run build 94 | env: 95 | CI: true 96 | -------------------------------------------------------------------------------- /api/src/utils/appStreamConfig.ts: -------------------------------------------------------------------------------- 1 | import { createFile, fileExists, readFile } from '@sasjs/utils' 2 | import { publishAppStream } from '../routes/appStream' 3 | import { AppStreamConfig } from '../types' 4 | 5 | import { getAppStreamConfigPath } from './file' 6 | 7 | export const loadAppStreamConfig = async () => { 8 | process.appStreamConfig = {} 9 | 10 | if (process.env.NODE_ENV === 'test') return 11 | 12 | const appStreamConfigPath = getAppStreamConfigPath() 13 | 14 | const content = (await fileExists(appStreamConfigPath)) 15 | ? await readFile(appStreamConfigPath) 16 | : '{}' 17 | 18 | let appStreamConfig: AppStreamConfig 19 | try { 20 | appStreamConfig = JSON.parse(content) 21 | 22 | if (!isValidAppStreamConfig(appStreamConfig)) throw 'invalid type' 23 | } catch (_) { 24 | appStreamConfig = {} 25 | } 26 | 27 | for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) { 28 | const { appLoc, streamWebFolder, streamLogo } = entry 29 | 30 | publishAppStream( 31 | appLoc, 32 | streamWebFolder, 33 | streamServiceName, 34 | streamLogo, 35 | false 36 | ) 37 | } 38 | 39 | process.logger.info('App Stream Config loaded!') 40 | } 41 | 42 | export const addEntryToAppStreamConfig = ( 43 | streamServiceName: string, 44 | appLoc: string, 45 | streamWebFolder: string, 46 | streamLogo?: string, 47 | addEntryToFile: boolean = true 48 | ) => { 49 | if (streamServiceName && appLoc && streamWebFolder) { 50 | process.appStreamConfig[streamServiceName] = { 51 | appLoc, 52 | streamWebFolder, 53 | streamLogo 54 | } 55 | if (addEntryToFile) saveAppStreamConfig() 56 | } 57 | } 58 | 59 | export const removeEntryFromAppStreamConfig = (streamServiceName: string) => { 60 | if (streamServiceName) { 61 | delete process.appStreamConfig[streamServiceName] 62 | saveAppStreamConfig() 63 | } 64 | } 65 | 66 | const saveAppStreamConfig = async () => { 67 | const appStreamConfigPath = getAppStreamConfigPath() 68 | 69 | try { 70 | await createFile( 71 | appStreamConfigPath, 72 | JSON.stringify(process.appStreamConfig, null, 2) 73 | ) 74 | } catch (_) {} 75 | } 76 | 77 | const isValidAppStreamConfig = (config: any) => { 78 | if (config) { 79 | return !Object.entries(config).some(([streamServiceName, entry]) => { 80 | const { appLoc, streamWebFolder, streamLogo } = entry as any 81 | 82 | return ( 83 | typeof streamServiceName !== 'string' || 84 | typeof appLoc !== 'string' || 85 | typeof streamWebFolder !== 'string' 86 | ) 87 | }) 88 | } 89 | return false 90 | } 91 | -------------------------------------------------------------------------------- /web/src/containers/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react' 2 | 3 | import { Box, Paper, Tab, styled } from '@mui/material' 4 | import TabContext from '@mui/lab/TabContext' 5 | import TabList from '@mui/lab/TabList' 6 | import TabPanel from '@mui/lab/TabPanel' 7 | 8 | import Permission from './permission' 9 | import Profile from './profile' 10 | import AuthConfig from './authConfig' 11 | 12 | import { AppContext, ModeType } from '../../context/appContext' 13 | import PermissionsContextProvider from '../../context/permissionsContext' 14 | 15 | const StyledTab = styled(Tab)({ 16 | background: 'black', 17 | margin: '0 5px 5px 0' 18 | }) 19 | 20 | const StyledTabpanel = styled(TabPanel)({ 21 | flexGrow: 1 22 | }) 23 | 24 | const Settings = () => { 25 | const appContext = useContext(AppContext) 26 | const [value, setValue] = useState('profile') 27 | 28 | const handleChange = (event: React.SyntheticEvent, newValue: string) => { 29 | setValue(newValue) 30 | } 31 | 32 | return ( 33 | 40 | 41 | 50 | 59 | 60 | {appContext.mode === ModeType.Server && ( 61 | 62 | )} 63 | {appContext.mode === ModeType.Server && appContext.isAdmin && ( 64 | 65 | )} 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | export default Settings 85 | -------------------------------------------------------------------------------- /web/src/components/filePathInputModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { Button, DialogActions, DialogContent, TextField } from '@mui/material' 4 | 5 | import { BootstrapDialogTitle } from './dialogTitle' 6 | import { BootstrapDialog } from './modal' 7 | 8 | type FilePathInputModalProps = { 9 | open: boolean 10 | setOpen: React.Dispatch> 11 | saveFile: (filePath: string) => void 12 | } 13 | 14 | const FilePathInputModal = ({ 15 | open, 16 | setOpen, 17 | saveFile 18 | }: FilePathInputModalProps) => { 19 | const [filePath, setFilePath] = useState('') 20 | const [hasError, setHasError] = useState(false) 21 | const [errorText, setErrorText] = useState('') 22 | 23 | const handleChange = (event: React.ChangeEvent) => { 24 | const value = event.target.value 25 | 26 | const specialChars = /[`!@#$%^&*()+\-=[\]{};':"\\|,<>?~]/ 27 | const fileExtension = /\.(exe|sh|htaccess)$/i 28 | 29 | if (specialChars.test(value)) { 30 | setHasError(true) 31 | setErrorText('can not have special characters') 32 | } else if (fileExtension.test(value)) { 33 | setHasError(true) 34 | setErrorText('can not save file with extensions [exe, sh, htaccess]') 35 | } else { 36 | setHasError(false) 37 | setErrorText('') 38 | } 39 | setFilePath(value) 40 | } 41 | 42 | const handleSubmit = (event: React.FormEvent) => { 43 | event.preventDefault() 44 | if (hasError || !filePath) return 45 | saveFile(filePath) 46 | } 47 | 48 | return ( 49 | setOpen(false)} open={open}> 50 | 51 | Save File 52 | 53 | 54 |
55 | 65 | 66 |
67 | 68 | 71 | 78 | 79 |
80 | ) 81 | } 82 | 83 | export default FilePathInputModal 84 | -------------------------------------------------------------------------------- /api/src/middlewares/authorize.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express' 2 | import User from '../model/User' 3 | import Permission from '../model/Permission' 4 | import { 5 | PermissionSettingForRoute, 6 | PermissionType 7 | } from '../controllers/permission' 8 | import { getPath, isPublicRoute, TopLevelRoutes } from '../utils' 9 | 10 | export const authorize: RequestHandler = async (req, res, next) => { 11 | const { user } = req 12 | 13 | if (!user) return res.sendStatus(401) 14 | 15 | // no need to check for permissions when user is admin 16 | if (user.isAdmin) return next() 17 | 18 | // no need to check for permissions when route is Public 19 | if (await isPublicRoute(req)) return next() 20 | 21 | const dbUser = await User.findOne({ id: user.userId }) 22 | if (!dbUser) return res.sendStatus(401) 23 | 24 | const path = getPath(req) 25 | const { baseUrl } = req 26 | const topLevelRoute = 27 | TopLevelRoutes.find((route) => baseUrl.startsWith(route)) || baseUrl 28 | 29 | // find permission w.r.t user 30 | const permission = await Permission.findOne({ 31 | path, 32 | type: PermissionType.route, 33 | user: dbUser._id 34 | }) 35 | 36 | if (permission) { 37 | if (permission.setting === PermissionSettingForRoute.grant) return next() 38 | else return res.sendStatus(401) 39 | } 40 | 41 | // find permission w.r.t user on top level 42 | const topLevelPermission = await Permission.findOne({ 43 | path: topLevelRoute, 44 | type: PermissionType.route, 45 | user: dbUser._id 46 | }) 47 | 48 | if (topLevelPermission) { 49 | if (topLevelPermission.setting === PermissionSettingForRoute.grant) 50 | return next() 51 | else return res.sendStatus(401) 52 | } 53 | 54 | let isPermissionDenied = false 55 | 56 | // find permission w.r.t user's groups 57 | for (const group of dbUser.groups) { 58 | const groupPermission = await Permission.findOne({ 59 | path, 60 | type: PermissionType.route, 61 | group 62 | }) 63 | 64 | if (groupPermission) { 65 | if (groupPermission.setting === PermissionSettingForRoute.grant) { 66 | return next() 67 | } else { 68 | isPermissionDenied = true 69 | } 70 | } 71 | } 72 | 73 | if (!isPermissionDenied) { 74 | // find permission w.r.t user's groups on top level 75 | for (const group of dbUser.groups) { 76 | const groupPermission = await Permission.findOne({ 77 | path: topLevelRoute, 78 | type: PermissionType.route, 79 | group 80 | }) 81 | if (groupPermission?.setting === PermissionSettingForRoute.grant) 82 | return next() 83 | } 84 | } 85 | 86 | return res.sendStatus(401) 87 | } 88 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "webpack-dev-server --config webpack.dev.ts --hot", 7 | "build": "webpack --config webpack.prod.ts" 8 | }, 9 | "dependencies": { 10 | "@emotion/react": "^11.4.1", 11 | "@emotion/styled": "^11.3.0", 12 | "@mui/icons-material": "^5.8.4", 13 | "@mui/lab": "^5.0.0-alpha.50", 14 | "@mui/material": "^5.0.3", 15 | "@mui/styles": "^5.0.1", 16 | "@testing-library/jest-dom": "^5.14.1", 17 | "@testing-library/react": "^11.2.7", 18 | "@testing-library/user-event": "^12.8.3", 19 | "@types/jest": "^26.0.24", 20 | "@types/node": "^12.20.28", 21 | "@types/react": "^17.0.27", 22 | "axios": "^1.12.2", 23 | "monaco-editor": "^0.33.0", 24 | "react": "^17.0.2", 25 | "react-copy-to-clipboard": "^5.1.0", 26 | "react-dom": "^17.0.2", 27 | "react-highlight": "^0.15.0", 28 | "react-monaco-editor": "^0.48.0", 29 | "react-router-dom": "^6.3.0", 30 | "react-toastify": "^9.0.1" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.16.0", 34 | "@babel/node": "^7.16.0", 35 | "@babel/plugin-proposal-class-properties": "^7.16.0", 36 | "@babel/preset-env": "^7.16.4", 37 | "@babel/preset-react": "^7.16.0", 38 | "@babel/preset-typescript": "^7.16.0", 39 | "@types/dotenv-webpack": "^7.0.3", 40 | "@types/prismjs": "^1.16.6", 41 | "@types/react": "^17.0.37", 42 | "@types/react-copy-to-clipboard": "^5.0.2", 43 | "@types/react-dom": "^17.0.11", 44 | "@types/react-highlight": "^0.12.5", 45 | "@types/react-router-dom": "^5.3.1", 46 | "babel-loader": "^8.2.3", 47 | "babel-plugin-prismjs": "^2.1.0", 48 | "copy-webpack-plugin": "^10.0.0", 49 | "css-loader": "^6.5.1", 50 | "dotenv-webpack": "^7.1.0", 51 | "eslint": "^8.5.0", 52 | "eslint-config-react-app": "^7.0.0", 53 | "eslint-webpack-plugin": "^3.1.1", 54 | "file-loader": "^6.2.0", 55 | "html-webpack-plugin": "5.5.0", 56 | "monaco-editor-webpack-plugin": "^7.0.1", 57 | "path": "0.12.7", 58 | "prettier": "^2.4.1", 59 | "sass": "^1.44.0", 60 | "sass-loader": "^12.3.0", 61 | "style-loader": "^3.3.1", 62 | "ts-loader": "^9.2.6", 63 | "typescript": "^4.5.2", 64 | "typescript-plugin-css-modules": "^5.0.1", 65 | "webpack": "5.64.3", 66 | "webpack-cli": "^4.9.2", 67 | "webpack-dev-server": "4.7.4" 68 | }, 69 | "eslintConfig": { 70 | "extends": [ 71 | "react-app", 72 | "react-app/jest" 73 | ] 74 | }, 75 | "browserslist": { 76 | "production": [ 77 | ">0.2%", 78 | "not dead", 79 | "not op_mini all" 80 | ], 81 | "development": [ 82 | "last 1 chrome version", 83 | "last 1 firefox version", 84 | "last 1 safari version" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /api/src/controllers/client.ts: -------------------------------------------------------------------------------- 1 | import { Security, Route, Tags, Example, Post, Body, Get } from 'tsoa' 2 | 3 | import Client, { 4 | ClientPayload, 5 | NUMBER_OF_SECONDS_IN_A_DAY 6 | } from '../model/Client' 7 | 8 | @Security('bearerAuth') 9 | @Route('SASjsApi/client') 10 | @Tags('Client') 11 | export class ClientController { 12 | /** 13 | * @summary Admin only task. Create client with the following attributes: 14 | * ClientId, 15 | * ClientSecret, 16 | * accessTokenExpiration (optional), 17 | * refreshTokenExpiration (optional) 18 | * 19 | */ 20 | @Example({ 21 | clientId: 'someFormattedClientID1234', 22 | clientSecret: 'someRandomCryptoString', 23 | accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY, 24 | refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30 25 | }) 26 | @Post('/') 27 | public async createClient( 28 | @Body() body: ClientPayload 29 | ): Promise { 30 | return createClient(body) 31 | } 32 | 33 | /** 34 | * @summary Admin only task. Returns the list of all the clients 35 | */ 36 | @Example([ 37 | { 38 | clientId: 'someClientID1234', 39 | clientSecret: 'someRandomCryptoString', 40 | accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY, 41 | refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30 42 | }, 43 | { 44 | clientId: 'someOtherClientID', 45 | clientSecret: 'someOtherRandomCryptoString', 46 | accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY, 47 | refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30 48 | } 49 | ]) 50 | @Get('/') 51 | public async getAllClients(): Promise { 52 | return getAllClients() 53 | } 54 | } 55 | 56 | const createClient = async (data: ClientPayload): Promise => { 57 | const { 58 | clientId, 59 | clientSecret, 60 | accessTokenExpiration, 61 | refreshTokenExpiration 62 | } = data 63 | 64 | // Checking if client is already in the database 65 | const clientExist = await Client.findOne({ clientId }) 66 | if (clientExist) throw new Error('Client ID already exists.') 67 | 68 | // Create a new client 69 | const client = new Client({ 70 | clientId, 71 | clientSecret, 72 | accessTokenExpiration, 73 | refreshTokenExpiration 74 | }) 75 | 76 | const savedClient = await client.save() 77 | 78 | return { 79 | clientId: savedClient.clientId, 80 | clientSecret: savedClient.clientSecret, 81 | accessTokenExpiration: savedClient.accessTokenExpiration, 82 | refreshTokenExpiration: savedClient.refreshTokenExpiration 83 | } 84 | } 85 | 86 | const getAllClients = async (): Promise => { 87 | return Client.find({}).select({ 88 | _id: 0, 89 | clientId: 1, 90 | clientSecret: 1, 91 | accessTokenExpiration: 1, 92 | refreshTokenExpiration: 1 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /api/src/routes/api/stp.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { 3 | executeProgramRawValidation, 4 | triggerProgramValidation 5 | } from '../../utils' 6 | import { STPController } from '../../controllers/' 7 | import { FileUploadController } from '../../controllers/internal' 8 | 9 | const stpRouter = express.Router() 10 | 11 | const fileUploadController = new FileUploadController() 12 | const controller = new STPController() 13 | 14 | stpRouter.get('/execute', async (req, res) => { 15 | const { error, value: query } = executeProgramRawValidation(req.query) 16 | if (error) return res.status(400).send(error.details[0].message) 17 | 18 | try { 19 | const response = await controller.executeGetRequest( 20 | req, 21 | query._program, 22 | query._debug 23 | ) 24 | 25 | if (response instanceof Buffer) { 26 | res.writeHead(200, (req as any).sasHeaders) 27 | return res.end(response) 28 | } 29 | 30 | res.send(response) 31 | } catch (err: any) { 32 | const statusCode = err.code 33 | 34 | delete err.code 35 | 36 | res.status(statusCode).send(err) 37 | } 38 | }) 39 | 40 | stpRouter.post( 41 | '/execute', 42 | fileUploadController.preUploadMiddleware, 43 | fileUploadController.getMulterUploadObject().any(), 44 | async (req, res: any) => { 45 | // below validations are moved to preUploadMiddleware 46 | // const { error: errQ, value: query } = executeProgramRawValidation(req.query) 47 | // const { error: errB, value: body } = executeProgramRawValidation(req.body) 48 | 49 | // if (errQ && errB) return res.status(400).send(errB.details[0].message) 50 | 51 | try { 52 | const response = await controller.executePostRequest( 53 | req, 54 | req.body, 55 | req.query?._program as string 56 | ) 57 | 58 | // TODO: investigate if this code is required 59 | // if (response instanceof Buffer) { 60 | // res.writeHead(200, (req as any).sasHeaders) 61 | // return res.end(response) 62 | // } 63 | 64 | res.send(response) 65 | } catch (err: any) { 66 | const statusCode = err.code 67 | 68 | delete err.code 69 | 70 | res.status(statusCode).send(err) 71 | } 72 | } 73 | ) 74 | 75 | stpRouter.post('/trigger', async (req, res) => { 76 | const { error, value: query } = triggerProgramValidation(req.query) 77 | 78 | if (error) return res.status(400).send(error.details[0].message) 79 | 80 | try { 81 | const response = await controller.triggerProgram( 82 | req, 83 | query._program, 84 | query._debug, 85 | query.expiresAfterMins 86 | ) 87 | 88 | res.status(200) 89 | res.send(response) 90 | } catch (err: any) { 91 | const statusCode = err.code 92 | 93 | delete err.code 94 | 95 | res.status(statusCode).send(err) 96 | } 97 | }) 98 | 99 | export default stpRouter 100 | -------------------------------------------------------------------------------- /api/src/routes/appStream/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import express, { Request } from 'express' 3 | import { authenticateAccessToken, generateCSRFToken } from '../../middlewares' 4 | import { folderExists } from '@sasjs/utils' 5 | 6 | import { addEntryToAppStreamConfig, getFilesFolder } from '../../utils' 7 | import { appStreamHtml } from './appStreamHtml' 8 | 9 | const appStreams: { [key: string]: string } = {} 10 | 11 | const router = express.Router() 12 | 13 | router.get('/', authenticateAccessToken, async (req, res) => { 14 | const content = appStreamHtml(process.appStreamConfig) 15 | 16 | res.cookie('XSRF-TOKEN', generateCSRFToken()) 17 | 18 | return res.send(content) 19 | }) 20 | 21 | export const publishAppStream = async ( 22 | appLoc: string, 23 | streamWebFolder: string, 24 | streamServiceName?: string, 25 | streamLogo?: string, 26 | addEntryToFile: boolean = true 27 | ) => { 28 | const driveFilesPath = getFilesFolder() 29 | 30 | const appLocParts = appLoc.replace(/^\//, '')?.split('/') 31 | const appLocPath = path.join(driveFilesPath, ...appLocParts) 32 | if (!appLocPath.includes(driveFilesPath)) { 33 | throw new Error('appLoc cannot be outside drive.') 34 | } 35 | 36 | const pathToDeployment = path.join(appLocPath, 'services', streamWebFolder) 37 | if (!pathToDeployment.includes(appLocPath)) { 38 | throw new Error('streamWebFolder cannot be outside appLoc.') 39 | } 40 | 41 | if (await folderExists(pathToDeployment)) { 42 | const appCount = process.appStreamConfig 43 | ? Object.keys(process.appStreamConfig).length 44 | : 0 45 | 46 | if (!streamServiceName) { 47 | streamServiceName = `AppStreamName${appCount + 1}` 48 | } 49 | 50 | appStreams[streamServiceName] = pathToDeployment 51 | 52 | addEntryToAppStreamConfig( 53 | streamServiceName, 54 | appLoc, 55 | streamWebFolder, 56 | streamLogo, 57 | addEntryToFile 58 | ) 59 | 60 | const sasJsPort = process.env.PORT || 5000 61 | process.logger.info( 62 | 'Serving Stream App: ', 63 | `http://localhost:${sasJsPort}/AppStream/${streamServiceName}` 64 | ) 65 | return { streamServiceName } 66 | } 67 | return {} 68 | } 69 | 70 | router.get(`/*`, authenticateAccessToken, function (req: Request, res, next) { 71 | const reqPath = req.path.replace(/^\//, '') 72 | 73 | // Redirecting to url with trailing slash for appStream base URL only 74 | if (reqPath.split('/').length === 1 && !reqPath.endsWith('/')) 75 | // navigating to same url with slash at start 76 | return res.redirect(301, `${reqPath}/`) 77 | 78 | const appStream = reqPath.split('/')[0] 79 | const appStreamFilesPath = appStreams[appStream] 80 | if (appStreamFilesPath) { 81 | // resourcePath is without appStream base path 82 | const resourcePath = reqPath.split('/').slice(1).join('/') || 'index.html' 83 | 84 | req.url = resourcePath 85 | 86 | return express.static(appStreamFilesPath)(req, res, next) 87 | } 88 | 89 | return res.send("There's no App Stream available here.") 90 | }) 91 | 92 | export default router 93 | -------------------------------------------------------------------------------- /web/src/containers/Studio/internal/components/runMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | FormControl, 5 | IconButton, 6 | MenuItem, 7 | Select, 8 | SelectChangeEvent, 9 | Tooltip 10 | } from '@mui/material' 11 | 12 | import { RocketLaunch } from '@mui/icons-material' 13 | 14 | type RunMenuProps = { 15 | selectedFilePath: string 16 | fileContent: string 17 | prevFileContent: string 18 | selectedRunTime: string 19 | runTimes: string[] 20 | handleChangeRunTime: (event: SelectChangeEvent) => void 21 | handleRunBtnClick: () => void 22 | } 23 | 24 | const RunMenu = ({ 25 | selectedFilePath, 26 | fileContent, 27 | prevFileContent, 28 | selectedRunTime, 29 | runTimes, 30 | handleChangeRunTime, 31 | handleRunBtnClick 32 | }: RunMenuProps) => { 33 | const launchProgram = () => { 34 | const pathName = 35 | window.location.pathname === '/' ? '' : window.location.pathname 36 | const baseUrl = window.location.origin + pathName 37 | 38 | window.open(`${baseUrl}/SASjsApi/stp/execute?_program=${selectedFilePath}`) 39 | } 40 | 41 | return ( 42 | <> 43 | 44 | 61 | 62 | {selectedFilePath ? ( 63 | 64 | 71 | 72 | 76 | 77 | 78 | 79 | 80 | 81 | ) : ( 82 | 83 | 84 | 96 | 97 | 98 | )} 99 | 100 | ) 101 | } 102 | 103 | export default RunMenu 104 | --------------------------------------------------------------------------------