diff options
-rw-r--r-- | src/public/css/style.css | 4 | ||||
-rw-r--r-- | src/public/js/form.js | 21 | ||||
-rw-r--r-- | src/routes/api.ts | 165 | ||||
-rw-r--r-- | src/views/pages/index.ejs | 13 | ||||
-rw-r--r-- | tsconfig.json | 2 |
5 files changed, 152 insertions, 53 deletions
diff --git a/src/public/css/style.css b/src/public/css/style.css index b1651df..a106926 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -28,7 +28,7 @@ p { /* Used for id=button1 */ -#actuate_but { +#actuate-but { background-color: #0a3f73; border: solid; border-color: #d5d6d2; @@ -45,7 +45,7 @@ p { /* Used for id=button1 - What the button looks like when a user puts one's cursor on it */ -#actuate_but:hover { +#actuate-but:hover { background-color: white; color: maroon; cursor: pointer; diff --git a/src/public/js/form.js b/src/public/js/form.js index 581d5a3..3fbeb50 100644 --- a/src/public/js/form.js +++ b/src/public/js/form.js @@ -1,3 +1,5 @@ +// This is split into comments so it doesn't look as gross with the closure tabs + window.onload = function () { document.getElementById('nojs').hidden = true; document.getElementById('block').hidden = false; @@ -10,6 +12,7 @@ document.getElementById('upload').onsubmit = function () { document.getElementById('upload-err').innerText = ''; document.getElementById('actuate-err').innerText = ''; document.getElementById('upload-response').innerText = ''; + document.getElementById('download-link').innerText = ''; // Make AJAX request let xhr = new XMLHttpRequest(); xhr.open('POST', '/api/v1/upload'); @@ -43,8 +46,7 @@ function actuate(file) { let xhr = new XMLHttpRequest(); xhr.open('POST', '/api/v1/actuate'); let data = { - name: file.name, - path: file.path, + file: file.file }; xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); xhr.setRequestHeader('X-CSRF-TOKEN', file.csrf); @@ -54,6 +56,7 @@ function actuate(file) { let response = JSON.parse(xhr.responseText); if (xhr.status === 200) { console.log(response); + createDownload(response.file); } else { // Display upload error message to the user document.getElementById('actuate-err').innerText = response.error; @@ -61,6 +64,20 @@ function actuate(file) { if (xhr.status === 500) console.error(response.error_msg); } + return; } }; +} + + +// Creates the download element +function createDownload(response) { + const tempName = response.filename; + const downloadName = response.name.split('.')[ 0 ]; + const downloadLink = document.createElement('a'); + downloadLink.setAttribute('href', `/api/v1/download/?filename=${tempName}`); + downloadLink.setAttribute('download', `${downloadName}.csv`); + downloadLink.innerText = 'Download CSV of results here.'; + document.getElementById('download-link').appendChild(downloadLink); + return; }
\ No newline at end of file diff --git a/src/routes/api.ts b/src/routes/api.ts index 1048406..798fb27 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -3,7 +3,6 @@ import cookieParser from 'cookie-parser'; import csurf from 'csurf'; import express, { Request, Response } from 'express'; import fileUpload, { UploadedFile } from 'express-fileupload'; -import { Stats } from 'fs'; import { access, stat } from 'fs/promises'; import { quote } from 'shell-quote'; @@ -62,13 +61,13 @@ api.route('/upload') if (file.mimetype !== 'text/x-python') return res.status(415).json({ error: 'File uploaded was not a Python file.' }); - res.status(201).json({ file: file.name, path: file.tempFilePath, msg: 'File uploaded successfully.', csrf: req.csrfToken() }); + res.status(201).json({ file: { file: file.name, path: file.tempFilePath }, msg: 'File uploaded successfully.', csrf: req.csrfToken() }); } catch (err) { // Generic error handler res.status(500).json({ error: 'An unknown error occurred while uploading the file.', error_msg: err }); } }) -// Fallback + // Fallback .all(csrf, (req: Request, res: Response) => { res.set('Allow', 'POST'); res.status(405).json({ error: 'Method not allowed.' }); @@ -78,12 +77,18 @@ api.route('/upload') Actuate the pendulum POST /api/v1/actuate Parameters: - name: The name of the file to run, currently unused - path: The path to the uploaded file on the server, passed in from /api/v1/upload on the website + file: { + name: The name of the file to run, currently unused + path: The path to the uploaded file on the server, passed in from /api/v1/upload on the website + } Returns: 200: { "stdout": "Hello from Python!\n", + "file": { + "name": "file.py", + "filename": "file-538126", + } } 400 for when the file passed in is not a regular file 403 when the file is not accessible @@ -99,54 +104,126 @@ api.route('/upload') */ api.route('/actuate') -// Snyk error mitigation, should be fine since the rate limiting is already in place -// file deepcode ignore NoRateLimitingForExpensiveWebOperation: This is already rate limited by the website, so we don't need to do it again + // Snyk error mitigation, should be fine since the rate limiting is already in place + // file deepcode ignore NoRateLimitingForExpensiveWebOperation: This is already rate limited by the website, so we don't need to do it again .post(csrf, async (req: Request, res: Response) => { - // Make sure the file being requested to run exists try { - await access(req.body.path); + const path: string = req.body.file.path; + // Verify that the file exists and is a regular file + // Return if not since the res will be sent by the verifyFile function + if (await verifyFile(path, res) !== true) + return; + + const escaped = quote([ path ]); + // Run the code + /* + TODO: + - Potentially add the limiter to one-per-person here + - Add a timeout + - Communicate to the machine to give up (the user as well), maybe just kill the process? + - Make this more secure + - HOW? + */ + let output = ''; + const actuation = spawn('python', escaped.split(' ')); + actuation.stdout.on('data', (data: Buffer) => { + output += data.toString(); + }); + actuation.stderr.on('data', (data: Buffer) => { + output += `STDERR: ${data.toString()}`; + }); + actuation.on('close', (code: number) => { + // Make sure the program exited with a code of 0 (success) + if (code !== 0) + return res.status(500).json({ error: `Program exited with exit code ${code}`, error_msg: output }); + const filename: string = (req.body.file.path as string).split('/').pop() as string; + return res.status(200).json({ stdout: output, file: { name: req.body.file.file, filename: filename } }); + }); + // Kill the process if it takes too long + // Default timeout is 120 seconds (2 minutes) + setTimeout(() => { + actuation.kill(); + }, 120000); } catch (err) { - return res.status(403).json({ error: 'File is not accessible or does not exist.' }); + // Generic error handler + return res.status(500).json({ error: 'An unknown error occurred while running the file.', error_msg: err }); } + }) + // Fallback + .all(csrf, (req: Request, res: Response) => { + res.set('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed.' }); + }); - const stats: Stats = await stat(req.body.path); - // Make sure the file being requested to run is a regular file - if (!stats.isFile()) - return res.status(400).json({ error: 'File is not a regular file.' }); - // Make sure the file being requested to run is not a directory - if (stats.isDirectory()) - return res.status(400).json({ error: 'File is a directory.' }); - - const escaped = quote([req.body.path]); - // Run the code - /* - TODO: - - Potentially add the limiter to one-per-person here - - Add a timeout - - Communicate to the machine to give up (the user as well), maybe just kill the process? - - Make this more secure - - HOW? - */ - let output = ''; - const actuation = spawn('python', escaped.split(' ')); - actuation.stdout.on('data', (data: Buffer) => { - output += data.toString(); - }); - actuation.stderr.on('data', (data: Buffer) => { - output += `STDERR: ${data.toString()}`; - }); - actuation.on('close', (code: number) => { - if (code !== 0) - return res.status(500).json({ error: `Program exited with exit code ${code}`, error_msg: output }); - return res.status(200).json({ stdout: output }); - }); +api.route('/download') + .get(csrf, async (req: Request, res: Response) => { + const path: string = `/tmp/${req.query.filename}.csv` as string; + + // Make sure no path traversal is attempted + // This regex matches all alphanumeric characters, underscores, and dashes. + // MAKE SURE THIS DOES NOT ALLOW PATH TRAVERSAL + if (/^[\w-]+$/.test(path)) + return res.status(403).json({ error: 'Get lost' }); + + // Verify that the file exists and is a regular file + // Return if not since the res will be sent by the verifyFile function + if (await verifyFile(path, res) !== true) + return; + // Read the file and send it to the client + res.type('text/csv'); + // Snyk error mitigation, should be fine since tmp is private and the simple regex above should prevent path traversal + // deepcode ignore PT: This is probably mitigated by the regex + return res.sendFile(path); }) -// Fallback + // Fallback .all(csrf, (req: Request, res: Response) => { - res.set('Allow', 'POST'); - res.status(405).json({ error: 'Method not allowed.' }); + res.set('Allow', 'GET'); + return res.status(405).json({ error: 'Method not allowed.' }); }); +/* + Verify that the file exists and is a regular file + Parameters: + path: The path to the file on the server + res: The response object to send the unsuccessful response to + Returns: + true: The file exists and is a regular file + false: The file does not exist or is not a regular file + ** AFTER THIS POINT, THE API HAS ALREADY SENT A RESPONSE, SO THE FUNCTION THAT CALLED IT SHOULD NOT RETURN ANOTHER RESPONSE ** +*/ +async function verifyFile(file: string, res: Response) { + // Make sure the file being requested to run exists + try { + await access(file); + } catch (err) { + res.status(403).json({ error: 'File is not accessible or does not exist.' }); + return false; + } + // This is a try catch because otherwise type checking will fail and get all messed up + // Handle your promise rejections, kids + try { + const stats = await stat(file); + // Make sure the file being requested to run is a regular file + if (!stats.isFile()) { + res.status(400).json({ error: 'File is not a regular file.' }); + return false; + } + // Make sure the file being requested to run is not a directory + else if (stats.isDirectory()) { + res.status(400).json({ error: 'File is a directory.' }); + return false; + } + + + // File does exist and is a regular file, so it is good to go + return true; + } + catch (err) { + res.status(404).json({ error: 'File is not accessible or does not exist.' }); + return false; + } +} + export default api; diff --git a/src/views/pages/index.ejs b/src/views/pages/index.ejs index 357baae..7c24e78 100644 --- a/src/views/pages/index.ejs +++ b/src/views/pages/index.ejs @@ -19,8 +19,12 @@ <h1>Please upload a Python file (.py file extension) to run on the Inverted Pendulum.</h1> <h3>A heavily documented example can be <a href="public/example.py">found here</a>.</h3> <h2> + <br /> <span class="error" id="upload-err"></span> <span class="error" id="actuate-err"></span> + <br> + <div id="upload-response"></div> + <div id="actuate-response"></div> </h2> <form id="upload" enctype="multipart/form-data"> @@ -28,11 +32,12 @@ <input type="file" name="file" accept=".py" /> <br /> <br /> <label id="Start">Start pendulum: </label> - <input type="submit" id="actuate_but" value="Actuate!" /> + <input type="submit" id="actuate-but" value="Actuate!" /> </form> - <h2> - <div id="upload-response"></div> - </h2> + <h3> + <span id="download-link"></span> + </h3> + </div> diff --git a/tsconfig.json b/tsconfig.json index 7c90de9..4ee1d0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,7 +45,7 @@ "outDir": "./dist", /* Specify an output folder for all emitted files. */ "removeComments": true, /* Disable emitting comments. */ // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ |