diff --git a/api/package.json b/api/package.json index d23cfba..346bb49 100644 --- a/api/package.json +++ b/api/package.json @@ -88,5 +88,10 @@ }, "configuration": { "sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sas" + }, + "nodemonConfig": { + "ignore": [ + "tmp/appStreamConfig.json" + ] } } diff --git a/api/public/sasjs-logo.svg b/api/public/sasjs-logo.svg new file mode 100644 index 0000000..2afe868 --- /dev/null +++ b/api/public/sasjs-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index fd3496a..5aaa786 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -214,6 +214,8 @@ components: type: string message: type: string + streamServiceName: + type: string example: $ref: '#/components/schemas/FileTree' required: @@ -225,6 +227,8 @@ components: properties: appLoc: type: string + streamWebFolder: + type: string fileTree: $ref: '#/components/schemas/FileTree' required: diff --git a/api/src/app.ts b/api/src/app.ts index 35dfa9b..17350ee 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -8,6 +8,7 @@ import cors from 'cors' import { connectDB, getWebBuildFolderPath, + loadAppStreamConfig, sasJSCoreMacros, setProcessVariables } from './utils' @@ -46,6 +47,8 @@ export default setProcessVariables().then(async () => { const { setupRoutes } = await import('./routes/setupRoutes') setupRoutes(app) + await loadAppStreamConfig() + // should be served after setting up web route // index.html needs to be injected with some js script. app.use(express.static(getWebBuildFolderPath())) diff --git a/api/src/controllers/drive.ts b/api/src/controllers/drive.ts index 121a99d..1b11684 100644 --- a/api/src/controllers/drive.ts +++ b/api/src/controllers/drive.ts @@ -29,12 +29,14 @@ import { getTmpFilesFolderPath } from '../utils' interface DeployPayload { appLoc: string + streamWebFolder?: string fileTree: FileTree } interface DeployResponse { status: string message: string + streamServiceName?: string example?: FileTree } @@ -190,14 +192,23 @@ const getFileTree = () => { } const deploy = async (data: DeployPayload) => { + const driveFilesPath = getTmpFilesFolderPath() + + const appLocParts = data.appLoc.replace(/^\//, '').split('/') + + const appLocPath = path + .join(getTmpFilesFolderPath(), ...appLocParts) + .replace(new RegExp('/', 'g'), path.sep) + + if (!appLocPath.includes(driveFilesPath)) { + throw new Error('appLoc cannot be outside drive.') + } + if (!isFileTree(data.fileTree)) { throw { code: 400, ...invalidDeployFormatResponse } } - await createFileTree( - data.fileTree.members, - data.appLoc.replace(/^\//, '').split('/') - ).catch((err) => { + await createFileTree(data.fileTree.members, appLocParts).catch((err) => { throw { code: 500, ...execDeployErrorResponse, ...err } }) diff --git a/api/src/middlewares/multer.ts b/api/src/middlewares/multer.ts index 52d0b2e..d46f466 100644 --- a/api/src/middlewares/multer.ts +++ b/api/src/middlewares/multer.ts @@ -1,9 +1,8 @@ import path from 'path' import { Request } from 'express' import multer, { FileFilterCallback, Options } from 'multer' -import { getTmpUploadsPath } from '../utils' +import { blockFileRegex, getTmpUploadsPath } from '../utils' -const acceptableExtensions = ['.sas'] const fieldNameSize = 300 const fileSize = 10485760 // 10 MB @@ -31,15 +30,11 @@ const fileFilter: Options['fileFilter'] = ( file: Express.Multer.File, callback: FileFilterCallback ) => { - const fileExtension = path.extname(file.originalname).toLocaleLowerCase() - - if (!acceptableExtensions.includes(fileExtension)) { + const fileExtension = path.extname(file.originalname) + const shouldBlockUpload = blockFileRegex.test(file.originalname) + if (shouldBlockUpload) { return callback( - new Error( - `File extension '${fileExtension}' not acceptable. Valid extension(s): ${acceptableExtensions.join( - ', ' - )}` - ) + new Error(`File extension '${fileExtension}' not acceptable.`) ) } diff --git a/api/src/routes/api/drive.ts b/api/src/routes/api/drive.ts index 0725696..bd6af29 100644 --- a/api/src/routes/api/drive.ts +++ b/api/src/routes/api/drive.ts @@ -22,9 +22,15 @@ driveRouter.post('/deploy', async (req, res) => { try { const response = await controller.deploy(body) - const appLoc = body.appLoc.replace(/^\//, '')?.split('/') - - publishAppStream(appLoc) + if (body.streamWebFolder) { + const { streamServiceName } = await publishAppStream( + body.appLoc, + body.streamWebFolder, + body.streamServiceName, + body.streamLogo + ) + response.streamServiceName = streamServiceName + } res.send(response) } catch (err: any) { diff --git a/api/src/routes/api/spec/drive.spec.ts b/api/src/routes/api/spec/drive.spec.ts index ef6cdd5..d570de6 100644 --- a/api/src/routes/api/spec/drive.spec.ts +++ b/api/src/routes/api/spec/drive.spec.ts @@ -266,7 +266,7 @@ describe('files', () => { it("should respond with Bad Request if filePath doesn't has correct extension", async () => { const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas') - const pathToUpload = '/my/path/code.oth' + const pathToUpload = '/my/path/code.exe' const res = await request(app) .post(`/SASjsApi/drive/file?_filePath=${pathToUpload}`) @@ -275,7 +275,7 @@ describe('files', () => { .attach('file', fileToAttachPath) .expect(400) - expect(res.text).toEqual('Valid extensions for filePath: .sas') + expect(res.text).toEqual('Invalid file extension') expect(res.body).toEqual({}) }) @@ -293,7 +293,7 @@ describe('files', () => { }) it("should respond with Bad Request if attached file doesn't has correct extension", async () => { - const fileToAttachPath = path.join(__dirname, 'files', 'sample.oth') + const fileToAttachPath = path.join(__dirname, 'files', 'sample.exe') const pathToUpload = '/my/path/code.sas' const res = await request(app) @@ -303,9 +303,7 @@ describe('files', () => { .attach('file', fileToAttachPath) .expect(400) - expect(res.text).toEqual( - `File extension '.oth' not acceptable. Valid extension(s): .sas` - ) + expect(res.text).toEqual(`File extension '.exe' not acceptable.`) expect(res.body).toEqual({}) }) @@ -426,7 +424,7 @@ describe('files', () => { it("should respond with Bad Request if filePath doesn't has correct extension", async () => { const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas') - const pathToUpload = '/my/path/code.oth' + const pathToUpload = '/my/path/code.exe' const res = await request(app) .patch(`/SASjsApi/drive/file?_filePath=${pathToUpload}`) @@ -435,7 +433,7 @@ describe('files', () => { .attach('file', fileToAttachPath) .expect(400) - expect(res.text).toEqual('Valid extensions for filePath: .sas') + expect(res.text).toEqual('Invalid file extension') expect(res.body).toEqual({}) }) @@ -453,7 +451,7 @@ describe('files', () => { }) it("should respond with Bad Request if attached file doesn't has correct extension", async () => { - const fileToAttachPath = path.join(__dirname, 'files', 'sample.oth') + const fileToAttachPath = path.join(__dirname, 'files', 'sample.exe') const pathToUpload = '/my/path/code.sas' const res = await request(app) @@ -463,9 +461,7 @@ describe('files', () => { .attach('file', fileToAttachPath) .expect(400) - expect(res.text).toEqual( - `File extension '.oth' not acceptable. Valid extension(s): .sas` - ) + expect(res.text).toEqual(`File extension '.exe' not acceptable.`) expect(res.body).toEqual({}) }) diff --git a/api/src/routes/api/spec/files/sample.oth b/api/src/routes/api/spec/files/sample.exe similarity index 100% rename from api/src/routes/api/spec/files/sample.oth rename to api/src/routes/api/spec/files/sample.exe diff --git a/api/src/routes/appStream/appStreamHtml.ts b/api/src/routes/appStream/appStreamHtml.ts new file mode 100644 index 0000000..70f7d59 --- /dev/null +++ b/api/src/routes/appStream/appStreamHtml.ts @@ -0,0 +1,53 @@ +import { AppStreamConfig } from '../../types' + +const style = `` + +const defaultAppLogo = '/sasjs-logo.svg' + +const singleAppStreamHtml = ( + streamServiceName: string, + appLoc: string, + logo?: string +) => + ` + + ${streamServiceName} + ` + +export const appStreamHtml = (appStreamConfig: AppStreamConfig) => ` + + + + ${style} + + +

App Stream

+
+ ${Object.entries(appStreamConfig) + .map(([streamServiceName, entry]) => + singleAppStreamHtml(streamServiceName, entry.appLoc, entry.streamLogo) + ) + .join('')} +
+ +` diff --git a/api/src/routes/appStream/index.ts b/api/src/routes/appStream/index.ts index a6725f4..793efbd 100644 --- a/api/src/routes/appStream/index.ts +++ b/api/src/routes/appStream/index.ts @@ -2,25 +2,75 @@ import path from 'path' import express from 'express' import { folderExists } from '@sasjs/utils' -import { getTmpFilesFolderPath } from '../../utils' +import { addEntryToAppStreamConfig, getTmpFilesFolderPath } from '../../utils' +import { appStreamHtml } from './appStreamHtml' const router = express.Router() -export const publishAppStream = async (appLoc: string[]) => { - const appLocUrl = encodeURI(appLoc.join('/')) - const appLocPath = appLoc.join(path.sep) +router.get('/', async (_, res) => { + const content = appStreamHtml(process.appStreamConfig) - const pathToDeployment = path.join( - getTmpFilesFolderPath(), - appLocPath, - 'services', - 'webv' - ) + return res.send(content) +}) + +export const publishAppStream = async ( + appLoc: string, + streamWebFolder: string, + streamServiceName?: string, + streamLogo?: string, + addEntryToFile: boolean = true +) => { + const driveFilesPath = getTmpFilesFolderPath() + + const appLocParts = appLoc.replace(/^\//, '')?.split('/') + const appLocPath = path.join(driveFilesPath, ...appLocParts) + if (!appLocPath.includes(driveFilesPath)) { + throw new Error('appLoc cannot be outside drive.') + } + + const pathToDeployment = path.join(appLocPath, 'services', streamWebFolder) + if (!pathToDeployment.includes(appLocPath)) { + throw new Error('streamWebFolder cannot be outside appLoc.') + } if (await folderExists(pathToDeployment)) { - router.use(`/${appLocUrl}`, express.static(pathToDeployment)) - console.log('Serving Stream App: ', appLocUrl) + const appCount = process.appStreamConfig + ? Object.keys(process.appStreamConfig).length + : 0 + + if (!streamServiceName) { + streamServiceName = `AppStreamName${appCount + 1}` + } else { + const alreadyDeployed = process.appStreamConfig[streamServiceName] + if (alreadyDeployed) { + if (alreadyDeployed.appLoc === appLoc) { + // redeploying to same streamServiceName + } else { + // trying to deploy to another existing streamServiceName + // assign new streamServiceName + streamServiceName = `${streamServiceName}-${appCount + 1}` + } + } + } + + router.use(`/${streamServiceName}`, express.static(pathToDeployment)) + + addEntryToAppStreamConfig( + streamServiceName, + appLoc, + streamWebFolder, + streamLogo, + addEntryToFile + ) + + const sasJsPort = process.env.PORT ?? 5000 + console.log( + 'Serving Stream App: ', + `http://localhost:${sasJsPort}/AppStream/${streamServiceName}` + ) + return { streamServiceName } } + return {} } export default router diff --git a/api/src/types/AppStreamConfig.ts b/api/src/types/AppStreamConfig.ts new file mode 100644 index 0000000..8907634 --- /dev/null +++ b/api/src/types/AppStreamConfig.ts @@ -0,0 +1,7 @@ +export interface AppStreamConfig { + [key: string]: { + appLoc: string + streamWebFolder: string + streamLogo?: string + } +} diff --git a/api/src/types/Process.d.ts b/api/src/types/Process.d.ts index 85f7d45..72e1c85 100644 --- a/api/src/types/Process.d.ts +++ b/api/src/types/Process.d.ts @@ -3,5 +3,6 @@ declare namespace NodeJS { sasLoc: string driveLoc: string sessionController?: import('../controllers/internal').SessionController + appStreamConfig: import('./').AppStreamConfig } } diff --git a/api/src/types/index.ts b/api/src/types/index.ts index 81bbff6..fcd3176 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -1,4 +1,5 @@ // TODO: uppercase types +export * from './AppStreamConfig' export * from './Execution' export * from './FileTree' export * from './InfoJWT' diff --git a/api/src/utils/appStreamConfig.ts b/api/src/utils/appStreamConfig.ts new file mode 100644 index 0000000..39c02af --- /dev/null +++ b/api/src/utils/appStreamConfig.ts @@ -0,0 +1,87 @@ +import { createFile, fileExists, readFile } from '@sasjs/utils' +import { publishAppStream } from '../routes/appStream' +import { AppStreamConfig } from '../types' + +import { getTmpAppStreamConfigPath } from './file' + +export const loadAppStreamConfig = async () => { + const appStreamConfigPath = getTmpAppStreamConfigPath() + + const content = (await fileExists(appStreamConfigPath)) + ? await readFile(appStreamConfigPath) + : '{}' + + let appStreamConfig: AppStreamConfig + try { + appStreamConfig = JSON.parse(content) + + if (!isValidAppStreamConfig(appStreamConfig)) throw 'invalid type' + } catch (_) { + appStreamConfig = {} + } + process.appStreamConfig = {} + + for (const [streamServiceName, entry] of Object.entries(appStreamConfig)) { + const { appLoc, streamWebFolder, streamLogo } = entry + + publishAppStream( + appLoc, + streamWebFolder, + streamServiceName, + streamLogo, + false + ) + } + + console.log('App Stream Config loaded!') +} + +export const addEntryToAppStreamConfig = ( + streamServiceName: string, + appLoc: string, + streamWebFolder: string, + streamLogo?: string, + addEntryToFile: boolean = true +) => { + if (streamServiceName && appLoc && streamWebFolder) { + process.appStreamConfig[streamServiceName] = { + appLoc, + streamWebFolder, + streamLogo + } + if (addEntryToFile) saveAppStreamConfig() + } +} + +export const removeEntryFromAppStreamConfig = (streamServiceName: string) => { + if (streamServiceName) { + delete process.appStreamConfig[streamServiceName] + saveAppStreamConfig() + } +} + +const saveAppStreamConfig = async () => { + const appStreamConfigPath = getTmpAppStreamConfigPath() + + try { + await createFile( + appStreamConfigPath, + JSON.stringify(process.appStreamConfig, null, 2) + ) + } catch (_) {} +} + +const isValidAppStreamConfig = (config: any) => { + if (config) { + return !Object.entries(config).some(([streamServiceName, entry]) => { + const { appLoc, streamWebFolder, streamLogo } = entry as any + + return ( + typeof streamServiceName !== 'string' || + typeof appLoc !== 'string' || + typeof streamWebFolder !== 'string' + ) + }) + } + return false +} diff --git a/api/src/utils/file.ts b/api/src/utils/file.ts index 7907959..2cce265 100644 --- a/api/src/utils/file.ts +++ b/api/src/utils/file.ts @@ -15,6 +15,9 @@ export const getWebBuildFolderPath = () => export const getTmpFolderPath = () => process.driveLoc +export const getTmpAppStreamConfigPath = () => + path.join(getTmpFolderPath(), 'appStreamConfig.json') + export const getTmpUploadsPath = () => path.join(getTmpFolderPath(), 'uploads') export const getTmpFilesFolderPath = () => diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index 6426f9f..1527080 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from './appStreamConfig' export * from './connectDB' export * from './extractHeaders' export * from './file' diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 106a8e9..c33fcbe 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -3,6 +3,8 @@ import Joi from 'joi' const usernameSchema = Joi.string().alphanum().min(6).max(20) const passwordSchema = Joi.string().min(6).max(1024) +export const blockFileRegex = /\.(exe|sh|htaccess)$/i + export const authorizeValidation = (data: any): Joi.ValidationResult => Joi.object({ username: usernameSchema.required(), @@ -69,21 +71,31 @@ export const registerClientValidation = (data: any): Joi.ValidationResult => export const deployValidation = (data: any): Joi.ValidationResult => Joi.object({ appLoc: Joi.string().pattern(/^\//).required().min(2), + streamServiceName: Joi.string(), + streamWebFolder: Joi.string(), + streamLogo: Joi.string(), fileTree: Joi.any().required() }).validate(data) +const filePathSchema = Joi.string() + .custom((value, helpers) => { + if (blockFileRegex.test(value)) return helpers.error('string.pattern.base') + + return value + }) + .required() + .messages({ + 'string.pattern.base': `Invalid file extension` + }) + export const fileBodyValidation = (data: any): Joi.ValidationResult => Joi.object({ - filePath: Joi.string().pattern(/.sas$/).required().messages({ - 'string.pattern.base': `Valid extensions for filePath: .sas` - }) + filePath: filePathSchema }).validate(data) export const fileParamValidation = (data: any): Joi.ValidationResult => Joi.object({ - _filePath: Joi.string().pattern(/.sas$/).required().messages({ - 'string.pattern.base': `Valid extensions for filePath: .sas` - }) + _filePath: filePathSchema }).validate(data) export const runSASValidation = (data: any): Joi.ValidationResult => diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx index 9efefa2..d536b7e 100644 --- a/web/src/components/header.tsx +++ b/web/src/components/header.tsx @@ -66,11 +66,21 @@ const Header = (props: any) => { variant="contained" color="primary" size="large" - startIcon={} - style={{ marginLeft: '50px' }} + endIcon={} > API Docs + )