diff --git a/README.md b/README.md index 0337750..c8cf517 100755 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ Once launched use `REACT_APP_API_URL=http://localhost:3000` in your `.env` and l ### Deployment Using an nginx reverse proxy to apply CORS (don't run ENABLE_CORS=true in prod) and SSL is the best method, the nginx default max client upload size of 10MB is fine for this appliction. +You want to declare a volume for `C:\app\results`, an example command for deployment is below. + +```bash +docker run -d --restart unless-stopped --name webdbg-api -v webdbg-results:C:\app\results -p 3000:3000 ghcr.io/r-techsupport/webdbg-api:latest +``` + ### PUT endpoint usage With a file ```bash diff --git a/api/Dockerfile b/api/Dockerfile index f2cc3a6..0415284 100755 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -16,6 +16,9 @@ COPY . . # Install application dependencies RUN npm install +# Declare a volume for results so they persist +VOLUME C:\\app\\results + # Expose the application port EXPOSE 3000 diff --git a/api/USAGE.md b/api/USAGE.md index b0d98da..454bea6 100755 --- a/api/USAGE.md +++ b/api/USAGE.md @@ -1,6 +1,6 @@ # BSOD-API -A basic API to injest `.dmp` files and return analyzed text. +A basic API to injest `.dmp` files and return a UUID in JSON associated with the results. Fetching the UUID will return the associated result in JSON. ## Usage With a file @@ -11,4 +11,9 @@ curl.exe -X PUT http://localhost:3000/analyze-dmp -F "dmpFile=@path/to/test.dmp" With a URL ```bash curl -X PUT http://localhost:3000/analyze-dmp -F "url=http://example.com/file.dmp" +``` + +Retrieve result JSON +```bash +curl -X GET http://chakotay.dev0.sh:3001/ ``` \ No newline at end of file diff --git a/api/api.js b/api/api.js index 0fb0370..c03f4db 100755 --- a/api/api.js +++ b/api/api.js @@ -42,6 +42,12 @@ if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir); } +// Esure the results directory exists +const resultsDir = path.join(__dirname, 'results'); +if (!fs.existsSync(resultsDir)) { + fs.mkdirSync(resultsDir); +} + // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { @@ -111,14 +117,21 @@ const checkFileHeader = (checkPath) => { return true; }; -// Function to execute analysis commands on local files +// Function to execute analysis commands on local files and save result as JSON const analyzeFile = async (filePath, res) => { logger.info(`Sending target: ${filePath} for analysis`); try { const analysisResult = await Analyze(filePath); - logger.info('Analysis output sent to client'); - res.json(JSON.parse(analysisResult)); + const resultObj = JSON.parse(analysisResult); + // Generate short result ID (first octet of UUID) + const resultId = uuidv4().split('-')[0]; + const resultPath = path.join(resultsDir, `${resultId}.json`); + // Save result to file + await fs.promises.writeFile(resultPath, JSON.stringify(resultObj, null, 2), 'utf8'); + logger.info(`Analysis result saved: ${resultPath}`); + // Return only the UUID to the client + res.json({ uuid: resultId }); } catch (error) { logger.error(`Failed to analyze target: ${error.message}`); res.status(500).send("An error occurred while analyzing the file"); @@ -127,6 +140,25 @@ const analyzeFile = async (filePath, res) => { } }; +// GET endpoint to fetch analysis result by UUID +app.get('/:uuid', async (req, res) => { + const { uuid } = req.params; + const resultPath = path.join(resultsDir, `${uuid}.json`); + logger.info(`Result requested: ${resultPath}`); + try { + if (!fs.existsSync(resultPath)) { + logger.info(`Result not found: ${resultPath}`); + return res.status(404).send('Result not found'); + } + const data = await fs.promises.readFile(resultPath, 'utf8'); + logger.info(`Result served: ${resultPath}`); + res.type('application/json').send(data); + } catch (err) { + logger.error(`Failed to fetch result for UUID ${uuid}: ${err.message}`); + res.status(500).send('An error occured while retrieving result'); + } +}); + // PUT and POST endpoint to receive .dmp file or URL and analyze it const handleAnalyzeDmp = async (req, res) => { const uploadName = req.uploadName || uuidv4().split('-')[0]; // Retrieve the upload name from the request object @@ -306,6 +338,38 @@ app.get('/', (req, res) => { }); }); +// Cleanup function to delete result files older than 7 days +const cleanupOldResults = async () => { + const now = Date.now(); + const weekMs = 7 * 24 * 60 * 60 * 1000; + try { + const files = await fs.promises.readdir(resultsDir); + let deletedCount = 0; + for (const file of files) { + if (file.endsWith('.json')) { + const filePath = path.join(resultsDir, file); + const stat = await fs.promises.stat(filePath); + if (now - stat.mtimeMs > weekMs) { + await fs.promises.unlink(filePath); + logger.info(`Deleted old result file: ${filePath}`); + deletedCount++; + } + } + } + if (deletedCount === 0) { + logger.info('Cleanup ran: no old result files deleted.'); + } + } catch (err) { + logger.error(`Cleanup error: ${err.message}`); + } +}; + +// Cleanup middleware: run after every request +app.use(async (req, res, next) => { + await cleanupOldResults(); + next(); +}); + // Centralized error handling middleware app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { diff --git a/swa/package-lock.json b/swa/package-lock.json index 4d447d4..5525942 100644 --- a/swa/package-lock.json +++ b/swa/package-lock.json @@ -14,6 +14,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-helmet": "^6.1.0", + "react-router-dom": "^7.9.4", "react-scripts": "5.0.1", "web-vitals": "^5.1.0" } @@ -13907,6 +13908,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", + "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz", + "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -14801,6 +14849,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16382,9 +16436,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", "peer": true, "bin": { @@ -16392,7 +16446,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/swa/package.json b/swa/package.json index 4b05527..ec99b09 100644 --- a/swa/package.json +++ b/swa/package.json @@ -3,14 +3,15 @@ "version": "0.1.0", "private": true, "dependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-helmet": "^6.1.0", - "react-scripts": "5.0.1", - "web-vitals": "^5.1.0" + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-helmet": "^6.1.0", + "react-router-dom": "^7.9.4", + "react-scripts": "5.0.1", + "web-vitals": "^5.1.0" }, "scripts": { "start": "react-scripts start", diff --git a/swa/src/App.js b/swa/src/App.js index d58c29b..040a4fe 100644 --- a/swa/src/App.js +++ b/swa/src/App.js @@ -1,4 +1,6 @@ import React, { useState } from 'react'; +import { BrowserRouter as Router, Route, Routes, useNavigate } from 'react-router-dom'; +import ResultPage from './ResultPage'; import { Helmet } from 'react-helmet'; import Footer from './footer'; @@ -12,6 +14,7 @@ const FileUpload = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [responseData, setResponseData] = useState(''); + const navigate = useNavigate(); const handleFileChange = (e) => { setFile(e.target.files[0]); @@ -23,14 +26,11 @@ const FileUpload = () => { const handleFileUpload = async () => { if (!file) return; - const formData = new FormData(); formData.append('dmpFile', file); - setLoading(true); setError(''); setResponseData(''); - try { const response = await fetch(API_URL, { method: 'PUT', @@ -40,11 +40,25 @@ const FileUpload = () => { setError('Error: File too large. The maximum allowed size is 10MB.'); return; } - const responseText = await response.text(); + const text = await response.text(); + let responseJson = null; + try { + responseJson = JSON.parse(text); + } catch (e) { + if (!response.ok) { + setError(text); + return; + } + } if (!response.ok) { - throw new Error(`${responseText}`); + setError(responseJson?.error || 'Upload failed'); + return; + } + if (responseJson && responseJson.uuid) { + navigate(`/${responseJson.uuid}`); + } else { + setError('No UUID returned from API'); } - setResponseData(responseText); } catch (error) { console.error(error); setError(`Error: ${error.message}`); @@ -55,11 +69,9 @@ const FileUpload = () => { const handleUrlSubmit = async () => { if (!url) return; - setLoading(true); setError(''); setResponseData(''); - try { const response = await fetch(`${API_URL}?url=${encodeURIComponent(url)}`, { method: 'PUT', @@ -68,11 +80,25 @@ const FileUpload = () => { setError('Error: File too large. The maximum allowed size is 10MB.'); return; } - const responseText = await response.text(); + const text = await response.text(); + let responseJson = null; + try { + responseJson = JSON.parse(text); + } catch (e) { + if (!response.ok) { + setError(text); + return; + } + } if (!response.ok) { - throw new Error(`${responseText}`); + setError(responseJson?.error || 'Upload failed'); + return; + } + if (responseJson && responseJson.uuid) { + navigate(`/${responseJson.uuid}`); + } else { + setError('No UUID returned from API'); } - setResponseData(responseText); } catch (error) { console.error(error); setError(`Error: ${error.message}`); @@ -81,15 +107,7 @@ const FileUpload = () => { } }; - // Function to validate JSON Response - const isValidJson = (data) => { - try { - JSON.parse(data); - return true; - } catch (e) { - return false; - } - }; + // Removed unused isValidJson // Function to sort JSON keys const sortJson = (data, order) => { @@ -107,11 +125,15 @@ const FileUpload = () => { // No clue what this does, but everything is rendered inside it if (Array.isArray(data)) { - return data.map((item, index) => ( -
- {renderJsonToHtml(item)} -
- )); + return data.map((item) => { + // Use a stable key: stringified item or item.key if available + const key = (item && typeof item === 'object' && item.key) ? item.key : JSON.stringify(item); + return ( +
+ {renderJsonToHtml(item)} +
+ ); + }); } // Define the key order to display in @@ -133,24 +155,24 @@ const FileUpload = () => { const regularItems = keyValueArray.filter(item => !specialKeys.includes(item.key)); // Render the regular items - const regularRender = regularItems.map((item, index) => ( - <> -

- {item.key} -

-
- {item.value} -
- + const regularRender = regularItems.map((item) => ( + +

+ {item.key} +

+
+ {item.value} +
+
)); // Render the special items with their own method - const specialRender = specialItems.map((item, index) => ( -
-
- Raw results -
{item.value}
-
+ const specialRender = specialItems.map((item) => ( +
+
+ Raw results +
{item.value}
+
)); @@ -170,16 +192,18 @@ const FileUpload = () => {
-
-
+
+
-
+
- {!error && !responseData &&

{loading ? 'Processing...' : 'Upload your .dmp file or a .zip file containing multiple .dmp files directly or via a direct link.'}

} - {error &&

{error}

} + {!error && !responseData &&

{loading ? 'Processing...' : 'Upload your .dmp file or a .zip file containing multiple .dmp files directly or via a direct link.'}

} + {error &&

{error}

} {responseData && ( <>{renderJsonToHtml(JSON.parse(responseData))} )} @@ -199,4 +223,13 @@ const FileUpload = () => { ); }; -export default FileUpload; +const App = () => ( + + + } /> + } /> + + +); + +export default App; diff --git a/swa/src/ResultPage.js b/swa/src/ResultPage.js new file mode 100644 index 0000000..57f044a --- /dev/null +++ b/swa/src/ResultPage.js @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; + +// Retrieve the site name and API URL from environment variables +const SITE_NAME = process.env.REACT_APP_SITE_NAME; +const API_URL = `${process.env.REACT_APP_API_URL}`; + +const isValidJson = (data) => { + try { + JSON.parse(data); + return true; + } catch (e) { + return false; + } +}; + +const sortJson = (data, order) => { + return order.reduce((acc, key) => { + if (Object.prototype.hasOwnProperty.call(data, key)) { + acc[key] = data[key]; + } + return acc; + }, {}); +}; + +const renderJsonToHtml = (data) => { + if (Array.isArray(data)) { + return data.map((item) => { + // Use a stable key: item.key if available, otherwise stringified item + const key = (item && typeof item === 'object' && item.key) ? item.key : JSON.stringify(item); + return ( +
+ {renderJsonToHtml(item)} +
+ ); + }); + } + const order = ["dmpName", "dmpInfo", "analysis", "post", "rawContent"]; + const specialKeys = ["rawContent"]; + const sortedData = sortJson(data, order); + const keyValueArray = Object.entries(sortedData).map(([key, value]) => ({ key, value })); + const specialItems = keyValueArray.filter(item => specialKeys.includes(item.key)); + const regularItems = keyValueArray.filter(item => !specialKeys.includes(item.key)); + const regularRender = regularItems.map((item) => ( + +

{item.key}

+
{item.value}
+
+ )); + const specialRender = specialItems.map((item) => ( +
+
+ Raw results +
{item.value}
+
+
+ )); + return ( + <> + {regularRender} + {specialRender} + + ); +}; + +const ResultPage = () => { + const { uuid } = useParams(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [responseData, setResponseData] = useState(''); + + useEffect(() => { + const fetchResult = async () => { + setLoading(true); + setError(''); + setResponseData(''); + try { + const res = await fetch(`${API_URL}/${uuid}`); + if (!res.ok) { + throw new Error(`Fetch failed: ${res.statusText}`); + } + const data = await res.text(); + setResponseData(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + fetchResult(); + }, [uuid]); + + return ( +
+ + {SITE_NAME} + +
+ + {loading &&

Loading...

} + {error &&

{error}

} + {responseData && ( + isValidJson(responseData) + ? <>{renderJsonToHtml(JSON.parse(responseData))} + :

Error: Invalid JSON received from backend.

+ )} +
+
+ ); +}; + +export default ResultPage;