Merge pull request #44 from amhsirak/develop

feat: programmatic access
This commit is contained in:
Karishma Shukla
2024-09-28 22:42:05 +05:30
committed by GitHub
11 changed files with 352 additions and 162 deletions

View File

@@ -1,5 +0,0 @@
const genAPIKey = () => {
return [...Array(30)]
.map((e) => ((Math.random() * 36) | 0).toString(36))
.join('');
};

105
server/src/api/record.ts Normal file
View File

@@ -0,0 +1,105 @@
import { readFile, readFiles } from "../workflow-management/storage";
import { Router, Request, Response } from 'express';
import { requireAPIKey } from "../middlewares/api";
export const router = Router();
const formatRecording = (recordingData: any) => {
const recordingMeta = recordingData.recording_meta;
const workflow = recordingData.recording.workflow || [];
const firstWorkflowStep = workflow[0]?.where?.url || '';
const inputParameters = [
{
type: "string",
name: "originUrl",
label: "Origin URL",
required: true,
defaultValue: firstWorkflowStep,
},
];
return {
id: recordingMeta.id,
name: recordingMeta.name,
createdAt: new Date(recordingMeta.create_date).getTime(),
inputParameters,
};
};
router.get("/api/robots", requireAPIKey, async (req: Request, res: Response) => {
try {
const fileContents = await readFiles('./../storage/recordings/');
const formattedRecordings = fileContents.map((fileContent: string) => {
const recordingData = JSON.parse(fileContent);
return formatRecording(recordingData);
});
const response = {
statusCode: 200,
messageCode: "success",
robots: {
totalCount: formattedRecordings.length,
items: formattedRecordings,
},
};
res.status(200).json(response);
} catch (error) {
console.error("Error fetching recordings:", error);
res.status(500).json({
statusCode: 500,
messageCode: "error",
message: "Failed to retrieve recordings",
});
}
});
const formatRecordingById = (recordingData: any) => {
const recordingMeta = recordingData.recording_meta;
const workflow = recordingData.recording.workflow || [];
const firstWorkflowStep = workflow[0]?.where?.url || '';
const inputParameters = [
{
type: "string",
name: "originUrl",
label: "Origin URL",
required: true,
defaultValue: firstWorkflowStep,
},
];
return {
id: recordingMeta.id,
name: recordingMeta.name,
createdAt: new Date(recordingMeta.create_date).getTime(),
inputParameters,
};
};
router.get("/api/robots/:fileName", requireAPIKey, async (req: Request, res: Response) => {
try {
const fileContent = await readFile(`./../storage/recordings/${req.params.fileName}.waw.json`);
const recordingData = JSON.parse(fileContent);
const formattedRecording = formatRecordingById(recordingData);
const response = {
statusCode: 200,
messageCode: "success",
robot: formattedRecording,
};
res.status(200).json(response);
} catch (error) {
console.error("Error fetching recording:", error);
res.status(404).json({
statusCode: 404,
messageCode: "not_found",
message: `Recording with name "${req.params.fileName}" not found.`,
});
}
});

View File

@@ -0,0 +1,15 @@
import { Request, Response } from "express";
import User from "../models/User";
export const requireAPIKey = async (req: Request, res: Response, next: any) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: "API key is missing" });
}
const user = await User.findOne({ where: { api_key: apiKey } });
if (!user) {
return res.status(403).json({ error: "Invalid API key" });
}
req.user = user;
next();
};

View File

@@ -5,6 +5,7 @@ interface UserAttributes {
id: number; id: number;
email: string; email: string;
password: string; password: string;
api_key?: string | null;
} }
// Optional fields for creating a new user // Optional fields for creating a new user
@@ -14,6 +15,7 @@ class User extends Model<UserAttributes, UserCreationAttributes> implements User
public id!: number; public id!: number;
public email!: string; public email!: string;
public password!: string; public password!: string;
public api_key!: string | null;
} }
User.init( User.init(
@@ -35,6 +37,10 @@ User.init(
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
}, },
api_key: {
type: DataTypes.STRING,
allowNull: true,
},
}, },
{ {
sequelize, sequelize,

40
server/src/routes/api.ts Normal file
View File

@@ -0,0 +1,40 @@
import { Router, Request, Response } from 'express';
import { genAPIKey } from '../utils/api';
import User from '../models/User';
export const router = Router();
interface AuthenticatedRequest extends Request {
user?: { id: string };
}
router.get('/generate-api-key', async (req: AuthenticatedRequest, res) => {
try {
if (!req.user) {
return res.status(401).json({ ok: false, error: 'Unauthorized' });
}
const user = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] },
});
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
if (user.api_key) {
return res.status(400).json({ message: 'API key already exists' });
}
const apiKey = genAPIKey();
user.api_key = apiKey;
await user.save();
return res.status(200).json({
message: 'API key generated successfully',
api_key: apiKey,
});
} catch (error) {
return res.status(500).json({ message: 'Error generating API key', error });
}
});

View File

@@ -3,6 +3,7 @@ import User from '../models/User';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { hashPassword, comparePassword } from '../utils/auth'; import { hashPassword, comparePassword } from '../utils/auth';
import { requireSignIn } from '../middlewares/auth'; import { requireSignIn } from '../middlewares/auth';
import { genAPIKey } from '../utils/api';
export const router = Router(); export const router = Router();
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
@@ -88,4 +89,35 @@ router.get('/current-user', requireSignIn, async (req: AuthenticatedRequest, res
console.error('Error in current-user route:', error); console.error('Error in current-user route:', error);
return res.status(500).json({ ok: false, error: `Could not fetch current user: ${error.message}` }); return res.status(500).json({ ok: false, error: `Could not fetch current user: ${error.message}` });
} }
}); });
router.post('/generate-api-key', requireSignIn, async (req: AuthenticatedRequest, res) => {
try {
if (!req.user) {
return res.status(401).json({ ok: false, error: 'Unauthorized' });
}
const user = await User.findByPk(req.user.id, {
attributes: { exclude: ['password'] },
});
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
if (user.api_key) {
return res.status(400).json({ message: 'API key already exists' });
}
const apiKey = genAPIKey();
user.api_key = apiKey;
await user.save();
return res.status(200).json({
message: 'API key generated successfully',
api_key: apiKey,
});
} catch (error) {
return res.status(500).json({ message: 'Error generating API key', error });
}
});

3
server/src/utils/api.ts Normal file
View File

@@ -0,0 +1,3 @@
export const genAPIKey = (): string => {
return [...Array(30)].map(() => ((Math.random() * 36) | 0).toString(36)).join('');
};

View File

@@ -122,4 +122,3 @@ export const processGoogleSheetUpdates = async () => {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
} }
}; };

View File

@@ -25,7 +25,7 @@ interface InterpretationLogProps {
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
} }
export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, setIsOpen }) => { export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, setIsOpen }) => {
const [log, setLog] = useState<string>(''); const [log, setLog] = useState<string>('');
const [selectedOption, setSelectedOption] = useState<string>('10'); const [selectedOption, setSelectedOption] = useState<string>('10');
const [customValue, setCustomValue] = useState(''); const [customValue, setCustomValue] = useState('');
@@ -66,12 +66,12 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
setLog((prevState) => setLog((prevState) =>
prevState + '\n' + '---------- Serializable output data received ----------' + '\n' prevState + '\n' + '---------- Serializable output data received ----------' + '\n'
+ JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------'); + JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------');
// Set table data // Set table data
if (Array.isArray(data)) { if (Array.isArray(data)) {
setTableData(data); setTableData(data);
} }
scrollLogToBottom(); scrollLogToBottom();
}, [log, scrollLogToBottom]); }, [log, scrollLogToBottom]);
@@ -104,7 +104,7 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
// Extract columns dynamically from the first item of tableData // Extract columns dynamically from the first item of tableData
const columns = tableData.length > 0 ? Object.keys(tableData[0]) : []; const columns = tableData.length > 0 ? Object.keys(tableData[0]) : [];
return ( return (
<div> <div>
<button <button

View File

@@ -7,7 +7,7 @@ import { TabPanel, TabContext } from "@mui/lab";
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import ImageIcon from '@mui/icons-material/Image'; import ImageIcon from '@mui/icons-material/Image';
import ArticleIcon from '@mui/icons-material/Article'; import ArticleIcon from '@mui/icons-material/Article';
import {Buffer} from 'buffer'; import { Buffer } from 'buffer';
import { useEffect } from "react"; import { useEffect } from "react";
import AssignmentIcon from '@mui/icons-material/Assignment'; import AssignmentIcon from '@mui/icons-material/Assignment';
@@ -19,7 +19,7 @@ interface RunContentProps {
abortRunHandler: () => void, abortRunHandler: () => void,
} }
export const RunContent = ({row, currentLog, interpretationInProgress, logEndRef, abortRunHandler}: RunContentProps) => { export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => {
const [tab, setTab] = React.useState<string>('log'); const [tab, setTab] = React.useState<string>('log');
useEffect(() => { useEffect(() => {
@@ -29,141 +29,142 @@ export const RunContent = ({row, currentLog, interpretationInProgress, logEndRef
return ( return (
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
<TabContext value={tab}> <TabContext value={tab}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}> <Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(e, newTab) => setTab(newTab)} aria-label="run-content-tabs"> <Tabs value={tab} onChange={(e, newTab) => setTab(newTab)} aria-label="run-content-tabs">
<Tab label="Log" value='log' /> <Tab label="Log" value='log' />
<Tab label="Input" value='input' /> <Tab label="Input" value='input' />
<Tab label="Output" value='output' /> <Tab label="Output" value='output' />
</Tabs> </Tabs>
</Box>
<TabPanel value='log'>
<Box sx={{ margin: 1,
background: '#19171c',
overflowY: 'scroll',
overflowX: 'scroll',
width: '700px',
height: 'fit-content',
maxHeight: '450px',
}}>
<div>
<Highlight className="javascript">
{interpretationInProgress ? currentLog : row.log}
</Highlight>
<div style={{ float:"left", clear: "both" }}
ref={logEndRef}/>
</div>
</Box> </Box>
{interpretationInProgress ? <Button <TabPanel value='log'>
color="error" <Box sx={{
onClick={abortRunHandler} margin: 1,
> background: '#19171c',
Stop overflowY: 'scroll',
</Button> : null} overflowX: 'scroll',
</TabPanel> width: '700px',
<TabPanel value='input' sx={{width: '700px'}}> height: 'fit-content',
<Typography variant='h6' sx={{display:'flex', alignItems:'center'}}> maxHeight: '450px',
<SettingsIcon sx={{marginRight: '15px'}}/> }}>
Interpreter settings <div>
</Typography> <Highlight className="javascript">
{ {interpretationInProgress ? currentLog : row.log}
Object.keys(row.interpreterSettings).map((setting, index) => { </Highlight>
if (setting === 'params') { <div style={{ float: "left", clear: "both" }}
return ( ref={logEndRef} />
<div key={`settings-${setting}-${index}`}> </div>
<Typography variant='h6' sx={{display:'flex', alignItems:'center'}} key={`setting-${index}`}> </Box>
<AssignmentIcon sx={{marginRight: '15px'}}/> {interpretationInProgress ? <Button
Recording parameters color="error"
</Typography> onClick={abortRunHandler}
{ >
Object.keys(row.interpreterSettings.params).map((param, index) => { Stop
return ( </Button> : null}
<Typography key={`recording-params-item-${index}`} sx={{margin: '10px'}}> </TabPanel>
{/*@ts-ignore*/} <TabPanel value='input' sx={{ width: '700px' }}>
{param}: {row.interpreterSettings.params[param].toString()} <Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
</Typography> <SettingsIcon sx={{ marginRight: '15px' }} />
) Interpreter settings
}) </Typography>
} {
</div> Object.keys(row.interpreterSettings).map((setting, index) => {
) if (setting === 'params') {
}
return (
<Typography key={`interpreter-settings-item-${index}`} sx={{margin: '10px'}}>
{/*@ts-ignore*/}
{setting}: {row.interpreterSettings[setting].toString()}
</Typography>
)
})
}
</TabPanel>
<TabPanel value='output' sx={{width: '700px'}}>
{ !row || !row.serializableOutput || !row.binaryOutput
|| (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0)
? <Typography>The output is empty.</Typography> : null }
{row.serializableOutput &&
Object.keys(row.serializableOutput).length !== 0 &&
<div>
<Typography variant='h6' sx={{display:'flex', alignItems:'center'}}>
<ArticleIcon sx={{marginRight: '15px'}}/>
Serializable output</Typography>
{ Object.keys(row.serializableOutput).map((key) => {
return (
<div key={`number-of-serializable-output-${key}`}>
<Typography>
{key}:
<a href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput[key], null, 2)}`}
download={key} style={{margin:'10px'}}>Download</a>
</Typography>
<Box sx={{
width: 'fit-content',
background: 'rgba(0,0,0,0.06)',
maxHeight: '300px',
overflow: 'scroll',
}}>
<pre key={`serializable-output-${key}`}>
{row.serializableOutput[key] ? JSON.stringify(row.serializableOutput[key], null, 2)
: 'The output is empty.'}
</pre>
</Box>
</div>
)
})}
</div>
}
{row.binaryOutput
&& Object.keys(row.binaryOutput).length !== 0 &&
<div>
<Typography variant='h6' sx={{display:'flex', alignItems:'center'}}>
<ImageIcon sx={{marginRight:'15px'}}/>
Binary output</Typography>
{ Object.keys(row.binaryOutput).map((key) => {
try {
const binaryBuffer = JSON.parse(row.binaryOutput[key].data);
const b64 = Buffer.from(binaryBuffer.data).toString('base64');
return ( return (
<Box key={`number-of-binary-output-${key}`} sx={{ <div key={`settings-${setting}-${index}`}>
width: 'max-content', <Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }} key={`setting-${index}`}>
}}> <AssignmentIcon sx={{ marginRight: '15px' }} />
<Typography key={`binary-output-key-${key}`}> Recording parameters
{key}:
<a href={`data:${row.binaryOutput[key].mimetype};base64,${b64}`}
download={key} style={{margin:'10px'}}>Download</a>
</Typography> </Typography>
<img key={`image-${key}`} src={`data:${row.binaryOutput[key].mimetype};base64,${b64}`} {
alt={key} height='auto' width='700px'/> Object.keys(row.interpreterSettings.params).map((param, index) => {
</Box> return (
<Typography key={`recording-params-item-${index}`} sx={{ margin: '10px' }}>
{/*@ts-ignore*/}
{param}: {row.interpreterSettings.params[param].toString()}
</Typography>
)
})
}
</div>
) )
} catch (e) {
console.log(e)
return <Typography key={`number-of-binary-output-${key}`}>
{key}: The image failed to render
</Typography>
} }
return (
<Typography key={`interpreter-settings-item-${index}`} sx={{ margin: '10px' }}>
{/*@ts-ignore*/}
{setting}: {row.interpreterSettings[setting].toString()}
</Typography>
)
})
}
</TabPanel>
<TabPanel value='output' sx={{ width: '700px' }}>
{!row || !row.serializableOutput || !row.binaryOutput
|| (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0)
? <Typography>The output is empty.</Typography> : null}
{row.serializableOutput &&
Object.keys(row.serializableOutput).length !== 0 &&
<div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ArticleIcon sx={{ marginRight: '15px' }} />
Serializable output</Typography>
{Object.keys(row.serializableOutput).map((key) => {
return (
<div key={`number-of-serializable-output-${key}`}>
<Typography>
{key}:
<a href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput[key], null, 2)}`}
download={key} style={{ margin: '10px' }}>Download</a>
</Typography>
<Box sx={{
width: 'fit-content',
background: 'rgba(0,0,0,0.06)',
maxHeight: '300px',
overflow: 'scroll',
}}>
<pre key={`serializable-output-${key}`}>
{row.serializableOutput[key] ? JSON.stringify(row.serializableOutput[key], null, 2)
: 'The output is empty.'}
</pre>
</Box>
</div>
)
})} })}
</div> </div>
} }
</TabPanel> {row.binaryOutput
&& Object.keys(row.binaryOutput).length !== 0 &&
<div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ImageIcon sx={{ marginRight: '15px' }} />
Binary output</Typography>
{Object.keys(row.binaryOutput).map((key) => {
try {
const binaryBuffer = JSON.parse(row.binaryOutput[key].data);
const b64 = Buffer.from(binaryBuffer.data).toString('base64');
return (
<Box key={`number-of-binary-output-${key}`} sx={{
width: 'max-content',
}}>
<Typography key={`binary-output-key-${key}`}>
{key}:
<a href={`data:${row.binaryOutput[key].mimetype};base64,${b64}`}
download={key} style={{ margin: '10px' }}>Download</a>
</Typography>
<img key={`image-${key}`} src={`data:${row.binaryOutput[key].mimetype};base64,${b64}`}
alt={key} height='auto' width='700px' />
</Box>
)
} catch (e) {
console.log(e)
return <Typography key={`number-of-binary-output-${key}`}>
{key}: The image failed to render
</Typography>
}
})}
</div>
}
</TabPanel>
</TabContext> </TabContext>
</Box> </Box>
); );

View File

@@ -21,7 +21,7 @@ const initialState = {
const AuthContext = createContext<{ const AuthContext = createContext<{
state: InitialStateType; state: InitialStateType;
dispatch: any; dispatch: React.Dispatch<ActionType>;
}>({ }>({
state: initialState, state: initialState,
dispatch: () => null, dispatch: () => null,
@@ -46,38 +46,34 @@ const reducer = (state: InitialStateType, action: ActionType) => {
const AuthProvider = ({ children }: AuthProviderProps) => { const AuthProvider = ({ children }: AuthProviderProps) => {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const navigate = useNavigate(); const navigate = useNavigate();
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
// get user info from local storage
useEffect(() => { useEffect(() => {
dispatch({ const storedUser = window.localStorage.getItem('user');
type: 'LOGIN', if (storedUser) {
payload: JSON.parse(window.localStorage.getItem('user') || 'null'), dispatch({ type: 'LOGIN', payload: JSON.parse(storedUser) });
}); }
}, []); }, []);
axios.interceptors.response.use( axios.interceptors.response.use(
function (response) { function (response) {
// any status code that lies within the range of 2XX causes this function to trigger
return response; return response;
}, },
function (error) { function (error) {
// any status codes that fall outside the range of 2XX cause this function to trigger const res = error.response;
let res = error.response;
if (res.status === 401 && res.config && !res.config.__isRetryRequest) { if (res.status === 401 && res.config && !res.config.__isRetryRequest) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios axios
.get('http://localhost:8080/auth/logout') .get('http://localhost:8080/auth/logout')
.then((data) => { .then(() => {
console.log('/401 error > logout'); console.log('/401 error > logout');
dispatch({ type: 'LOGOUT' }); dispatch({ type: 'LOGOUT' });
window.localStorage.removeItem('user'); window.localStorage.removeItem('user');
navigate('/login'); // Replace router.push with navigate navigate('/login');
}) })
.catch((err) => { .catch((err) => {
console.log('AXIOS INTERCEPTORS ERROR:', err); console.error('AXIOS INTERCEPTORS ERROR:', err);
reject(error); reject(error);
}); });
}); });
@@ -86,16 +82,12 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
} }
); );
// csrf - include tokens in the axios header every time a request is made
useEffect(() => { useEffect(() => {
const getCsrfToken = async () => { const getCsrfToken = async () => {
try { try {
const { data } = await axios.get('http://localhost:8080/csrf-token'); const { data } = await axios.get('http://localhost:8080/csrf-token');
console.log('CSRF Token Response:', data); if (data.csrfToken) {
if (data && data.csrfToken) {
(axios.defaults.headers as any)['X-CSRF-TOKEN'] = data.csrfToken; (axios.defaults.headers as any)['X-CSRF-TOKEN'] = data.csrfToken;
} else {
console.error('CSRF token not found in the response');
} }
} catch (error) { } catch (error) {
console.error('Error fetching CSRF token:', error); console.error('Error fetching CSRF token:', error);
@@ -105,8 +97,10 @@ const AuthProvider = ({ children }: AuthProviderProps) => {
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ state, dispatch }}>{children}</AuthContext.Provider> <AuthContext.Provider value={{ state, dispatch }}>
{children}
</AuthContext.Provider>
); );
}; };
export { AuthContext, AuthProvider }; export { AuthContext, AuthProvider };