>
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 |
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 | Cancel
51 |
52 | Delete
53 |
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 |
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 | You need to enable JavaScript to run this app.
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 | updatePermission(permissionSetting)}
66 | disabled={permission?.setting === permissionSetting}
67 | >
68 | Update
69 |
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 | Copy to Clipboard
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 |
66 |
67 |
68 | setOpen(false)}>
69 | Cancel
70 |
71 | saveFile(filePath)}
74 | disabled={hasError || !filePath}
75 | >
76 | Save
77 |
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 |
53 |
59 | RUN
60 |
61 |
62 | {selectedFilePath ? (
63 |
64 |
71 |
72 |
76 |
77 |
78 |
79 |
80 |
81 | ) : (
82 |
83 |
84 |
90 | {runTimes.map((runTime) => (
91 |
92 | {runTime}
93 |
94 | ))}
95 |
96 |
97 |
98 | )}
99 | >
100 | )
101 | }
102 |
103 | export default RunMenu
104 |
--------------------------------------------------------------------------------