import path from 'path' import express, { Express } from 'express' import { Security, Request, Route, Tags, Example, Post, Body, Response, Query, Get, Patch, UploadedFile, FormField, Delete, Hidden } from 'tsoa' import { fileExists, moveFile, createFolder, deleteFile as deleteFileOnSystem, deleteFolder as deleteFolderOnSystem, folderExists, listFilesInFolder, listSubFoldersInFolder, isFolder, FileTree, isFileTree } from '@sasjs/utils' import { createFileTree, ExecutionController, getTreeExample } from './internal' import { TreeNode } from '../types' import { getFilesFolder } from '../utils' interface DeployPayload { appLoc: string streamWebFolder?: string fileTree: FileTree } interface DeployResponse { status: string message: string streamServiceName?: string example?: FileTree } interface GetFileResponse { status: string fileContent?: string message?: string } interface GetFileTreeResponse { status: string tree: TreeNode } interface FileFolderResponse { status: string message?: string } interface AddFolderPayload { /** * Location of folder * @example "/Public/someFolder" */ folderPath: string } interface RenamePayload { /** * Old path of file/folder * @example "/Public/someFolder" */ oldPath: string /** * New path of file/folder * @example "/Public/newFolder" */ newPath: string } const fileTreeExample = getTreeExample() const successDeployResponse: DeployResponse = { status: 'success', message: 'Files deployed successfully to @sasjs/server.' } const invalidDeployFormatResponse: DeployResponse = { status: 'failure', message: 'Provided not supported data format.', example: fileTreeExample } const execDeployErrorResponse: DeployResponse = { status: 'failure', message: 'Deployment failed!' } @Security('bearerAuth') @Route('SASjsApi/drive') @Tags('Drive') export class DriveController { /** * @summary Creates/updates files within SASjs Drive using provided payload. * */ @Example(successDeployResponse) @Response(400, 'Invalid Format', invalidDeployFormatResponse) @Response(500, 'Execution Error', execDeployErrorResponse) @Post('/deploy') public async deploy(@Body() body: DeployPayload): Promise { return deploy(body) } /** * Accepts JSON file and zipped compressed JSON file as well. * Compressed file should only contain one JSON file and should have same name * as of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip * Any other file or JSON file in zipped will be ignored! * * @summary Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file. * */ @Example(successDeployResponse) @Response(400, 'Invalid Format', invalidDeployFormatResponse) @Response(500, 'Execution Error', execDeployErrorResponse) @Post('/deploy/upload') public async deployUpload( @UploadedFile() file: Express.Multer.File, // passing here for API docs @Query() @Hidden() body?: DeployPayload // Hidden decorator has be optional ): Promise { return deploy(body!) } /** * * @summary Get file from SASjs Drive * @query _filePath Location of SAS program * @example _filePath "/Public/somefolder/some.file" */ @Get('/file') public async getFile( @Request() request: express.Request, @Query() _filePath: string ) { return getFile(request, _filePath) } /** * * @summary Get folder contents from SASjs Drive * @query _folderPath Location of SAS program * @example _folderPath "/Public/somefolder" */ @Get('/folder') public async getFolder(@Query() _folderPath?: string) { return getFolder(_folderPath) } /** * * @summary Delete file from SASjs Drive * @query _filePath Location of file * @example _filePath "/Public/somefolder/some.file" */ @Delete('/file') public async deleteFile(@Query() _filePath: string) { return deleteFile(_filePath) } /** * * @summary Delete folder from SASjs Drive * @query _folderPath Location of folder * @example _folderPath "/Public/somefolder/" */ @Delete('/folder') public async deleteFolder(@Query() _folderPath: string) { return deleteFolder(_folderPath) } /** * 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 file * @example _filePath "/Public/somefolder/some.file.sas" * */ @Example({ status: 'success' }) @Response(403, 'File already exists', { status: 'failure', message: 'File request failed.' }) @Post('/file') public async saveFile( @UploadedFile() file: Express.Multer.File, @Query() _filePath?: string, @FormField() filePath?: string ): Promise { return saveFile((_filePath ?? filePath)!, file) } /** * @summary Create an empty folder in SASjs Drive * */ @Example({ status: 'success' }) @Response(409, 'Folder already exists', { status: 'failure', message: 'Add folder request failed.' }) @Post('/folder') public async addFolder( @Body() body: AddFolderPayload ): Promise { return addFolder(body.folderPath) } /** * 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(403, `File doesn't exist`, { status: 'failure', message: 'File request failed.' }) @Patch('/file') public async updateFile( @UploadedFile() file: Express.Multer.File, @Query() _filePath?: string, @FormField() filePath?: string ): Promise { return updateFile((_filePath ?? filePath)!, file) } /** * @summary Renames a file/folder in SASjs Drive * */ @Example({ status: 'success' }) @Response(409, 'Folder already exists', { status: 'failure', message: 'rename request failed.' }) @Post('/rename') public async rename( @Body() body: RenamePayload ): Promise { return rename(body.oldPath, body.newPath) } /** * @summary Fetch file tree within SASjs Drive. * */ @Get('/filetree') public async getFileTree(): Promise { return getFileTree() } } const getFileTree = () => { const tree = new ExecutionController().buildDirectoryTree() return { status: 'success', tree } } const deploy = async (data: DeployPayload) => { const driveFilesPath = getFilesFolder() const appLocParts = data.appLoc.replace(/^\//, '').split('/') const appLocPath = path .join(getFilesFolder(), ...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, appLocParts).catch((err) => { throw { code: 500, ...execDeployErrorResponse, ...err } }) return successDeployResponse } const getFile = async (req: express.Request, filePath: string) => { const driveFilesPath = getFilesFolder() const filePathFull = path .join(getFilesFolder(), filePath) .replace(new RegExp('/', 'g'), path.sep) if (!filePathFull.includes(driveFilesPath)) throw { code: 400, status: 'Bad Request', message: `Can't get file outside drive.` } if (!(await fileExists(filePathFull))) throw { code: 404, status: 'Not Found', message: `File doesn't exist.` } const extension = path.extname(filePathFull).toLowerCase() if (extension === '.sas') { req.res?.setHeader('Content-type', 'text/plain') } req.res?.sendFile(path.resolve(filePathFull), { dotfiles: 'allow' }) } const getFolder = async (folderPath?: string) => { const driveFilesPath = getFilesFolder() if (folderPath) { const folderPathFull = path .join(getFilesFolder(), folderPath) .replace(new RegExp('/', 'g'), path.sep) if (!folderPathFull.includes(driveFilesPath)) throw { code: 400, status: 'Bad Request', message: `Can't get folder outside drive.` } if (!(await folderExists(folderPathFull))) throw { code: 404, status: 'Not Found', message: `Folder doesn't exist.` } if (!(await isFolder(folderPathFull))) throw { code: 400, status: 'Bad Request', message: 'Not a Folder.' } const files: string[] = await listFilesInFolder(folderPathFull) const folders: string[] = await listSubFoldersInFolder(folderPathFull) return { files, folders } } const files: string[] = await listFilesInFolder(driveFilesPath) const folders: string[] = await listSubFoldersInFolder(driveFilesPath) return { files, folders } } const deleteFile = async (filePath: string) => { const driveFilesPath = getFilesFolder() const filePathFull = path .join(getFilesFolder(), filePath) .replace(new RegExp('/', 'g'), path.sep) if (!filePathFull.includes(driveFilesPath)) throw { code: 400, status: 'Bad Request', message: `Can't delete file outside drive.` } if (!(await fileExists(filePathFull))) throw { code: 404, status: 'Not Found', message: `File doesn't exist.` } await deleteFileOnSystem(filePathFull) return { status: 'success' } } const deleteFolder = async (folderPath: string) => { const driveFolderPath = getFilesFolder() const folderPathFull = path .join(getFilesFolder(), folderPath) .replace(new RegExp('/', 'g'), path.sep) if (!folderPathFull.includes(driveFolderPath)) throw { code: 400, status: 'Bad Request', message: `Can't delete folder outside drive.` } if (!(await folderExists(folderPathFull))) throw { code: 404, status: 'Not Found', message: `Folder doesn't exist.` } await deleteFolderOnSystem(folderPathFull) return { status: 'success' } } const saveFile = async ( filePath: string, multerFile: Express.Multer.File ): Promise => { const driveFilesPath = getFilesFolder() const filePathFull = path .join(driveFilesPath, filePath) .replace(new RegExp('/', 'g'), path.sep) if (!filePathFull.includes(driveFilesPath)) throw { code: 400, status: 'Bad Request', message: `Can't put file outside drive.` } if (await fileExists(filePathFull)) throw { code: 409, status: 'Conflict', message: 'File already exists.' } const folderPath = path.dirname(filePathFull) await createFolder(folderPath) await moveFile(multerFile.path, filePathFull) return { status: 'success' } } const addFolder = async (folderPath: string): Promise => { const drivePath = getFilesFolder() const folderPathFull = path .join(drivePath, folderPath) .replace(new RegExp('/', 'g'), path.sep) if (!folderPathFull.includes(drivePath)) throw { code: 400, status: 'Bad Request', message: `Can't put folder outside drive.` } if (await folderExists(folderPathFull)) throw { code: 409, status: 'Conflict', message: 'Folder already exists.' } await createFolder(folderPathFull) return { status: 'success' } } const rename = async ( oldPath: string, newPath: string ): Promise => { const drivePath = getFilesFolder() const oldPathFull = path .join(drivePath, oldPath) .replace(new RegExp('/', 'g'), path.sep) const newPathFull = path .join(drivePath, newPath) .replace(new RegExp('/', 'g'), path.sep) if (!oldPathFull.includes(drivePath)) throw { code: 400, status: 'Bad Request', message: `Old path can't be outside of drive.` } if (!newPathFull.includes(drivePath)) throw { code: 400, status: 'Bad Request', message: `New path can't be outside of drive.` } if (await isFolder(oldPathFull)) { if (await folderExists(newPathFull)) throw { code: 409, status: 'Conflict', message: 'Folder with new name already exists.' } else moveFile(oldPathFull, newPathFull) return { status: 'success' } } else if (await fileExists(oldPathFull)) { if (await fileExists(newPathFull)) throw { code: 409, status: 'Conflict', message: 'File with new name already exists.' } else moveFile(oldPathFull, newPathFull) return { status: 'success' } } throw { code: 404, status: 'Not Found', message: 'No file/folder found for provided path.' } } const updateFile = async ( filePath: string, multerFile: Express.Multer.File ): Promise => { const driveFilesPath = getFilesFolder() const filePathFull = path .join(driveFilesPath, filePath) .replace(new RegExp('/', 'g'), path.sep) if (!filePathFull.includes(driveFilesPath)) throw { code: 400, status: 'Bad Request', message: `Can't modify file outside drive.` } if (!(await fileExists(filePathFull))) throw { code: 404, status: 'Not Found', message: `File doesn't exist.` } await moveFile(multerFile.path, filePathFull) return { status: 'success' } }