Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 77 additions & 23 deletions api/src/middlewares/FileHandlerMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import fs from 'fs';
import multer from 'multer';
import { Request, Response, NextFunction } from 'express';
import {v4 as uuidv4} from 'uuid';
import { sanitizePathSegment } from '../utils/path-utils';
import path from 'path';

const addFilenameToBody = (...fieldNames: string[]) => (req: any, res: any, next: NextFunction) => {
fieldNames.forEach(fieldName => {
Expand Down Expand Up @@ -33,45 +35,97 @@ const handleFileUpload = (imageFieldNames: string[], folder: string) => {
}
}

const handlePricingUpload = (pricingFieldNames: string[], folder: string) => {
const handlePricingUpload = (pricingFieldNames: string[], baseFolder: string) => {
const storage = multer.diskStorage({
destination: function (req, file, cb) {
fs.mkdirSync(folder + `/${req.body.saasName}`, { recursive: true })
cb(null, folder)
destination: (req, _file, cb) => {
try {
const saasName = sanitizePathSegment(req.body?.saasName, "unknown-saas")
const targetDir = path.resolve(baseFolder, saasName)

// Ensure the directory exists
fs.mkdirSync(targetDir, { recursive: true })

cb(null, targetDir)
} catch (error) {
cb(error as Error, baseFolder)
}
},
filename: function (req, file, cb) {
if (file) {
cb(null, req.body.saasName + "/" + req.body.version + '.' + file.originalname.split('.').pop())
} else {
cb(new Error('File does not exist'), "fail.yml")

filename: (req, file, cb) => {
try {
if (!file) {
cb(new Error("File does not exist"), "fail.yml")
return
}

const version = sanitizePathSegment(req.body?.version, "0.0.0")

// Keep original extension (including .yml / .yaml)
const ext = path.extname(file.originalname) || ".yml"

cb(null, `${version}${ext}`)
} catch (error) {
cb(error as Error, "fail.yml")
}
}
})

if (pricingFieldNames.length === 1) {
return multer({ storage }).single(pricingFieldNames[0])
} else {
const fields = pricingFieldNames.map(pricingFieldNames => { return { name: pricingFieldNames, maxCount: 1 } })
return multer({ storage }).fields(fields)
}

const fields = pricingFieldNames.map((name) => ({ name, maxCount: 1 }))
return multer({ storage }).fields(fields)
}

const handleCollectionUpload = (collectionFieldNames: string[], folder: string) => {
const handleCollectionUpload = (
collectionFieldNames: string[],
baseFolder: string
) => {
const storage = multer.diskStorage({
destination: function (req, file, cb) {
fs.mkdirSync(folder + `/`, { recursive: true })
cb(null, folder)
},
filename: function (req, file, cb) {
cb(null, uuidv4() + '.' + file.originalname.split('.').pop())
destination: (_req, _file, cb) => {
try {
const targetDir = path.resolve(baseFolder)

fs.mkdirSync(targetDir, { recursive: true })

cb(null, targetDir)
} catch (error) {
cb(error as Error, baseFolder)
}
},

filename: (_req, file, cb) => {
try {
if (!file) {
cb(new Error("File does not exist"), "fail.zip")
return
}

const extension = path.extname(file.originalname) || ".zip"

cb(null, `${uuidv4()}${extension}`)
} catch (error) {
cb(error as Error, "fail.zip")
}
}
})

if (collectionFieldNames.length === 1) {
return multer({ storage: storage, limits: {fileSize: 2 * 1024 * 1024} }).single(collectionFieldNames[0])
} else {
const fields = collectionFieldNames.map(collectionFieldNames => { return { name: collectionFieldNames, maxCount: 1 } })
return multer({ storage }).fields(fields)
return multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }
}).single(collectionFieldNames[0])
}

const fields = collectionFieldNames.map((name) => ({
name,
maxCount: 1
}))

return multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }
}).fields(fields)
}
export { handleFileUpload, addFilenameToBody, handlePricingUpload, handleCollectionUpload }
9 changes: 7 additions & 2 deletions api/src/routes/PricingCollectionRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import PricingController from '../controllers/PricingController';
import { handleValidation } from '../middlewares/ValidationHandlingMiddleware';
import * as PricingCollectionValidator from '../controllers/validation/PricingCollectionValidation';
import { handleCollectionUpload } from '../middlewares/FileHandlerMiddleware';
import path from 'path';

const loadFileRoutes = function (app: express.Application) {
const pricingCollectionController = new PricingCollectionController();
const pricingController = new PricingController();
const upload = handleCollectionUpload(['zip'], './public/static/collections');

const upload = handleCollectionUpload(
['zip'],
path.resolve(process.cwd(), 'public', 'static', 'collections')
);

const baseUrl = process.env.BASE_URL_PATH;

Expand Down Expand Up @@ -40,7 +45,7 @@ const loadFileRoutes = function (app: express.Application) {
)
.delete(isLoggedIn, pricingCollectionController.destroy);

app
app
.route(baseUrl + '/pricings/collections/:userId/:collectionName/download')
.get(pricingCollectionController.downloadCollection);
};
Expand Down
6 changes: 5 additions & 1 deletion api/src/routes/PricingRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import PricingController from '../controllers/PricingController';
import { handlePricingUpload } from '../middlewares/FileHandlerMiddleware';
import * as PricingValidator from '../controllers/validation/PricingValidation';
import { handleValidation } from '../middlewares/ValidationHandlingMiddleware';
import path from 'path';

const loadFileRoutes = function (app: express.Application) {
const pricingController = new PricingController();
const upload = handlePricingUpload(['yaml'], './public/static/pricings/uploaded');
const upload = handlePricingUpload(
["yaml"],
path.resolve(process.cwd(), "public", "static", "pricings", "uploaded")
)

const baseUrl = process.env.BASE_URL_PATH;

Expand Down
20 changes: 20 additions & 0 deletions api/src/utils/path-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Conservative sanitization for filesystem path segments.
* - Removes Windows-illegal characters: < > : " / \ | ? *
* - Collapses whitespace
* - Blocks path traversal via "." and ".."
*/
export const sanitizePathSegment = (raw: unknown, fallback: string): string => {
const value = typeof raw === "string" ? raw.trim() : ""
if (!value) return fallback

// Remove illegal characters and normalize spaces
const cleaned = value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, "") // illegal + control chars
.replace(/\s+/g, "_")

// Prevent "." / ".." and empty results
if (cleaned === "." || cleaned === ".." || cleaned.length === 0) return fallback

return cleaned
}
8 changes: 0 additions & 8 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 65 additions & 14 deletions frontend/src/modules/harvey/components/ChatTranscript.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Box, Typography, Paper, Button, Accordion, AccordionSummary, AccordionDetails, CircularProgress } from '@mui/material';
import {
Box,
Typography,
Paper,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
} from '@mui/material';
import { grey, primary } from '../../core/theme/palette';

import type { ChatMessage, PromptPreset } from '../types/types';
import usePlayground from '../hooks/usePlayground';

interface Props {
messages: ChatMessage[];
Expand All @@ -13,22 +23,36 @@ interface Props {
}

function ChatTranscript({ messages, isLoading, promptPresets = [], onPresetSelect }: Props) {
const isPlaygroundEnabled = usePlayground();

const isPresetGalleyEnabled = !isPlaygroundEnabled && promptPresets.length > 0 && onPresetSelect;

return (
<Box sx={{ height: '100%', overflowY: 'auto', p: 2 }} aria-live="polite" aria-busy={isLoading}>
{messages.length === 0 && !isLoading ? (
<Box sx={{ textAlign: 'center', mt: 8 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h1" sx={{ fontSize: '3rem', mb: 2 }}>💬</Typography>
<Typography variant="h1" sx={{ fontSize: '3rem', mb: 2 }}>
💬
</Typography>
<Typography variant="h4" sx={{ fontWeight: 600, mb: 2, color: grey[800] }}>
Welcome to H.A.R.V.E.Y.
</Typography>
<Typography variant="body1" sx={{ color: grey[600] }}>
Enabling seamless execution of the Pricing Intelligence Interpretation Process.
</Typography>
</Box>
{promptPresets.length > 0 && onPresetSelect && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: '600px', mx: 'auto' }}>
{promptPresets.map((preset) => (
{isPresetGalleyEnabled && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
maxWidth: '600px',
mx: 'auto',
}}
>
{promptPresets.map(preset => (
<Button
key={preset.id}
variant="outlined"
Expand All @@ -41,8 +65,8 @@ function ChatTranscript({ messages, isLoading, promptPresets = [], onPresetSelec
borderColor: grey[300],
'&:hover': {
borderColor: primary[500],
backgroundColor: primary[100]
}
backgroundColor: primary[100],
},
}}
>
<Typography>{preset.label}</Typography>
Expand All @@ -52,14 +76,14 @@ function ChatTranscript({ messages, isLoading, promptPresets = [], onPresetSelec
)}
</Box>
) : null}
{messages.map((message) => (
{messages.map(message => (
<Paper
key={message.id}
sx={{
mb: 2,
p: 2,
backgroundColor: message.role === 'user' ? primary[100] : grey[100],
borderLeft: `4px solid ${message.role === 'user' ? primary[500] : grey[500]}`
borderLeft: `4px solid ${message.role === 'user' ? primary[500] : grey[500]}`,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
Expand All @@ -70,7 +94,12 @@ function ChatTranscript({ messages, isLoading, promptPresets = [], onPresetSelec
{new Date(message.createdAt).toLocaleTimeString()}
</Typography>
</Box>
<Box sx={{ '& p': { mb: 1 }, '& pre': { backgroundColor: grey[200], p: 1, borderRadius: 1, overflowX: 'auto' } }}>
<Box
sx={{
'& p': { mb: 1 },
'& pre': { backgroundColor: grey[200], p: 1, borderRadius: 1, overflowX: 'auto' },
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
</Box>
{message.metadata?.plan || message.metadata?.result ? (
Expand All @@ -81,16 +110,38 @@ function ChatTranscript({ messages, isLoading, promptPresets = [], onPresetSelec
<AccordionDetails>
{message.metadata.plan ? (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>Planner</Typography>
<Box component="pre" sx={{ backgroundColor: grey[200], p: 1, borderRadius: 1, overflowX: 'auto', fontSize: '0.875rem' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Planner
</Typography>
<Box
component="pre"
sx={{
backgroundColor: grey[200],
p: 1,
borderRadius: 1,
overflowX: 'auto',
fontSize: '0.875rem',
}}
>
{JSON.stringify(message.metadata.plan, null, 2)}
</Box>
</Box>
) : null}
{message.metadata.result ? (
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>Result</Typography>
<Box component="pre" sx={{ backgroundColor: grey[200], p: 1, borderRadius: 1, overflowX: 'auto', fontSize: '0.875rem' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Result
</Typography>
<Box
component="pre"
sx={{
backgroundColor: grey[200],
p: 1,
borderRadius: 1,
overflowX: 'auto',
fontSize: '0.875rem',
}}
>
{JSON.stringify(message.metadata.result, null, 2)}
</Box>
</Box>
Expand Down
Loading
Loading