diff --git a/api/package-lock.json b/api/package-lock.json index 63321ef..2573a50 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -11,6 +11,7 @@ "@sasjs/core": "4.9.0", "@sasjs/utils": "2.34.1", "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^4.17.1", "joi": "^17.4.2", @@ -27,6 +28,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.2", + "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.12", "@types/express": "^4.17.12", "@types/jest": "^26.0.24", @@ -1794,6 +1796,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -3254,6 +3265,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -11381,6 +11412,15 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -12583,6 +12623,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/api/package.json b/api/package.json index cfb672c..d3b763f 100644 --- a/api/package.json +++ b/api/package.json @@ -48,6 +48,7 @@ "@sasjs/core": "4.9.0", "@sasjs/utils": "2.34.1", "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^4.17.1", "joi": "^17.4.2", @@ -61,6 +62,7 @@ }, "devDependencies": { "@types/bcryptjs": "^2.4.2", + "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.12", "@types/express": "^4.17.12", "@types/jest": "^26.0.24", diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index e22f671..89678b5 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -233,21 +233,6 @@ components: - status type: object additionalProperties: false - FilePayload: - properties: - filePath: - type: string - description: 'Path of the file' - example: /Public/somefolder/some.file - fileContent: - type: string - description: 'Contents of the file' - example: 'Contents of the File' - required: - - filePath - - fileContent - type: object - additionalProperties: false TreeNode: properties: name: @@ -600,6 +585,7 @@ paths: responses: '204': description: 'No content' + description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." summary: 'Get file from SASjs Drive' tags: - Drive @@ -609,11 +595,57 @@ paths: parameters: - in: query - name: filePath - required: true + name: _filePath + required: false schema: type: string example: /Public/somefolder/some.file + requestBody: + required: false + content: + multipart/form-data: + schema: + type: object + properties: + filePath: + type: string + delete: + operationId: DeleteFile + responses: + '200': + description: Ok + content: + application/json: + schema: + properties: + status: {type: string} + required: + - status + type: object + description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." + summary: 'Delete file from SASjs Drive' + tags: + - Drive + security: + - + bearerAuth: [] + parameters: + - + in: query + name: _filePath + required: false + schema: + type: string + example: /Public/somefolder/some.file + requestBody: + required: false + content: + multipart/form-data: + schema: + type: object + properties: + filePath: + type: string post: operationId: SaveFile responses: @@ -626,7 +658,7 @@ paths: examples: 'Example 1': value: {status: success} - '400': + '403': description: 'File already exists' content: application/json: @@ -635,7 +667,7 @@ paths: examples: 'Example 1': value: {status: failure, message: 'File request failed.'} - description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provided else API will respond with Bad Request." + description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." summary: 'Create a file in SASjs Drive' tags: - Drive @@ -677,8 +709,8 @@ paths: examples: 'Example 1': value: {status: success} - '400': - description: 'Unable to get File' + '403': + description: "" content: application/json: schema: @@ -686,19 +718,36 @@ paths: examples: 'Example 1': value: {status: failure, message: 'File request failed.'} + description: "It's optional to either provide `_filePath` in url as query parameter\nOr provide `filePath` in body as form field.\nBut it's required to provide else API will respond with Bad Request." summary: 'Modify a file in SASjs Drive' tags: - Drive security: - bearerAuth: [] - parameters: [] + parameters: + - + description: 'Location of SAS program' + in: query + name: _filePath + required: false + schema: + type: string + example: /Public/somefolder/some.file.sas requestBody: required: true content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/FilePayload' + type: object + properties: + file: + type: string + format: binary + filePath: + type: string + required: + - file /SASjsApi/drive/filetree: get: operationId: GetFileTree @@ -1054,7 +1103,7 @@ paths: anyOf: - {type: string} - {type: string, format: byte} - description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. Setting _debug=131 will\ncause the log to be streamed in the output.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response headers can be adjusted using the mfs_httpheader() macro. Any\nfile type can be returned, including binary files such as zip or xls.\n\nThis behaviour differs for POST requests, in which case the reponse is\nalways JSON." + description: "Trigger a SAS program using it's location in the _program URL parameter.\nEnable debugging using the _debug URL parameter. Setting _debug=131 will\ncause the log to be streamed in the output.\n\nAdditional URL parameters are turned into SAS macro variables.\n\nAny files provided in the request body are placed into the SAS session with\ncorresponding _WEBIN_XXX variables created.\n\nThe response headers can be adjusted using the mfs_httpheader() macro. Any\nfile type can be returned, including binary files such as zip or xls.\n\nIf _debug is >= 131, response headers will contain Content-Type: 'text/plain'\n\nThis behaviour differs for POST requests, in which case the response is\nalways JSON." summary: 'Execute Stored Program, return raw _webout content.' tags: - STP diff --git a/api/src/app.ts b/api/src/app.ts index 6139779..b711fab 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -1,6 +1,7 @@ import path from 'path' import express, { ErrorRequestHandler } from 'express' import morgan from 'morgan' +import cookieParser from 'cookie-parser' import dotenv from 'dotenv' import cors from 'cors' @@ -26,8 +27,9 @@ if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') { app.use(cors({ credentials: true, origin: whiteList })) } -app.use(express.json({ limit: '50mb' })) +app.use(cookieParser()) app.use(morgan('tiny')) +app.use(express.json({ limit: '50mb' })) app.use(express.static(path.join(__dirname, '../public'))) app.use(express.static(getWebBuildFolderPath())) diff --git a/api/src/controllers/drive.ts b/api/src/controllers/drive.ts index e799599..8b752a8 100644 --- a/api/src/controllers/drive.ts +++ b/api/src/controllers/drive.ts @@ -13,9 +13,15 @@ import { Get, Patch, UploadedFile, - FormField + FormField, + Delete } from 'tsoa' -import { fileExists, createFile, moveFile, createFolder } from '@sasjs/utils' +import { + fileExists, + moveFile, + createFolder, + deleteFile as deleteFileOnSystem +} from '@sasjs/utils' import { createFileTree, ExecutionController, getTreeExample } from './internal' import { FileTree, isFileTree, TreeNode } from '../types' @@ -25,18 +31,6 @@ interface DeployPayload { appLoc?: string fileTree: FileTree } -interface FilePayload { - /** - * Path of the file - * @example "/Public/somefolder/some.file" - */ - filePath: string - /** - * Contents of the file - * @example "Contents of the File" - */ - fileContent: string -} interface DeployResponse { status: string @@ -93,22 +87,45 @@ export class DriveController { } /** + * It's optional to either provide `_filePath` in url as query parameter + * Or provide `filePath` in body as form field. + * But it's required to provide else API will respond with Bad Request. + * * @summary Get file from SASjs Drive - * @query filePath Location of SAS program - * @example filePath "/Public/somefolder/some.file" + * @query _filePath Location of SAS program + * @example _filePath "/Public/somefolder/some.file" */ @Get('/file') public async getFile( @Request() request: express.Request, - @Query() filePath: string + + @Query() _filePath?: string, + @FormField() filePath?: string ) { - return getFile(request, filePath) + return getFile(request, (_filePath ?? filePath)!) } /** * It's optional to either provide `_filePath` in url as query parameter * Or provide `filePath` in body as form field. - * But it's required to provided else API will respond with Bad Request. + * But it's required to provide else API will respond with Bad Request. + * + * @summary Delete file from SASjs Drive + * @query _filePath Location of SAS program + * @example _filePath "/Public/somefolder/some.file" + */ + @Delete('/file') + public async deleteFile( + @Query() _filePath?: string, + @FormField() filePath?: string + ) { + return deleteFile((_filePath ?? filePath)!) + } + + /** + * It's optional to either provide `_filePath` in url as query parameter + * Or provide `filePath` in body as form field. + * But it's required to provide else API will respond with Bad Request. * * @summary Create a file in SASjs Drive * @param _filePath Location of SAS program @@ -118,7 +135,7 @@ export class DriveController { @Example({ status: 'success' }) - @Response(400, 'File already exists', { + @Response(403, 'File already exists', { status: 'failure', message: 'File request failed.' }) @@ -132,21 +149,29 @@ export class DriveController { } /** + * It's optional to either provide `_filePath` in url as query parameter + * Or provide `filePath` in body as form field. + * But it's required to provide else API will respond with Bad Request. + * * @summary Modify a file in SASjs Drive + * @param _filePath Location of SAS program + * @example _filePath "/Public/somefolder/some.file.sas" * */ @Example({ status: 'success' }) - @Response(400, 'Unable to get File', { + @Response(403, `File doesn't exist`, { status: 'failure', message: 'File request failed.' }) @Patch('/file') public async updateFile( - @Body() body: FilePayload + @UploadedFile() file: Express.Multer.File, + @Query() _filePath?: string, + @FormField() filePath?: string ): Promise { - return updateFile(body) + return updateFile((_filePath ?? filePath)!, file) } /** @@ -194,7 +219,32 @@ const getFile = async (req: express.Request, filePath: string) => { throw new Error('File does not exist.') } - req.res?.download(filePathFull) + const extension = path.extname(filePathFull).toLowerCase() + if (extension === '.sas') { + req.res?.setHeader('Content-type', 'text/plain') + } + + req.res?.sendFile(path.resolve(filePathFull)) +} + +const deleteFile = async (filePath: string) => { + const driveFilesPath = getTmpFilesFolderPath() + + const filePathFull = path + .join(getTmpFilesFolderPath(), filePath) + .replace(new RegExp('/', 'g'), path.sep) + + if (!filePathFull.includes(driveFilesPath)) { + throw new Error('Cannot delete file outside drive.') + } + + if (!(await fileExists(filePathFull))) { + throw new Error('File does not exist.') + } + + await deleteFileOnSystem(filePathFull) + + return { status: 'success' } } const saveFile = async ( @@ -222,25 +272,27 @@ const saveFile = async ( return { status: 'success' } } -const updateFile = async (body: FilePayload): Promise => { - const { filePath, fileContent } = body - try { - const filePathFull = path - .join(getTmpFilesFolderPath(), filePath) - .replace(new RegExp('/', 'g'), path.sep) +const updateFile = async ( + filePath: string, + multerFile: Express.Multer.File +): Promise => { + const driveFilesPath = getTmpFilesFolderPath() - await validateFilePath(filePathFull) - await createFile(filePathFull, fileContent) + const filePathFull = path + .join(driveFilesPath, filePath) + .replace(new RegExp('/', 'g'), path.sep) - return { status: 'success' } - } catch (err: any) { - throw { - code: 400, - status: 'failure', - message: 'File request failed.', - error: typeof err === 'object' ? err.toString() : err - } + if (!filePathFull.includes(driveFilesPath)) { + throw new Error('Cannot modify file outside drive.') } + + if (!(await fileExists(filePathFull))) { + throw new Error(`File doesn't exist.`) + } + + await moveFile(multerFile.path, filePathFull) + + return { status: 'success' } } const validateFilePath = async (filePath: string) => { diff --git a/api/src/controllers/internal/Execution.ts b/api/src/controllers/internal/Execution.ts index 3e60a9d..06649bf 100644 --- a/api/src/controllers/internal/Execution.ts +++ b/api/src/controllers/internal/Execution.ts @@ -14,6 +14,7 @@ import { generateFileUploadSasCode, getTmpFilesFolderPath, HTTPHeaders, + isDebugOn, sasJSCoreMacros } from '../../utils' @@ -160,9 +161,6 @@ ${program}` : await readFile(weboutPath) : '' - const debugValue = - typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug - // it should be deleted by scheduleSessionDestroy session.inUse = false @@ -170,8 +168,7 @@ ${program}` return { httpHeaders, webout, - log: - (debugValue && debugValue >= 131) || session.crashed ? log : undefined + log: isDebugOn(vars) || session.crashed ? log : undefined } } @@ -179,7 +176,7 @@ ${program}` httpHeaders, result: fileResponse ? webout - : (debugValue && debugValue >= 131) || session.crashed + : isDebugOn(vars) || session.crashed ? `${webout}

SAS Log

${log}
` : webout } diff --git a/api/src/controllers/stp.ts b/api/src/controllers/stp.ts index c29b432..93a4654 100644 --- a/api/src/controllers/stp.ts +++ b/api/src/controllers/stp.ts @@ -21,6 +21,7 @@ import { PreProgramVars } from '../types' import { getTmpFilesFolderPath, HTTPHeaders, + isDebugOn, LogLine, makeFilesNamesMap, parseLogToArray @@ -62,7 +63,9 @@ export class STPController { * The response headers can be adjusted using the mfs_httpheader() macro. Any * file type can be returned, including binary files such as zip or xls. * - * This behaviour differs for POST requests, in which case the reponse is + * If _debug is >= 131, response headers will contain Content-Type: 'text/plain' + * + * This behaviour differs for POST requests, in which case the response is * always JSON. * * @summary Execute Stored Program, return raw _webout content. @@ -140,6 +143,13 @@ const executeReturnRaw = async ( query )) as ExecuteReturnRaw + // Should over-ride response header for + // debug on GET request to see entire log + // rendering on browser. + if (isDebugOn(query)) { + httpHeaders['content-type'] = 'text/plain' + } + req.res?.set(httpHeaders) if (result instanceof Buffer) { diff --git a/api/src/middlewares/authenticateToken.ts b/api/src/middlewares/authenticateToken.ts index b53900e..d61473f 100644 --- a/api/src/middlewares/authenticateToken.ts +++ b/api/src/middlewares/authenticateToken.ts @@ -43,7 +43,9 @@ const authenticateToken = ( } const authHeader = req.headers['authorization'] - const token = authHeader?.split(' ')[1] + const token = + authHeader?.split(' ')[1] ?? + (tokenType === 'accessToken' ? req.cookies.accessToken : '') if (!token) return res.sendStatus(401) jwt.verify(token, key, async (err: any, data: any) => { diff --git a/api/src/routes/api/auth.ts b/api/src/routes/api/auth.ts index df359bc..ab45b55 100644 --- a/api/src/routes/api/auth.ts +++ b/api/src/routes/api/auth.ts @@ -55,8 +55,9 @@ authRouter.post('/token', async (req, res) => { const controller = new AuthController() try { const response = await controller.token(body) + const { accessToken } = response - res.send(response) + res.cookie('accessToken', accessToken).send(response) } catch (err: any) { res.status(403).send(err.toString()) } diff --git a/api/src/routes/api/drive.ts b/api/src/routes/api/drive.ts index bee9375..881d884 100644 --- a/api/src/routes/api/drive.ts +++ b/api/src/routes/api/drive.ts @@ -3,12 +3,7 @@ import { deleteFile } from '@sasjs/utils' import { multerSingle } from '../../middlewares/multer' import { DriveController } from '../../controllers/' -import { - getFileDriveValidation, - updateFileDriveValidation, - uploadFileBodyValidation, - uploadFileParamValidation -} from '../../utils' +import { fileBodyValidation, fileParamValidation } from '../../utils' const controller = new DriveController() @@ -28,11 +23,27 @@ driveRouter.post('/deploy', async (req, res) => { }) driveRouter.get('/file', async (req, res) => { - const { error, value: query } = getFileDriveValidation(req.query) - if (error) return res.status(400).send(error.details[0].message) + const { error: errQ, value: query } = fileParamValidation(req.query) + const { error: errB, value: body } = fileBodyValidation(req.body) + + if (errQ && errB) return res.status(400).send(errB.details[0].message) try { - await controller.getFile(req, query.filePath) + await controller.getFile(req, query._filePath, body.filePath) + } catch (err: any) { + res.status(403).send(err.toString()) + } +}) + +driveRouter.delete('/file', async (req, res) => { + const { error: errQ, value: query } = fileParamValidation(req.query) + const { error: errB, value: body } = fileBodyValidation(req.body) + + if (errQ && errB) return res.status(400).send(errB.details[0].message) + + try { + const response = await controller.deleteFile(query._filePath, body.filePath) + res.send(response) } catch (err: any) { res.status(403).send(err.toString()) } @@ -42,8 +53,8 @@ driveRouter.post( '/file', (...arg) => multerSingle('file', arg), async (req, res) => { - const { error: errQ, value: query } = uploadFileParamValidation(req.query) - const { error: errB, value: body } = uploadFileBodyValidation(req.body) + const { error: errQ, value: query } = fileParamValidation(req.query) + const { error: errB, value: body } = fileBodyValidation(req.body) if (errQ && errB) { if (req.file) await deleteFile(req.file.path) @@ -66,21 +77,33 @@ driveRouter.post( } ) -driveRouter.patch('/file', async (req, res) => { - const { error, value: body } = updateFileDriveValidation(req.body) - if (error) return res.status(400).send(error.details[0].message) +driveRouter.patch( + '/file', + (...arg) => multerSingle('file', arg), + async (req, res) => { + const { error: errQ, value: query } = fileParamValidation(req.query) + const { error: errB, value: body } = fileBodyValidation(req.body) - try { - const response = await controller.updateFile(body) - res.send(response) - } catch (err: any) { - const statusCode = err.code + if (errQ && errB) { + if (req.file) await deleteFile(req.file.path) + return res.status(400).send(errB.details[0].message) + } - delete err.code + if (!req.file) return res.status(400).send('"file" is not present.') - res.status(statusCode).send(err) + try { + const response = await controller.updateFile( + req.file, + query._filePath, + body.filePath + ) + res.send(response) + } catch (err: any) { + await deleteFile(req.file.path) + res.status(403).send(err.toString()) + } } -}) +) driveRouter.get('/fileTree', async (req, res) => { try { diff --git a/api/src/routes/api/spec/drive.spec.ts b/api/src/routes/api/spec/drive.spec.ts index 1f70a56..1d979bb 100644 --- a/api/src/routes/api/spec/drive.spec.ts +++ b/api/src/routes/api/spec/drive.spec.ts @@ -321,6 +321,166 @@ describe('files', () => { expect(res.body).toEqual({}) }) }) + + describe('update', () => { + it('should update a SAS file on drive having filePath as form field', async () => { + const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas') + const pathToUpload = '/my/path/code.sas' + + const pathToCopy = path.join( + fileUtilModules.getTmpFilesFolderPath(), + pathToUpload + ) + await copy(fileToAttachPath, pathToCopy) + + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', pathToUpload) + .attach('file', fileToAttachPath) + + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual({ + status: 'success' + }) + }) + + it('should update a SAS file on drive having _filePath as query param', async () => { + const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas') + const pathToUpload = '/my/path/code.sas' + + const pathToCopy = path.join( + fileUtilModules.getTmpFilesFolderPath(), + pathToUpload + ) + await copy(fileToAttachPath, pathToCopy) + + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', pathToUpload) + .attach('file', fileToAttachPath) + + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual({ + status: 'success' + }) + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app) + .patch('/SASjsApi/drive/file') + .field('filePath', '/my/path/code.sas') + .attach('file', path.join(__dirname, 'files', 'sample.sas')) + .expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if file is not present', async () => { + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', `/my/path/code-${generateTimestamp()}.sas`) + .attach('file', path.join(__dirname, 'files', 'sample.sas')) + .expect(403) + + expect(res.text).toEqual(`Error: File doesn't exist.`) + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if filePath outside Drive', async () => { + const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas') + const pathToUpload = '/../path/code.sas' + + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', pathToUpload) + .attach('file', fileToAttachPath) + .expect(403) + + expect(res.text).toEqual('Error: Cannot modify file outside drive.') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if filePath is missing', async () => { + const fileToAttachPath = path.join(__dirname, 'files', 'sample.sas') + + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .attach('file', fileToAttachPath) + .expect(400) + + expect(res.text).toEqual(`"filePath" is required`) + expect(res.body).toEqual({}) + }) + + 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 res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', pathToUpload) + .attach('file', fileToAttachPath) + .expect(400) + + expect(res.text).toEqual('Valid extensions for filePath: .sas') + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if file is missing', async () => { + const pathToUpload = '/my/path/code.sas' + + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', pathToUpload) + .expect(400) + + expect(res.text).toEqual('"file" is not present.') + expect(res.body).toEqual({}) + }) + + it("should respond with Bad Request if attached file doesn't has correct extension", async () => { + const fileToAttachPath = path.join(__dirname, 'files', 'sample.oth') + const pathToUpload = '/my/path/code.sas' + + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', pathToUpload) + .attach('file', fileToAttachPath) + .expect(400) + + expect(res.text).toEqual( + `File extension '.oth' not acceptable. Valid extension(s): .sas` + ) + expect(res.body).toEqual({}) + }) + + it('should respond with Bad Request if attached file exceeds file limit', async () => { + const pathToUpload = '/my/path/code.sas' + + const attachedFile = Buffer.from('.'.repeat(20 * 1024 * 1024)) + + const res = await request(app) + .patch('/SASjsApi/drive/file') + .auth(accessToken, { type: 'bearer' }) + .field('filePath', pathToUpload) + .attach('file', attachedFile, 'another.sas') + .expect(400) + + expect(res.text).toEqual( + 'File size is over limit. File limit is: 10 MB' + ) + expect(res.body).toEqual({}) + }) + }) }) }) diff --git a/api/src/utils/index.ts b/api/src/utils/index.ts index d9700a9..6426f9f 100644 --- a/api/src/utils/index.ts +++ b/api/src/utils/index.ts @@ -4,6 +4,7 @@ export * from './file' export * from './generateAccessToken' export * from './generateAuthCode' export * from './generateRefreshToken' +export * from './isDebugOn' export * from './getCertificates' export * from './getDesktopFields' export * from './parseLogToArray' diff --git a/api/src/utils/isDebugOn.ts b/api/src/utils/isDebugOn.ts new file mode 100644 index 0000000..604f76d --- /dev/null +++ b/api/src/utils/isDebugOn.ts @@ -0,0 +1,8 @@ +import { ExecutionVars } from '../controllers/internal' + +export const isDebugOn = (vars: ExecutionVars) => { + const debugValue = + typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug + + return !!(debugValue && debugValue >= 131) +} diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 1bd1c31..aff7251 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -66,25 +66,14 @@ export const registerClientValidation = (data: any): Joi.ValidationResult => clientSecret: Joi.string().required() }).validate(data) -export const getFileDriveValidation = (data: any): Joi.ValidationResult => - Joi.object({ - filePath: Joi.string().required() - }).validate(data) - -export const updateFileDriveValidation = (data: any): Joi.ValidationResult => - Joi.object({ - filePath: Joi.string().required(), - fileContent: Joi.string().required() - }).validate(data) - -export const uploadFileBodyValidation = (data: any): Joi.ValidationResult => +export const fileBodyValidation = (data: any): Joi.ValidationResult => Joi.object({ filePath: Joi.string().pattern(/.sas$/).required().messages({ 'string.pattern.base': `Valid extensions for filePath: .sas` }) }).validate(data) -export const uploadFileParamValidation = (data: any): Joi.ValidationResult => +export const fileParamValidation = (data: any): Joi.ValidationResult => Joi.object({ _filePath: Joi.string().pattern(/.sas$/).required().messages({ 'string.pattern.base': `Valid extensions for filePath: .sas` diff --git a/web/src/containers/Drive/main.tsx b/web/src/containers/Drive/main.tsx index 365b950..7fbf730 100644 --- a/web/src/containers/Drive/main.tsx +++ b/web/src/containers/Drive/main.tsx @@ -42,11 +42,15 @@ const Main = (props: any) => { setEditMode(true) } else { setIsLoading(true) + + const formData = new FormData() + + const stringBlob = new Blob([fileContent], { type: 'text/plain' }) + formData.append('file', stringBlob, 'filename.sas') + formData.append('filePath', props.selectedFilePath) + axios - .patch(`/SASjsApi/drive/file`, { - filePath: props.selectedFilePath, - fileContent: fileContent - }) + .patch(`/SASjsApi/drive/file`, formData) .then((res) => { setEditMode(false) })