mirror of
https://github.com/dstotijn/hetty.git
synced 2025-07-01 18:47:29 -04:00
Add project management
This commit is contained in:
17
README.md
17
README.md
@ -12,7 +12,7 @@
|
|||||||
- [x] Web interface (Next.js) with proxy log viewer.
|
- [x] Web interface (Next.js) with proxy log viewer.
|
||||||
- [ ] Add scope support to the proxy.
|
- [ ] Add scope support to the proxy.
|
||||||
- [ ] Full text search (with regex) in proxy log viewer.
|
- [ ] Full text search (with regex) in proxy log viewer.
|
||||||
- [ ] Project management.
|
- [x] Project management.
|
||||||
- [ ] Sender module for sending manual HTTP requests, either from scratch or based
|
- [ ] Sender module for sending manual HTTP requests, either from scratch or based
|
||||||
off requests from the proxy log.
|
off requests from the proxy log.
|
||||||
- [ ] Attacker module for automated sending of HTTP requests. Leverage the concurrency
|
- [ ] Attacker module for automated sending of HTTP requests. Leverage the concurrency
|
||||||
@ -56,12 +56,7 @@ Alternatively, you can run Hetty via Docker. See: [`dstotijn/hetty`](https://hub
|
|||||||
on Docker Hub.
|
on Docker Hub.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ docker run \
|
$ docker run -v $HOME/.hetty:/root/.hetty -p 127.0.0.1:8080:8080 dstotijn/hetty
|
||||||
-v $HOME/.hetty/hetty_key.pem:/root/.hetty/hetty_key.pem \
|
|
||||||
-v $HOME/.hetty/hetty_cert.pem:/root/.hetty/hetty_cert.pem \
|
|
||||||
-v $HOME/.hetty/hetty.db:/root/.hetty/hetty.db \
|
|
||||||
-p 127.0.0.1:8080:8080 \
|
|
||||||
dstotijn/hetty
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@ -80,15 +75,15 @@ Usage of ./hetty:
|
|||||||
File path to admin build
|
File path to admin build
|
||||||
-cert string
|
-cert string
|
||||||
CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem")
|
CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem")
|
||||||
-db string
|
|
||||||
Database file path (default "~/.hetty/hetty.db")
|
|
||||||
-key string
|
-key string
|
||||||
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
|
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
|
||||||
|
-projects string
|
||||||
|
Projects directory path (default "~/.hetty/projects")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Certificate Setup and Installation
|
## Certificate Setup and Installation
|
||||||
|
|
||||||
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
|
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
|
||||||
Hetty will need to be set up. Furthermore, the CA certificate may need to be
|
Hetty will need to be set up. Furthermore, the CA certificate may need to be
|
||||||
installed to the host for them to be trusted by your browser. The following steps
|
installed to the host for them to be trusted by your browser. The following steps
|
||||||
will cover how you can generate your certificate, provide them to hetty, and how
|
will cover how you can generate your certificate, provide them to hetty, and how
|
||||||
@ -115,7 +110,7 @@ certificate with hetty, simply run the command with no arguments
|
|||||||
hetty
|
hetty
|
||||||
```
|
```
|
||||||
|
|
||||||
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
|
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
|
||||||
`~/.hetty/hetty_cert.pem` respectively.
|
`~/.hetty/hetty_cert.pem` respectively.
|
||||||
|
|
||||||
#### Generating CA certificates with OpenSSL
|
#### Generating CA certificates with OpenSSL
|
||||||
|
@ -21,12 +21,15 @@ import MenuIcon from "@material-ui/icons/Menu";
|
|||||||
import HomeIcon from "@material-ui/icons/Home";
|
import HomeIcon from "@material-ui/icons/Home";
|
||||||
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
|
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
|
||||||
import SendIcon from "@material-ui/icons/Send";
|
import SendIcon from "@material-ui/icons/Send";
|
||||||
|
import FolderIcon from "@material-ui/icons/Folder";
|
||||||
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
|
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
|
||||||
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
|
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
export enum Page {
|
export enum Page {
|
||||||
Home,
|
Home,
|
||||||
|
GetStarted,
|
||||||
|
Projects,
|
||||||
ProxySetup,
|
ProxySetup,
|
||||||
ProxyLogs,
|
ProxyLogs,
|
||||||
Sender,
|
Sender,
|
||||||
@ -233,6 +236,22 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
|||||||
<ListItemText primary="Sender" />
|
<ListItemText primary="Sender" />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/projects" passHref>
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
component="a"
|
||||||
|
key="projects"
|
||||||
|
selected={page === Page.Projects}
|
||||||
|
className={classes.listItem}
|
||||||
|
>
|
||||||
|
<Tooltip title="Projects">
|
||||||
|
<ListItemIcon className={classes.listItemIcon}>
|
||||||
|
<FolderIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<ListItemText primary="Projects" />
|
||||||
|
</ListItem>
|
||||||
|
</Link>
|
||||||
</List>
|
</List>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<main className={classes.content}>
|
<main className={classes.content}>
|
||||||
|
122
admin/src/components/projects/NewProject.tsx
Normal file
122
admin/src/components/projects/NewProject.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { gql, useMutation } from "@apollo/client";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
createStyles,
|
||||||
|
makeStyles,
|
||||||
|
TextField,
|
||||||
|
Theme,
|
||||||
|
Typography,
|
||||||
|
} from "@material-ui/core";
|
||||||
|
import AddIcon from "@material-ui/icons/Add";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
projectName: {
|
||||||
|
marginTop: -6,
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const OPEN_PROJECT = gql`
|
||||||
|
mutation OpenProject($name: String!) {
|
||||||
|
openProject(name: $name) {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function NewProject(): JSX.Element {
|
||||||
|
const classes = useStyles();
|
||||||
|
const [input, setInput] = useState(null);
|
||||||
|
|
||||||
|
const [openProject, { error, loading }] = useMutation(OPEN_PROJECT, {
|
||||||
|
onError: () => {},
|
||||||
|
onCompleted() {
|
||||||
|
input.value = "";
|
||||||
|
},
|
||||||
|
update(cache, { data: { openProject } }) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
activeProject() {
|
||||||
|
const activeProjRef = cache.writeFragment({
|
||||||
|
id: openProject.name,
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment ActiveProject on Project {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return activeProjRef;
|
||||||
|
},
|
||||||
|
projects(_, { DELETE }) {
|
||||||
|
cache.writeFragment({
|
||||||
|
id: openProject.name,
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment OpenProject on Project {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return DELETE;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNewProjectForm = (e: React.SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openProject({ variables: { name: input.value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Box mb={3}>
|
||||||
|
<Typography variant="h6">New project</Typography>
|
||||||
|
</Box>
|
||||||
|
<form onSubmit={handleNewProjectForm} autoComplete="off">
|
||||||
|
<TextField
|
||||||
|
className={classes.projectName}
|
||||||
|
color="secondary"
|
||||||
|
inputProps={{
|
||||||
|
id: "projectName",
|
||||||
|
ref: (node) => {
|
||||||
|
setInput(node);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
label="Project name"
|
||||||
|
placeholder="Project name…"
|
||||||
|
error={Boolean(error)}
|
||||||
|
helperText={error && error.message}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={classes.button}
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
|
||||||
|
>
|
||||||
|
Create & open project
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewProject;
|
311
admin/src/components/projects/ProjectList.tsx
Normal file
311
admin/src/components/projects/ProjectList.tsx
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
createStyles,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemAvatar,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
ListItemText,
|
||||||
|
makeStyles,
|
||||||
|
Snackbar,
|
||||||
|
Theme,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@material-ui/core";
|
||||||
|
import CloseIcon from "@material-ui/icons/Close";
|
||||||
|
import DescriptionIcon from "@material-ui/icons/Description";
|
||||||
|
import DeleteIcon from "@material-ui/icons/Delete";
|
||||||
|
import LaunchIcon from "@material-ui/icons/Launch";
|
||||||
|
import { Alert } from "@material-ui/lab";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
projectsList: {
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
},
|
||||||
|
activeProject: {
|
||||||
|
color: theme.palette.getContrastText(theme.palette.secondary.main),
|
||||||
|
backgroundColor: theme.palette.secondary.main,
|
||||||
|
},
|
||||||
|
deleteProjectButton: {
|
||||||
|
color: theme.palette.error.main,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const PROJECTS = gql`
|
||||||
|
query Projects {
|
||||||
|
projects {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const OPEN_PROJECT = gql`
|
||||||
|
mutation OpenProject($name: String!) {
|
||||||
|
openProject(name: $name) {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CLOSE_PROJECT = gql`
|
||||||
|
mutation CloseProject {
|
||||||
|
closeProject {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DELETE_PROJECT = gql`
|
||||||
|
mutation DeleteProject($name: String!) {
|
||||||
|
deleteProject(name: $name) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function ProjectList(): JSX.Element {
|
||||||
|
const classes = useStyles();
|
||||||
|
const { loading: projLoading, error: projErr, data: projData } = useQuery(
|
||||||
|
PROJECTS
|
||||||
|
);
|
||||||
|
const [
|
||||||
|
openProject,
|
||||||
|
{ error: openProjErr, loading: openProjLoading },
|
||||||
|
] = useMutation(OPEN_PROJECT, {
|
||||||
|
errorPolicy: "all",
|
||||||
|
onError: () => {},
|
||||||
|
update(cache, { data: { openProject } }) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
activeProject() {
|
||||||
|
const activeProjRef = cache.writeFragment({
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment ActiveProject on Project {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return activeProjRef;
|
||||||
|
},
|
||||||
|
projects(_, { DELETE }) {
|
||||||
|
cache.writeFragment({
|
||||||
|
id: openProject.name,
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment OpenProject on Project {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return DELETE;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
|
||||||
|
errorPolicy: "all",
|
||||||
|
onError: () => {},
|
||||||
|
update(cache) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
activeProject() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
projects(_, { DELETE }) {
|
||||||
|
return DELETE;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [
|
||||||
|
deleteProject,
|
||||||
|
{ loading: deleteProjLoading, error: deleteProjErr },
|
||||||
|
] = useMutation(DELETE_PROJECT, {
|
||||||
|
errorPolicy: "all",
|
||||||
|
onError: () => {},
|
||||||
|
update(cache) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
projects(_, { DELETE }) {
|
||||||
|
return DELETE;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setDeleteDiagOpen(false);
|
||||||
|
setDeleteNotifOpen(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deleteProjName, setDeleteProjName] = useState(null);
|
||||||
|
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
|
||||||
|
const handleDeleteButtonClick = (name: string) => {
|
||||||
|
setDeleteProjName(name);
|
||||||
|
setDeleteDiagOpen(true);
|
||||||
|
};
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
deleteProject({ variables: { name: deleteProjName } });
|
||||||
|
};
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setDeleteDiagOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
|
||||||
|
const handleCloseDeleteNotif = (_, reason?: string) => {
|
||||||
|
if (reason === "clickaway") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteNotifOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
|
||||||
|
<DialogTitle>
|
||||||
|
Delete project “<strong>{deleteProjName}</strong>”?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Deleting a project permanently removes its database file from disk.
|
||||||
|
This action is irreversible.
|
||||||
|
</DialogContentText>
|
||||||
|
{deleteProjErr && (
|
||||||
|
<Alert severity="error">
|
||||||
|
Error closing project: {deleteProjErr.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleDeleteCancel} autoFocus>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={classes.deleteProjectButton}
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={deleteProjLoading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={deleteNotifOpen}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={handleCloseDeleteNotif}
|
||||||
|
>
|
||||||
|
<Alert onClose={handleCloseDeleteNotif} severity="info">
|
||||||
|
Project <strong>{deleteProjName}</strong> was deleted.
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Box mb={3}>
|
||||||
|
<Typography variant="h6">Manage projects</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box mb={4}>
|
||||||
|
{projLoading && <CircularProgress />}
|
||||||
|
{projErr && (
|
||||||
|
<Alert severity="error">
|
||||||
|
Error fetching projects: {projErr.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{openProjErr && (
|
||||||
|
<Alert severity="error">
|
||||||
|
Error opening project: {openProjErr.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{closeProjErr && (
|
||||||
|
<Alert severity="error">
|
||||||
|
Error closing project: {closeProjErr.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{projData?.projects.length > 0 && (
|
||||||
|
<List className={classes.projectsList}>
|
||||||
|
{projData.projects.map((project) => (
|
||||||
|
<ListItem key={project.name}>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar
|
||||||
|
className={
|
||||||
|
project.isActive ? classes.activeProject : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DescriptionIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText>
|
||||||
|
{project.name} {project.isActive && <em>(Active)</em>}
|
||||||
|
</ListItemText>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
{project.isActive && (
|
||||||
|
<Tooltip title="Close project">
|
||||||
|
<IconButton onClick={() => closeProject()}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!project.isActive && (
|
||||||
|
<Tooltip title="Open project">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disabled={openProjLoading || projLoading}
|
||||||
|
onClick={() =>
|
||||||
|
openProject({
|
||||||
|
variables: { name: project.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LaunchIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Delete project">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handleDeleteButtonClick(project.name)}
|
||||||
|
disabled={project.isActive}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
{projData?.projects.length === 0 && (
|
||||||
|
<Alert severity="info">
|
||||||
|
There are no projects. Create one to get started.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectList;
|
@ -1,7 +1,12 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { gql, useQuery } from "@apollo/client";
|
import { gql, useQuery } from "@apollo/client";
|
||||||
import { useState } from "react";
|
import Link from "next/link";
|
||||||
import { Box, Typography, CircularProgress } from "@material-ui/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Link as MaterialLink,
|
||||||
|
} from "@material-ui/core";
|
||||||
import Alert from "@material-ui/lab/Alert";
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
|
||||||
import RequestList from "./RequestList";
|
import RequestList from "./RequestList";
|
||||||
@ -42,6 +47,17 @@ function LogsOverview(): JSX.Element {
|
|||||||
return <CircularProgress />;
|
return <CircularProgress />;
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
|
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
|
||||||
|
return (
|
||||||
|
<Alert severity="info">
|
||||||
|
There is no project active.{" "}
|
||||||
|
<Link href="/projects" passHref>
|
||||||
|
<MaterialLink color="secondary">Create or open</MaterialLink>
|
||||||
|
</Link>{" "}
|
||||||
|
one first.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
|
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
|
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
|
||||||
import { concatPagination } from "@apollo/client/utilities";
|
|
||||||
|
|
||||||
let apolloClient;
|
let apolloClient;
|
||||||
|
|
||||||
@ -12,10 +11,8 @@ function createApolloClient() {
|
|||||||
}),
|
}),
|
||||||
cache: new InMemoryCache({
|
cache: new InMemoryCache({
|
||||||
typePolicies: {
|
typePolicies: {
|
||||||
Query: {
|
Project: {
|
||||||
fields: {
|
keyFields: ["name"],
|
||||||
allPosts: concatPagination(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -11,6 +11,12 @@ const theme = createMuiTheme({
|
|||||||
secondary: {
|
secondary: {
|
||||||
main: teal["A400"],
|
main: teal["A400"],
|
||||||
},
|
},
|
||||||
|
info: {
|
||||||
|
main: teal["A400"],
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
main: teal["A400"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
h2: {
|
h2: {
|
||||||
|
36
admin/src/pages/get-started/index.tsx
Normal file
36
admin/src/pages/get-started/index.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Box, Link as MaterialLink, Typography } from "@material-ui/core";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import Layout, { Page } from "../../components/Layout";
|
||||||
|
|
||||||
|
function Index(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Layout page={Page.GetStarted} title="Get started">
|
||||||
|
<Box p={4}>
|
||||||
|
<Box mb={3}>
|
||||||
|
<Typography variant="h4">Get started</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography paragraph>
|
||||||
|
You’ve loaded a (new) project. What’s next? You can now use the MITM
|
||||||
|
proxy and review HTTP requests and responses via the{" "}
|
||||||
|
<Link href="/proxy/logs" passHref>
|
||||||
|
<MaterialLink color="secondary">Proxy logs</MaterialLink>
|
||||||
|
</Link>
|
||||||
|
. Stuck? Ask for help on the{" "}
|
||||||
|
<MaterialLink
|
||||||
|
href="https://github.com/dstotijn/hetty/discussions"
|
||||||
|
color="secondary"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Discussions forum
|
||||||
|
</MaterialLink>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Index;
|
@ -1,17 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
CircularProgress,
|
||||||
createStyles,
|
createStyles,
|
||||||
IconButton,
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemAvatar,
|
||||||
|
ListItemText,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
|
TextField,
|
||||||
Theme,
|
Theme,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@material-ui/core";
|
} from "@material-ui/core";
|
||||||
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
|
import AddIcon from "@material-ui/icons/Add";
|
||||||
import SendIcon from "@material-ui/icons/Send";
|
import FolderIcon from "@material-ui/icons/Folder";
|
||||||
|
import DescriptionIcon from "@material-ui/icons/Description";
|
||||||
|
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import Layout, { Page } from "../components/Layout";
|
import Layout, { Page } from "../components/Layout";
|
||||||
|
import { Alert } from "@material-ui/lab";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
@ -24,14 +37,108 @@ const useStyles = makeStyles((theme: Theme) =>
|
|||||||
lineHeight: 2,
|
lineHeight: 2,
|
||||||
marginBottom: theme.spacing(5),
|
marginBottom: theme.spacing(5),
|
||||||
},
|
},
|
||||||
|
projectName: {
|
||||||
|
marginTop: -6,
|
||||||
|
marginRight: theme.spacing(2),
|
||||||
|
},
|
||||||
button: {
|
button: {
|
||||||
marginRight: theme.spacing(2),
|
marginRight: theme.spacing(2),
|
||||||
},
|
},
|
||||||
|
activeProject: {
|
||||||
|
color: theme.palette.getContrastText(theme.palette.secondary.main),
|
||||||
|
backgroundColor: theme.palette.secondary.main,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ACTIVE_PROJECT = gql`
|
||||||
|
query ActiveProject {
|
||||||
|
activeProject {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const OPEN_PROJECT = gql`
|
||||||
|
mutation OpenProject($name: String!) {
|
||||||
|
openProject(name: $name) {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
function Index(): JSX.Element {
|
function Index(): JSX.Element {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
|
const router = useRouter();
|
||||||
|
const [input, setInput] = useState(null);
|
||||||
|
const { error: activeProjErr, data: activeProjData } = useQuery(
|
||||||
|
ACTIVE_PROJECT,
|
||||||
|
{
|
||||||
|
pollInterval: 1000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const [
|
||||||
|
openProject,
|
||||||
|
{ error: openProjErr, data: openProjData, loading: openProjLoading },
|
||||||
|
] = useMutation(OPEN_PROJECT, {
|
||||||
|
onError: () => {},
|
||||||
|
onCompleted({ openProject }) {
|
||||||
|
if (openProject) {
|
||||||
|
router.push("/get-started");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update(cache, { data: { openProject } }) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
activeProject() {
|
||||||
|
const activeProjRef = cache.writeFragment({
|
||||||
|
id: openProject.name,
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment ActiveProject on Project {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return activeProjRef;
|
||||||
|
},
|
||||||
|
projects(_, { DELETE }) {
|
||||||
|
cache.writeFragment({
|
||||||
|
id: openProject.name,
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment OpenProject on Project {
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return DELETE;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleForm = (e: React.SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openProject({ variables: { name: input.value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeProjErr) {
|
||||||
|
return (
|
||||||
|
<Layout page={Page.Home} title="">
|
||||||
|
<Alert severity="error">
|
||||||
|
Error fetching active project: {activeProjErr.message}
|
||||||
|
</Alert>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout page={Page.Home} title="">
|
<Layout page={Page.Home} title="">
|
||||||
<Box p={4}>
|
<Box p={4}>
|
||||||
@ -42,38 +149,105 @@ function Index(): JSX.Element {
|
|||||||
The simple HTTP toolkit for security research.
|
The simple HTTP toolkit for security research.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography className={classes.subtitle} paragraph>
|
<Typography className={classes.subtitle} paragraph>
|
||||||
What if security testing was intuitive, powerful, and good looking?
|
What if security testing was intuitive, powerful, and good looking?
|
||||||
What if it was <strong>free</strong>, instead of $400 per year?{" "}
|
What if it was <strong>free</strong>, instead of $400 per year?{" "}
|
||||||
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
|
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
|
||||||
<code>:8080</code>…
|
<code>:8080</code>…
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box>
|
|
||||||
<Link href="/proxy" passHref>
|
{activeProjData?.activeProject?.name ? (
|
||||||
|
<div>
|
||||||
|
<Box mb={1}>
|
||||||
|
<Typography variant="h6">Active project:</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box ml={-2} mb={2}>
|
||||||
|
<List>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar className={classes.activeProject}>
|
||||||
|
<DescriptionIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText primary={activeProjData.activeProject.name} />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Link href="/get-started" passHref>
|
||||||
|
<Button
|
||||||
|
className={classes.button}
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
color="secondary"
|
||||||
|
size="large"
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/projects" passHref>
|
||||||
|
<Button
|
||||||
|
className={classes.button}
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
size="large"
|
||||||
|
startIcon={<FolderIcon />}
|
||||||
|
>
|
||||||
|
Manage projects
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleForm} autoComplete="off">
|
||||||
|
<TextField
|
||||||
|
className={classes.projectName}
|
||||||
|
color="secondary"
|
||||||
|
inputProps={{
|
||||||
|
id: "projectName",
|
||||||
|
ref: (node) => {
|
||||||
|
setInput(node);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
label="Project name"
|
||||||
|
placeholder="Project name…"
|
||||||
|
error={Boolean(openProjErr)}
|
||||||
|
helperText={openProjErr && openProjErr.message}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
className={classes.button}
|
className={classes.button}
|
||||||
|
type="submit"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
component="a"
|
|
||||||
size="large"
|
size="large"
|
||||||
startIcon={<SettingsEthernetIcon />}
|
disabled={
|
||||||
|
openProjLoading || Boolean(openProjData?.openProject?.name)
|
||||||
|
}
|
||||||
|
startIcon={
|
||||||
|
openProjLoading || openProjData?.openProject ? (
|
||||||
|
<CircularProgress size={22} />
|
||||||
|
) : (
|
||||||
|
<AddIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Setup proxy
|
Create project
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
<Link href="/projects" passHref>
|
||||||
<Link href="/proxy" passHref>
|
<Button
|
||||||
<Button
|
className={classes.button}
|
||||||
className={classes.button}
|
variant="outlined"
|
||||||
variant="contained"
|
component="a"
|
||||||
color="primary"
|
size="large"
|
||||||
component="a"
|
startIcon={<FolderIcon />}
|
||||||
size="large"
|
>
|
||||||
startIcon={<SendIcon />}
|
Open project…
|
||||||
>
|
</Button>
|
||||||
Send HTTP requests
|
</Link>
|
||||||
</Button>
|
</form>
|
||||||
</Link>
|
)}
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
33
admin/src/pages/projects/index.tsx
Normal file
33
admin/src/pages/projects/index.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Divider, Grid, Typography } from "@material-ui/core";
|
||||||
|
import Layout, { Page } from "../../components/Layout";
|
||||||
|
import NewProject from "../../components/projects/NewProject";
|
||||||
|
import ProjectList from "../../components/projects/ProjectList";
|
||||||
|
|
||||||
|
function Index(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Layout page={Page.Projects} title="Projects">
|
||||||
|
<Box p={4}>
|
||||||
|
<Box mb={3}>
|
||||||
|
<Typography variant="h4">Projects</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography paragraph>
|
||||||
|
Projects contain settings and data generated/processed by Hetty. They
|
||||||
|
are stored as SQLite database files on disk.
|
||||||
|
</Typography>
|
||||||
|
<Box my={4}>
|
||||||
|
<Divider />
|
||||||
|
</Box>
|
||||||
|
<Box mb={8}>
|
||||||
|
<NewProject />
|
||||||
|
</Box>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item xs={12} sm={8} md={6} lg={6}>
|
||||||
|
<ProjectList />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Index;
|
@ -11,10 +11,9 @@ import (
|
|||||||
|
|
||||||
rice "github.com/GeertJohan/go.rice"
|
rice "github.com/GeertJohan/go.rice"
|
||||||
"github.com/dstotijn/hetty/pkg/api"
|
"github.com/dstotijn/hetty/pkg/api"
|
||||||
"github.com/dstotijn/hetty/pkg/db/sqlite"
|
"github.com/dstotijn/hetty/pkg/proj"
|
||||||
"github.com/dstotijn/hetty/pkg/proxy"
|
"github.com/dstotijn/hetty/pkg/proxy"
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
"github.com/dstotijn/hetty/pkg/scope"
|
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql/handler"
|
"github.com/99designs/gqlgen/graphql/handler"
|
||||||
"github.com/99designs/gqlgen/graphql/playground"
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
@ -25,7 +24,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
caCertFile string
|
caCertFile string
|
||||||
caKeyFile string
|
caKeyFile string
|
||||||
dbFile string
|
projPath string
|
||||||
addr string
|
addr string
|
||||||
adminPath string
|
adminPath string
|
||||||
)
|
)
|
||||||
@ -33,7 +32,7 @@ var (
|
|||||||
func main() {
|
func main() {
|
||||||
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", "CA certificate filepath. Creates a new CA certificate is file doesn't exist")
|
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", "CA certificate filepath. Creates a new CA certificate is file doesn't exist")
|
||||||
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem", "CA private key filepath. Creates a new CA private key if file doesn't exist")
|
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem", "CA private key filepath. Creates a new CA private key if file doesn't exist")
|
||||||
flag.StringVar(&dbFile, "db", "~/.hetty/hetty.db", "Database file path")
|
flag.StringVar(&projPath, "projects", "~/.hetty/projects", "Projects directory path")
|
||||||
flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"")
|
flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"")
|
||||||
flag.StringVar(&adminPath, "adminPath", "", "File path to admin build")
|
flag.StringVar(&adminPath, "adminPath", "", "File path to admin build")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@ -47,9 +46,9 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err)
|
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err)
|
||||||
}
|
}
|
||||||
dbFile, err := homedir.Expand(dbFile)
|
projPath, err := homedir.Expand(projPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err)
|
log.Fatalf("[FATAL] Could not parse projects filepath: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing CA certificate and key from disk, or generate and write
|
// Load existing CA certificate and key from disk, or generate and write
|
||||||
@ -59,16 +58,15 @@ func main() {
|
|||||||
log.Fatalf("[FATAL] Could not create/load CA key pair: %v", err)
|
log.Fatalf("[FATAL] Could not create/load CA key pair: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sqlite.New(dbFile)
|
projService, err := proj.NewService(projPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not initialize database: %v", err)
|
log.Fatalf("[FATAL] Could not create new project service: %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer projService.Close()
|
||||||
|
|
||||||
scope := scope.New(nil)
|
|
||||||
reqLogService := reqlog.NewService(reqlog.Config{
|
reqLogService := reqlog.NewService(reqlog.Config{
|
||||||
Scope: scope,
|
Scope: projService.Scope,
|
||||||
Repository: db,
|
Repository: projService.Database(),
|
||||||
})
|
})
|
||||||
|
|
||||||
p, err := proxy.NewProxy(caCert, caKey)
|
p, err := proxy.NewProxy(caCert, caKey)
|
||||||
@ -103,6 +101,7 @@ func main() {
|
|||||||
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/"))
|
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/"))
|
||||||
adminRouter.Path("/api/graphql/").Handler(handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
|
adminRouter.Path("/api/graphql/").Handler(handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
|
||||||
RequestLogService: reqLogService,
|
RequestLogService: reqLogService,
|
||||||
|
ProjectService: projService,
|
||||||
}})))
|
}})))
|
||||||
|
|
||||||
// Admin interface.
|
// Admin interface.
|
||||||
|
5
go.mod
5
go.mod
@ -3,16 +3,15 @@ module github.com/dstotijn/hetty
|
|||||||
go 1.15
|
go 1.15
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/gqlgen v0.11.3
|
github.com/99designs/gqlgen v0.13.0
|
||||||
github.com/GeertJohan/go.rice v1.0.0
|
github.com/GeertJohan/go.rice v1.0.0
|
||||||
github.com/Masterminds/squirrel v1.4.0
|
github.com/Masterminds/squirrel v1.4.0
|
||||||
github.com/gorilla/mux v1.7.4
|
github.com/gorilla/mux v1.7.4
|
||||||
github.com/gorilla/websocket v1.4.0 // indirect
|
|
||||||
github.com/hashicorp/golang-lru v0.5.1 // indirect
|
github.com/hashicorp/golang-lru v0.5.1 // indirect
|
||||||
github.com/jmoiron/sqlx v1.2.0
|
github.com/jmoiron/sqlx v1.2.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.4
|
github.com/mattn/go-sqlite3 v1.14.4
|
||||||
github.com/mitchellh/go-homedir v1.1.0
|
github.com/mitchellh/go-homedir v1.1.0
|
||||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.0.1
|
github.com/vektah/gqlparser/v2 v2.1.0
|
||||||
google.golang.org/appengine v1.6.6 // indirect
|
google.golang.org/appengine v1.6.6 // indirect
|
||||||
)
|
)
|
||||||
|
6
go.sum
6
go.sum
@ -1,5 +1,7 @@
|
|||||||
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
|
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
|
||||||
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
|
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
|
||||||
|
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
|
||||||
|
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg=
|
github.com/GeertJohan/go.incremental v1.0.0 h1:7AH+pY1XUgQE4Y1HcXYaMqAI0m9yrFqo/jt0CW30vsg=
|
||||||
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
|
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
|
||||||
@ -39,6 +41,8 @@ github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTM
|
|||||||
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
|
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
|
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
|
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
|
||||||
@ -105,6 +109,8 @@ github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWp
|
|||||||
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
||||||
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
|
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
|
||||||
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
1248
pkg/api/generated.go
1248
pkg/api/generated.go
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type CloseProjectResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteProjectResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPHeader struct {
|
type HTTPHeader struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
@ -34,6 +42,11 @@ type HTTPResponseLog struct {
|
|||||||
Headers []HTTPHeader `json:"headers"`
|
Headers []HTTPHeader `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPMethod string
|
type HTTPMethod string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -7,20 +7,35 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/99designs/gqlgen/graphql"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proj"
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
RequestLogService *reqlog.Service
|
RequestLogService *reqlog.Service
|
||||||
|
ProjectService *proj.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
type queryResolver struct{ *Resolver }
|
type queryResolver struct{ *Resolver }
|
||||||
|
type mutationResolver struct{ *Resolver }
|
||||||
|
|
||||||
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||||
|
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
|
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
|
||||||
opts := reqlog.FindRequestsOptions{OmitOutOfScope: false}
|
opts := reqlog.FindRequestsOptions{OmitOutOfScope: false}
|
||||||
reqs, err := r.RequestLogService.FindRequests(ctx, opts)
|
reqs, err := r.RequestLogService.FindRequests(ctx, opts)
|
||||||
|
if err == reqlog.ErrNoProject {
|
||||||
|
return nil, &gqlerror.Error{
|
||||||
|
Path: graphql.GetPath(ctx),
|
||||||
|
Message: "No active project.",
|
||||||
|
Extensions: map[string]interface{}{
|
||||||
|
"code": "no_active_project",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not query repository for requests: %v", err)
|
return nil, fmt.Errorf("could not query repository for requests: %v", err)
|
||||||
}
|
}
|
||||||
@ -116,3 +131,65 @@ func parseRequestLog(req reqlog.Request) (HTTPRequestLog, error) {
|
|||||||
|
|
||||||
return log, nil
|
return log, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) OpenProject(ctx context.Context, name string) (*Project, error) {
|
||||||
|
p, err := r.ProjectService.Open(name)
|
||||||
|
if err == proj.ErrInvalidName {
|
||||||
|
return nil, gqlerror.Errorf("Project name must only contain alphanumeric or space chars.")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not open project: %v", err)
|
||||||
|
}
|
||||||
|
return &Project{
|
||||||
|
Name: p.Name,
|
||||||
|
IsActive: p.IsActive,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
|
||||||
|
p, err := r.ProjectService.ActiveProject()
|
||||||
|
if err == proj.ErrNoProject {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not open project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Project{
|
||||||
|
Name: p.Name,
|
||||||
|
IsActive: p.IsActive,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
|
||||||
|
p, err := r.ProjectService.Projects()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not get projects: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projects := make([]Project, len(p))
|
||||||
|
for i, proj := range p {
|
||||||
|
projects[i] = Project{
|
||||||
|
Name: proj.Name,
|
||||||
|
IsActive: proj.IsActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CloseProject(ctx context.Context) (*CloseProjectResult, error) {
|
||||||
|
if err := r.ProjectService.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not close project: %v", err)
|
||||||
|
}
|
||||||
|
return &CloseProjectResult{true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error) {
|
||||||
|
if err := r.ProjectService.Delete(name); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not delete project: %v", err)
|
||||||
|
}
|
||||||
|
return &DeleteProjectResult{
|
||||||
|
Success: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
@ -23,9 +23,30 @@ type HttpHeader {
|
|||||||
value: String!
|
value: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Project {
|
||||||
|
name: String!
|
||||||
|
isActive: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CloseProjectResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteProjectResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
httpRequestLog(id: ID!): HttpRequestLog
|
httpRequestLog(id: ID!): HttpRequestLog
|
||||||
httpRequestLogs: [HttpRequestLog!]!
|
httpRequestLogs: [HttpRequestLog!]!
|
||||||
|
activeProject: Project
|
||||||
|
projects: [Project!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
openProject(name: String!): Project
|
||||||
|
closeProject: CloseProjectResult!
|
||||||
|
deleteProject(name: String!): DeleteProjectResult!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HttpMethod {
|
enum HttpMethod {
|
||||||
|
@ -3,11 +3,10 @@ package sqlite
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
@ -33,13 +32,10 @@ type httpRequestLogsQuery struct {
|
|||||||
joinResponse bool
|
joinResponse bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new Client.
|
// Open opens a database.
|
||||||
func New(filename string) (*Client, error) {
|
func (c *Client) Open(filename string) error {
|
||||||
// Create directory for DB if it doesn't exist yet.
|
if c.db != nil {
|
||||||
if dbDir, _ := filepath.Split(filename); dbDir != "" {
|
return errors.New("sqlite: database already open")
|
||||||
if _, err := os.Stat(dbDir); os.IsNotExist(err) {
|
|
||||||
os.Mkdir(dbDir, 0755)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := make(url.Values)
|
opts := make(url.Values)
|
||||||
@ -48,24 +44,24 @@ func New(filename string) (*Client, error) {
|
|||||||
dsn := fmt.Sprintf("file:%v?%v", filename, opts.Encode())
|
dsn := fmt.Sprintf("file:%v?%v", filename, opts.Encode())
|
||||||
db, err := sqlx.Open("sqlite3", dsn)
|
db, err := sqlx.Open("sqlite3", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return fmt.Errorf("sqlite: could not open database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("sqlite: could not ping database: %v", err)
|
return fmt.Errorf("sqlite: could not ping database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &Client{db: db}
|
if err := prepareSchema(db); err != nil {
|
||||||
|
return fmt.Errorf("sqlite: could not prepare schema: %v", err)
|
||||||
if err := c.prepareSchema(); err != nil {
|
|
||||||
return nil, fmt.Errorf("sqlite: could not prepare schema: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{db: db}, nil
|
c.db = db
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) prepareSchema() error {
|
func prepareSchema(db *sqlx.DB) error {
|
||||||
_, err := c.db.Exec(`CREATE TABLE IF NOT EXISTS http_requests (
|
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS http_requests (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
proto TEXT,
|
proto TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
@ -77,7 +73,7 @@ func (c Client) prepareSchema() error {
|
|||||||
return fmt.Errorf("could not create http_requests table: %v", err)
|
return fmt.Errorf("could not create http_requests table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.db.Exec(`CREATE TABLE IF NOT EXISTS http_responses (
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS http_responses (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
|
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
|
||||||
proto TEXT,
|
proto TEXT,
|
||||||
@ -90,7 +86,7 @@ func (c Client) prepareSchema() error {
|
|||||||
return fmt.Errorf("could not create http_responses table: %v", err)
|
return fmt.Errorf("could not create http_responses table: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.db.Exec(`CREATE TABLE IF NOT EXISTS http_headers (
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS http_headers (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
|
req_id INTEGER REFERENCES http_requests(id) ON DELETE CASCADE,
|
||||||
res_id INTEGER REFERENCES http_responses(id) ON DELETE CASCADE,
|
res_id INTEGER REFERENCES http_responses(id) ON DELETE CASCADE,
|
||||||
@ -104,9 +100,16 @@ func (c Client) prepareSchema() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close uses the underlying database.
|
// Close uses the underlying database if it's open.
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
return c.db.Close()
|
if c.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.db.Close(); err != nil {
|
||||||
|
return fmt.Errorf("sqlite: could not close database: %v", err)
|
||||||
|
}
|
||||||
|
c.db = nil
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqFieldToColumnMap = map[string]string{
|
var reqFieldToColumnMap = map[string]string{
|
||||||
@ -136,6 +139,10 @@ func (c *Client) FindRequestLogs(
|
|||||||
opts reqlog.FindRequestsOptions,
|
opts reqlog.FindRequestsOptions,
|
||||||
scope *scope.Scope,
|
scope *scope.Scope,
|
||||||
) (reqLogs []reqlog.Request, err error) {
|
) (reqLogs []reqlog.Request, err error) {
|
||||||
|
if c.db == nil {
|
||||||
|
return nil, reqlog.ErrNoProject
|
||||||
|
}
|
||||||
|
|
||||||
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
|
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
|
||||||
|
|
||||||
reqQuery := sq.
|
reqQuery := sq.
|
||||||
@ -178,6 +185,9 @@ func (c *Client) FindRequestLogs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FindRequestLogByID(ctx context.Context, id int64) (reqlog.Request, error) {
|
func (c *Client) FindRequestLogByID(ctx context.Context, id int64) (reqlog.Request, error) {
|
||||||
|
if c.db == nil {
|
||||||
|
return reqlog.Request{}, reqlog.ErrNoProject
|
||||||
|
}
|
||||||
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
|
httpReqLogsQuery := parseHTTPRequestLogsQuery(ctx)
|
||||||
|
|
||||||
reqQuery := sq.
|
reqQuery := sq.
|
||||||
@ -218,6 +228,9 @@ func (c *Client) AddRequestLog(
|
|||||||
body []byte,
|
body []byte,
|
||||||
timestamp time.Time,
|
timestamp time.Time,
|
||||||
) (*reqlog.Request, error) {
|
) (*reqlog.Request, error) {
|
||||||
|
if c.db == nil {
|
||||||
|
return nil, reqlog.ErrNoProject
|
||||||
|
}
|
||||||
|
|
||||||
reqLog := &reqlog.Request{
|
reqLog := &reqlog.Request{
|
||||||
Request: req,
|
Request: req,
|
||||||
@ -289,6 +302,10 @@ func (c *Client) AddResponseLog(
|
|||||||
body []byte,
|
body []byte,
|
||||||
timestamp time.Time,
|
timestamp time.Time,
|
||||||
) (*reqlog.Response, error) {
|
) (*reqlog.Response, error) {
|
||||||
|
if c.db == nil {
|
||||||
|
return nil, reqlog.ErrNoProject
|
||||||
|
}
|
||||||
|
|
||||||
resLog := &reqlog.Response{
|
resLog := &reqlog.Response{
|
||||||
RequestID: reqID,
|
RequestID: reqID,
|
||||||
Response: res,
|
Response: res,
|
||||||
@ -495,3 +512,7 @@ func (c *Client) queryHeaders(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) IsOpen() bool {
|
||||||
|
return c.db != nil
|
||||||
|
}
|
||||||
|
135
pkg/proj/proj.go
Normal file
135
pkg/proj/proj.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package proj
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dstotijn/hetty/pkg/db/sqlite"
|
||||||
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service is used for managing projects.
|
||||||
|
type Service struct {
|
||||||
|
dbPath string
|
||||||
|
db *sqlite.Client
|
||||||
|
name string
|
||||||
|
|
||||||
|
Scope *scope.Scope
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
Name string
|
||||||
|
IsActive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoProject = errors.New("proj: no open project")
|
||||||
|
ErrInvalidName = errors.New("proj: invalid name, must be alphanumeric or whitespace chars")
|
||||||
|
)
|
||||||
|
|
||||||
|
var nameRegexp = regexp.MustCompile(`^[\w\d\s]+$`)
|
||||||
|
|
||||||
|
// NewService returns a new Service.
|
||||||
|
func NewService(dbPath string) (*Service, error) {
|
||||||
|
// Create directory for DBs if it doesn't exist yet.
|
||||||
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
|
if err := os.MkdirAll(dbPath, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("proj: could not create project directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
dbPath: dbPath,
|
||||||
|
db: &sqlite.Client{},
|
||||||
|
Scope: scope.New(nil),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the currently open project database (if there is one).
|
||||||
|
func (svc *Service) Close() error {
|
||||||
|
if err := svc.db.Close(); err != nil {
|
||||||
|
return fmt.Errorf("proj: could not close project: %v", err)
|
||||||
|
}
|
||||||
|
svc.name = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a project database file from disk (if there is one).
|
||||||
|
func (svc *Service) Delete(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return errors.New("proj: name cannot be empty")
|
||||||
|
}
|
||||||
|
if svc.name == name {
|
||||||
|
return fmt.Errorf("proj: project (%v) is active", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(filepath.Join(svc.dbPath, name+".db")); err != nil {
|
||||||
|
return fmt.Errorf("proj: could not remove database file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database returns the currently open database. If no database is open, it will
|
||||||
|
// return `nil`.
|
||||||
|
func (svc *Service) Database() *sqlite.Client {
|
||||||
|
return svc.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens a database identified with `name`. If a database with this
|
||||||
|
// identifier doesn't exist yet, it will be automatically created.
|
||||||
|
func (svc *Service) Open(name string) (Project, error) {
|
||||||
|
if !nameRegexp.MatchString(name) {
|
||||||
|
return Project{}, ErrInvalidName
|
||||||
|
}
|
||||||
|
if err := svc.db.Close(); err != nil {
|
||||||
|
return Project{}, fmt.Errorf("proj: could not close previously open database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(svc.dbPath, name+".db")
|
||||||
|
|
||||||
|
err := svc.db.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return Project{}, fmt.Errorf("proj: could not open database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.name = name
|
||||||
|
|
||||||
|
return Project{
|
||||||
|
Name: name,
|
||||||
|
IsActive: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) ActiveProject() (Project, error) {
|
||||||
|
if !svc.db.IsOpen() {
|
||||||
|
return Project{}, ErrNoProject
|
||||||
|
}
|
||||||
|
|
||||||
|
return Project{
|
||||||
|
Name: svc.name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *Service) Projects() ([]Project, error) {
|
||||||
|
files, err := ioutil.ReadDir(svc.dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("proj: could not read projects directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projects := make([]Project, len(files))
|
||||||
|
for i, file := range files {
|
||||||
|
projName := strings.TrimSuffix(file.Name(), ".db")
|
||||||
|
projects[i] = Project{
|
||||||
|
Name: projName,
|
||||||
|
IsActive: svc.name == projName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects, nil
|
||||||
|
}
|
@ -83,13 +83,13 @@ func LoadOrCreateCA(caKeyFile, caCertFile string) (*x509.Certificate, *rsa.Priva
|
|||||||
keyDir, _ := filepath.Split(caKeyFile)
|
keyDir, _ := filepath.Split(caKeyFile)
|
||||||
if keyDir != "" {
|
if keyDir != "" {
|
||||||
if _, err := os.Stat(keyDir); os.IsNotExist(err) {
|
if _, err := os.Stat(keyDir); os.IsNotExist(err) {
|
||||||
os.Mkdir(keyDir, 0755)
|
os.MkdirAll(keyDir, 0755)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keyDir, _ = filepath.Split(caCertFile)
|
keyDir, _ = filepath.Split(caCertFile)
|
||||||
if keyDir != "" {
|
if keyDir != "" {
|
||||||
if _, err := os.Stat("keyDir"); os.IsNotExist(err) {
|
if _, err := os.Stat("keyDir"); os.IsNotExist(err) {
|
||||||
os.Mkdir(keyDir, 0755)
|
os.MkdirAll(keyDir, 0755)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,6 +8,10 @@ import (
|
|||||||
"github.com/dstotijn/hetty/pkg/scope"
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type RepositoryProvider interface {
|
||||||
|
Repository() Repository
|
||||||
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
FindRequestLogs(ctx context.Context, opts FindRequestsOptions, scope *scope.Scope) ([]Request, error)
|
FindRequestLogs(ctx context.Context, opts FindRequestsOptions, scope *scope.Scope) ([]Request, error)
|
||||||
FindRequestLogByID(ctx context.Context, id int64) (Request, error)
|
FindRequestLogByID(ctx context.Context, id int64) (Request, error)
|
||||||
|
@ -19,7 +19,10 @@ type contextKey int
|
|||||||
|
|
||||||
const LogBypassedKey contextKey = 0
|
const LogBypassedKey contextKey = 0
|
||||||
|
|
||||||
var ErrRequestNotFound = errors.New("reqlog: request not found")
|
var (
|
||||||
|
ErrRequestNotFound = errors.New("reqlog: request not found")
|
||||||
|
ErrNoProject = errors.New("reqlog: no project")
|
||||||
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
ID int64
|
ID int64
|
||||||
@ -133,6 +136,11 @@ func (svc *Service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
|
|||||||
}
|
}
|
||||||
|
|
||||||
reqLog, err := svc.addRequest(req.Context(), *clone, body, now)
|
reqLog, err := svc.addRequest(req.Context(), *clone, body, now)
|
||||||
|
if err == ErrNoProject {
|
||||||
|
ctx := context.WithValue(req.Context(), LogBypassedKey, true)
|
||||||
|
*req = *req.WithContext(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ERROR] Could not store request log: %v", err)
|
log.Printf("[ERROR] Could not store request log: %v", err)
|
||||||
return
|
return
|
||||||
|
Reference in New Issue
Block a user