Compare commits

..

11 Commits

107 changed files with 6779 additions and 12691 deletions

View File

@ -42,6 +42,33 @@ brews:
test: |
system "#{bin}/hetty -v"
snapcrafts:
- publish: true
summary: An HTTP toolkit for security research.
description: |
Hetty is an HTTP toolkit for security research. It aims to become an open
source alternative to commercial software like Burp Suite Pro, with
powerful features tailored to the needs of the infosec and bug bounty
community.
grade: stable
confinement: strict
license: MIT
apps:
hetty:
command: hetty
plugs: ["network", "network-bind"]
scoop:
bucket:
owner: hettysoft
name: scoop-bucket
commit_author:
name: David Stotijn
email: dstotijn@gmail.com
homepage: https://hetty.xyz
description: An HTTP toolkit for security research.
license: MIT
checksum:
name_template: "checksums.txt"
@ -53,4 +80,4 @@ changelog:
filters:
exclude:
- "^docs:"
- "^test:"
- "^test:"

View File

@ -1,10 +1,8 @@
<h1>
<img src="https://hetty.xyz/img/hetty_light.svg#gh-light-mode-only" width="240"/>
<img src="https://hetty.xyz/img/hetty_dark.svg#gh-dark-mode-only" width="240"/>
</h1>
<img src="https://user-images.githubusercontent.com/983924/156430531-6193e187-7400-436b-81c6-f86862783ea5.svg#gh-light-mode-only" width="240"/>
<img src="https://user-images.githubusercontent.com/983924/156430660-9d5bd555-dcfd-47e2-ba70-54294c20c1b4.svg#gh-dark-mode-only" width="240"/>
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https://actions-badge.atrox.dev/dstotijn/hetty/badge&label=build&logo=none&color=25ae8f)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
[![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fdstotijn%2Fhetty%2Fbadge%3Fref%3Dmain&label=build&color=24ae8f)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=25ae8f)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-25ae8f)](https://hetty.xyz/)
@ -19,6 +17,7 @@ features tailored to the needs of the infosec and bug bounty community.
- Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search
- HTTP client for manually creating/editing requests, and replay proxied requests
- Intercept requests and responses for manual review (edit, send/receive, cancel)
- Scope support, to help keep work organized
- Easy-to-use web based admin interface
- Project based database storage, to help keep work organized
@ -55,7 +54,7 @@ sudo snap install hetty
#### Windows
```sh
scoop bucket add hettysoft https://github.com/hettysoft/scoop.git
scoop bucket add hettysoft https://github.com/hettysoft/scoop-bucket.git
scoop install hettysoft/hetty
```

View File

@ -17,7 +17,12 @@
"prettier/prettier": ["error"],
"@next/next/no-css-tags": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"ignoreRestSiblings": true
}
],
"import/default": "off",

View File

@ -1,11 +1,12 @@
import AltRouteIcon from "@mui/icons-material/AltRoute";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import FolderIcon from "@mui/icons-material/Folder";
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
import HomeIcon from "@mui/icons-material/Home";
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import MenuIcon from "@mui/icons-material/Menu";
import SendIcon from "@mui/icons-material/Send";
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
import {
Theme,
useTheme,
@ -19,6 +20,7 @@ import {
CSSObject,
Box,
ListItemText,
Badge,
} from "@mui/material";
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
import MuiDrawer from "@mui/material/Drawer";
@ -28,15 +30,18 @@ import Link from "next/link";
import React, { useState } from "react";
import { useActiveProject } from "lib/ActiveProjectContext";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
export enum Page {
Home,
GetStarted,
Intercept,
Projects,
ProxySetup,
ProxyLogs,
Sender,
Scope,
Settings,
}
const drawerWidth = 240;
@ -135,6 +140,7 @@ interface Props {
export function Layout({ title, page, children }: Props): JSX.Element {
const activeProject = useActiveProject();
const interceptedRequests = useInterceptedRequests();
const theme = useTheme();
const [open, setOpen] = useState(false);
@ -204,12 +210,24 @@ export function Layout({ title, page, children }: Props): JSX.Element {
</Link>
<Link href="/proxy/logs" passHref>
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
<Tooltip title="Proxy">
<Tooltip title="Proxy logs">
<ListItemIcon>
<SettingsEthernetIcon />
<FormatListBulletedIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Proxy" />
<ListItemText primary="Logs" />
</ListItemButton>
</Link>
<Link href="/proxy/intercept" passHref>
<ListItemButton key="proxyIntercept" disabled={!activeProject} selected={page === Page.Intercept}>
<Tooltip title="Proxy intercept">
<ListItemIcon>
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
</ListItemIcon>
</Tooltip>
<ListItemText primary="Intercept" />
</ListItemButton>
</Link>
<Link href="/sender" passHref>

View File

@ -0,0 +1,366 @@
import CancelIcon from "@mui/icons-material/Cancel";
import DownloadIcon from "@mui/icons-material/Download";
import SendIcon from "@mui/icons-material/Send";
import SettingsIcon from "@mui/icons-material/Settings";
import { Alert, Box, Button, CircularProgress, IconButton, Tooltip, Typography } from "@mui/material";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import Link from "lib/components/Link";
import RequestTabs from "lib/components/RequestTabs";
import ResponseStatus from "lib/components/ResponseStatus";
import ResponseTabs from "lib/components/ResponseTabs";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
HttpProtocol,
HttpRequest,
useCancelRequestMutation,
useCancelResponseMutation,
useGetInterceptedRequestQuery,
useModifyRequestMutation,
useModifyResponseMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
function EditRequest(): JSX.Element {
const router = useRouter();
const interceptedRequests = useInterceptedRequests();
useEffect(() => {
// If there's no request selected and there are pending reqs, navigate to
// the first one in the list. This helps you quickly review/handle reqs
// without having to manually select the next one in the requests table.
if (router.isReady && !router.query.id && interceptedRequests?.length) {
const req = interceptedRequests[0];
router.replace(`/proxy/intercept?id=${req.id}`);
}
}, [router, interceptedRequests]);
const reqId = router.query.id as string | undefined;
const [method, setMethod] = useState(HttpMethod.Get);
const [url, setURL] = useState("");
const [proto, setProto] = useState(HttpProto.Http20);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [reqHeaders, setReqHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [resHeaders, setResHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [reqBody, setReqBody] = useState("");
const [resBody, setResBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => {
setQueryParams((prev) => {
const updated = updateKeyPairItem(key, value, idx, prev);
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleQueryParamDelete = (idx: number) => {
setQueryParams((prev) => {
const updated = prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length));
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleReqHeaderChange = (key: string, value: string, idx: number) => {
setReqHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleReqHeaderDelete = (idx: number) => {
setReqHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleResHeaderChange = (key: string, value: string, idx: number) => {
setResHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleResHeaderDelete = (idx: number) => {
setResHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleURLChange = (url: string) => {
setURL(url);
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex === -1) {
setQueryParams([{ key: "", value: "" }]);
return;
}
const newQueryParams = queryParamsFromURL(url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
};
const getReqResult = useGetInterceptedRequestQuery({
variables: { id: reqId as string },
skip: reqId === undefined,
onCompleted: ({ interceptedRequest }) => {
if (!interceptedRequest) {
return;
}
setURL(interceptedRequest.url);
setMethod(interceptedRequest.method);
setReqBody(interceptedRequest.body || "");
const newQueryParams = queryParamsFromURL(interceptedRequest.url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newReqHeaders = sortKeyValuePairs(interceptedRequest.headers || []);
setReqHeaders([...newReqHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResBody(interceptedRequest.response?.body || "");
const newResHeaders = sortKeyValuePairs(interceptedRequest.response?.headers || []);
setResHeaders([...newResHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
},
});
const interceptedReq =
reqId && !getReqResult?.data?.interceptedRequest?.response ? getReqResult?.data?.interceptedRequest : undefined;
const interceptedRes = reqId ? getReqResult?.data?.interceptedRequest?.response : undefined;
const [modifyRequest, modifyReqResult] = useModifyRequestMutation();
const [cancelRequest, cancelReqResult] = useCancelRequestMutation();
const [modifyResponse, modifyResResult] = useModifyResponseMutation();
const [cancelResponse, cancelResResult] = useCancelResponseMutation();
const onActionCompleted = () => {
setURL("");
setMethod(HttpMethod.Get);
setReqBody("");
setQueryParams([]);
setReqHeaders([]);
router.replace(`/proxy/intercept`);
};
const handleFormSubmit: React.FormEventHandler = (e) => {
e.preventDefault();
if (interceptedReq) {
modifyRequest({
variables: {
request: {
id: interceptedReq.id,
url,
method,
proto: httpProtoMap.get(proto) || HttpProtocol.Http20,
headers: reqHeaders.filter((kv) => kv.key !== ""),
body: reqBody || undefined,
},
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
}
if (interceptedRes) {
modifyResponse({
variables: {
response: {
requestID: interceptedRes.id,
proto: interceptedRes.proto, // TODO: Allow modifying
statusCode: interceptedRes.statusCode, // TODO: Allow modifying
statusReason: interceptedRes.statusReason, // TODO: Allow modifying
headers: resHeaders.filter((kv) => kv.key !== ""),
body: resBody || undefined,
},
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
}
};
const handleReqCancelClick = () => {
if (!interceptedReq) {
return;
}
cancelRequest({
variables: {
id: interceptedReq.id,
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
};
const handleResCancelClick = () => {
if (!interceptedRes) {
return;
}
cancelResponse({
variables: {
requestID: interceptedRes.id,
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
};
return (
<Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar
method={method}
onMethodChange={interceptedReq ? setMethod : undefined}
url={url.toString()}
onUrlChange={interceptedReq ? handleURLChange : undefined}
proto={proto}
onProtoChange={interceptedReq ? setProto : undefined}
sx={{ flex: "1 auto" }}
/>
{!interceptedRes && (
<>
<Button
variant="contained"
disableElevation
type="submit"
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
startIcon={modifyReqResult.loading ? <CircularProgress size={22} /> : <SendIcon />}
>
Send
</Button>
<Button
variant="contained"
color="error"
disableElevation
onClick={handleReqCancelClick}
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
startIcon={cancelReqResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
>
Cancel
</Button>
</>
)}
{interceptedRes && (
<>
<Button
variant="contained"
disableElevation
type="submit"
disabled={modifyResResult.loading || cancelResResult.loading}
endIcon={modifyResResult.loading ? <CircularProgress size={22} /> : <DownloadIcon />}
>
Receive
</Button>
<Button
variant="contained"
color="error"
disableElevation
onClick={handleResCancelClick}
disabled={modifyResResult.loading || cancelResResult.loading}
endIcon={cancelResResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
>
Cancel
</Button>
</>
)}
<Tooltip title="Intercept settings">
<IconButton LinkComponent={Link} href="/settings#intercept">
<SettingsIcon />
</IconButton>
</Tooltip>
</Box>
{modifyReqResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{modifyReqResult.error.message}
</Alert>
)}
{cancelReqResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{cancelReqResult.error.message}
</Alert>
)}
</Box>
<Box flex="1 auto" overflow="scroll">
{interceptedReq && (
<Box sx={{ height: "100%", pb: 2 }}>
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
Request
</Typography>
<RequestTabs
queryParams={interceptedReq ? queryParams : []}
headers={interceptedReq ? reqHeaders : []}
body={reqBody}
onQueryParamChange={interceptedReq ? handleQueryParamChange : undefined}
onQueryParamDelete={interceptedReq ? handleQueryParamDelete : undefined}
onHeaderChange={interceptedReq ? handleReqHeaderChange : undefined}
onHeaderDelete={interceptedReq ? handleReqHeaderDelete : undefined}
onBodyChange={interceptedReq ? setReqBody : undefined}
/>
</Box>
)}
{interceptedRes && (
<Box sx={{ height: "100%", pb: 2 }}>
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
Response
</Typography>
{interceptedRes && (
<Box sx={{ float: "right", mt: 0.2 }}>
<ResponseStatus
proto={interceptedRes.proto}
statusCode={interceptedRes.statusCode}
statusReason={interceptedRes.statusReason}
/>
</Box>
)}
</Box>
<ResponseTabs
headers={interceptedRes ? resHeaders : []}
body={resBody}
onHeaderChange={interceptedRes ? handleResHeaderChange : undefined}
onHeaderDelete={interceptedRes ? handleResHeaderDelete : undefined}
onBodyChange={interceptedRes ? setResBody : undefined}
hasResponse={interceptedRes !== undefined && interceptedRes !== null}
/>
</Box>
)}
</Box>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
import EditRequest from "./EditRequest";
import Requests from "./Requests";
import SplitPane from "lib/components/SplitPane";
export default function Sender(): JSX.Element {
return (
<Box sx={{ height: "100%", position: "relative" }}>
<SplitPane split="horizontal" size="70%">
<Box sx={{ width: "100%", pt: "0.75rem" }}>
<EditRequest />
</Box>
<Box sx={{ height: "100%", overflow: "scroll" }}>
<Requests />
</Box>
</SplitPane>
</Box>
);
}

View File

@ -0,0 +1,33 @@
import { Box, Paper, Typography } from "@mui/material";
import { useRouter } from "next/router";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import RequestsTable from "lib/components/RequestsTable";
function Requests(): JSX.Element {
const interceptedRequests = useInterceptedRequests();
const router = useRouter();
const activeId = router.query.id as string | undefined;
const handleRowClick = (id: string) => {
router.push(`/proxy/intercept?id=${id}`);
};
return (
<Box>
{interceptedRequests && interceptedRequests.length > 0 && (
<RequestsTable requests={interceptedRequests} onRowClick={handleRowClick} activeRowId={activeId} />
)}
<Box sx={{ mt: 2, height: "100%" }}>
{interceptedRequests?.length === 0 && (
<Paper variant="centered">
<Typography>No pending intercepted requests.</Typography>
</Paper>
)}
</Box>
</Box>
);
}
export default Requests;

View File

@ -0,0 +1,5 @@
mutation CancelRequest($id: ID!) {
cancelRequest(id: $id) {
success
}
}

View File

@ -0,0 +1,5 @@
mutation CancelResponse($requestID: ID!) {
cancelResponse(requestID: $requestID) {
success
}
}

View File

@ -0,0 +1,24 @@
query GetInterceptedRequest($id: ID!) {
interceptedRequest(id: $id) {
id
url
method
proto
headers {
key
value
}
body
response {
id
proto
statusCode
statusReason
headers {
key
value
}
body
}
}
}

View File

@ -0,0 +1,5 @@
mutation ModifyRequest($request: ModifyRequestInput!) {
modifyRequest(request: $request) {
success
}
}

View File

@ -0,0 +1,5 @@
mutation ModifyResponse($response: ModifyResponseInput!) {
modifyResponse(response: $response) {
success
}
}

View File

@ -2,6 +2,7 @@ import CloseIcon from "@mui/icons-material/Close";
import DeleteIcon from "@mui/icons-material/Delete";
import DescriptionIcon from "@mui/icons-material/Description";
import LaunchIcon from "@mui/icons-material/Launch";
import SettingsIcon from "@mui/icons-material/Settings";
import { Alert } from "@mui/lab";
import {
Avatar,
@ -29,6 +30,7 @@ import React, { useState } from "react";
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
import Link, { NextLinkComposed } from "lib/components/Link";
import {
ProjectsQuery,
useCloseProjectMutation,
@ -179,6 +181,11 @@ function ProjectList(): JSX.Element {
{project.name} {project.isActive && <em>(Active)</em>}
</ListItemText>
<ListItemSecondaryAction>
<Tooltip title="Project settings">
<IconButton LinkComponent={Link} href="/settings" disabled={!project.isActive}>
<SettingsIcon />
</IconButton>
</Tooltip>
{project.isActive && (
<Tooltip title="Close project">
<IconButton onClick={() => closeProject()}>

View File

@ -0,0 +1,15 @@
query ActiveProject {
activeProject {
id
name
isActive
settings {
intercept {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}
}
}

View File

@ -0,0 +1,61 @@
import AltRouteIcon from "@mui/icons-material/AltRoute";
import DeleteIcon from "@mui/icons-material/Delete";
import { Alert } from "@mui/lab";
import { Badge, Button, IconButton, Tooltip } from "@mui/material";
import Link from "next/link";
import { useActiveProject } from "lib/ActiveProjectContext";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
import { HttpRequestLogsDocument, useClearHttpRequestLogMutation } from "lib/graphql/generated";
function Actions(): JSX.Element {
const activeProject = useActiveProject();
const interceptedRequests = useInterceptedRequests();
const [clearHTTPRequestLog, clearLogsResult] = useClearHttpRequestLogMutation({
refetchQueries: [{ query: HttpRequestLogsDocument }],
});
const clearHTTPConfirmationDialog = useConfirmationDialog();
return (
<div>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
{clearLogsResult.error && <Alert severity="error">Failed to clear HTTP logs: {clearLogsResult.error}</Alert>}
{(activeProject?.settings.intercept.requestsEnabled || activeProject?.settings.intercept.responsesEnabled) && (
<Link href="/proxy/intercept/?id=" passHref>
<Button
variant="contained"
disabled={interceptedRequests === null || interceptedRequests.length === 0}
color="primary"
component="a"
size="large"
startIcon={
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
}
sx={{ mr: 1 }}
>
Review Intercepted
</Button>
</Link>
)}
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
);
}
export default Actions;

View File

@ -14,6 +14,7 @@ import {
import { useRouter } from "next/router";
import { useState } from "react";
import Actions from "./Actions";
import LogDetail from "./LogDetail";
import Search from "./Search";
@ -94,7 +95,14 @@ export function RequestLogs(): JSX.Element {
return (
<Box display="flex" flexDirection="column" height="100%">
<Search />
<Box display="flex">
<Box flex="1 auto">
<Search />
</Box>
<Box pt={0.5}>
<Actions />
</Box>
</Box>
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
<SplitPane split="horizontal" size={"40%"}>
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>

View File

@ -1,4 +1,3 @@
import DeleteIcon from "@mui/icons-material/Delete";
import FilterListIcon from "@mui/icons-material/FilterList";
import SearchIcon from "@mui/icons-material/Search";
import { Alert } from "@mui/lab";
@ -17,11 +16,8 @@ import {
import IconButton from "@mui/material/IconButton";
import React, { useRef, useState } from "react";
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
import {
HttpRequestLogFilterDocument,
HttpRequestLogsDocument,
useClearHttpRequestLogMutation,
useHttpRequestLogFilterQuery,
useSetHttpRequestLogFilterMutation,
} from "lib/graphql/generated";
@ -49,11 +45,6 @@ function Search(): JSX.Element {
},
});
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHttpRequestLogMutation({
refetchQueries: [{ query: HttpRequestLogsDocument }],
});
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLFormElement>(null);
const [filterOpen, setFilterOpen] = useState(false);
@ -81,7 +72,6 @@ function Search(): JSX.Element {
<Box>
<Error prefix="Error fetching filter" error={filterResult.error} />
<Error prefix="Error setting filter" error={setFilterResult.error} />
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
@ -161,21 +151,7 @@ function Search(): JSX.Element {
</Popper>
</Paper>
</ClickAwayListener>
<Box style={{ marginLeft: "auto" }}>
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
</Box>
);
}

View File

@ -1,15 +1,4 @@
import {
Alert,
Box,
BoxProps,
Button,
InputLabel,
FormControl,
MenuItem,
Select,
TextField,
Typography,
} from "@mui/material";
import { Alert, Box, Button, Typography } from "@mui/material";
import { useRouter } from "next/router";
import React, { useState } from "react";
@ -17,76 +6,16 @@ import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
GetSenderRequestQuery,
useCreateOrUpdateSenderRequestMutation,
HttpProtocol,
useGetSenderRequestQuery,
useSendRequestMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
enum HttpProto {
Http10 = "HTTP/1.0",
Http11 = "HTTP/1.1",
Http20 = "HTTP/2.0",
}
const httpProtoMap = new Map([
[HttpProto.Http10, HttpProtocol.Http10],
[HttpProto.Http11, HttpProtocol.Http11],
[HttpProto.Http20, HttpProtocol.Http20],
]);
function updateKeyPairItem(key: string, value: string, idx: number, items: KeyValuePair[]): KeyValuePair[] {
const updated = [...items];
updated[idx] = { key, value };
// Append an empty key-value pair if the last item in the array isn't blank
// anymore.
if (items.length - 1 === idx && items[idx].key === "" && items[idx].value === "") {
updated.push({ key: "", value: "" });
}
return updated;
}
function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
// Note: We don't use the `URL` interface, because we're potentially dealing
// with malformed/incorrect URLs, which would yield TypeErrors when constructed
// via `URL`.
let newURL = url;
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex !== -1) {
newURL = newURL.slice(0, questionMarkIndex);
}
const searchParams = new URLSearchParams();
for (const { key, value } of queryParams.filter(({ key }) => key !== "")) {
searchParams.append(key, value);
}
const rawQueryParams = decodeURI(searchParams.toString());
if (rawQueryParams == "") {
return newURL;
}
return newURL + "?" + rawQueryParams;
}
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
function EditRequest(): JSX.Element {
const router = useRouter();
@ -263,94 +192,4 @@ function EditRequest(): JSX.Element {
);
}
interface UrlBarProps extends BoxProps {
method: HttpMethod;
onMethodChange: (method: HttpMethod) => void;
url: string;
onUrlChange: (url: string) => void;
proto: HttpProto;
onProtoChange: (proto: HttpProto) => void;
}
function UrlBar(props: UrlBarProps) {
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
return (
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
<FormControl>
<InputLabel id="req-method-label">Method</InputLabel>
<Select
labelId="req-method-label"
id="req-method"
value={method}
label="Method"
onChange={(e) => onMethodChange(e.target.value as HttpMethod)}
sx={{
width: "8rem",
".MuiOutlinedInput-notchedOutline": {
borderRightWidth: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderRightWidth: 1,
},
}}
>
{Object.values(HttpMethod).map((method) => (
<MenuItem key={method} value={method}>
{method}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="URL"
placeholder="E.g. “https://example.com/foobar”"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
required
variant="outlined"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: {
".MuiOutlinedInput-notchedOutline": {
borderRadius: 0,
},
},
}}
sx={{ flexGrow: 1 }}
/>
<FormControl>
<InputLabel id="req-proto-label">Protocol</InputLabel>
<Select
labelId="req-proto-label"
id="req-proto"
value={proto}
label="Protocol"
onChange={(e) => onProtoChange(e.target.value as HttpProto)}
sx={{
".MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 1,
},
}}
>
{Object.values(HttpProto).map((proto) => (
<MenuItem key={proto} value={proto}>
{proto}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,307 @@
import { useApolloClient } from "@apollo/client";
import { TabContext, TabPanel } from "@mui/lab";
import TabList from "@mui/lab/TabList";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
FormControlLabel,
FormHelperText,
Snackbar,
Switch,
Tab,
TextField,
TextFieldProps,
Typography,
} from "@mui/material";
import MaterialLink from "@mui/material/Link";
import { SwitchBaseProps } from "@mui/material/internal/SwitchBase";
import { useEffect, useState } from "react";
import { useActiveProject } from "lib/ActiveProjectContext";
import Link from "lib/components/Link";
import { ActiveProjectDocument, useUpdateInterceptSettingsMutation } from "lib/graphql/generated";
import { withoutTypename } from "lib/graphql/omitTypename";
enum TabValue {
Intercept = "intercept",
}
function FilterTextField(props: TextFieldProps): JSX.Element {
return (
<TextField
color="primary"
variant="outlined"
InputProps={{
sx: { fontFamily: "'JetBrains Mono', monospace" },
autoCorrect: "false",
spellCheck: "false",
}}
InputLabelProps={{
shrink: true,
}}
margin="normal"
sx={{ mr: 1 }}
{...props}
/>
);
}
export default function Settings(): JSX.Element {
const client = useApolloClient();
const activeProject = useActiveProject();
const [updateInterceptSettings, updateIntercepSettingsResult] = useUpdateInterceptSettingsMutation({
onCompleted(data) {
client.cache.updateQuery({ query: ActiveProjectDocument }, (cachedData) => ({
activeProject: {
...cachedData.activeProject,
settings: {
...cachedData.activeProject.settings,
intercept: data.updateInterceptSettings,
},
},
}));
setInterceptReqFilter(data.updateInterceptSettings.requestFilter || "");
setInterceptResFilter(data.updateInterceptSettings.responseFilter || "");
setSettingsUpdatedOpen(true);
},
});
const [interceptReqFilter, setInterceptReqFilter] = useState("");
const [interceptResFilter, setInterceptResFilter] = useState("");
useEffect(() => {
setInterceptReqFilter(activeProject?.settings.intercept.requestFilter || "");
}, [activeProject?.settings.intercept.requestFilter]);
useEffect(() => {
setInterceptResFilter(activeProject?.settings.intercept.responseFilter || "");
}, [activeProject?.settings.intercept.responseFilter]);
const handleReqInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) {
e.preventDefault();
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
requestsEnabled: checked,
},
},
});
};
const handleResInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) {
e.preventDefault();
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responsesEnabled: checked,
},
},
});
};
const handleInterceptReqFilter = () => {
if (!activeProject) {
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
requestFilter: interceptReqFilter,
},
},
});
};
const handleInterceptResFilter = () => {
if (!activeProject) {
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responseFilter: interceptResFilter,
},
},
});
};
const [tabValue, setTabValue] = useState(TabValue.Intercept);
const [settingsUpdatedOpen, setSettingsUpdatedOpen] = useState(false);
const handleSettingsUpdatedClose = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setSettingsUpdatedOpen(false);
};
const tabSx = {
textTransform: "none",
};
return (
<Box p={4}>
<Snackbar open={settingsUpdatedOpen} autoHideDuration={3000} onClose={handleSettingsUpdatedClose}>
<Alert onClose={handleSettingsUpdatedClose} severity="info">
Intercept settings have been updated.
</Alert>
</Snackbar>
<Typography variant="h4" sx={{ mb: 2 }}>
Settings
</Typography>
<Typography paragraph sx={{ mb: 4 }}>
Settings allow you to tweak the behaviour of Hettys features.
</Typography>
<Typography variant="h5" sx={{ mb: 2 }}>
Project settings
</Typography>
{!activeProject && (
<Typography paragraph>
There is no project active. To configure project settings, first <Link href="/projects">open a project</Link>.
</Typography>
)}
{activeProject && (
<>
<TabContext value={tabValue}>
<TabList onChange={(_, value) => setTabValue(value)} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tab value={TabValue.Intercept} label="Intercept" sx={tabSx} />
</TabList>
<TabPanel value={TabValue.Intercept} sx={{ px: 0 }}>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Requests
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
disabled={updateIntercepSettingsResult.loading}
onChange={handleReqInterceptEnabled}
checked={activeProject.settings.intercept.requestsEnabled}
/>
}
label="Enable request interception"
labelPlacement="start"
sx={{ display: "inline-block", m: 0 }}
/>
<FormHelperText>
When enabled, incoming HTTP requests to the proxy are stalled for{" "}
<Link href="/proxy/intercept">manual review</Link>.
</FormHelperText>
</FormControl>
<form>
<FormControl sx={{ width: "50%" }}>
<FilterTextField
label="Request filter"
placeholder={`Example: method = "GET" OR url =~ "/foobar"`}
value={interceptReqFilter}
onChange={(e) => setInterceptReqFilter(e.target.value)}
/>
<FormHelperText>
Filter expression to match incoming requests on. When set, only matching requests are intercepted.{" "}
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept?utm_source=hettyapp#request-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText>
</FormControl>
<Button
type="submit"
variant="text"
color="primary"
size="large"
sx={{
mt: 2,
py: 1.8,
}}
onClick={handleInterceptReqFilter}
disabled={updateIntercepSettingsResult.loading}
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
>
Update
</Button>
</form>
<Typography variant="h6" sx={{ mt: 3 }}>
Responses
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
disabled={updateIntercepSettingsResult.loading}
onChange={handleResInterceptEnabled}
checked={activeProject.settings.intercept.responsesEnabled}
/>
}
label="Enable response interception"
labelPlacement="start"
sx={{ display: "inline-block", m: 0 }}
/>
<FormHelperText>
When enabled, HTTP responses received by the proxy are stalled for{" "}
<Link href="/proxy/intercept">manual review</Link>.
</FormHelperText>
</FormControl>
<form>
<FormControl sx={{ width: "50%" }}>
<FilterTextField
label="Response filter"
placeholder={`Example: statusCode =~ "^2" OR body =~ "foobar"`}
value={interceptResFilter}
onChange={(e) => setInterceptResFilter(e.target.value)}
/>
<FormHelperText>
Filter expression to match received responses on. When set, only matching responses are intercepted.{" "}
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept/?utm_source=hettyapp#response-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText>
</FormControl>
<Button
type="submit"
variant="text"
color="primary"
size="large"
sx={{
mt: 2,
py: 1.8,
}}
onClick={handleInterceptResFilter}
disabled={updateIntercepSettingsResult.loading}
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
>
Update
</Button>
</form>
</TabPanel>
</TabContext>
</>
)}
</Box>
);
}

View File

@ -0,0 +1,8 @@
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
updateInterceptSettings(input: $input) {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}

View File

@ -1,6 +1,6 @@
import React, { createContext, useContext } from "react";
import { Project, useProjectsQuery } from "./graphql/generated";
import { Project, useActiveProjectQuery } from "./graphql/generated";
const ActiveProjectContext = createContext<Project | null>(null);
@ -9,8 +9,8 @@ interface Props {
}
export function ActiveProjectProvider({ children }: Props): JSX.Element {
const { data } = useProjectsQuery();
const project = data?.projects.find((project) => project.isActive) || null;
const { data } = useActiveProjectQuery();
const project = data?.activeProject || null;
return <ActiveProjectContext.Provider value={project}>{children}</ActiveProjectContext.Provider>;
}

View File

@ -0,0 +1,22 @@
import React, { createContext, useContext } from "react";
import { GetInterceptedRequestsQuery, useGetInterceptedRequestsQuery } from "./graphql/generated";
const InterceptedRequestsContext = createContext<GetInterceptedRequestsQuery["interceptedRequests"] | null>(null);
interface Props {
children?: React.ReactNode | undefined;
}
export function InterceptedRequestsProvider({ children }: Props): JSX.Element {
const { data } = useGetInterceptedRequestsQuery({
pollInterval: 1000,
});
const reqs = data?.interceptedRequests || null;
return <InterceptedRequestsContext.Provider value={reqs}>{children}</InterceptedRequestsContext.Provider>;
}
export function useInterceptedRequests() {
return useContext(InterceptedRequestsContext);
}

View File

@ -0,0 +1,94 @@
import MuiLink, { LinkProps as MuiLinkProps } from "@mui/material/Link";
import { styled } from "@mui/material/styles";
import clsx from "clsx";
import NextLink, { LinkProps as NextLinkProps } from "next/link";
import { useRouter } from "next/router";
import * as React from "react";
// Add support for the sx prop for consistency with the other branches.
const Anchor = styled("a")({});
interface NextLinkComposedProps
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">,
Omit<NextLinkProps, "href" | "as"> {
to: NextLinkProps["href"];
linkAs?: NextLinkProps["as"];
}
export const NextLinkComposed = React.forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
props,
ref
) {
const { to, linkAs, replace, scroll, shallow, prefetch, locale, ...other } = props;
return (
<NextLink
href={to}
prefetch={prefetch}
as={linkAs}
replace={replace}
scroll={scroll}
shallow={shallow}
passHref
locale={locale}
>
<Anchor ref={ref} {...other} />
</NextLink>
);
});
export type LinkProps = {
activeClassName?: string;
as?: NextLinkProps["as"];
href: NextLinkProps["href"];
linkAs?: NextLinkProps["as"]; // Useful when the as prop is shallow by styled().
noLinkStyle?: boolean;
} & Omit<NextLinkComposedProps, "to" | "linkAs" | "href"> &
Omit<MuiLinkProps, "href">;
// A styled version of the Next.js Link component:
// https://nextjs.org/docs/api-reference/next/link
const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(function Link(props, ref) {
const {
activeClassName = "active",
as,
className: classNameProps,
href,
linkAs: linkAsProp,
locale,
noLinkStyle,
prefetch,
replace,
role, // Link don't have roles.
scroll,
shallow,
...other
} = props;
const router = useRouter();
const pathname = typeof href === "string" ? href : href.pathname;
const className = clsx(classNameProps, {
[activeClassName]: router.pathname === pathname && activeClassName,
});
const isExternal = typeof href === "string" && (href.indexOf("http") === 0 || href.indexOf("mailto:") === 0);
if (isExternal) {
if (noLinkStyle) {
return <Anchor className={className} href={href} ref={ref} {...other} />;
}
return <MuiLink className={className} href={href} ref={ref} {...other} />;
}
const linkAs = linkAsProp || as;
const nextjsProps = { to: href, linkAs, replace, scroll, shallow, prefetch, locale };
if (noLinkStyle) {
return <NextLinkComposed className={className} ref={ref} {...nextjsProps} {...other} />;
}
return <MuiLink component={NextLinkComposed} className={className} ref={ref} {...nextjsProps} {...other} />;
});
export default Link;

View File

@ -2,13 +2,16 @@ import { TabContext, TabList, TabPanel } from "@mui/lab";
import { Box, Paper, Tab, Typography } from "@mui/material";
import React, { useState } from "react";
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
import Editor from "lib/components/Editor";
import { KeyValuePairTable } from "lib/components/KeyValuePair";
import { HttpResponseLog } from "lib/graphql/generated";
interface ResponseTabsProps {
headers: HttpResponseLog["headers"];
body: HttpResponseLog["body"];
headers: KeyValuePair[];
onHeaderChange?: KeyValuePairTableProps["onChange"];
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
body?: string | null;
onBodyChange?: (value: string) => void;
hasResponse: boolean;
}
@ -24,7 +27,7 @@ const reqNotSent = (
);
function ResponseTabs(props: ResponseTabsProps): JSX.Element {
const { headers, body, hasResponse } = props;
const { headers, onHeaderChange, onHeaderDelete, body, onBodyChange, hasResponse } = props;
const [tabValue, setTabValue] = useState(TabValue.Body);
const contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
@ -33,6 +36,8 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
textTransform: "none",
};
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
return (
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
<TabContext value={tabValue}>
@ -43,20 +48,25 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
sx={tabSx}
/>
<Tab
value={TabValue.Headers}
label={"Headers" + (headers.length ? ` (${headers.length})` : "")}
sx={tabSx}
/>
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
</TabList>
</Box>
<Box flex="1 auto" overflow="hidden">
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
{body && <Editor content={body} contentType={contentType} />}
{hasResponse && (
<Editor
content={body || ""}
onChange={(value) => {
onBodyChange && onBodyChange(value || "");
}}
monacoOptions={{ readOnly: onBodyChange === undefined }}
contentType={contentType}
/>
)}
{!hasResponse && reqNotSent}
</TabPanel>
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
{headers.length > 0 && <KeyValuePairTable items={headers} />}
{hasResponse && <KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />}
{!hasResponse && reqNotSent}
</TabPanel>
</Box>

View File

@ -0,0 +1,122 @@
import { Box, BoxProps, FormControl, InputLabel, MenuItem, Select, TextField } from "@mui/material";
import { HttpProtocol } from "lib/graphql/generated";
export enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
export enum HttpProto {
Http10 = "HTTP/1.0",
Http11 = "HTTP/1.1",
Http20 = "HTTP/2.0",
}
export const httpProtoMap = new Map([
[HttpProto.Http10, HttpProtocol.Http10],
[HttpProto.Http11, HttpProtocol.Http11],
[HttpProto.Http20, HttpProtocol.Http20],
]);
interface UrlBarProps extends BoxProps {
method: HttpMethod;
onMethodChange?: (method: HttpMethod) => void;
url: string;
onUrlChange?: (url: string) => void;
proto: HttpProto;
onProtoChange?: (proto: HttpProto) => void;
}
function UrlBar(props: UrlBarProps) {
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
return (
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
<FormControl>
<InputLabel id="req-method-label">Method</InputLabel>
<Select
labelId="req-method-label"
id="req-method"
value={method}
label="Method"
disabled={!onMethodChange}
onChange={(e) => onMethodChange && onMethodChange(e.target.value as HttpMethod)}
sx={{
width: "8rem",
".MuiOutlinedInput-notchedOutline": {
borderRightWidth: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderRightWidth: 1,
},
}}
>
{Object.values(HttpMethod).map((method) => (
<MenuItem key={method} value={method}>
{method}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="URL"
placeholder="E.g. “https://example.com/foobar”"
value={url}
disabled={!onUrlChange}
onChange={(e) => onUrlChange && onUrlChange(e.target.value)}
required
variant="outlined"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: {
".MuiOutlinedInput-notchedOutline": {
borderRadius: 0,
},
},
}}
sx={{ flexGrow: 1 }}
/>
<FormControl>
<InputLabel id="req-proto-label">Protocol</InputLabel>
<Select
labelId="req-proto-label"
id="req-proto"
value={proto}
label="Protocol"
disabled={!onProtoChange}
onChange={(e) => onProtoChange && onProtoChange(e.target.value as HttpProto)}
sx={{
".MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 1,
},
}}
>
{Object.values(HttpProto).map((proto) => (
<MenuItem key={proto} value={proto}>
{proto}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
export default UrlBar;

View File

@ -18,6 +18,16 @@ export type Scalars = {
URL: any;
};
export type CancelRequestResult = {
__typename?: 'CancelRequestResult';
success: Scalars['Boolean'];
};
export type CancelResponseResult = {
__typename?: 'CancelResponseResult';
success: Scalars['Boolean'];
};
export type ClearHttpRequestLogResult = {
__typename?: 'ClearHTTPRequestLogResult';
success: Scalars['Boolean'];
@ -67,6 +77,17 @@ export enum HttpProtocol {
Http20 = 'HTTP20'
}
export type HttpRequest = {
__typename?: 'HttpRequest';
body?: Maybe<Scalars['String']>;
headers: Array<HttpHeader>;
id: Scalars['ID'];
method: HttpMethod;
proto: HttpProtocol;
response?: Maybe<HttpResponse>;
url: Scalars['URL'];
};
export type HttpRequestLog = {
__typename?: 'HttpRequestLog';
body?: Maybe<Scalars['String']>;
@ -90,6 +111,17 @@ export type HttpRequestLogFilterInput = {
searchExpression?: InputMaybe<Scalars['String']>;
};
export type HttpResponse = {
__typename?: 'HttpResponse';
body?: Maybe<Scalars['String']>;
headers: Array<HttpHeader>;
/** Will be the same ID as its related request ID. */
id: Scalars['ID'];
proto: HttpProtocol;
statusCode: Scalars['Int'];
statusReason: Scalars['String'];
};
export type HttpResponseLog = {
__typename?: 'HttpResponseLog';
body?: Maybe<Scalars['String']>;
@ -101,8 +133,47 @@ export type HttpResponseLog = {
statusReason: Scalars['String'];
};
export type InterceptSettings = {
__typename?: 'InterceptSettings';
requestFilter?: Maybe<Scalars['String']>;
requestsEnabled: Scalars['Boolean'];
responseFilter?: Maybe<Scalars['String']>;
responsesEnabled: Scalars['Boolean'];
};
export type ModifyRequestInput = {
body?: InputMaybe<Scalars['String']>;
headers?: InputMaybe<Array<HttpHeaderInput>>;
id: Scalars['ID'];
method: HttpMethod;
modifyResponse?: InputMaybe<Scalars['Boolean']>;
proto: HttpProtocol;
url: Scalars['URL'];
};
export type ModifyRequestResult = {
__typename?: 'ModifyRequestResult';
success: Scalars['Boolean'];
};
export type ModifyResponseInput = {
body?: InputMaybe<Scalars['String']>;
headers?: InputMaybe<Array<HttpHeaderInput>>;
proto: HttpProtocol;
requestID: Scalars['ID'];
statusCode: Scalars['Int'];
statusReason: Scalars['String'];
};
export type ModifyResponseResult = {
__typename?: 'ModifyResponseResult';
success: Scalars['Boolean'];
};
export type Mutation = {
__typename?: 'Mutation';
cancelRequest: CancelRequestResult;
cancelResponse: CancelResponseResult;
clearHTTPRequestLog: ClearHttpRequestLogResult;
closeProject: CloseProjectResult;
createOrUpdateSenderRequest: SenderRequest;
@ -110,11 +181,24 @@ export type Mutation = {
createSenderRequestFromHttpRequestLog: SenderRequest;
deleteProject: DeleteProjectResult;
deleteSenderRequests: DeleteSenderRequestsResult;
modifyRequest: ModifyRequestResult;
modifyResponse: ModifyResponseResult;
openProject?: Maybe<Project>;
sendRequest: SenderRequest;
setHttpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
setScope: Array<ScopeRule>;
setSenderRequestFilter?: Maybe<SenderRequestFilter>;
updateInterceptSettings: InterceptSettings;
};
export type MutationCancelRequestArgs = {
id: Scalars['ID'];
};
export type MutationCancelResponseArgs = {
requestID: Scalars['ID'];
};
@ -138,6 +222,16 @@ export type MutationDeleteProjectArgs = {
};
export type MutationModifyRequestArgs = {
request: ModifyRequestInput;
};
export type MutationModifyResponseArgs = {
response: ModifyResponseInput;
};
export type MutationOpenProjectArgs = {
id: Scalars['ID'];
};
@ -162,11 +256,22 @@ export type MutationSetSenderRequestFilterArgs = {
filter?: InputMaybe<SenderRequestFilterInput>;
};
export type MutationUpdateInterceptSettingsArgs = {
input: UpdateInterceptSettingsInput;
};
export type Project = {
__typename?: 'Project';
id: Scalars['ID'];
isActive: Scalars['Boolean'];
name: Scalars['String'];
settings: ProjectSettings;
};
export type ProjectSettings = {
__typename?: 'ProjectSettings';
intercept: InterceptSettings;
};
export type Query = {
@ -175,6 +280,8 @@ export type Query = {
httpRequestLog?: Maybe<HttpRequestLog>;
httpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
httpRequestLogs: Array<HttpRequestLog>;
interceptedRequest?: Maybe<HttpRequest>;
interceptedRequests: Array<HttpRequest>;
projects: Array<Project>;
scope: Array<ScopeRule>;
senderRequest?: Maybe<SenderRequest>;
@ -187,6 +294,11 @@ export type QueryHttpRequestLogArgs = {
};
export type QueryInterceptedRequestArgs = {
id: Scalars['ID'];
};
export type QuerySenderRequestArgs = {
id: Scalars['ID'];
};
@ -248,6 +360,53 @@ export type SenderRequestInput = {
url: Scalars['URL'];
};
export type UpdateInterceptSettingsInput = {
requestFilter?: InputMaybe<Scalars['String']>;
requestsEnabled: Scalars['Boolean'];
responseFilter?: InputMaybe<Scalars['String']>;
responsesEnabled: Scalars['Boolean'];
};
export type CancelRequestMutationVariables = Exact<{
id: Scalars['ID'];
}>;
export type CancelRequestMutation = { __typename?: 'Mutation', cancelRequest: { __typename?: 'CancelRequestResult', success: boolean } };
export type CancelResponseMutationVariables = Exact<{
requestID: Scalars['ID'];
}>;
export type CancelResponseMutation = { __typename?: 'Mutation', cancelResponse: { __typename?: 'CancelResponseResult', success: boolean } };
export type GetInterceptedRequestQueryVariables = Exact<{
id: Scalars['ID'];
}>;
export type GetInterceptedRequestQuery = { __typename?: 'Query', interceptedRequest?: { __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod, proto: HttpProtocol, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }>, response?: { __typename?: 'HttpResponse', id: string, proto: HttpProtocol, statusCode: number, statusReason: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null } | null };
export type ModifyRequestMutationVariables = Exact<{
request: ModifyRequestInput;
}>;
export type ModifyRequestMutation = { __typename?: 'Mutation', modifyRequest: { __typename?: 'ModifyRequestResult', success: boolean } };
export type ModifyResponseMutationVariables = Exact<{
response: ModifyResponseInput;
}>;
export type ModifyResponseMutation = { __typename?: 'Mutation', modifyResponse: { __typename?: 'ModifyResponseResult', success: boolean } };
export type ActiveProjectQueryVariables = Exact<{ [key: string]: never; }>;
export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } } } | null };
export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>;
@ -353,7 +512,249 @@ export type GetSenderRequestsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSenderRequestsQuery = { __typename?: 'Query', senderRequests: Array<{ __typename?: 'SenderRequest', id: string, url: any, method: HttpMethod, response?: { __typename?: 'HttpResponseLog', id: string, statusCode: number, statusReason: string } | null }> };
export type UpdateInterceptSettingsMutationVariables = Exact<{
input: UpdateInterceptSettingsInput;
}>;
export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } };
export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetInterceptedRequestsQuery = { __typename?: 'Query', interceptedRequests: Array<{ __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod, response?: { __typename?: 'HttpResponse', statusCode: number, statusReason: string } | null }> };
export const CancelRequestDocument = gql`
mutation CancelRequest($id: ID!) {
cancelRequest(id: $id) {
success
}
}
`;
export type CancelRequestMutationFn = Apollo.MutationFunction<CancelRequestMutation, CancelRequestMutationVariables>;
/**
* __useCancelRequestMutation__
*
* To run a mutation, you first call `useCancelRequestMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCancelRequestMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [cancelRequestMutation, { data, loading, error }] = useCancelRequestMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useCancelRequestMutation(baseOptions?: Apollo.MutationHookOptions<CancelRequestMutation, CancelRequestMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CancelRequestMutation, CancelRequestMutationVariables>(CancelRequestDocument, options);
}
export type CancelRequestMutationHookResult = ReturnType<typeof useCancelRequestMutation>;
export type CancelRequestMutationResult = Apollo.MutationResult<CancelRequestMutation>;
export type CancelRequestMutationOptions = Apollo.BaseMutationOptions<CancelRequestMutation, CancelRequestMutationVariables>;
export const CancelResponseDocument = gql`
mutation CancelResponse($requestID: ID!) {
cancelResponse(requestID: $requestID) {
success
}
}
`;
export type CancelResponseMutationFn = Apollo.MutationFunction<CancelResponseMutation, CancelResponseMutationVariables>;
/**
* __useCancelResponseMutation__
*
* To run a mutation, you first call `useCancelResponseMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCancelResponseMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [cancelResponseMutation, { data, loading, error }] = useCancelResponseMutation({
* variables: {
* requestID: // value for 'requestID'
* },
* });
*/
export function useCancelResponseMutation(baseOptions?: Apollo.MutationHookOptions<CancelResponseMutation, CancelResponseMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CancelResponseMutation, CancelResponseMutationVariables>(CancelResponseDocument, options);
}
export type CancelResponseMutationHookResult = ReturnType<typeof useCancelResponseMutation>;
export type CancelResponseMutationResult = Apollo.MutationResult<CancelResponseMutation>;
export type CancelResponseMutationOptions = Apollo.BaseMutationOptions<CancelResponseMutation, CancelResponseMutationVariables>;
export const GetInterceptedRequestDocument = gql`
query GetInterceptedRequest($id: ID!) {
interceptedRequest(id: $id) {
id
url
method
proto
headers {
key
value
}
body
response {
id
proto
statusCode
statusReason
headers {
key
value
}
body
}
}
}
`;
/**
* __useGetInterceptedRequestQuery__
*
* To run a query within a React component, call `useGetInterceptedRequestQuery` and pass it any options that fit your needs.
* When your component renders, `useGetInterceptedRequestQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetInterceptedRequestQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useGetInterceptedRequestQuery(baseOptions: Apollo.QueryHookOptions<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>(GetInterceptedRequestDocument, options);
}
export function useGetInterceptedRequestLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>(GetInterceptedRequestDocument, options);
}
export type GetInterceptedRequestQueryHookResult = ReturnType<typeof useGetInterceptedRequestQuery>;
export type GetInterceptedRequestLazyQueryHookResult = ReturnType<typeof useGetInterceptedRequestLazyQuery>;
export type GetInterceptedRequestQueryResult = Apollo.QueryResult<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>;
export const ModifyRequestDocument = gql`
mutation ModifyRequest($request: ModifyRequestInput!) {
modifyRequest(request: $request) {
success
}
}
`;
export type ModifyRequestMutationFn = Apollo.MutationFunction<ModifyRequestMutation, ModifyRequestMutationVariables>;
/**
* __useModifyRequestMutation__
*
* To run a mutation, you first call `useModifyRequestMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useModifyRequestMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [modifyRequestMutation, { data, loading, error }] = useModifyRequestMutation({
* variables: {
* request: // value for 'request'
* },
* });
*/
export function useModifyRequestMutation(baseOptions?: Apollo.MutationHookOptions<ModifyRequestMutation, ModifyRequestMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ModifyRequestMutation, ModifyRequestMutationVariables>(ModifyRequestDocument, options);
}
export type ModifyRequestMutationHookResult = ReturnType<typeof useModifyRequestMutation>;
export type ModifyRequestMutationResult = Apollo.MutationResult<ModifyRequestMutation>;
export type ModifyRequestMutationOptions = Apollo.BaseMutationOptions<ModifyRequestMutation, ModifyRequestMutationVariables>;
export const ModifyResponseDocument = gql`
mutation ModifyResponse($response: ModifyResponseInput!) {
modifyResponse(response: $response) {
success
}
}
`;
export type ModifyResponseMutationFn = Apollo.MutationFunction<ModifyResponseMutation, ModifyResponseMutationVariables>;
/**
* __useModifyResponseMutation__
*
* To run a mutation, you first call `useModifyResponseMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useModifyResponseMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [modifyResponseMutation, { data, loading, error }] = useModifyResponseMutation({
* variables: {
* response: // value for 'response'
* },
* });
*/
export function useModifyResponseMutation(baseOptions?: Apollo.MutationHookOptions<ModifyResponseMutation, ModifyResponseMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ModifyResponseMutation, ModifyResponseMutationVariables>(ModifyResponseDocument, options);
}
export type ModifyResponseMutationHookResult = ReturnType<typeof useModifyResponseMutation>;
export type ModifyResponseMutationResult = Apollo.MutationResult<ModifyResponseMutation>;
export type ModifyResponseMutationOptions = Apollo.BaseMutationOptions<ModifyResponseMutation, ModifyResponseMutationVariables>;
export const ActiveProjectDocument = gql`
query ActiveProject {
activeProject {
id
name
isActive
settings {
intercept {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}
}
}
`;
/**
* __useActiveProjectQuery__
*
* To run a query within a React component, call `useActiveProjectQuery` and pass it any options that fit your needs.
* When your component renders, `useActiveProjectQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useActiveProjectQuery({
* variables: {
* },
* });
*/
export function useActiveProjectQuery(baseOptions?: Apollo.QueryHookOptions<ActiveProjectQuery, ActiveProjectQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ActiveProjectQuery, ActiveProjectQueryVariables>(ActiveProjectDocument, options);
}
export function useActiveProjectLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ActiveProjectQuery, ActiveProjectQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ActiveProjectQuery, ActiveProjectQueryVariables>(ActiveProjectDocument, options);
}
export type ActiveProjectQueryHookResult = ReturnType<typeof useActiveProjectQuery>;
export type ActiveProjectLazyQueryHookResult = ReturnType<typeof useActiveProjectLazyQuery>;
export type ActiveProjectQueryResult = Apollo.QueryResult<ActiveProjectQuery, ActiveProjectQueryVariables>;
export const CloseProjectDocument = gql`
mutation CloseProject {
closeProject {
@ -982,4 +1383,80 @@ export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHook
}
export type GetSenderRequestsQueryHookResult = ReturnType<typeof useGetSenderRequestsQuery>;
export type GetSenderRequestsLazyQueryHookResult = ReturnType<typeof useGetSenderRequestsLazyQuery>;
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
export const UpdateInterceptSettingsDocument = gql`
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
updateInterceptSettings(input: $input) {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}
`;
export type UpdateInterceptSettingsMutationFn = Apollo.MutationFunction<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>;
/**
* __useUpdateInterceptSettingsMutation__
*
* To run a mutation, you first call `useUpdateInterceptSettingsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateInterceptSettingsMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [updateInterceptSettingsMutation, { data, loading, error }] = useUpdateInterceptSettingsMutation({
* variables: {
* input: // value for 'input'
* },
* });
*/
export function useUpdateInterceptSettingsMutation(baseOptions?: Apollo.MutationHookOptions<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>(UpdateInterceptSettingsDocument, options);
}
export type UpdateInterceptSettingsMutationHookResult = ReturnType<typeof useUpdateInterceptSettingsMutation>;
export type UpdateInterceptSettingsMutationResult = Apollo.MutationResult<UpdateInterceptSettingsMutation>;
export type UpdateInterceptSettingsMutationOptions = Apollo.BaseMutationOptions<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>;
export const GetInterceptedRequestsDocument = gql`
query GetInterceptedRequests {
interceptedRequests {
id
url
method
response {
statusCode
statusReason
}
}
}
`;
/**
* __useGetInterceptedRequestsQuery__
*
* To run a query within a React component, call `useGetInterceptedRequestsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetInterceptedRequestsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetInterceptedRequestsQuery({
* variables: {
* },
* });
*/
export function useGetInterceptedRequestsQuery(baseOptions?: Apollo.QueryHookOptions<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>(GetInterceptedRequestsDocument, options);
}
export function useGetInterceptedRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>(GetInterceptedRequestsDocument, options);
}
export type GetInterceptedRequestsQueryHookResult = ReturnType<typeof useGetInterceptedRequestsQuery>;
export type GetInterceptedRequestsLazyQueryHookResult = ReturnType<typeof useGetInterceptedRequestsLazyQuery>;
export type GetInterceptedRequestsQueryResult = Apollo.QueryResult<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>;

View File

@ -0,0 +1,11 @@
query GetInterceptedRequests {
interceptedRequests {
id
url
method
response {
statusCode
statusReason
}
}
}

View File

@ -8,7 +8,22 @@ function createApolloClient() {
link: new HttpLink({
uri: "/api/graphql/",
}),
cache: new InMemoryCache(),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
interceptedRequests: {
merge(_, incoming) {
return incoming;
},
},
},
},
ProjectSettings: {
merge: true,
},
},
}),
});
}

View File

@ -0,0 +1,16 @@
import { KeyValuePair } from "./components/KeyValuePair";
function updateKeyPairItem(key: string, value: string, idx: number, items: KeyValuePair[]): KeyValuePair[] {
const updated = [...items];
updated[idx] = { key, value };
// Append an empty key-value pair if the last item in the array isn't blank
// anymore.
if (items.length - 1 === idx && items[idx].key === "" && items[idx].value === "") {
updated.push({ key: "", value: "" });
}
return updated;
}
export default updateKeyPairItem;

View File

@ -0,0 +1,28 @@
import { KeyValuePair } from "./components/KeyValuePair";
function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
// Note: We don't use the `URL` interface, because we're potentially dealing
// with malformed/incorrect URLs, which would yield TypeErrors when constructed
// via `URL`.
let newURL = url;
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex !== -1) {
newURL = newURL.slice(0, questionMarkIndex);
}
const searchParams = new URLSearchParams();
for (const { key, value } of queryParams.filter(({ key }) => key !== "")) {
searchParams.append(key, value);
}
const rawQueryParams = decodeURI(searchParams.toString());
if (rawQueryParams == "") {
return newURL;
}
return newURL + "?" + rawQueryParams;
}
export default updateURLQueryParams;

View File

@ -7,6 +7,7 @@ import Head from "next/head";
import React from "react";
import { ActiveProjectProvider } from "lib/ActiveProjectContext";
import { InterceptedRequestsProvider } from "lib/InterceptedRequestsContext";
import { useApollo } from "lib/graphql/useApollo";
import createEmotionCache from "lib/mui/createEmotionCache";
import theme from "lib/mui/theme";
@ -32,10 +33,12 @@ export default function MyApp(props: MyAppProps) {
</Head>
<ApolloProvider client={apolloClient}>
<ActiveProjectProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
<InterceptedRequestsProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</InterceptedRequestsProvider>
</ActiveProjectProvider>
</ApolloProvider>
</CacheProvider>

View File

@ -0,0 +1,12 @@
import { Layout, Page } from "features/Layout";
import Intercept from "features/intercept/components/Intercept";
function ProxyIntercept(): JSX.Element {
return (
<Layout page={Page.Intercept} title="Proxy intercept">
<Intercept />
</Layout>
);
}
export default ProxyIntercept;

View File

@ -0,0 +1,12 @@
import { Layout, Page } from "features/Layout";
import Settings from "features/settings/components/Settings";
function Index(): JSX.Element {
return (
<Layout page={Page.Settings} title="Settings">
<Settings />
</Layout>
);
}
export default Index;

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@ import (
"github.com/dstotijn/hetty/pkg/db/badger"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/sender"
@ -175,16 +176,21 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
Logger: cmd.config.logger.Named("reqlog").Sugar(),
})
interceptService := intercept.NewService(intercept.Config{
Logger: cmd.config.logger.Named("intercept").Sugar(),
})
senderService := sender.NewService(sender.Config{
Repository: badger,
ReqLogService: reqLogService,
})
projService, err := proj.NewService(proj.Config{
Repository: badger,
ReqLogService: reqLogService,
SenderService: senderService,
Scope: scope,
Repository: badger,
InterceptService: interceptService,
ReqLogService: reqLogService,
SenderService: senderService,
Scope: scope,
})
if err != nil {
cmd.config.logger.Fatal("Failed to create new projects service.", zap.Error(err))
@ -201,6 +207,8 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
proxy.UseRequestModifier(reqLogService.RequestModifier)
proxy.UseResponseModifier(reqLogService.ResponseModifier)
proxy.UseRequestModifier(interceptService.RequestModifier)
proxy.UseResponseModifier(interceptService.ResponseModifier)
fsSub, err := fs.Sub(adminContent, "admin")
if err != nil {
@ -231,6 +239,7 @@ func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
adminRouter.Path(gqlEndpoint).Handler(api.HTTPHandler(&api.Resolver{
ProjectService: projService,
RequestLogService: reqLogService,
InterceptService: interceptService,
SenderService: senderService,
}, gqlEndpoint))

12
docs/.gitignore vendored
View File

@ -1,12 +0,0 @@
pids
logs
node_modules
npm-debug.log
coverage/
run
dist
.DS_Store
.nyc_output
.basement
config.local.js
basement_dist

View File

@ -1,21 +0,0 @@
{
"name": "hetty-docs",
"version": "0.1.0",
"description": "An HTTP toolkit for security research.",
"main": "index.js",
"authors": {
"name": "David Stotijn",
"email": "dstotijn@gmail.com"
},
"repository": "github.com/dstotijn/hetty/docs",
"scripts": {
"dev": "vuepress dev src",
"build": "vuepress build src"
},
"license": "MIT",
"devDependencies": {
"markdown-it-imsize": "^2.0.1",
"vuepress": "^1.5.3"
},
"dependencies": {}
}

View File

@ -1,77 +0,0 @@
const { description } = require("../../package");
module.exports = {
port: 3000,
title: "Hetty",
description: description,
head: [
["meta", { name: "theme-color", content: "#30e3b7" }],
["meta", { name: "apple-mobile-web-app-capable", content: "yes" }],
[
"meta",
{ name: "apple-mobile-web-app-status-bar-style", content: "black" },
],
[
"meta",
{
property: "og:title",
content: "Hetty",
},
],
[
"meta",
{
property: "og:description",
content: "An HTTP toolkit for security research.",
},
],
[
"meta",
{
property: "og:image",
content: "https://hetty.xyz/assets/hetty_v0.2.0_header.png",
},
],
],
themeConfig: {
repo: "dstotijn/hetty",
editLinks: true,
docsDir: "docs/src",
editLinkText: "",
lastUpdated: true,
logo: "/assets/logo.png",
nav: [
{
text: "Guide",
link: "/guide/",
},
{
text: "Appendix",
link: "/appendix/",
},
],
sidebar: {
"/guide/": [
{
title: "Guide",
collapsable: false,
children: ["", "getting-started", "modules"],
},
],
"/appendix/": [
{
title: "Appendix",
collapsable: false,
children: [""],
},
],
},
},
plugins: ["@vuepress/plugin-back-to-top", "@vuepress/plugin-medium-zoom"],
markdown: {
toc: { includeLevel: [2] },
extendMarkdown: (md) => {
md.use(require("markdown-it-imsize"));
},
},
};

View File

@ -1,14 +0,0 @@
/**
* Client app enhancement file.
*
* https://v1.vuepress.vuejs.org/guide/basic-config.html#app-level-enhancements
*/
export default ({
Vue, // the version of Vue being used in the VuePress app
options, // the options for the root Vue instance
router, // the router instance for the app
siteData // site metadata
}) => {
// ...apply enhancements for the site.
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,9 +0,0 @@
/**
* Custom Styles here.
*
* refhttps://v1.vuepress.vuejs.org/config/#index-styl
*/
.home .hero img
width 450px
max-width 100%!important

View File

@ -1,11 +0,0 @@
/**
* Custom palette here.
*
* refhttps://v1.vuepress.vuejs.org/zh/config/#palette-styl
*/
$accentColor = #2CC09B
$textColor = #2c3e50
$borderColor = #eaecef
$codeBgColor = #282c34
$badgeTipColor = #2CC09B

View File

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2018-present, Yuxi (Evan) You
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -1,171 +0,0 @@
<template>
<form
id="search-form"
class="algolia-search-wrapper search-box"
role="search"
>
<input
id="algolia-search-input"
class="search-query"
:placeholder="placeholder"
>
</form>
</template>
<script>
export default {
name: 'AlgoliaSearchBox',
props: ['options'],
data () {
return {
placeholder: undefined
}
},
watch: {
$lang (newValue) {
this.update(this.options, newValue)
},
options (newValue) {
this.update(newValue, this.$lang)
}
},
mounted () {
this.initialize(this.options, this.$lang)
this.placeholder = this.$site.themeConfig.searchPlaceholder || ''
},
methods: {
initialize (userOptions, lang) {
Promise.all([
import(/* webpackChunkName: "docsearch" */ 'docsearch.js/dist/cdn/docsearch.min.js'),
import(/* webpackChunkName: "docsearch" */ 'docsearch.js/dist/cdn/docsearch.min.css')
]).then(([docsearch]) => {
docsearch = docsearch.default
const { algoliaOptions = {}} = userOptions
docsearch(Object.assign(
{},
userOptions,
{
inputSelector: '#algolia-search-input',
// #697 Make docsearch work well at i18n mode.
algoliaOptions: Object.assign({
'facetFilters': [`lang:${lang}`].concat(algoliaOptions.facetFilters || [])
}, algoliaOptions),
handleSelected: (input, event, suggestion) => {
const { pathname, hash } = new URL(suggestion.url)
const routepath = pathname.replace(this.$site.base, '/')
const _hash = decodeURIComponent(hash)
this.$router.push(`${routepath}${_hash}`)
}
}
))
})
},
update (options, lang) {
this.$el.innerHTML = '<input id="algolia-search-input" class="search-query">'
this.initialize(options, lang)
}
}
}
</script>
<style lang="stylus">
.algolia-search-wrapper
& > span
vertical-align middle
.algolia-autocomplete
line-height normal
.ds-dropdown-menu
background-color #fff
border 1px solid #999
border-radius 4px
font-size 16px
margin 6px 0 0
padding 4px
text-align left
&:before
border-color #999
[class*=ds-dataset-]
border none
padding 0
.ds-suggestions
margin-top 0
.ds-suggestion
border-bottom 1px solid $borderColor
.algolia-docsearch-suggestion--highlight
color #2c815b
.algolia-docsearch-suggestion
border-color $borderColor
padding 0
.algolia-docsearch-suggestion--category-header
padding 5px 10px
margin-top 0
background $accentColor
color #fff
font-weight 600
.algolia-docsearch-suggestion--highlight
background rgba(255, 255, 255, 0.6)
.algolia-docsearch-suggestion--wrapper
padding 0
.algolia-docsearch-suggestion--title
font-weight 600
margin-bottom 0
color $textColor
.algolia-docsearch-suggestion--subcategory-column
vertical-align top
padding 5px 7px 5px 5px
border-color $borderColor
background #f1f3f5
&:after
display none
.algolia-docsearch-suggestion--subcategory-column-text
color #555
.algolia-docsearch-footer
border-color $borderColor
.ds-cursor .algolia-docsearch-suggestion--content
background-color #e7edf3 !important
color $textColor
@media (min-width: $MQMobile)
.algolia-search-wrapper
.algolia-autocomplete
.algolia-docsearch-suggestion
.algolia-docsearch-suggestion--subcategory-column
float none
width 150px
min-width 150px
display table-cell
.algolia-docsearch-suggestion--content
float none
display table-cell
width 100%
vertical-align top
.ds-dropdown-menu
min-width 515px !important
@media (max-width: $MQMobile)
.algolia-search-wrapper
.ds-dropdown-menu
min-width calc(100vw - 4rem) !important
max-width calc(100vw - 4rem) !important
.algolia-docsearch-suggestion--wrapper
padding 5px 7px 5px 5px !important
.algolia-docsearch-suggestion--subcategory-column
padding 0 !important
background white !important
.algolia-docsearch-suggestion--subcategory-column-text:after
content " > "
font-size 10px
line-height 14.4px
display inline-block
width 5px
margin -3px 3px 0
vertical-align middle
</style>

View File

@ -1,252 +0,0 @@
<template>
<div
class="dropdown-wrapper"
:class="{ open }"
>
<button
class="dropdown-title"
type="button"
:aria-label="dropdownAriaLabel"
@click="handleDropdown"
>
<span class="title">{{ item.text }}</span>
<span
class="arrow down"
/>
</button>
<button
class="mobile-dropdown-title"
type="button"
:aria-label="dropdownAriaLabel"
@click="setOpen(!open)"
>
<span class="title">{{ item.text }}</span>
<span
class="arrow"
:class="open ? 'down' : 'right'"
/>
</button>
<DropdownTransition>
<ul
v-show="open"
class="nav-dropdown"
>
<li
v-for="(subItem, index) in item.items"
:key="subItem.link || index"
class="dropdown-item"
>
<h4 v-if="subItem.type === 'links'">
{{ subItem.text }}
</h4>
<ul
v-if="subItem.type === 'links'"
class="dropdown-subitem-wrapper"
>
<li
v-for="childSubItem in subItem.items"
:key="childSubItem.link"
class="dropdown-subitem"
>
<NavLink
:item="childSubItem"
@focusout="
isLastItemOfArray(childSubItem, subItem.items) &&
isLastItemOfArray(subItem, item.items) &&
setOpen(false)
"
/>
</li>
</ul>
<NavLink
v-else
:item="subItem"
@focusout="isLastItemOfArray(subItem, item.items) && setOpen(false)"
/>
</li>
</ul>
</DropdownTransition>
</div>
</template>
<script>
import NavLink from '@theme/components/NavLink.vue'
import DropdownTransition from '@theme/components/DropdownTransition.vue'
import last from 'lodash/last'
export default {
name: 'DropdownLink',
components: {
NavLink,
DropdownTransition
},
props: {
item: {
required: true
}
},
data () {
return {
open: false
}
},
computed: {
dropdownAriaLabel () {
return this.item.ariaLabel || this.item.text
}
},
watch: {
$route () {
this.open = false
}
},
methods: {
setOpen (value) {
this.open = value
},
isLastItemOfArray (item, array) {
return last(array) === item
},
/**
* Open the dropdown when user tab and click from keyboard.
*
* Use event.detail to detect tab and click from keyboard. Ref: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail
* The Tab + Click is UIEvent > KeyboardEvent, so the detail is 0.
*/
handleDropdown () {
const isTriggerByTab = event.detail === 0
if (isTriggerByTab) this.setOpen(!this.open)
}
}
}
</script>
<style lang="stylus">
.dropdown-wrapper
cursor pointer
.dropdown-title
display block
font-size 0.9rem
font-family inherit
cursor inherit
padding inherit
line-height 1.4rem
background transparent
border none
font-weight 500
color $textColor
&:hover
border-color transparent
.arrow
vertical-align middle
margin-top -1px
margin-left 0.4rem
.mobile-dropdown-title
@extends .dropdown-title
display none
font-weight 600
font-size inherit
&:hover
color $accentColor
.nav-dropdown
.dropdown-item
color inherit
line-height 1.7rem
h4
margin 0.45rem 0 0
border-top 1px solid #eee
padding 1rem 1.5rem 0.45rem 1.25rem
.dropdown-subitem-wrapper
padding 0
list-style none
.dropdown-subitem
font-size 0.9em
a
display block
line-height 1.7rem
position relative
border-bottom none
font-weight 400
margin-bottom 0
padding 0 1.5rem 0 1.25rem
&:hover
color $accentColor
&.router-link-active
color $accentColor
&::after
content ""
width 0
height 0
border-left 5px solid $accentColor
border-top 3px solid transparent
border-bottom 3px solid transparent
position absolute
top calc(50% - 2px)
left 9px
&:first-child h4
margin-top 0
padding-top 0
border-top 0
@media (max-width: $MQMobile)
.dropdown-wrapper
&.open .dropdown-title
margin-bottom 0.5rem
.dropdown-title
display: none
.mobile-dropdown-title
display: block
.nav-dropdown
transition height .1s ease-out
overflow hidden
.dropdown-item
h4
border-top 0
margin-top 0
padding-top 0
h4, & > a
font-size 15px
line-height 2rem
.dropdown-subitem
font-size 14px
padding-left 1rem
@media (min-width: $MQMobile)
.dropdown-wrapper
height 1.8rem
&:hover .nav-dropdown,
&.open .nav-dropdown
// override the inline style.
display block !important
&.open:blur
display none
.nav-dropdown
display none
// Avoid height shaked by clicking
height auto !important
box-sizing border-box;
max-height calc(100vh - 2.7rem)
overflow-y auto
position absolute
top 100%
right 0
background-color #fff
padding 0.6rem 0
border 1px solid #ddd
border-bottom-color #ccc
text-align left
border-radius 0.25rem
white-space nowrap
margin 0
</style>

View File

@ -1,33 +0,0 @@
<template>
<transition
name="dropdown"
@enter="setHeight"
@after-enter="unsetHeight"
@before-leave="setHeight"
>
<slot />
</transition>
</template>
<script>
export default {
name: 'DropdownTransition',
methods: {
setHeight (items) {
// explicitly set height so that it can be transitioned
items.style.height = items.scrollHeight + 'px'
},
unsetHeight (items) {
items.style.height = ''
}
}
}
</script>
<style lang="stylus">
.dropdown-enter, .dropdown-leave-to
height 0 !important
</style>

View File

@ -1,197 +0,0 @@
<template>
<main
class="home"
:aria-labelledby="data.heroText !== null ? 'main-title' : null"
>
<header class="hero">
<h1 v-if="data.heroImage" id="main-title">
<img :src="$withBase(data.heroImage)" :alt="data.heroAlt || 'hero'" />
</h1>
<p v-if="data.tagline !== null" class="description">
{{ data.tagline || $description || "Welcome to your VuePress site" }}
</p>
<p v-if="data.actionText && data.actionLink" class="action">
<NavLink class="action-button" :item="actionLink" />
</p>
</header>
<div v-if="data.features && data.features.length" class="features">
<div
v-for="(feature, index) in data.features"
:key="index"
class="feature"
>
<h2>{{ feature.title }}</h2>
<p>{{ feature.details }}</p>
</div>
</div>
<Content class="theme-default-content custom" />
<div v-if="data.footer" class="footer">
{{ data.footer }}
</div>
</main>
</template>
<script>
import NavLink from "@theme/components/NavLink.vue";
export default {
name: "Home",
components: { NavLink },
computed: {
data() {
return this.$page.frontmatter;
},
actionLink() {
return {
link: this.data.actionLink,
text: this.data.actionText,
};
},
},
};
</script>
<style lang="stylus">
.home {
padding: $navbarHeight 2rem 0;
max-width: $homePageWidth;
margin: 0px auto;
display: block;
.hero {
text-align: center;
img {
max-width: 100%;
max-height: 280px;
display: block;
margin: 3rem auto 1.5rem;
}
h1 {
font-size: 3rem;
}
h1, .description, .action {
margin: 1.8rem auto;
}
.description {
max-width: 35rem;
font-size: 1.6rem;
line-height: 1.3;
color: lighten($textColor, 40%);
}
.action-button {
display: inline-block;
font-size: 1.2rem;
color: #fff;
background-color: $accentColor;
padding: 0.8rem 1.6rem;
border-radius: 4px;
transition: background-color 0.1s ease;
box-sizing: border-box;
border-bottom: 1px solid darken($accentColor, 10%);
&:hover {
background-color: lighten($accentColor, 10%);
}
}
}
.features {
border-top: 1px solid $borderColor;
padding: 1.2rem 0;
margin-top: 2.5rem;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
align-content: stretch;
justify-content: space-between;
}
.feature {
flex-grow: 1;
flex-basis: 30%;
max-width: 30%;
h2 {
font-size: 1.4rem;
font-weight: 500;
border-bottom: none;
padding-bottom: 0;
color: lighten($textColor, 10%);
}
p {
color: lighten($textColor, 25%);
}
}
.footer {
padding: 2.5rem;
border-top: 1px solid $borderColor;
text-align: center;
color: lighten($textColor, 25%);
}
}
@media (max-width: $MQMobile) {
.home {
.features {
flex-direction: column;
}
.feature {
max-width: 100%;
padding: 0 2.5rem;
}
}
}
@media (max-width: $MQMobileNarrow) {
.home {
padding-left: 1.5rem;
padding-right: 1.5rem;
.hero {
img {
max-height: 210px;
margin: 2rem auto 1.2rem;
}
h1 {
font-size: 2rem;
}
h1, .description, .action {
margin: 1.2rem auto;
}
.description {
font-size: 1.2rem;
}
.action-button {
font-size: 1rem;
padding: 0.6rem 1.2rem;
}
}
.feature {
h2 {
font-size: 1.25rem;
}
}
}
}
</style>

View File

@ -1,90 +0,0 @@
<template>
<RouterLink
v-if="isInternal"
class="nav-link"
:to="link"
:exact="exact"
@focusout.native="focusoutAction"
>
{{ item.text }}
</RouterLink>
<a
v-else
:href="link"
class="nav-link external"
:target="target"
:rel="rel"
@focusout="focusoutAction"
>
{{ item.text }}
<OutboundLink v-if="isBlankTarget" />
</a>
</template>
<script>
import { isExternal, isMailto, isTel, ensureExt } from '../util'
export default {
name: 'NavLink',
props: {
item: {
required: true
}
},
computed: {
link () {
return ensureExt(this.item.link)
},
exact () {
if (this.$site.locales) {
return Object.keys(this.$site.locales).some(rootLink => rootLink === this.link)
}
return this.link === '/'
},
isNonHttpURI () {
return isMailto(this.link) || isTel(this.link)
},
isBlankTarget () {
return this.target === '_blank'
},
isInternal () {
return !isExternal(this.link) && !this.isBlankTarget
},
target () {
if (this.isNonHttpURI) {
return null
}
if (this.item.target) {
return this.item.target
}
return isExternal(this.link) ? '_blank' : ''
},
rel () {
if (this.isNonHttpURI) {
return null
}
if (this.item.rel === false) {
return null
}
if (this.item.rel) {
return this.item.rel
}
return this.isBlankTarget ? 'noopener noreferrer' : null
}
},
methods: {
focusoutAction () {
this.$emit('focusout')
}
}
}
</script>

View File

@ -1,156 +0,0 @@
<template>
<nav
v-if="userLinks.length || repoLink"
class="nav-links"
>
<!-- user links -->
<div
v-for="item in userLinks"
:key="item.link"
class="nav-item"
>
<DropdownLink
v-if="item.type === 'links'"
:item="item"
/>
<NavLink
v-else
:item="item"
/>
</div>
<!-- repo link -->
<a
v-if="repoLink"
:href="repoLink"
class="repo-link"
target="_blank"
rel="noopener noreferrer"
>
{{ repoLabel }}
<OutboundLink />
</a>
</nav>
</template>
<script>
import DropdownLink from '@theme/components/DropdownLink.vue'
import { resolveNavLinkItem } from '../util'
import NavLink from '@theme/components/NavLink.vue'
export default {
name: 'NavLinks',
components: {
NavLink,
DropdownLink
},
computed: {
userNav () {
return this.$themeLocaleConfig.nav || this.$site.themeConfig.nav || []
},
nav () {
const { locales } = this.$site
if (locales && Object.keys(locales).length > 1) {
const currentLink = this.$page.path
const routes = this.$router.options.routes
const themeLocales = this.$site.themeConfig.locales || {}
const languageDropdown = {
text: this.$themeLocaleConfig.selectText || 'Languages',
ariaLabel: this.$themeLocaleConfig.ariaLabel || 'Select language',
items: Object.keys(locales).map(path => {
const locale = locales[path]
const text = themeLocales[path] && themeLocales[path].label || locale.lang
let link
// Stay on the current page
if (locale.lang === this.$lang) {
link = currentLink
} else {
// Try to stay on the same page
link = currentLink.replace(this.$localeConfig.path, path)
// fallback to homepage
if (!routes.some(route => route.path === link)) {
link = path
}
}
return { text, link }
})
}
return [...this.userNav, languageDropdown]
}
return this.userNav
},
userLinks () {
return (this.nav || []).map(link => {
return Object.assign(resolveNavLinkItem(link), {
items: (link.items || []).map(resolveNavLinkItem)
})
})
},
repoLink () {
const { repo } = this.$site.themeConfig
if (repo) {
return /^https?:/.test(repo)
? repo
: `https://github.com/${repo}`
}
return null
},
repoLabel () {
if (!this.repoLink) return
if (this.$site.themeConfig.repoLabel) {
return this.$site.themeConfig.repoLabel
}
const repoHost = this.repoLink.match(/^https?:\/\/[^/]+/)[0]
const platforms = ['GitHub', 'GitLab', 'Bitbucket']
for (let i = 0; i < platforms.length; i++) {
const platform = platforms[i]
if (new RegExp(platform, 'i').test(repoHost)) {
return platform
}
}
return 'Source'
}
}
}
</script>
<style lang="stylus">
.nav-links
display inline-block
a
line-height 1.4rem
color inherit
&:hover, &.router-link-active
color $accentColor
.nav-item
position relative
display inline-block
margin-left 1.5rem
line-height 2rem
&:first-child
margin-left 0
.repo-link
margin-left 1.5rem
@media (max-width: $MQMobile)
.nav-links
.nav-item, .repo-link
margin-left 0
@media (min-width: $MQMobile)
.nav-links a
&:hover, &.router-link-active
color $textColor
.nav-item > a:not(.external)
&:hover, &.router-link-active
margin-bottom -2px
border-bottom 2px solid lighten($accentColor, 8%)
</style>

View File

@ -1,162 +0,0 @@
<template>
<header class="navbar">
<SidebarButton @toggle-sidebar="$emit('toggle-sidebar')" />
<RouterLink :to="$localePath" class="home-link">
<img
v-if="$site.themeConfig.logo"
class="logo"
:src="$withBase($site.themeConfig.logo)"
:alt="$siteTitle"
/>
</RouterLink>
<div
class="links"
:style="
linksWrapMaxWidth
? {
'max-width': linksWrapMaxWidth + 'px',
}
: {}
"
>
<AlgoliaSearchBox v-if="isAlgoliaSearch" :options="algolia" />
<SearchBox
v-else-if="
$site.themeConfig.search !== false &&
$page.frontmatter.search !== false
"
/>
<NavLinks class="can-hide" />
</div>
</header>
</template>
<script>
import AlgoliaSearchBox from "@AlgoliaSearchBox";
import SearchBox from "@SearchBox";
import SidebarButton from "@theme/components/SidebarButton.vue";
import NavLinks from "@theme/components/NavLinks.vue";
export default {
name: "Navbar",
components: {
SidebarButton,
NavLinks,
SearchBox,
AlgoliaSearchBox,
},
data() {
return {
linksWrapMaxWidth: null,
};
},
computed: {
algolia() {
return (
this.$themeLocaleConfig.algolia || this.$site.themeConfig.algolia || {}
);
},
isAlgoliaSearch() {
return this.algolia && this.algolia.apiKey && this.algolia.indexName;
},
},
mounted() {
const MOBILE_DESKTOP_BREAKPOINT = 719; // refer to config.styl
const NAVBAR_VERTICAL_PADDING =
parseInt(css(this.$el, "paddingLeft")) +
parseInt(css(this.$el, "paddingRight"));
const handleLinksWrapWidth = () => {
if (document.documentElement.clientWidth < MOBILE_DESKTOP_BREAKPOINT) {
this.linksWrapMaxWidth = null;
} else {
this.linksWrapMaxWidth =
this.$el.offsetWidth -
NAVBAR_VERTICAL_PADDING -
((this.$refs.siteName && this.$refs.siteName.offsetWidth) || 0);
}
};
handleLinksWrapWidth();
window.addEventListener("resize", handleLinksWrapWidth, false);
},
};
function css(el, property) {
// NOTE: Known bug, will return 'auto' if style value is 'auto'
const win = el.ownerDocument.defaultView;
// null means not to return pseudo styles
return win.getComputedStyle(el, null)[property];
}
</script>
<style lang="stylus">
$navbar-vertical-padding = 0.7rem;
$navbar-horizontal-padding = 1.5rem;
.navbar {
padding: $navbar-vertical-padding $navbar-horizontal-padding;
line-height: $navbarHeight - 1.4rem;
a, span, img {
display: inline-block;
}
.logo {
height: $navbarHeight - 1.4rem;
min-width: $navbarHeight - 1.4rem;
margin-right: 0.8rem;
vertical-align: top;
}
.site-name {
font-size: 1.3rem;
font-weight: 600;
color: $textColor;
position: relative;
}
.links {
padding-left: 1.5rem;
box-sizing: border-box;
background-color: white;
white-space: nowrap;
font-size: 0.9rem;
position: absolute;
right: $navbar-horizontal-padding;
top: $navbar-vertical-padding;
display: flex;
.search-box {
flex: 0 0 auto;
vertical-align: top;
}
}
}
@media (max-width: $MQMobile) {
.navbar {
padding-left: 4rem;
.can-hide {
display: none;
}
.links {
padding-left: 1.5rem;
}
.site-name {
width: calc(100vw - 9.4rem);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
</style>

View File

@ -1,31 +0,0 @@
<template>
<main class="page">
<slot name="top" />
<Content class="theme-default-content" />
<PageEdit />
<PageNav v-bind="{ sidebarItems }" />
<slot name="bottom" />
</main>
</template>
<script>
import PageEdit from '@theme/components/PageEdit.vue'
import PageNav from '@theme/components/PageNav.vue'
export default {
components: { PageEdit, PageNav },
props: ['sidebarItems']
}
</script>
<style lang="stylus">
@require '../styles/wrapper.styl'
.page
padding-bottom 2rem
display block
</style>

View File

@ -1,155 +0,0 @@
<template>
<footer class="page-edit">
<div
v-if="editLink"
class="edit-link"
>
<a
:href="editLink"
target="_blank"
rel="noopener noreferrer"
>{{ editLinkText }}</a>
<OutboundLink />
</div>
<div
v-if="lastUpdated"
class="last-updated"
>
<span class="prefix">{{ lastUpdatedText }}:</span>
<span class="time">{{ lastUpdated }}</span>
</div>
</footer>
</template>
<script>
import isNil from 'lodash/isNil'
import { endingSlashRE, outboundRE } from '../util'
export default {
name: 'PageEdit',
computed: {
lastUpdated () {
return this.$page.lastUpdated
},
lastUpdatedText () {
if (typeof this.$themeLocaleConfig.lastUpdated === 'string') {
return this.$themeLocaleConfig.lastUpdated
}
if (typeof this.$site.themeConfig.lastUpdated === 'string') {
return this.$site.themeConfig.lastUpdated
}
return 'Last Updated'
},
editLink () {
const showEditLink = isNil(this.$page.frontmatter.editLink)
? this.$site.themeConfig.editLinks
: this.$page.frontmatter.editLink
const {
repo,
docsDir = '',
docsBranch = 'master',
docsRepo = repo
} = this.$site.themeConfig
if (showEditLink && docsRepo && this.$page.relativePath) {
return this.createEditLink(
repo,
docsRepo,
docsDir,
docsBranch,
this.$page.relativePath
)
}
return null
},
editLinkText () {
return (
this.$themeLocaleConfig.editLinkText
|| this.$site.themeConfig.editLinkText
|| `Edit this page`
)
}
},
methods: {
createEditLink (repo, docsRepo, docsDir, docsBranch, path) {
const bitbucket = /bitbucket.org/
if (bitbucket.test(docsRepo)) {
const base = docsRepo
return (
base.replace(endingSlashRE, '')
+ `/src`
+ `/${docsBranch}/`
+ (docsDir ? docsDir.replace(endingSlashRE, '') + '/' : '')
+ path
+ `?mode=edit&spa=0&at=${docsBranch}&fileviewer=file-view-default`
)
}
const gitlab = /gitlab.com/
if (gitlab.test(docsRepo)) {
const base = docsRepo
return (
base.replace(endingSlashRE, '')
+ `/-/edit`
+ `/${docsBranch}/`
+ (docsDir ? docsDir.replace(endingSlashRE, '') + '/' : '')
+ path
)
}
const base = outboundRE.test(docsRepo)
? docsRepo
: `https://github.com/${docsRepo}`
return (
base.replace(endingSlashRE, '')
+ '/edit'
+ `/${docsBranch}/`
+ (docsDir ? docsDir.replace(endingSlashRE, '') + '/' : '')
+ path
)
}
}
}
</script>
<style lang="stylus">
@require '../styles/wrapper.styl'
.page-edit
@extend $wrapper
padding-top 1rem
padding-bottom 1rem
overflow auto
.edit-link
display inline-block
a
color lighten($textColor, 25%)
margin-right 0.25rem
.last-updated
float right
font-size 0.9em
.prefix
font-weight 500
color lighten($textColor, 25%)
.time
font-weight 400
color #767676
@media (max-width: $MQMobile)
.page-edit
.edit-link
margin-bottom 0.5rem
.last-updated
font-size 0.8em
float none
text-align left
</style>

View File

@ -1,163 +0,0 @@
<template>
<div
v-if="prev || next"
class="page-nav"
>
<p class="inner">
<span
v-if="prev"
class="prev"
>
<a
v-if="prev.type === 'external'"
class="prev"
:href="prev.path"
target="_blank"
rel="noopener noreferrer"
>
{{ prev.title || prev.path }}
<OutboundLink />
</a>
<RouterLink
v-else
class="prev"
:to="prev.path"
>
{{ prev.title || prev.path }}
</RouterLink>
</span>
<span
v-if="next"
class="next"
>
<a
v-if="next.type === 'external'"
:href="next.path"
target="_blank"
rel="noopener noreferrer"
>
{{ next.title || next.path }}
<OutboundLink />
</a>
<RouterLink
v-else
:to="next.path"
>
{{ next.title || next.path }}
</RouterLink>
</span>
</p>
</div>
</template>
<script>
import { resolvePage } from '../util'
import isString from 'lodash/isString'
import isNil from 'lodash/isNil'
export default {
name: 'PageNav',
props: ['sidebarItems'],
computed: {
prev () {
return resolvePageLink(LINK_TYPES.PREV, this)
},
next () {
return resolvePageLink(LINK_TYPES.NEXT, this)
}
}
}
function resolvePrev (page, items) {
return find(page, items, -1)
}
function resolveNext (page, items) {
return find(page, items, 1)
}
const LINK_TYPES = {
NEXT: {
resolveLink: resolveNext,
getThemeLinkConfig: ({ nextLinks }) => nextLinks,
getPageLinkConfig: ({ frontmatter }) => frontmatter.next
},
PREV: {
resolveLink: resolvePrev,
getThemeLinkConfig: ({ prevLinks }) => prevLinks,
getPageLinkConfig: ({ frontmatter }) => frontmatter.prev
}
}
function resolvePageLink (
linkType,
{ $themeConfig, $page, $route, $site, sidebarItems }
) {
const { resolveLink, getThemeLinkConfig, getPageLinkConfig } = linkType
// Get link config from theme
const themeLinkConfig = getThemeLinkConfig($themeConfig)
// Get link config from current page
const pageLinkConfig = getPageLinkConfig($page)
// Page link config will overwrite global theme link config if defined
const link = isNil(pageLinkConfig) ? themeLinkConfig : pageLinkConfig
if (link === false) {
return
} else if (isString(link)) {
return resolvePage($site.pages, link, $route.path)
} else {
return resolveLink($page, sidebarItems)
}
}
function find (page, items, offset) {
const res = []
flatten(items, res)
for (let i = 0; i < res.length; i++) {
const cur = res[i]
if (cur.type === 'page' && cur.path === decodeURIComponent(page.path)) {
return res[i + offset]
}
}
}
function flatten (items, res) {
for (let i = 0, l = items.length; i < l; i++) {
if (items[i].type === 'group') {
flatten(items[i].children || [], res)
} else {
res.push(items[i])
}
}
}
</script>
<style lang="stylus">
@require '../styles/wrapper.styl'
.page-nav
@extend $wrapper
padding-top 1rem
padding-bottom 0
.inner
min-height 2rem
margin-top 0
border-top 1px solid $borderColor
padding-top 1rem
overflow auto // clear float
.next
float right
</style>

View File

@ -1,64 +0,0 @@
<template>
<aside class="sidebar">
<NavLinks />
<slot name="top" />
<SidebarLinks
:depth="0"
:items="items"
/>
<slot name="bottom" />
</aside>
</template>
<script>
import SidebarLinks from '@theme/components/SidebarLinks.vue'
import NavLinks from '@theme/components/NavLinks.vue'
export default {
name: 'Sidebar',
components: { SidebarLinks, NavLinks },
props: ['items']
}
</script>
<style lang="stylus">
.sidebar
ul
padding 0
margin 0
list-style-type none
a
display inline-block
.nav-links
display none
border-bottom 1px solid $borderColor
padding 0.5rem 0 0.75rem 0
a
font-weight 600
.nav-item, .repo-link
display block
line-height 1.25rem
font-size 1.1em
padding 0.5rem 0 0.5rem 1.5rem
& > .sidebar-links
padding 1.5rem 0
& > li > a.sidebar-link
font-size 1.1em
line-height 1.7
font-weight bold
& > li:not(:first-child)
margin-top .75rem
@media (max-width: $MQMobile)
.sidebar
.nav-links
display block
.dropdown-wrapper .nav-dropdown .dropdown-item a.router-link-active::after
top calc(1rem - 2px)
& > .sidebar-links
padding 1rem 0
</style>

View File

@ -1,40 +0,0 @@
<template>
<div
class="sidebar-button"
@click="$emit('toggle-sidebar')"
>
<svg
class="icon"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
role="img"
viewBox="0 0 448 512"
>
<path
fill="currentColor"
d="M436 124H12c-6.627 0-12-5.373-12-12V80c0-6.627 5.373-12 12-12h424c6.627 0 12 5.373 12 12v32c0 6.627-5.373 12-12 12zm0 160H12c-6.627 0-12-5.373-12-12v-32c0-6.627 5.373-12 12-12h424c6.627 0 12 5.373 12 12v32c0 6.627-5.373 12-12 12zm0 160H12c-6.627 0-12-5.373-12-12v-32c0-6.627 5.373-12 12-12h424c6.627 0 12 5.373 12 12v32c0 6.627-5.373 12-12 12z"
class=""
/>
</svg>
</div>
</template>
<style lang="stylus">
.sidebar-button
cursor pointer
display none
width 1.25rem
height 1.25rem
position absolute
padding 0.6rem
top 0.6rem
left 1rem
.icon
display block
width 1.25rem
height 1.25rem
@media (max-width: $MQMobile)
.sidebar-button
display block
</style>

View File

@ -1,141 +0,0 @@
<template>
<section
class="sidebar-group"
:class="[
{
collapsable,
'is-sub-group': depth !== 0
},
`depth-${depth}`
]"
>
<RouterLink
v-if="item.path"
class="sidebar-heading clickable"
:class="{
open,
'active': isActive($route, item.path)
}"
:to="item.path"
@click.native="$emit('toggle')"
>
<span>{{ item.title }}</span>
<span
v-if="collapsable"
class="arrow"
:class="open ? 'down' : 'right'"
/>
</RouterLink>
<p
v-else
class="sidebar-heading"
:class="{ open }"
@click="$emit('toggle')"
>
<span>{{ item.title }}</span>
<span
v-if="collapsable"
class="arrow"
:class="open ? 'down' : 'right'"
/>
</p>
<DropdownTransition>
<SidebarLinks
v-if="open || !collapsable"
class="sidebar-group-items"
:items="item.children"
:sidebar-depth="item.sidebarDepth"
:initial-open-group-index="item.initialOpenGroupIndex"
:depth="depth + 1"
/>
</DropdownTransition>
</section>
</template>
<script>
import { isActive } from '../util'
import DropdownTransition from '@theme/components/DropdownTransition.vue'
export default {
name: 'SidebarGroup',
components: {
DropdownTransition
},
props: [
'item',
'open',
'collapsable',
'depth'
],
// ref: https://vuejs.org/v2/guide/components-edge-cases.html#Circular-References-Between-Components
beforeCreate () {
this.$options.components.SidebarLinks = require('@theme/components/SidebarLinks.vue').default
},
methods: { isActive }
}
</script>
<style lang="stylus">
.sidebar-group
.sidebar-group
padding-left 0.5em
&:not(.collapsable)
.sidebar-heading:not(.clickable)
cursor auto
color inherit
// refine styles of nested sidebar groups
&.is-sub-group
padding-left 0
& > .sidebar-heading
font-size 0.95em
line-height 1.4
font-weight normal
padding-left 2rem
&:not(.clickable)
opacity 0.5
& > .sidebar-group-items
padding-left 1rem
& > li > .sidebar-link
font-size: 0.95em;
border-left none
&.depth-2
& > .sidebar-heading
border-left none
.sidebar-heading
color $textColor
transition color .15s ease
cursor pointer
font-size 1.1em
font-weight bold
// text-transform uppercase
padding 0.35rem 1.5rem 0.35rem 1.25rem
width 100%
box-sizing border-box
margin 0
border-left 0.25rem solid transparent
&.open, &:hover
color inherit
.arrow
position relative
top -0.12em
left 0.5em
&.clickable
&.active
font-weight 600
color $accentColor
border-left-color $accentColor
&:hover
color $accentColor
.sidebar-group-items
transition height .1s ease-out
font-size 0.95em
overflow hidden
</style>

View File

@ -1,133 +0,0 @@
<script>
import { isActive, hashRE, groupHeaders } from '../util'
export default {
functional: true,
props: ['item', 'sidebarDepth'],
render (h,
{
parent: {
$page,
$site,
$route,
$themeConfig,
$themeLocaleConfig
},
props: {
item,
sidebarDepth
}
}) {
// use custom active class matching logic
// due to edge case of paths ending with / + hash
const selfActive = isActive($route, item.path)
// for sidebar: auto pages, a hash link should be active if one of its child
// matches
const active = item.type === 'auto'
? selfActive || item.children.some(c => isActive($route, item.basePath + '#' + c.slug))
: selfActive
const link = item.type === 'external'
? renderExternal(h, item.path, item.title || item.path)
: renderLink(h, item.path, item.title || item.path, active)
const maxDepth = [
$page.frontmatter.sidebarDepth,
sidebarDepth,
$themeLocaleConfig.sidebarDepth,
$themeConfig.sidebarDepth,
1
].find(depth => depth !== undefined)
const displayAllHeaders = $themeLocaleConfig.displayAllHeaders
|| $themeConfig.displayAllHeaders
if (item.type === 'auto') {
return [link, renderChildren(h, item.children, item.basePath, $route, maxDepth)]
} else if ((active || displayAllHeaders) && item.headers && !hashRE.test(item.path)) {
const children = groupHeaders(item.headers)
return [link, renderChildren(h, children, item.path, $route, maxDepth)]
} else {
return link
}
}
}
function renderLink (h, to, text, active, level) {
const component = {
props: {
to,
activeClass: '',
exactActiveClass: ''
},
class: {
active,
'sidebar-link': true
}
}
if (level > 2) {
component.style = {
'padding-left': level + 'rem'
}
}
return h('RouterLink', component, text)
}
function renderChildren (h, children, path, route, maxDepth, depth = 1) {
if (!children || depth > maxDepth) return null
return h('ul', { class: 'sidebar-sub-headers' }, children.map(c => {
const active = isActive(route, path + '#' + c.slug)
return h('li', { class: 'sidebar-sub-header' }, [
renderLink(h, path + '#' + c.slug, c.title, active, c.level - 1),
renderChildren(h, c.children, path, route, maxDepth, depth + 1)
])
}))
}
function renderExternal (h, to, text) {
return h('a', {
attrs: {
href: to,
target: '_blank',
rel: 'noopener noreferrer'
},
class: {
'sidebar-link': true
}
}, [text, h('OutboundLink')])
}
</script>
<style lang="stylus">
.sidebar .sidebar-sub-headers
padding-left 1rem
font-size 0.95em
a.sidebar-link
font-size 1em
font-weight 400
display inline-block
color $textColor
border-left 0.25rem solid transparent
padding 0.35rem 1rem 0.35rem 1.25rem
line-height 1.4
width: 100%
box-sizing: border-box
&:hover
color $accentColor
&.active
font-weight 600
color $accentColor
border-left-color $accentColor
.sidebar-group &
padding-left 2rem
.sidebar-sub-headers &
padding-top 0.25rem
padding-bottom 0.25rem
border-left none
&.active
font-weight 500
</style>

View File

@ -1,103 +0,0 @@
<template>
<ul
v-if="items.length"
class="sidebar-links"
>
<li
v-for="(item, i) in items"
:key="i"
>
<SidebarGroup
v-if="item.type === 'group'"
:item="item"
:open="i === openGroupIndex"
:collapsable="item.collapsable || item.collapsible"
:depth="depth"
@toggle="toggleGroup(i)"
/>
<SidebarLink
v-else
:sidebar-depth="sidebarDepth"
:item="item"
/>
</li>
</ul>
</template>
<script>
import SidebarGroup from '@theme/components/SidebarGroup.vue'
import SidebarLink from '@theme/components/SidebarLink.vue'
import { isActive } from '../util'
export default {
name: 'SidebarLinks',
components: { SidebarGroup, SidebarLink },
props: [
'items',
'depth', // depth of current sidebar links
'sidebarDepth', // depth of headers to be extracted
'initialOpenGroupIndex'
],
data () {
return {
openGroupIndex: this.initialOpenGroupIndex || 0
}
},
watch: {
'$route' () {
this.refreshIndex()
}
},
created () {
this.refreshIndex()
},
methods: {
refreshIndex () {
const index = resolveOpenGroupIndex(
this.$route,
this.items
)
if (index > -1) {
this.openGroupIndex = index
}
},
toggleGroup (index) {
this.openGroupIndex = index === this.openGroupIndex ? -1 : index
},
isActive (page) {
return isActive(this.$route, page.regularPath)
}
}
}
function resolveOpenGroupIndex (route, items) {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (descendantIsActive(route, item)) {
return i
}
}
return -1
}
function descendantIsActive (route, item) {
if (item.type === 'group') {
return item.children.some(child => {
if (child.type === 'group') {
return descendantIsActive(route, child)
} else {
return child.type === 'page' && isActive(route, child.path)
}
})
}
return false
}
</script>

View File

@ -1,44 +0,0 @@
<script>
export default {
functional: true,
props: {
type: {
type: String,
default: 'tip'
},
text: String,
vertical: {
type: String,
default: 'top'
}
},
render (h, { props, slots }) {
return h('span', {
class: ['badge', props.type],
style: {
verticalAlign: props.vertical
}
}, props.text || slots().default)
}
}
</script>
<style lang="stylus" scoped>
.badge
display inline-block
font-size 14px
height 18px
line-height 18px
border-radius 3px
padding 0 6px
color white
background-color #42b983
&.tip, &.green
background-color $badgeTipColor
&.error
background-color $badgeErrorColor
&.warning, &.warn, &.yellow
background-color $badgeWarningColor
& + &
margin-left 5px
</style>

View File

@ -1,36 +0,0 @@
<template>
<div
class="theme-code-block"
:class="{ 'theme-code-block__active': active }"
>
<slot />
</div>
</template>
<script>
export default {
name: 'CodeBlock',
props: {
title: {
type: String,
required: true
},
active: {
type: Boolean,
default: false
}
}
}
</script>
<style scoped>
.theme-code-block {
display: none;
}
.theme-code-block__active {
display: block;
}
.theme-code-block > pre {
background-color: orange;
}
</style>

View File

@ -1,105 +0,0 @@
<template>
<div class="theme-code-group">
<div class="theme-code-group__nav">
<ul class="theme-code-group__ul">
<li
v-for="(tab, i) in codeTabs"
:key="tab.title"
class="theme-code-group__li"
>
<button
class="theme-code-group__nav-tab"
:class="{
'theme-code-group__nav-tab-active': i === activeCodeTabIndex,
}"
@click="changeCodeTab(i)"
>
{{ tab.title }}
</button>
</li>
</ul>
</div>
<slot />
<pre
v-if="codeTabs.length < 1"
class="pre-blank"
>// Make sure to add code blocks to your code group</pre>
</div>
</template>
<script>
export default {
name: 'CodeGroup',
data () {
return {
codeTabs: [],
activeCodeTabIndex: -1
}
},
watch: {
activeCodeTabIndex (index) {
this.codeTabs.forEach(tab => {
tab.elm.classList.remove('theme-code-block__active')
})
this.codeTabs[index].elm.classList.add('theme-code-block__active')
}
},
mounted () {
this.codeTabs = (this.$slots.default || []).filter(slot => Boolean(slot.componentOptions)).map((slot, index) => {
if (slot.componentOptions.propsData.active === '') {
this.activeCodeTabIndex = index
}
return {
title: slot.componentOptions.propsData.title,
elm: slot.elm
}
})
if (this.activeCodeTabIndex === -1 && this.codeTabs.length > 0) {
this.activeCodeTabIndex = 0
}
},
methods: {
changeCodeTab (index) {
this.activeCodeTabIndex = index
}
}
}
</script>
<style lang="stylus" scoped>
.theme-code-group {}
.theme-code-group__nav {
margin-bottom: -35px;
background-color: $codeBgColor;
padding-bottom: 22px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
padding-left: 10px;
padding-top: 10px;
}
.theme-code-group__ul {
margin: auto 0;
padding-left: 0;
display: inline-flex;
list-style: none;
}
.theme-code-group__li {}
.theme-code-group__nav-tab {
border: 0;
padding: 5px;
cursor: pointer;
background-color: transparent;
font-size: 0.85em;
line-height: 1.4;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
}
.theme-code-group__nav-tab-active {
border-bottom: #42b983 1px solid;
}
.pre-blank {
color: #42b983;
}
</style>

View File

@ -1,59 +0,0 @@
const path = require('path')
// Theme API.
module.exports = (options, ctx) => {
const { themeConfig, siteConfig } = ctx
// resolve algolia
const isAlgoliaSearch = (
themeConfig.algolia
|| Object
.keys(siteConfig.locales && themeConfig.locales || {})
.some(base => themeConfig.locales[base].algolia)
)
const enableSmoothScroll = themeConfig.smoothScroll === true
return {
alias () {
return {
'@AlgoliaSearchBox': isAlgoliaSearch
? path.resolve(__dirname, 'components/AlgoliaSearchBox.vue')
: path.resolve(__dirname, 'noopModule.js')
}
},
plugins: [
['@vuepress/active-header-links', options.activeHeaderLinks],
'@vuepress/search',
'@vuepress/plugin-nprogress',
['container', {
type: 'tip',
defaultTitle: {
'/': 'TIP',
'/zh/': '提示'
}
}],
['container', {
type: 'warning',
defaultTitle: {
'/': 'WARNING',
'/zh/': '注意'
}
}],
['container', {
type: 'danger',
defaultTitle: {
'/': 'WARNING',
'/zh/': '警告'
}
}],
['container', {
type: 'details',
before: info => `<details class="custom-block details">${info ? `<summary>${info}</summary>` : ''}\n`,
after: () => '</details>\n'
}],
['smooth-scroll', enableSmoothScroll]
]
}
}

View File

@ -1,30 +0,0 @@
<template>
<div class="theme-container">
<div class="theme-default-content">
<h1>404</h1>
<blockquote>{{ getMsg() }}</blockquote>
<RouterLink to="/">
Take me home.
</RouterLink>
</div>
</div>
</template>
<script>
const msgs = [
`There's nothing here.`,
`How did we get here?`,
`That's a Four-Oh-Four.`,
`Looks like we've got some broken links.`
]
export default {
methods: {
getMsg () {
return msgs[Math.floor(Math.random() * msgs.length)]
}
}
}
</script>

View File

@ -1,137 +0,0 @@
<template>
<div
class="theme-container"
:class="pageClasses"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
>
<Navbar v-if="shouldShowNavbar" @toggle-sidebar="toggleSidebar" />
<div class="sidebar-mask" @click="toggleSidebar(false)" />
<Sidebar :items="sidebarItems" @toggle-sidebar="toggleSidebar">
<template #top>
<slot name="sidebar-top" />
</template>
<template #bottom>
<slot name="sidebar-bottom" />
</template>
</Sidebar>
<Home v-if="$page.frontmatter.home" />
<Page v-else :sidebar-items="sidebarItems">
<template #top>
<slot name="page-top" />
</template>
<template #bottom>
<slot name="page-bottom" />
</template>
</Page>
</div>
</template>
<script>
import Home from "@theme/components/Home.vue";
import Navbar from "@theme/components/Navbar.vue";
import Page from "@theme/components/Page.vue";
import Sidebar from "@theme/components/Sidebar.vue";
import { resolveSidebarItems } from "../util";
export default {
name: "Layout",
components: {
Home,
Page,
Sidebar,
Navbar,
},
data() {
return {
isSidebarOpen: false,
};
},
computed: {
shouldShowNavbar() {
const { themeConfig } = this.$site;
const { frontmatter } = this.$page;
if (frontmatter.navbar === false || themeConfig.navbar === false) {
return false;
}
return (
this.$title ||
themeConfig.logo ||
themeConfig.repo ||
themeConfig.nav ||
this.$themeLocaleConfig.nav
);
},
shouldShowSidebar() {
const { frontmatter } = this.$page;
return (
!frontmatter.home &&
frontmatter.sidebar !== false &&
this.sidebarItems.length
);
},
sidebarItems() {
return resolveSidebarItems(
this.$page,
this.$page.regularPath,
this.$site,
this.$localePath
);
},
pageClasses() {
const userPageClass = this.$page.frontmatter.pageClass;
return [
{
"no-navbar": !this.shouldShowNavbar,
"sidebar-open": this.isSidebarOpen,
"no-sidebar": !this.shouldShowSidebar,
},
userPageClass,
];
},
},
mounted() {
this.$router.afterEach(() => {
this.isSidebarOpen = false;
});
},
methods: {
toggleSidebar(to) {
this.isSidebarOpen = typeof to === "boolean" ? to : !this.isSidebarOpen;
this.$emit("toggle-sidebar", this.isSidebarOpen);
},
// side swipe
onTouchStart(e) {
this.touchStart = {
x: e.changedTouches[0].clientX,
y: e.changedTouches[0].clientY,
};
},
onTouchEnd(e) {
const dx = e.changedTouches[0].clientX - this.touchStart.x;
const dy = e.changedTouches[0].clientY - this.touchStart.y;
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {
if (dx > 0 && this.touchStart.x <= 80) {
this.toggleSidebar(true);
} else {
this.toggleSidebar(false);
}
}
},
},
};
</script>

View File

@ -1 +0,0 @@
export default {}

View File

@ -1,22 +0,0 @@
@require './config'
.arrow
display inline-block
width 0
height 0
&.up
border-left 4px solid transparent
border-right 4px solid transparent
border-bottom 6px solid $arrowBgColor
&.down
border-left 4px solid transparent
border-right 4px solid transparent
border-top 6px solid $arrowBgColor
&.right
border-top 4px solid transparent
border-bottom 4px solid transparent
border-left 6px solid $arrowBgColor
&.left
border-top 4px solid transparent
border-bottom 4px solid transparent
border-right 6px solid $arrowBgColor

View File

@ -1,137 +0,0 @@
{$contentClass}
code
color lighten($textColor, 20%)
padding 0.25rem 0.5rem
margin 0
font-size 0.85em
background-color rgba(27,31,35,0.05)
border-radius 3px
.token
&.deleted
color #EC5975
&.inserted
color $accentColor
{$contentClass}
pre, pre[class*="language-"]
line-height 1.4
padding 1.25rem 1.5rem
margin 0.85rem 0
background-color $codeBgColor
border-radius 6px
overflow auto
code
color #fff
padding 0
background-color transparent
border-radius 0
div[class*="language-"]
position relative
background-color $codeBgColor
border-radius 6px
.highlight-lines
user-select none
padding-top 1.3rem
position absolute
top 0
left 0
width 100%
line-height 1.4
.highlighted
background-color rgba(0, 0, 0, 66%)
pre, pre[class*="language-"]
background transparent
position relative
z-index 1
&::before
position absolute
z-index 3
top 0.8em
right 1em
font-size 0.75rem
color rgba(255, 255, 255, 0.4)
&:not(.line-numbers-mode)
.line-numbers-wrapper
display none
&.line-numbers-mode
.highlight-lines .highlighted
position relative
&:before
content ' '
position absolute
z-index 3
left 0
top 0
display block
width $lineNumbersWrapperWidth
height 100%
background-color rgba(0, 0, 0, 66%)
pre
padding-left $lineNumbersWrapperWidth + 1 rem
vertical-align middle
.line-numbers-wrapper
position absolute
top 0
width $lineNumbersWrapperWidth
text-align center
color rgba(255, 255, 255, 0.3)
padding 1.25rem 0
line-height 1.4
br
user-select none
.line-number
position relative
z-index 4
user-select none
font-size 0.85em
&::after
content ''
position absolute
z-index 2
top 0
left 0
width $lineNumbersWrapperWidth
height 100%
border-radius 6px 0 0 6px
border-right 1px solid rgba(0, 0, 0, 66%)
background-color $codeBgColor
for lang in $codeLang
div{'[class~="language-' + lang + '"]'}
&:before
content ('' + lang)
div[class~="language-javascript"]
&:before
content "js"
div[class~="language-typescript"]
&:before
content "ts"
div[class~="language-markup"]
&:before
content "html"
div[class~="language-markdown"]
&:before
content "md"
div[class~="language-json"]:before
content "json"
div[class~="language-ruby"]:before
content "rb"
div[class~="language-python"]:before
content "py"
div[class~="language-bash"]:before
content "sh"
div[class~="language-php"]:before
content "php"
@import '~prismjs/themes/prism-tomorrow.css'

View File

@ -1 +0,0 @@
$contentClass = '.theme-default-content'

View File

@ -1,44 +0,0 @@
.custom-block
.custom-block-title
font-weight 600
margin-bottom -0.4rem
&.tip, &.warning, &.danger
padding .1rem 1.5rem
border-left-width .5rem
border-left-style solid
margin 1rem 0
&.tip
background-color #f3f5f7
border-color #42b983
&.warning
background-color rgba(255,229,100,.3)
border-color darken(#ffe564, 35%)
color darken(#ffe564, 70%)
.custom-block-title
color darken(#ffe564, 50%)
a
color $textColor
&.danger
background-color #ffe6e6
border-color darken(red, 20%)
color darken(red, 70%)
.custom-block-title
color darken(red, 40%)
a
color $textColor
&.details
display block
position relative
border-radius 2px
margin 1.6em 0
padding 1.6em
background-color #eee
h4
margin-top 0
figure, p
&:last-child
margin-bottom 0
padding-bottom 0
summary
outline none
cursor pointer

View File

@ -1,200 +0,0 @@
@require './config'
@require './code'
@require './custom-blocks'
@require './arrow'
@require './wrapper'
@require './toc'
html, body
padding 0
margin 0
background-color #fff
body
font-family -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif
-webkit-font-smoothing antialiased
-moz-osx-font-smoothing grayscale
font-size 16px
color $textColor
.page
padding-left $sidebarWidth
.navbar
position fixed
z-index 20
top 0
left 0
right 0
height $navbarHeight
background-color #fff
box-sizing border-box
border-bottom 1px solid $borderColor
.sidebar-mask
position fixed
z-index 9
top 0
left 0
width 100vw
height 100vh
display none
.sidebar
font-size 16px
background-color #fff
width $sidebarWidth
position fixed
z-index 10
margin 0
top $navbarHeight
left 0
bottom 0
box-sizing border-box
border-right 1px solid $borderColor
overflow-y auto
{$contentClass}:not(.custom)
@extend $wrapper
> *:first-child
margin-top $navbarHeight
a:hover
text-decoration underline
p.demo
padding 1rem 1.5rem
border 1px solid #ddd
border-radius 4px
img
max-width 100%
{$contentClass}.custom
padding 0
margin 0
img
max-width 100%
a
font-weight 500
color $accentColor
text-decoration none
p a code
font-weight 400
color $accentColor
kbd
background #eee
border solid 0.15rem #ddd
border-bottom solid 0.25rem #ddd
border-radius 0.15rem
padding 0 0.15em
blockquote
font-size 1rem
color #999;
border-left .2rem solid #dfe2e5
margin 1rem 0
padding .25rem 0 .25rem 1rem
& > p
margin 0
ul, ol
padding-left 1.2em
strong
font-weight 600
h1, h2, h3, h4, h5, h6
font-weight 600
line-height 1.25
{$contentClass}:not(.custom) > &
margin-top (0.5rem - $navbarHeight)
padding-top ($navbarHeight + 1rem)
margin-bottom 0
&:first-child
margin-top -1.5rem
margin-bottom 1rem
+ p, + pre, + .custom-block
margin-top 2rem
&:hover .header-anchor
opacity: 1
h1
font-size 2.2rem
h2
font-size 1.65rem
padding-bottom .3rem
border-bottom 1px solid $borderColor
h3
font-size 1.35rem
a.header-anchor
font-size 0.85em
float left
margin-left -0.87em
padding-right 0.23em
margin-top 0.125em
opacity 0
&:hover
text-decoration none
code, kbd, .line-number
font-family source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace
p, ul, ol
line-height 1.7
hr
border 0
border-top 1px solid $borderColor
table
border-collapse collapse
margin 1rem 0
display: block
overflow-x: auto
tr
border-top 1px solid #dfe2e5
&:nth-child(2n)
background-color #f6f8fa
th, td
border 1px solid #dfe2e5
padding .6em 1em
.theme-container
&.sidebar-open
.sidebar-mask
display: block
&.no-navbar
{$contentClass}:not(.custom) > h1, h2, h3, h4, h5, h6
margin-top 1.5rem
padding-top 0
.sidebar
top 0
@media (min-width: ($MQMobile + 1px))
.theme-container.no-sidebar
.sidebar
display none
.page
padding-left 0
@require 'mobile.styl'

View File

@ -1,37 +0,0 @@
@require './config'
$mobileSidebarWidth = $sidebarWidth * 0.82
// narrow desktop / iPad
@media (max-width: $MQNarrow)
.sidebar
font-size 15px
width $mobileSidebarWidth
.page
padding-left $mobileSidebarWidth
// wide mobile
@media (max-width: $MQMobile)
.sidebar
top 0
padding-top $navbarHeight
transform translateX(-100%)
transition transform .2s ease
.page
padding-left 0
.theme-container
&.sidebar-open
.sidebar
transform translateX(0)
&.no-navbar
.sidebar
padding-top: 0
// narrow mobile
@media (max-width: $MQMobileNarrow)
h1
font-size 1.9rem
{$contentClass}
div[class*="language-"]
margin 0.85rem -1.5rem
border-radius 0

View File

@ -1,3 +0,0 @@
.table-of-contents
.badge
vertical-align middle

View File

@ -1,9 +0,0 @@
$wrapper
max-width $contentWidth
margin 0 auto
padding 2rem 2.5rem
@media (max-width: $MQNarrow)
padding 2rem
@media (max-width: $MQMobileNarrow)
padding 1.5rem

View File

@ -1,244 +0,0 @@
export const hashRE = /#.*$/
export const extRE = /\.(md|html)$/
export const endingSlashRE = /\/$/
export const outboundRE = /^[a-z]+:/i
export function normalize (path) {
return decodeURI(path)
.replace(hashRE, '')
.replace(extRE, '')
}
export function getHash (path) {
const match = path.match(hashRE)
if (match) {
return match[0]
}
}
export function isExternal (path) {
return outboundRE.test(path)
}
export function isMailto (path) {
return /^mailto:/.test(path)
}
export function isTel (path) {
return /^tel:/.test(path)
}
export function ensureExt (path) {
if (isExternal(path)) {
return path
}
const hashMatch = path.match(hashRE)
const hash = hashMatch ? hashMatch[0] : ''
const normalized = normalize(path)
if (endingSlashRE.test(normalized)) {
return path
}
return normalized + '.html' + hash
}
export function isActive (route, path) {
const routeHash = decodeURIComponent(route.hash)
const linkHash = getHash(path)
if (linkHash && routeHash !== linkHash) {
return false
}
const routePath = normalize(route.path)
const pagePath = normalize(path)
return routePath === pagePath
}
export function resolvePage (pages, rawPath, base) {
if (isExternal(rawPath)) {
return {
type: 'external',
path: rawPath
}
}
if (base) {
rawPath = resolvePath(rawPath, base)
}
const path = normalize(rawPath)
for (let i = 0; i < pages.length; i++) {
if (normalize(pages[i].regularPath) === path) {
return Object.assign({}, pages[i], {
type: 'page',
path: ensureExt(pages[i].path)
})
}
}
console.error(`[vuepress] No matching page found for sidebar item "${rawPath}"`)
return {}
}
function resolvePath (relative, base, append) {
const firstChar = relative.charAt(0)
if (firstChar === '/') {
return relative
}
if (firstChar === '?' || firstChar === '#') {
return base + relative
}
const stack = base.split('/')
// remove trailing segment if:
// - not appending
// - appending to trailing slash (last segment is empty)
if (!append || !stack[stack.length - 1]) {
stack.pop()
}
// resolve relative path
const segments = relative.replace(/^\//, '').split('/')
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
if (segment === '..') {
stack.pop()
} else if (segment !== '.') {
stack.push(segment)
}
}
// ensure leading slash
if (stack[0] !== '') {
stack.unshift('')
}
return stack.join('/')
}
/**
* @param { Page } page
* @param { string } regularPath
* @param { SiteData } site
* @param { string } localePath
* @returns { SidebarGroup }
*/
export function resolveSidebarItems (page, regularPath, site, localePath) {
const { pages, themeConfig } = site
const localeConfig = localePath && themeConfig.locales
? themeConfig.locales[localePath] || themeConfig
: themeConfig
const pageSidebarConfig = page.frontmatter.sidebar || localeConfig.sidebar || themeConfig.sidebar
if (pageSidebarConfig === 'auto') {
return resolveHeaders(page)
}
const sidebarConfig = localeConfig.sidebar || themeConfig.sidebar
if (!sidebarConfig) {
return []
} else {
const { base, config } = resolveMatchingConfig(regularPath, sidebarConfig)
if (config === 'auto') {
return resolveHeaders(page)
}
return config
? config.map(item => resolveItem(item, pages, base))
: []
}
}
/**
* @param { Page } page
* @returns { SidebarGroup }
*/
function resolveHeaders (page) {
const headers = groupHeaders(page.headers || [])
return [{
type: 'group',
collapsable: false,
title: page.title,
path: null,
children: headers.map(h => ({
type: 'auto',
title: h.title,
basePath: page.path,
path: page.path + '#' + h.slug,
children: h.children || []
}))
}]
}
export function groupHeaders (headers) {
// group h3s under h2
headers = headers.map(h => Object.assign({}, h))
let lastH2
headers.forEach(h => {
if (h.level === 2) {
lastH2 = h
} else if (lastH2) {
(lastH2.children || (lastH2.children = [])).push(h)
}
})
return headers.filter(h => h.level === 2)
}
export function resolveNavLinkItem (linkItem) {
return Object.assign(linkItem, {
type: linkItem.items && linkItem.items.length ? 'links' : 'link'
})
}
/**
* @param { Route } route
* @param { Array<string|string[]> | Array<SidebarGroup> | [link: string]: SidebarConfig } config
* @returns { base: string, config: SidebarConfig }
*/
export function resolveMatchingConfig (regularPath, config) {
if (Array.isArray(config)) {
return {
base: '/',
config: config
}
}
for (const base in config) {
if (ensureEndingSlash(regularPath).indexOf(encodeURI(base)) === 0) {
return {
base,
config: config[base]
}
}
}
return {}
}
function ensureEndingSlash (path) {
return /(\.html|\/)$/.test(path)
? path
: path + '/'
}
function resolveItem (item, pages, base, groupDepth = 1) {
if (typeof item === 'string') {
return resolvePage(pages, item, base)
} else if (Array.isArray(item)) {
return Object.assign(resolvePage(pages, item[0], base), {
title: item[1]
})
} else {
const children = item.children || []
if (children.length === 0 && item.path) {
return Object.assign(resolvePage(pages, item.path, base), {
title: item.title
})
}
return {
type: 'group',
path: item.path,
title: item.title,
sidebarDepth: item.sidebarDepth,
initialOpenGroupIndex: item.initialOpenGroupIndex,
children: children.map(child => resolveItem(child, pages, base, groupDepth + 1)),
collapsable: item.collapsable !== false
}
}
}

View File

@ -1,45 +0,0 @@
---
sidebarDepth: 1
sidebar: auto
---
# Appendix
## GraphQL API
Hetty exposes a GraphQL API over HTTP for managing all its features. This API is
used by the web admin interface; a Next.js app using Apollo Client.
### Playground
You can also introspect and manually experiment with the API via the included GraphQL Playground. To access it, start Hetty and visit: [http://localhost:8080/api/playground](http://localhost:8080/api/playground).
### Schema
<<< @/../pkg/api/schema.graphql
Source: [pkg/api/schema.graphql](https://github.com/dstotijn/hetty/blob/master/pkg/api/schema.graphql)
## License
MIT License
Copyright (c) 2021 David Stotijn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -1,84 +0,0 @@
# Getting Started
## Installation
Hetty compiles to a static binary, with an embedded BadgerDB database and web
admin interface.
### Install pre-built release (recommended)
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
### Build from source
#### Prerequisites
- [Go 1.16](https://golang.org/)
- [Yarn](https://yarnpkg.com/)
When building from source, the static resources for the admin interface
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/). The generated
files will be embedded (using the [embed](https://golang.org/pkg/embed/)
package) when you use the `build` Makefile target.
Clone the repository and use the `build` make target to create a binary:
```
$ git clone git@github.com:dstotijn/hetty.git
$ cd hetty
$ make build
```
### Docker
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
For persistent storage of CA certificate and project databases, mount a volume:
```
$ mkdir -p $HOME/.hetty
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
```
## Usage
When Hetty is started, by default it listens on `:8080` and is accessible via
[http://localhost:8080](http://localhost:8080). Depending on incoming HTTP
requests, it either acts as a MITM proxy, or it serves the API and web interface.
By default, the projects database files and CA certificates are stored in a `.hetty`
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
on Windows).
To start, ensure `hetty` (downloaded from a release, or manually built) is in your
`$PATH` and run:
```
$ hetty
```
You should see:
```
2022/01/26 10:34:24 [INFO] Hetty (v0.3.2) is running on :8080 ...
```
Then, visit [http://localhost:8080](http://localhost:8080) to get started.
### Configuration
An overview of available configuration flags:
```
$ hetty -h
Usage of ./hetty:
-addr string
TCP address to listen on, in the form "host:port" (default ":8080")
-adminPath string
File path to admin build
-cert string
CA certificate filepath. Creates a new CA certificate if file doesn't exist (default "~/.hetty/hetty_cert.pem")
-key string
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
-db string
Database directory path (default "~/.hetty/db")
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

View File

@ -1,33 +0,0 @@
---
sidebarDepth: 0
---
# Introduction
![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=18BA91&style=flat-square)
![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=18BA91&style=flat-square)
**Hetty** is an HTTP toolkit for security research. It aims to become an open
source alternative to commercial software like Burp Suite Pro, with powerful
features tailored to the needs of the infosec and bug bounty community.
![Hetty proxy logs screenshot](./hetty_v0.2.0_header.png =1280x)
## Features
- Machine-in-the-middle (MITM) HTTP/1.1 proxy with logs
- Project based database storage (BadgerDB)
- Scope support
- Headless management API using GraphQL
- Embedded web admin interface (Next.js)
::: tip INFO
Hetty is in early development. Additional features are planned
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a>
for details.
:::
## Sponsors
[![Sponsored by Tines](/assets/tines-sponsorship-badge.png =140x)](https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@ -1,234 +0,0 @@
---
sidebarDepth: 3
---
# Modules
Hetty consists of various _modules_ that together form an HTTP toolkit. They
typically are managed via the web admin interface. Some modules expose settings
and behavior that is leveraged by other modules.
The available modules:
[[toc]]
## Projects
Projects are stored in a single BadgerDB database on disk.
They allow you organize your work, for example to split your work between research
targets.
You can create multiple projects, but only one can be open at a time. Most other
modules are useful only if you have a project opened, so creating a project is
typically the first thing you do when you start using Hetty.
### Creating a new project
When you open the Hetty admin interface after starting the program, youll be prompted
on the homepage to “Manage projects”, which leads to the “Projects” page where
you can open an existing project or create a new one:
![Creating a project](./create_project.png =417x)
::: tip INFO
Projects are stored in a single BadgerDB database, stored in `$HOME/.hetty/db` on Linux
and macOS, and `%USERPROFILE%/.hetty/db` on Windows. You can override this path with
the `-db` flag. See: [Usage](/guide/getting-started.md#usage).
:::
### Managing projects
You can open and delete existing projects on the “Projects” page, available via
the folder icon in the menu bar.
![Managing projects](./manage_projects.png =594x)
An opened (_active_) project is listed in green. You can close it using the “X”
button. To delete a project, use the trash bin icon.
::: danger
Deleting a project is irreversible.
:::
## Proxy
Hetty features a HTTP/1.1 proxy server with machine-in-the-middle (MITM) behavior.
For now, its only configuration is done via command line flags.
::: tip INFO
Support for HTTP/2 and WebSockets are currently not supported, but this will
likely be addressed in the (near) future.
:::
### Network address
To configure the network address that the proxy listens on, use the `-addr` flag
when starting Hetty. The address needs to be in the format `[host]:port`. E.g.
`localhost:3000` or `:3000`. If the host in the address is empty or a literal
unspecified IP address, Hetty listens on all available unicast and anycast IP
addresses of the local system.
::: tip INFO
When not specified with `-addr`, Hetty by default listens on `:8080`.
:::
Example of starting Hetty, binding to port `3000` on all IPs of the local system:
```
$ hetty -addr :3000
```
### Using the proxy
To use Hetty as an HTTP proxy server, youll need to configure your HTTP client (e.g.
your browser or mobile OS). Refer to your client documentation or use a search
engine to find instructions for setting a HTTP proxy.
### Certificate Authority (CA)
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 needs to be
installed to the host for them to be trusted by your browser. The following steps
will cover how you can generate a certificate, provide it to Hetty, and how
you can install it in your local CA store.
::: tip INFO
Certificate management features (e.g. automated installing of a root CA to your local
OS or browser trust store) are planned for a future release. In the meantime, please
use the instructions below.
:::
#### Generating a CA certificate
You can generate a CA keypair two different ways. The first is bundled directly
with Hetty, and simplifies the process immensely. The alternative is using OpenSSL
to generate them, which provides more control over expiration time and cryptography
used, but requires you install the OpenSSL tooling. The first is suggested for any
beginners trying to get started.
#### Generating CA certificates with hetty
Hetty will generate the default key and certificate on its own if none are supplied
or found in `~/.hetty/` when first running the CLI. To generate a default key and
certificate with hetty, simply run the command with no arguments.
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
`~/.hetty/hetty_cert.pem` respectively.
#### Generating CA certificates with OpenSSL
::: tip INFO
This following instructions are for Linux but should provide guidance for Windows
and macOS as well.
:::
You can start off by generating a new key and CA certificate which will both expire
after a month.
```
$ mkdir ~/.hetty
$ openssl req -newkey rsa:2048 -new -nodes -x509 -days 31 -keyout ~/.hetty/hetty_key.pem -out ~/.hetty/hetty_cert.pem
```
The default location which `hetty` will check for the key and CA certificate is under
`~/.hetty/`, at `hetty_key.pem` and `hetty_cert.pem` respectively. You can move them
here and `hetty` will detect them automatically. Otherwise, you can specify the
location of these as arguments to `hetty`.
```
\$ hetty -key /some/directory/key.pem -cert /some/directory/cert.pem
```
#### Trusting the CA certificate
In order for your browser to allow traffic to the local Hetty proxy, you may need
to install these certificates to your local CA store.
On Ubuntu, you can update your local CA store with the certificate by running the
following commands:
```
$ sudo cp ~/.hetty/hetty_cert.pem /usr/local/share/ca-certificates/hetty.crt
$ sudo update-ca-certificates
```
On Windows, you would add your certificate by using the Certificate Manager,
which you can run via:
```
certmgr.msc
```
On macOS, you can add your certificate by using the Keychain Access program. This
can be found under `Application/Utilities/Keychain Access.app`. After opening this,
drag the certificate into the app. Next, open the certificate in the app, enter the
_Trust_ section, and under _When using this certificate_ select _Always Trust_.
::: tip INFO
Various Linux distributions may require other steps or commands for updating
their certificate authority. See the documentation relevant to your distribution for
more information on how to update the system to trust your self-signed certificate.
:::
## Scope
The scope module lets you define _rules_ that other modules can use to control
their behavior. For example, the [proxy logs module](#proxy-logs) can be configured to only
show logs for in-scope requests; meaning only requests are shown that match one
or more scope rules.
### Managing scope rules
You can manage scope rules via the “Scope” page, available via the crosshair icon
in the menu bar.
A rule consists of a _type_ and a regular expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax)).
The only supported type at the moment is “URL”.
::: tip INFO
Just like all module configuration, scope rules are defined and stored per-project.
:::
#### Adding a rule
On the ”Scope” page, enter a regular expression and click “Add rule”:
![Adding a scope rule](./add_scope_rule.png =592x)
_Example: Rule that matches URLs with `example.com` (or any subdomain) on any path._
#### Deleting rules
Use the trash icon next to an existing scope rule to delete it.
## Proxy logs
You can few logs captured by the Proxy module on the Proxy logs page, available
via the proxy icon in the menu bar.
![Proxy logs overview](./proxy_logs.png =1207x)
### Showing a log entry
Click a row in the overview table to view log details in the bottom request and
response panes. When a request and/or response has a body, it's shown below the
HTTP headers. Header keys and values can be copied to clipboard by clicking them.
### Filtering logs
To only show log entries that match any of the [scope rules](#scope), click the
filter icon in the search bar and select “Only show in-scope requests”:
![Only show in-scope requests](./filter_in_scope.png =431x)
::: tip INFO
At the moment of writing (`v0.2.0`), text based search is not implemented yet.
:::

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

View File

@ -1,6 +0,0 @@
---
home: true
heroImage: https://hetty.xyz/assets/logo.png
actionText: Read the docs →
actionLink: /guide/
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,14 @@ import (
"github.com/oklog/ulid"
)
type CancelRequestResult struct {
Success bool `json:"success"`
}
type CancelResponseResult struct {
Success bool `json:"success"`
}
type ClearHTTPRequestLogResult struct {
Success bool `json:"success"`
}
@ -38,6 +46,16 @@ type HTTPHeaderInput struct {
Value string `json:"value"`
}
type HTTPRequest struct {
ID ulid.ULID `json:"id"`
URL *url.URL `json:"url"`
Method HTTPMethod `json:"method"`
Proto HTTPProtocol `json:"proto"`
Headers []HTTPHeader `json:"headers"`
Body *string `json:"body"`
Response *HTTPResponse `json:"response"`
}
type HTTPRequestLog struct {
ID ulid.ULID `json:"id"`
URL string `json:"url"`
@ -59,6 +77,16 @@ type HTTPRequestLogFilterInput struct {
SearchExpression *string `json:"searchExpression"`
}
type HTTPResponse struct {
// Will be the same ID as its related request ID.
ID ulid.ULID `json:"id"`
Proto HTTPProtocol `json:"proto"`
StatusCode int `json:"statusCode"`
StatusReason string `json:"statusReason"`
Body *string `json:"body"`
Headers []HTTPHeader `json:"headers"`
}
type HTTPResponseLog struct {
// Will be the same ID as its related request ID.
ID ulid.ULID `json:"id"`
@ -69,10 +97,49 @@ type HTTPResponseLog struct {
Headers []HTTPHeader `json:"headers"`
}
type InterceptSettings struct {
RequestsEnabled bool `json:"requestsEnabled"`
ResponsesEnabled bool `json:"responsesEnabled"`
RequestFilter *string `json:"requestFilter"`
ResponseFilter *string `json:"responseFilter"`
}
type ModifyRequestInput struct {
ID ulid.ULID `json:"id"`
URL *url.URL `json:"url"`
Method HTTPMethod `json:"method"`
Proto HTTPProtocol `json:"proto"`
Headers []HTTPHeaderInput `json:"headers"`
Body *string `json:"body"`
ModifyResponse *bool `json:"modifyResponse"`
}
type ModifyRequestResult struct {
Success bool `json:"success"`
}
type ModifyResponseInput struct {
RequestID ulid.ULID `json:"requestID"`
Proto HTTPProtocol `json:"proto"`
Headers []HTTPHeaderInput `json:"headers"`
Body *string `json:"body"`
StatusCode int `json:"statusCode"`
StatusReason string `json:"statusReason"`
}
type ModifyResponseResult struct {
Success bool `json:"success"`
}
type Project struct {
ID ulid.ULID `json:"id"`
Name string `json:"name"`
IsActive bool `json:"isActive"`
ID ulid.ULID `json:"id"`
Name string `json:"name"`
IsActive bool `json:"isActive"`
Settings *ProjectSettings `json:"settings"`
}
type ProjectSettings struct {
Intercept *InterceptSettings `json:"intercept"`
}
type ScopeHeader struct {
@ -128,6 +195,13 @@ type SenderRequestInput struct {
Body *string `json:"body"`
}
type UpdateInterceptSettingsInput struct {
RequestsEnabled bool `json:"requestsEnabled"`
ResponsesEnabled bool `json:"responsesEnabled"`
RequestFilter *string `json:"requestFilter"`
ResponseFilter *string `json:"responseFilter"`
}
type HTTPMethod string
const (

View File

@ -3,9 +3,12 @@ package api
//go:generate go run github.com/99designs/gqlgen
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"regexp"
"strings"
@ -15,6 +18,8 @@ import (
"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
@ -36,6 +41,7 @@ var revHTTPProtocolMap = map[HTTPProtocol]string{
type Resolver struct {
ProjectService proj.Service
RequestLogService reqlog.Service
InterceptService *intercept.Service
SenderService sender.Service
}
@ -179,11 +185,9 @@ func (r *mutationResolver) CreateProject(ctx context.Context, name string) (*Pro
return nil, fmt.Errorf("could not open project: %w", err)
}
return &Project{
ID: p.ID,
Name: p.Name,
IsActive: r.ProjectService.IsProjectActive(p.ID),
}, nil
project := parseProject(r.ProjectService, p)
return &project, nil
}
func (r *mutationResolver) OpenProject(ctx context.Context, id ulid.ULID) (*Project, error) {
@ -194,11 +198,9 @@ func (r *mutationResolver) OpenProject(ctx context.Context, id ulid.ULID) (*Proj
return nil, fmt.Errorf("could not open project: %w", err)
}
return &Project{
ID: p.ID,
Name: p.Name,
IsActive: r.ProjectService.IsProjectActive(p.ID),
}, nil
project := parseProject(r.ProjectService, p)
return &project, nil
}
func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
@ -209,11 +211,9 @@ func (r *queryResolver) ActiveProject(ctx context.Context) (*Project, error) {
return nil, fmt.Errorf("could not open project: %w", err)
}
return &Project{
ID: p.ID,
Name: p.Name,
IsActive: r.ProjectService.IsProjectActive(p.ID),
}, nil
project := parseProject(r.ProjectService, p)
return &project, nil
}
func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
@ -224,11 +224,7 @@ func (r *queryResolver) Projects(ctx context.Context) ([]Project, error) {
projects := make([]Project, len(p))
for i, proj := range p {
projects[i] = Project{
ID: proj.ID,
Name: proj.Name,
IsActive: r.ProjectService.IsProjectActive(proj.ID),
}
projects[i] = parseProject(r.ProjectService, proj)
}
return projects, nil
@ -520,6 +516,166 @@ func (r *mutationResolver) DeleteSenderRequests(ctx context.Context) (*DeleteSen
return &DeleteSenderRequestsResult{true}, nil
}
func (r *queryResolver) InterceptedRequests(ctx context.Context) (httpReqs []HTTPRequest, err error) {
items := r.InterceptService.Items()
for _, item := range items {
req, err := parseInterceptItem(item)
if err != nil {
return nil, err
}
httpReqs = append(httpReqs, req)
}
return httpReqs, nil
}
func (r *queryResolver) InterceptedRequest(ctx context.Context, id ulid.ULID) (*HTTPRequest, error) {
item, err := r.InterceptService.ItemByID(id)
if errors.Is(err, intercept.ErrRequestNotFound) {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("could not get request by ID: %w", err)
}
req, err := parseInterceptItem(item)
if err != nil {
return nil, err
}
return &req, nil
}
func (r *mutationResolver) ModifyRequest(ctx context.Context, input ModifyRequestInput) (*ModifyRequestResult, error) {
body := ""
if input.Body != nil {
body = *input.Body
}
//nolint:noctx
req, err := http.NewRequest(input.Method.String(), input.URL.String(), strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to construct HTTP request: %w", err)
}
for _, header := range input.Headers {
req.Header.Add(header.Key, header.Value)
}
err = r.InterceptService.ModifyRequest(input.ID, req, input.ModifyResponse)
if err != nil {
return nil, fmt.Errorf("could not modify http request: %w", err)
}
return &ModifyRequestResult{Success: true}, nil
}
func (r *mutationResolver) CancelRequest(ctx context.Context, id ulid.ULID) (*CancelRequestResult, error) {
err := r.InterceptService.CancelRequest(id)
if err != nil {
return nil, fmt.Errorf("could not cancel http request: %w", err)
}
return &CancelRequestResult{Success: true}, nil
}
func (r *mutationResolver) ModifyResponse(
ctx context.Context,
input ModifyResponseInput,
) (*ModifyResponseResult, error) {
res := &http.Response{
Header: make(http.Header),
Status: fmt.Sprintf("%v %v", input.StatusCode, input.StatusReason),
StatusCode: input.StatusCode,
Proto: revHTTPProtocolMap[input.Proto],
}
var ok bool
if res.ProtoMajor, res.ProtoMinor, ok = http.ParseHTTPVersion(res.Proto); !ok {
return nil, fmt.Errorf("malformed HTTP version: %q", res.Proto)
}
var body string
if input.Body != nil {
body = *input.Body
}
res.Body = io.NopCloser(strings.NewReader(body))
for _, header := range input.Headers {
res.Header.Add(header.Key, header.Value)
}
err := r.InterceptService.ModifyResponse(input.RequestID, res)
if err != nil {
return nil, fmt.Errorf("could not modify http request: %w", err)
}
return &ModifyResponseResult{Success: true}, nil
}
func (r *mutationResolver) CancelResponse(ctx context.Context, requestID ulid.ULID) (*CancelResponseResult, error) {
err := r.InterceptService.CancelResponse(requestID)
if err != nil {
return nil, fmt.Errorf("could not cancel http response: %w", err)
}
return &CancelResponseResult{Success: true}, nil
}
func (r *mutationResolver) UpdateInterceptSettings(
ctx context.Context,
input UpdateInterceptSettingsInput,
) (*InterceptSettings, error) {
settings := intercept.Settings{
RequestsEnabled: input.RequestsEnabled,
ResponsesEnabled: input.ResponsesEnabled,
}
if input.RequestFilter != nil && *input.RequestFilter != "" {
expr, err := search.ParseQuery(*input.RequestFilter)
if err != nil {
return nil, fmt.Errorf("could not parse request filter: %w", err)
}
settings.RequestFilter = expr
}
if input.ResponseFilter != nil && *input.ResponseFilter != "" {
expr, err := search.ParseQuery(*input.ResponseFilter)
if err != nil {
return nil, fmt.Errorf("could not parse response filter: %w", err)
}
settings.ResponseFilter = expr
}
err := r.ProjectService.UpdateInterceptSettings(ctx, settings)
if errors.Is(err, proj.ErrNoProject) {
return nil, noActiveProjectErr(ctx)
} else if err != nil {
return nil, fmt.Errorf("could not update intercept settings: %w", err)
}
updated := &InterceptSettings{
RequestsEnabled: settings.RequestsEnabled,
ResponsesEnabled: settings.ResponsesEnabled,
}
if settings.RequestFilter != nil {
reqFilter := settings.RequestFilter.String()
updated.RequestFilter = &reqFilter
}
if settings.ResponseFilter != nil {
resFilter := settings.ResponseFilter.String()
updated.ResponseFilter = &resFilter
}
return updated, nil
}
func parseSenderRequest(req sender.Request) (SenderRequest, error) {
method := HTTPMethod(req.Method)
if method != "" && !method.IsValid() {
@ -575,6 +731,155 @@ func parseSenderRequest(req sender.Request) (SenderRequest, error) {
return senderReq, nil
}
func parseHTTPRequest(req *http.Request) (HTTPRequest, error) {
method := HTTPMethod(req.Method)
if method != "" && !method.IsValid() {
return HTTPRequest{}, fmt.Errorf("http request has invalid method: %v", method)
}
reqProto := httpProtocolMap[req.Proto]
if !reqProto.IsValid() {
return HTTPRequest{}, fmt.Errorf("http request has invalid protocol: %v", req.Proto)
}
id, ok := proxy.RequestIDFromContext(req.Context())
if !ok {
return HTTPRequest{}, errors.New("http request has missing ID")
}
httpReq := HTTPRequest{
ID: id,
URL: req.URL,
Method: method,
Proto: HTTPProtocol(req.Proto),
}
if req.Header != nil {
httpReq.Headers = make([]HTTPHeader, 0)
for key, values := range req.Header {
for _, value := range values {
httpReq.Headers = append(httpReq.Headers, HTTPHeader{
Key: key,
Value: value,
})
}
}
}
if req.Body != nil {
body, err := ioutil.ReadAll(req.Body)
if err != nil {
return HTTPRequest{}, fmt.Errorf("failed to read request body: %w", err)
}
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
bodyStr := string(body)
httpReq.Body = &bodyStr
}
return httpReq, nil
}
func parseHTTPResponse(res *http.Response) (HTTPResponse, error) {
resProto := httpProtocolMap[res.Proto]
if !resProto.IsValid() {
return HTTPResponse{}, fmt.Errorf("http response has invalid protocol: %v", res.Proto)
}
id, ok := proxy.RequestIDFromContext(res.Request.Context())
if !ok {
return HTTPResponse{}, errors.New("http response has missing ID")
}
httpRes := HTTPResponse{
ID: id,
Proto: resProto,
StatusCode: res.StatusCode,
}
statusReasonSubs := strings.SplitN(res.Status, " ", 2)
if len(statusReasonSubs) == 2 {
httpRes.StatusReason = statusReasonSubs[1]
}
if res.Header != nil {
httpRes.Headers = make([]HTTPHeader, 0)
for key, values := range res.Header {
for _, value := range values {
httpRes.Headers = append(httpRes.Headers, HTTPHeader{
Key: key,
Value: value,
})
}
}
}
if res.Body != nil {
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return HTTPResponse{}, fmt.Errorf("failed to read response body: %w", err)
}
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
bodyStr := string(body)
httpRes.Body = &bodyStr
}
return httpRes, nil
}
func parseInterceptItem(item intercept.Item) (req HTTPRequest, err error) {
if item.Response != nil {
req, err = parseHTTPRequest(item.Response.Request)
if err != nil {
return HTTPRequest{}, err
}
res, err := parseHTTPResponse(item.Response)
if err != nil {
return HTTPRequest{}, err
}
req.Response = &res
} else if item.Request != nil {
req, err = parseHTTPRequest(item.Request)
if err != nil {
return HTTPRequest{}, err
}
}
return req, nil
}
func parseProject(projSvc proj.Service, p proj.Project) Project {
project := Project{
ID: p.ID,
Name: p.Name,
IsActive: projSvc.IsProjectActive(p.ID),
Settings: &ProjectSettings{
Intercept: &InterceptSettings{
RequestsEnabled: p.Settings.InterceptRequests,
ResponsesEnabled: p.Settings.InterceptResponses,
},
},
}
if p.Settings.InterceptRequestFilter != nil {
interceptReqFilter := p.Settings.InterceptRequestFilter.String()
project.Settings.Intercept.RequestFilter = &interceptReqFilter
}
if p.Settings.InterceptResponseFilter != nil {
interceptResFilter := p.Settings.InterceptResponseFilter.String()
project.Settings.Intercept.ResponseFilter = &interceptResFilter
}
return project
}
func stringPtrToRegexp(s *string) (*regexp.Regexp, error) {
if s == nil {
return nil, nil

View File

@ -30,6 +30,11 @@ type Project {
id: ID!
name: String!
isActive: Boolean!
settings: ProjectSettings!
}
type ProjectSettings {
intercept: InterceptSettings!
}
type ScopeRule {
@ -116,6 +121,77 @@ type SenderRequestFilter {
searchExpression: String
}
type HttpRequest {
id: ID!
url: URL!
method: HttpMethod!
proto: HttpProtocol!
headers: [HttpHeader!]!
body: String
response: HttpResponse
}
type HttpResponse {
"""
Will be the same ID as its related request ID.
"""
id: ID!
proto: HttpProtocol!
statusCode: Int!
statusReason: String!
body: String
headers: [HttpHeader!]!
}
input ModifyRequestInput {
id: ID!
url: URL!
method: HttpMethod!
proto: HttpProtocol!
headers: [HttpHeaderInput!]
body: String
modifyResponse: Boolean
}
type ModifyRequestResult {
success: Boolean!
}
type CancelRequestResult {
success: Boolean!
}
input ModifyResponseInput {
requestID: ID!
proto: HttpProtocol!
headers: [HttpHeaderInput!]
body: String
statusCode: Int!
statusReason: String!
}
type ModifyResponseResult {
success: Boolean!
}
type CancelResponseResult {
success: Boolean!
}
input UpdateInterceptSettingsInput {
requestsEnabled: Boolean!
responsesEnabled: Boolean!
requestFilter: String
responseFilter: String
}
type InterceptSettings {
requestsEnabled: Boolean!
responsesEnabled: Boolean!
requestFilter: String
responseFilter: String
}
type Query {
httpRequestLog(id: ID!): HttpRequestLog
httpRequestLogs: [HttpRequestLog!]!
@ -125,6 +201,8 @@ type Query {
scope: [ScopeRule!]!
senderRequest(id: ID!): SenderRequest
senderRequests: [SenderRequest!]!
interceptedRequests: [HttpRequest!]!
interceptedRequest(id: ID!): HttpRequest
}
type Mutation {
@ -142,6 +220,13 @@ type Mutation {
createSenderRequestFromHttpRequestLog(id: ID!): SenderRequest!
sendRequest(id: ID!): SenderRequest!
deleteSenderRequests: DeleteSenderRequestsResult!
modifyRequest(request: ModifyRequestInput!): ModifyRequestResult!
cancelRequest(id: ID!): CancelRequestResult!
modifyResponse(response: ModifyResponseInput!): ModifyResponseResult!
cancelResponse(requestID: ID!): CancelResponseResult!
updateInterceptSettings(
input: UpdateInterceptSettingsInput!
): InterceptSettings!
}
enum HttpMethod {

View File

@ -11,6 +11,7 @@ import (
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
@ -33,10 +34,12 @@ type Service interface {
SetScopeRules(ctx context.Context, rules []scope.Rule) error
SetRequestLogFindFilter(ctx context.Context, filter reqlog.FindRequestsFilter) error
SetSenderRequestFindFilter(ctx context.Context, filter sender.FindRequestsFilter) error
UpdateInterceptSettings(ctx context.Context, settings intercept.Settings) error
}
type service struct {
repo Repository
interceptSvc *intercept.Service
reqLogSvc reqlog.Service
senderSvc sender.Service
scope *scope.Scope
@ -53,13 +56,22 @@ type Project struct {
}
type Settings struct {
// Request log settings
ReqLogBypassOutOfScope bool
ReqLogOnlyFindInScope bool
ReqLogSearchExpr search.Expression
// Intercept settings
InterceptRequests bool
InterceptResponses bool
InterceptRequestFilter search.Expression
InterceptResponseFilter search.Expression
// Sender settings
SenderOnlyFindInScope bool
SenderSearchExpr search.Expression
// Scope settings
ScopeRules []scope.Rule
}
@ -73,19 +85,21 @@ var (
var nameRegexp = regexp.MustCompile(`^[\w\d\s]+$`)
type Config struct {
Repository Repository
ReqLogService reqlog.Service
SenderService sender.Service
Scope *scope.Scope
Repository Repository
InterceptService *intercept.Service
ReqLogService reqlog.Service
SenderService sender.Service
Scope *scope.Scope
}
// NewService returns a new Service.
func NewService(cfg Config) (Service, error) {
return &service{
repo: cfg.Repository,
reqLogSvc: cfg.ReqLogService,
senderSvc: cfg.SenderService,
scope: cfg.Scope,
repo: cfg.Repository,
interceptSvc: cfg.InterceptService,
reqLogSvc: cfg.ReqLogService,
senderSvc: cfg.SenderService,
scope: cfg.Scope,
}, nil
}
@ -120,6 +134,12 @@ func (svc *service) CloseProject() error {
svc.reqLogSvc.SetActiveProjectID(ulid.ULID{})
svc.reqLogSvc.SetBypassOutOfScopeRequests(false)
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{})
svc.interceptSvc.UpdateSettings(intercept.Settings{
RequestsEnabled: false,
ResponsesEnabled: false,
RequestFilter: nil,
ResponseFilter: nil,
})
svc.senderSvc.SetActiveProjectID(ulid.ULID{})
svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{})
svc.scope.SetRules(nil)
@ -152,6 +172,7 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
svc.activeProjectID = project.ID
// Request log settings.
svc.reqLogSvc.SetFindReqsFilter(reqlog.FindRequestsFilter{
ProjectID: project.ID,
OnlyInScope: project.Settings.ReqLogOnlyFindInScope,
@ -160,6 +181,15 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
svc.reqLogSvc.SetBypassOutOfScopeRequests(project.Settings.ReqLogBypassOutOfScope)
svc.reqLogSvc.SetActiveProjectID(project.ID)
// Intercept settings.
svc.interceptSvc.UpdateSettings(intercept.Settings{
RequestsEnabled: project.Settings.InterceptRequests,
ResponsesEnabled: project.Settings.InterceptResponses,
RequestFilter: project.Settings.InterceptRequestFilter,
ResponseFilter: project.Settings.InterceptResponseFilter,
})
// Sender settings.
svc.senderSvc.SetActiveProjectID(project.ID)
svc.senderSvc.SetFindReqsFilter(sender.FindRequestsFilter{
ProjectID: project.ID,
@ -167,6 +197,7 @@ func (svc *service) OpenProject(ctx context.Context, projectID ulid.ULID) (Proje
SearchExpr: project.Settings.SenderSearchExpr,
})
// Scope settings.
svc.scope.SetRules(project.Settings.ScopeRules)
return project, nil
@ -264,3 +295,24 @@ func (svc *service) SetSenderRequestFindFilter(ctx context.Context, filter sende
func (svc *service) IsProjectActive(projectID ulid.ULID) bool {
return projectID.Compare(svc.activeProjectID) == 0
}
func (svc *service) UpdateInterceptSettings(ctx context.Context, settings intercept.Settings) error {
project, err := svc.ActiveProject(ctx)
if err != nil {
return err
}
project.Settings.InterceptRequests = settings.RequestsEnabled
project.Settings.InterceptResponses = settings.ResponsesEnabled
project.Settings.InterceptRequestFilter = settings.RequestFilter
project.Settings.InterceptResponseFilter = settings.ResponseFilter
err = svc.repo.UpsertProject(ctx, project)
if err != nil {
return fmt.Errorf("proj: failed to update project: %w", err)
}
svc.interceptSvc.UpdateSettings(settings)
return nil
}

35
pkg/proxy/gzip.go Normal file
View File

@ -0,0 +1,35 @@
package proxy
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"net/http"
)
func gunzipResponseBody(res *http.Response) error {
if res.Header.Get("Content-Encoding") != "gzip" {
return nil
}
gzipReader, err := gzip.NewReader(res.Body)
if err != nil {
return fmt.Errorf("proxy: could not create gzip reader: %w", err)
}
defer gzipReader.Close()
buf := &bytes.Buffer{}
//nolint:gosec
if _, err := io.Copy(buf, gzipReader); err != nil {
return fmt.Errorf("proxy: could not read gzipped response body: %w", err)
}
res.Body = io.NopCloser(buf)
res.Header.Del("Content-Encoding")
res.Header.Set("Content-Length", fmt.Sprint(buf.Len()))
res.ContentLength = int64(buf.Len())
return nil
}

View File

@ -0,0 +1,395 @@
package intercept
import (
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"strconv"
"strings"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
//nolint:unparam
var reqFilterKeyFns = map[string]func(req *http.Request) (string, error){
"proto": func(req *http.Request) (string, error) { return req.Proto, nil },
"url": func(req *http.Request) (string, error) {
if req.URL == nil {
return "", nil
}
return req.URL.String(), nil
},
"method": func(req *http.Request) (string, error) { return req.Method, nil },
"body": func(req *http.Request) (string, error) {
if req.Body == nil {
return "", nil
}
body, err := io.ReadAll(req.Body)
if err != nil {
return "", err
}
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
return string(body), nil
},
}
//nolint:unparam
var resFilterKeyFns = map[string]func(res *http.Response) (string, error){
"proto": func(res *http.Response) (string, error) { return res.Proto, nil },
"statusCode": func(res *http.Response) (string, error) { return strconv.Itoa(res.StatusCode), nil },
"statusReason": func(res *http.Response) (string, error) {
statusReasonSubs := strings.SplitN(res.Status, " ", 2)
if len(statusReasonSubs) != 2 {
return "", fmt.Errorf("invalid response status %q", res.Status)
}
return statusReasonSubs[1], nil
},
"body": func(res *http.Response) (string, error) {
if res.Body == nil {
return "", nil
}
body, err := io.ReadAll(res.Body)
if err != nil {
return "", err
}
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
return string(body), nil
},
}
// MatchRequestFilter returns true if an HTTP request matches the request filter expression.
func MatchRequestFilter(req *http.Request, expr search.Expression) (bool, error) {
switch e := expr.(type) {
case search.PrefixExpression:
return matchReqPrefixExpr(req, e)
case search.InfixExpression:
return matchReqInfixExpr(req, e)
case search.StringLiteral:
return matchReqStringLiteral(req, e)
default:
return false, fmt.Errorf("expression type (%T) not supported", expr)
}
}
func matchReqPrefixExpr(req *http.Request, expr search.PrefixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpNot:
match, err := MatchRequestFilter(req, expr.Right)
if err != nil {
return false, err
}
return !match, nil
default:
return false, errors.New("operator is not supported")
}
}
func matchReqInfixExpr(req *http.Request, expr search.InfixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpAnd:
left, err := MatchRequestFilter(req, expr.Left)
if err != nil {
return false, err
}
right, err := MatchRequestFilter(req, expr.Right)
if err != nil {
return false, err
}
return left && right, nil
case search.TokOpOr:
left, err := MatchRequestFilter(req, expr.Left)
if err != nil {
return false, err
}
right, err := MatchRequestFilter(req, expr.Right)
if err != nil {
return false, err
}
return left || right, nil
}
left, ok := expr.Left.(search.StringLiteral)
if !ok {
return false, errors.New("left operand must be a string literal")
}
leftVal, err := getMappedStringLiteralFromReq(req, left.Value)
if err != nil {
return false, fmt.Errorf("failed to get string literal from request for left operand: %w", err)
}
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
right, ok := expr.Right.(search.RegexpLiteral)
if !ok {
return false, errors.New("right operand must be a regular expression")
}
switch expr.Operator {
case search.TokOpRe:
return right.MatchString(leftVal), nil
case search.TokOpNotRe:
return !right.MatchString(leftVal), nil
}
}
right, ok := expr.Right.(search.StringLiteral)
if !ok {
return false, errors.New("right operand must be a string literal")
}
rightVal, err := getMappedStringLiteralFromReq(req, right.Value)
if err != nil {
return false, fmt.Errorf("failed to get string literal from request for right operand: %w", err)
}
switch expr.Operator {
case search.TokOpEq:
return leftVal == rightVal, nil
case search.TokOpNotEq:
return leftVal != rightVal, nil
case search.TokOpGt:
// TODO(?) attempt to parse as int.
return leftVal > rightVal, nil
case search.TokOpLt:
// TODO(?) attempt to parse as int.
return leftVal < rightVal, nil
case search.TokOpGtEq:
// TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil
case search.TokOpLtEq:
// TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil
default:
return false, errors.New("unsupported operator")
}
}
func getMappedStringLiteralFromReq(req *http.Request, s string) (string, error) {
fn, ok := reqFilterKeyFns[s]
if ok {
return fn(req)
}
return s, nil
}
func matchReqStringLiteral(req *http.Request, strLiteral search.StringLiteral) (bool, error) {
for _, fn := range reqFilterKeyFns {
value, err := fn(req)
if err != nil {
return false, err
}
if strings.Contains(strings.ToLower(value), strings.ToLower(strLiteral.Value)) {
return true, nil
}
}
return false, nil
}
func MatchRequestScope(req *http.Request, s *scope.Scope) (bool, error) {
for _, rule := range s.Rules() {
if rule.URL != nil && req.URL != nil {
if matches := rule.URL.MatchString(req.URL.String()); matches {
return true, nil
}
}
for key, values := range req.Header {
var keyMatches, valueMatches bool
if rule.Header.Key != nil {
if matches := rule.Header.Key.MatchString(key); matches {
keyMatches = true
}
}
if rule.Header.Value != nil {
for _, value := range values {
if matches := rule.Header.Value.MatchString(value); matches {
valueMatches = true
break
}
}
}
// When only key or value is set, match on whatever is set.
// When both are set, both must match.
switch {
case rule.Header.Key != nil && rule.Header.Value == nil && keyMatches:
return true, nil
case rule.Header.Key == nil && rule.Header.Value != nil && valueMatches:
return true, nil
case rule.Header.Key != nil && rule.Header.Value != nil && keyMatches && valueMatches:
return true, nil
}
}
if rule.Body != nil {
body, err := io.ReadAll(req.Body)
if err != nil {
return false, fmt.Errorf("failed to read request body: %w", err)
}
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
if matches := rule.Body.Match(body); matches {
return true, nil
}
}
}
return false, nil
}
// MatchResponseFilter returns true if an HTTP response matches the response filter expression.
func MatchResponseFilter(res *http.Response, expr search.Expression) (bool, error) {
switch e := expr.(type) {
case search.PrefixExpression:
return matchResPrefixExpr(res, e)
case search.InfixExpression:
return matchResInfixExpr(res, e)
case search.StringLiteral:
return matchResStringLiteral(res, e)
default:
return false, fmt.Errorf("expression type (%T) not supported", expr)
}
}
func matchResPrefixExpr(res *http.Response, expr search.PrefixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpNot:
match, err := MatchResponseFilter(res, expr.Right)
if err != nil {
return false, err
}
return !match, nil
default:
return false, errors.New("operator is not supported")
}
}
func matchResInfixExpr(res *http.Response, expr search.InfixExpression) (bool, error) {
switch expr.Operator {
case search.TokOpAnd:
left, err := MatchResponseFilter(res, expr.Left)
if err != nil {
return false, err
}
right, err := MatchResponseFilter(res, expr.Right)
if err != nil {
return false, err
}
return left && right, nil
case search.TokOpOr:
left, err := MatchResponseFilter(res, expr.Left)
if err != nil {
return false, err
}
right, err := MatchResponseFilter(res, expr.Right)
if err != nil {
return false, err
}
return left || right, nil
}
left, ok := expr.Left.(search.StringLiteral)
if !ok {
return false, errors.New("left operand must be a string literal")
}
leftVal, err := getMappedStringLiteralFromRes(res, left.Value)
if err != nil {
return false, fmt.Errorf("failed to get string literal from response for left operand: %w", err)
}
if expr.Operator == search.TokOpRe || expr.Operator == search.TokOpNotRe {
right, ok := expr.Right.(search.RegexpLiteral)
if !ok {
return false, errors.New("right operand must be a regular expression")
}
switch expr.Operator {
case search.TokOpRe:
return right.MatchString(leftVal), nil
case search.TokOpNotRe:
return !right.MatchString(leftVal), nil
}
}
right, ok := expr.Right.(search.StringLiteral)
if !ok {
return false, errors.New("right operand must be a string literal")
}
rightVal, err := getMappedStringLiteralFromRes(res, right.Value)
if err != nil {
return false, fmt.Errorf("failed to get string literal from response for right operand: %w", err)
}
switch expr.Operator {
case search.TokOpEq:
return leftVal == rightVal, nil
case search.TokOpNotEq:
return leftVal != rightVal, nil
case search.TokOpGt:
// TODO(?) attempt to parse as int.
return leftVal > rightVal, nil
case search.TokOpLt:
// TODO(?) attempt to parse as int.
return leftVal < rightVal, nil
case search.TokOpGtEq:
// TODO(?) attempt to parse as int.
return leftVal >= rightVal, nil
case search.TokOpLtEq:
// TODO(?) attempt to parse as int.
return leftVal <= rightVal, nil
default:
return false, errors.New("unsupported operator")
}
}
func getMappedStringLiteralFromRes(res *http.Response, s string) (string, error) {
fn, ok := resFilterKeyFns[s]
if ok {
return fn(res)
}
return s, nil
}
func matchResStringLiteral(res *http.Response, strLiteral search.StringLiteral) (bool, error) {
for _, fn := range resFilterKeyFns {
value, err := fn(res)
if err != nil {
return false, err
}
if strings.Contains(strings.ToLower(value), strings.ToLower(strLiteral.Value)) {
return true, nil
}
}
return false, nil
}

View File

@ -0,0 +1,452 @@
package intercept
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"sync"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/log"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/search"
)
var (
ErrRequestAborted = errors.New("intercept: request was aborted")
ErrRequestNotFound = errors.New("intercept: request not found")
ErrRequestDone = errors.New("intercept: request is done")
ErrResponseNotFound = errors.New("intercept: response not found")
)
type contextKey int
const interceptResponseKey contextKey = 0
// Request represents a server received HTTP request, alongside a channel for sending a modified version of it to the
// routine that's awaiting it. Also contains a channel for receiving a cancellation signal.
type Request struct {
req *http.Request
ch chan<- *http.Request
done <-chan struct{}
}
// Response represents an HTTP response from a proxied request, alongside a channel for sending a modified version of it
// to the routine that's awaiting it. Also contains a channel for receiving a cancellation signal.
type Response struct {
res *http.Response
ch chan<- *http.Response
done <-chan struct{}
}
type Item struct {
Request *http.Request
Response *http.Response
}
type Service struct {
reqMu *sync.RWMutex
resMu *sync.RWMutex
requests map[ulid.ULID]Request
responses map[ulid.ULID]Response
logger log.Logger
requestsEnabled bool
responsesEnabled bool
reqFilter search.Expression
resFilter search.Expression
}
type Config struct {
Logger log.Logger
RequestsEnabled bool
ResponsesEnabled bool
RequestFilter search.Expression
ResponseFilter search.Expression
}
// RequestIDs implements sort.Interface.
type RequestIDs []ulid.ULID
func NewService(cfg Config) *Service {
s := &Service{
reqMu: &sync.RWMutex{},
resMu: &sync.RWMutex{},
requests: make(map[ulid.ULID]Request),
responses: make(map[ulid.ULID]Response),
logger: cfg.Logger,
requestsEnabled: cfg.RequestsEnabled,
responsesEnabled: cfg.ResponsesEnabled,
reqFilter: cfg.RequestFilter,
resFilter: cfg.ResponseFilter,
}
if s.logger == nil {
s.logger = log.NewNopLogger()
}
return s
}
// RequestModifier is a proxy.RequestModifyMiddleware for intercepting HTTP requests.
func (svc *Service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestModifyFunc {
return func(req *http.Request) {
// This is a blocking operation, that gets unblocked when either a modified request is returned or an error
// (typically `context.Canceled`).
modifiedReq, err := svc.InterceptRequest(req.Context(), req)
switch {
case errors.Is(err, ErrRequestAborted):
svc.logger.Debugw("Stopping intercept, request was aborted.")
// Prevent further processing by replacing req.Context with a cancelled context value.
// This will cause the http.Roundtripper in the `proxy` package to
// handle this request as an error.
ctx, cancel := context.WithCancel(context.Background())
cancel()
*req = *req.WithContext(ctx)
case errors.Is(err, context.Canceled):
svc.logger.Debugw("Stopping intercept, context was cancelled.")
case err != nil:
svc.logger.Errorw("Failed to intercept request.",
"error", err)
default:
*req = *modifiedReq
next(req)
}
}
}
// InterceptRequest adds an HTTP request to an array of pending intercepted requests, alongside channels used for
// sending a cancellation signal and receiving a modified request. It's safe for concurrent use.
func (svc *Service) InterceptRequest(ctx context.Context, req *http.Request) (*http.Request, error) {
reqID, ok := proxy.RequestIDFromContext(ctx)
if !ok {
svc.logger.Errorw("Failed to intercept: context doesn't have an ID.")
return req, nil
}
if !svc.requestsEnabled {
// If request intercept is disabled, return the incoming request as-is.
svc.logger.Debugw("Bypassed request interception: feature disabled.")
return req, nil
}
if svc.reqFilter != nil {
match, err := MatchRequestFilter(req, svc.reqFilter)
if err != nil {
return nil, fmt.Errorf("intercept: failed to match request rules for request (id: %v): %w",
reqID.String(), err,
)
}
if !match {
svc.logger.Debugw("Bypassed request interception: request rules don't match.")
return req, nil
}
}
ch := make(chan *http.Request)
done := make(chan struct{})
svc.reqMu.Lock()
svc.requests[reqID] = Request{
req: req,
ch: ch,
done: done,
}
svc.reqMu.Unlock()
// Whatever happens next (modified request returned, or a context cancelled error), any blocked channel senders
// should be unblocked, and the request should be removed from the requests queue.
defer func() {
close(done)
svc.reqMu.Lock()
defer svc.reqMu.Unlock()
delete(svc.requests, reqID)
}()
select {
case modReq := <-ch:
if modReq == nil {
return nil, ErrRequestAborted
}
return modReq, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// ModifyRequest sends a modified HTTP request to the related channel, or returns ErrRequestDone when the request was
// cancelled. It's safe for concurrent use.
func (svc *Service) ModifyRequest(reqID ulid.ULID, modReq *http.Request, modifyResponse *bool) error {
svc.reqMu.RLock()
req, ok := svc.requests[reqID]
svc.reqMu.RUnlock()
if !ok {
return ErrRequestNotFound
}
*modReq = *modReq.WithContext(req.req.Context())
if modifyResponse != nil {
*modReq = *modReq.WithContext(WithInterceptResponse(modReq.Context(), *modifyResponse))
}
select {
case <-req.done:
return ErrRequestDone
case req.ch <- modReq:
return nil
}
}
// CancelRequest ensures an intercepted request is dropped.
func (svc *Service) CancelRequest(reqID ulid.ULID) error {
return svc.ModifyRequest(reqID, nil, nil)
}
func (svc *Service) ClearRequests() {
svc.reqMu.Lock()
defer svc.reqMu.Unlock()
for _, req := range svc.requests {
select {
case <-req.done:
case req.ch <- nil:
}
}
}
func (svc *Service) ClearResponses() {
svc.resMu.Lock()
defer svc.resMu.Unlock()
for _, res := range svc.responses {
select {
case <-res.done:
case res.ch <- nil:
}
}
}
// Items returns a list of pending items (requests and responses). It's safe for concurrent use.
func (svc *Service) Items() []Item {
svc.reqMu.RLock()
defer svc.reqMu.RUnlock()
svc.resMu.RLock()
defer svc.resMu.RUnlock()
reqIDs := make([]ulid.ULID, 0, len(svc.requests)+len(svc.responses))
for id := range svc.requests {
reqIDs = append(reqIDs, id)
}
for id := range svc.responses {
reqIDs = append(reqIDs, id)
}
sort.Sort(RequestIDs(reqIDs))
items := make([]Item, len(reqIDs))
for i, id := range reqIDs {
item := Item{}
if req, ok := svc.requests[id]; ok {
item.Request = req.req
}
if res, ok := svc.responses[id]; ok {
item.Response = res.res
}
items[i] = item
}
return items
}
func (svc *Service) UpdateSettings(settings Settings) {
// When updating from requests `enabled` -> `disabled`, clear any pending reqs.
if svc.requestsEnabled && !settings.RequestsEnabled {
svc.ClearRequests()
}
// When updating from responses `enabled` -> `disabled`, clear any pending responses.
if svc.responsesEnabled && !settings.ResponsesEnabled {
svc.ClearResponses()
}
svc.requestsEnabled = settings.RequestsEnabled
svc.responsesEnabled = settings.ResponsesEnabled
svc.reqFilter = settings.RequestFilter
svc.resFilter = settings.ResponseFilter
}
// ItemByID returns an intercepted item (request and possible response) by ID. It's safe for concurrent use.
func (svc *Service) ItemByID(id ulid.ULID) (Item, error) {
svc.reqMu.RLock()
defer svc.reqMu.RUnlock()
svc.resMu.RLock()
defer svc.resMu.RUnlock()
item := Item{}
found := false
if req, ok := svc.requests[id]; ok {
item.Request = req.req
found = true
}
if res, ok := svc.responses[id]; ok {
item.Response = res.res
found = true
}
if !found {
return Item{}, ErrRequestNotFound
}
return item, nil
}
func (ids RequestIDs) Len() int {
return len(ids)
}
func (ids RequestIDs) Less(i, j int) bool {
return ids[i].Compare(ids[j]) == -1
}
func (ids RequestIDs) Swap(i, j int) {
ids[i], ids[j] = ids[j], ids[i]
}
func WithInterceptResponse(ctx context.Context, value bool) context.Context {
return context.WithValue(ctx, interceptResponseKey, value)
}
func ShouldInterceptResponseFromContext(ctx context.Context) (bool, bool) {
shouldIntercept, ok := ctx.Value(interceptResponseKey).(bool)
return shouldIntercept, ok
}
// ResponseModifier is a proxy.ResponseModifyMiddleware for intercepting HTTP responses.
func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.ResponseModifyFunc {
return func(res *http.Response) error {
// This is a blocking operation, that gets unblocked when either a modified response is returned or an error.
//nolint:bodyclose
modifiedRes, err := svc.InterceptResponse(res.Request.Context(), res)
if err != nil {
return fmt.Errorf("failed to intercept response: %w", err)
}
*res = *modifiedRes
return next(res)
}
}
// InterceptResponse adds an HTTP response to an array of pending intercepted responses, alongside channels used for
// sending a cancellation signal and receiving a modified response. It's safe for concurrent use.
func (svc *Service) InterceptResponse(ctx context.Context, res *http.Response) (*http.Response, error) {
reqID, ok := proxy.RequestIDFromContext(ctx)
if !ok {
svc.logger.Errorw("Failed to intercept: context doesn't have an ID.")
return res, nil
}
shouldIntercept, ok := ShouldInterceptResponseFromContext(ctx)
if ok && !shouldIntercept {
// If the related request explicitly disabled response intercept, return the response as-is.
svc.logger.Debugw("Bypassed response interception: related request explicitly disabled response intercept.")
return res, nil
}
// If global response intercept is disabled and interception is *not* explicitly enabled for this response: bypass.
if !svc.responsesEnabled && !(ok && shouldIntercept) {
svc.logger.Debugw("Bypassed response interception: feature disabled.")
return res, nil
}
if svc.resFilter != nil {
match, err := MatchResponseFilter(res, svc.resFilter)
if err != nil {
return nil, fmt.Errorf("intercept: failed to match response rules for response (id: %v): %w",
reqID.String(), err,
)
}
if !match {
svc.logger.Debugw("Bypassed response interception: response rules don't match.")
return res, nil
}
}
ch := make(chan *http.Response)
done := make(chan struct{})
svc.resMu.Lock()
svc.responses[reqID] = Response{
res: res,
ch: ch,
done: done,
}
svc.resMu.Unlock()
// Whatever happens next (modified response returned, or a context cancelled error), any blocked channel senders
// should be unblocked, and the response should be removed from the responses queue.
defer func() {
close(done)
svc.resMu.Lock()
defer svc.resMu.Unlock()
delete(svc.responses, reqID)
}()
select {
case modRes := <-ch:
if modRes == nil {
return nil, ErrRequestAborted
}
return modRes, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// ModifyResponse sends a modified HTTP response to the related channel, or returns ErrRequestDone when the related
// request was cancelled. It's safe for concurrent use.
func (svc *Service) ModifyResponse(reqID ulid.ULID, modRes *http.Response) error {
svc.resMu.RLock()
res, ok := svc.responses[reqID]
svc.resMu.RUnlock()
if !ok {
return ErrRequestNotFound
}
if modRes != nil {
modRes.Request = res.res.Request
}
select {
case <-res.done:
return ErrRequestDone
case res.ch <- modRes:
return nil
}
}
// CancelResponse ensures an intercepted response is dropped.
func (svc *Service) CancelResponse(reqID ulid.ULID) error {
return svc.ModifyResponse(reqID, nil)
}

View File

@ -0,0 +1,270 @@
package intercept_test
import (
"context"
"errors"
"math/rand"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/oklog/ulid"
"go.uber.org/zap"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/proxy/intercept"
)
//nolint:gosec
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
func TestRequestModifier(t *testing.T) {
t.Parallel()
t.Run("modify request that's not found", func(t *testing.T) {
t.Parallel()
logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(),
RequestsEnabled: true,
ResponsesEnabled: false,
})
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
err := svc.ModifyRequest(reqID, nil, nil)
if !errors.Is(err, intercept.ErrRequestNotFound) {
t.Fatalf("expected `intercept.ErrRequestNotFound`, got: %v", err)
}
})
t.Run("modify request that's done", func(t *testing.T) {
t.Parallel()
logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(),
RequestsEnabled: true,
ResponsesEnabled: false,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
*req = *req.WithContext(ctx)
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
next := func(req *http.Request) {}
go svc.RequestModifier(next)(req)
// Wait shortly, to allow the req modifier goroutine to add `req` to the
// array of intercepted reqs.
time.Sleep(10 * time.Millisecond)
cancel()
modReq := req.Clone(req.Context())
modReq.Header.Set("X-Foo", "bar")
err := svc.ModifyRequest(reqID, modReq, nil)
if !errors.Is(err, intercept.ErrRequestDone) {
t.Fatalf("expected `intercept.ErrRequestDone`, got: %v", err)
}
})
t.Run("modify intercepted request", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
req.Header.Set("X-Foo", "foo")
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
modReq := req.Clone(context.Background())
modReq.Header.Set("X-Foo", "bar")
logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(),
RequestsEnabled: true,
ResponsesEnabled: false,
})
var got *http.Request
next := func(req *http.Request) {
got = req.Clone(context.Background())
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
svc.RequestModifier(next)(req)
wg.Done()
}()
// Wait shortly, to allow the req modifier goroutine to add `req` to the
// array of intercepted reqs.
time.Sleep(10 * time.Millisecond)
err := svc.ModifyRequest(reqID, modReq, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wg.Wait()
if got == nil {
t.Fatal("expected `got` not to be nil")
}
if exp := "bar"; exp != got.Header.Get("X-Foo") {
t.Fatalf("incorrect modified request header value (expected: %v, got: %v)", exp, got.Header.Get("X-Foo"))
}
})
}
func TestResponseModifier(t *testing.T) {
t.Parallel()
t.Run("modify response that's not found", func(t *testing.T) {
t.Parallel()
logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(),
RequestsEnabled: false,
ResponsesEnabled: true,
})
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
err := svc.ModifyResponse(reqID, nil)
if !errors.Is(err, intercept.ErrRequestNotFound) {
t.Fatalf("expected `intercept.ErrRequestNotFound`, got: %v", err)
}
})
t.Run("modify response of request that's done", func(t *testing.T) {
t.Parallel()
logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(),
RequestsEnabled: false,
ResponsesEnabled: true,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
*req = *req.WithContext(ctx)
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
res := &http.Response{
Request: req,
Header: make(http.Header),
}
res.Header.Add("X-Foo", "foo")
var modErr error
var wg sync.WaitGroup
wg.Add(1)
next := func(res *http.Response) error { return nil }
go func() {
defer wg.Done()
modErr = svc.ResponseModifier(next)(res)
}()
// Wait shortly, to allow the res modifier goroutine to add `res` to the
// array of intercepted responses.
time.Sleep(10 * time.Millisecond)
cancel()
modRes := *res
modRes.Header = make(http.Header)
modRes.Header.Set("X-Foo", "bar")
err := svc.ModifyResponse(reqID, &modRes)
if !errors.Is(err, intercept.ErrRequestDone) {
t.Fatalf("expected `intercept.ErrRequestDone`, got: %v", err)
}
wg.Wait()
if !errors.Is(modErr, context.Canceled) {
t.Fatalf("expected `context.Canceled`, got: %v", modErr)
}
})
t.Run("modify intercepted response", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "https://example.com/foo", nil)
req.Header.Set("X-Foo", "foo")
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
*req = *req.WithContext(proxy.WithRequestID(req.Context(), reqID))
res := &http.Response{
Request: req,
Header: make(http.Header),
}
res.Header.Add("X-Foo", "foo")
modRes := *res
modRes.Header = make(http.Header)
modRes.Header.Set("X-Foo", "bar")
logger, _ := zap.NewDevelopment()
svc := intercept.NewService(intercept.Config{
Logger: logger.Sugar(),
RequestsEnabled: false,
ResponsesEnabled: true,
})
var gotHeader string
var next proxy.ResponseModifyFunc = func(res *http.Response) error {
gotHeader = res.Header.Get("X-Foo")
return nil
}
var modErr error
var wg sync.WaitGroup
wg.Add(1)
go func() {
modErr = svc.ResponseModifier(next)(res)
wg.Done()
}()
// Wait shortly, to allow the res modifier goroutine to add `req` to the
// array of intercepted reqs.
time.Sleep(10 * time.Millisecond)
err := svc.ModifyResponse(reqID, &modRes)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wg.Wait()
if modErr != nil {
t.Fatalf("unexpected error: %v", modErr)
}
if exp := "bar"; exp != gotHeader {
t.Fatalf("incorrect modified request header value (expected: %v, got: %v)", exp, gotHeader)
}
})
}

View File

@ -0,0 +1,10 @@
package intercept
import "github.com/dstotijn/hetty/pkg/search"
type Settings struct {
RequestsEnabled bool
ResponsesEnabled bool
RequestFilter search.Expression
ResponseFilter search.Expression
}

View File

@ -7,16 +7,24 @@ import (
"crypto/x509"
"errors"
"fmt"
"math/rand"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/oklog/ulid"
"github.com/dstotijn/hetty/pkg/log"
)
//nolint:gosec
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
type contextKey int
const ReqLogIDKey contextKey = 0
const reqIDKey contextKey = 0
// Proxy implements http.Handler and offers MITM behaviour for modifying
// HTTP requests and responses.
@ -54,7 +62,25 @@ func NewProxy(cfg Config) (*Proxy, error) {
p.logger = log.NewNopLogger()
}
transport := &http.Transport{
// Values taken from `http.DefaultTransport`.
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// Non-default transport values.
DisableCompression: true,
}
p.handler = &httputil.ReverseProxy{
Transport: transport,
Director: p.modifyRequest,
ModifyResponse: p.modifyResponse,
ErrorHandler: p.errorHandler,
@ -69,6 +95,10 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
reqID := ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy)
ctx := context.WithValue(r.Context(), reqIDKey, reqID)
*r = *r.WithContext(ctx)
p.handler.ServeHTTP(w, r)
}
@ -91,6 +121,25 @@ func (p *Proxy) modifyRequest(r *http.Request) {
// set this header.
r.Header["X-Forwarded-For"] = nil
// Strip unsupported encodings.
if acceptEncs := r.Header.Get("Accept-Encoding"); acceptEncs != "" {
directives := strings.Split(acceptEncs, ",")
updated := make([]string, 0, len(directives))
for _, directive := range directives {
stripped := strings.TrimSpace(directive)
if strings.HasPrefix(stripped, "*") || strings.HasPrefix(stripped, "gzip") {
updated = append(updated, stripped)
}
}
if len(updated) == 0 {
r.Header.Del("Accept-Encoding")
} else {
r.Header.Set("Accept-Encoding", strings.Join(updated, ", "))
}
}
fn := nopReqModifier
for i := len(p.reqModifiers) - 1; i >= 0; i-- {
@ -103,6 +152,11 @@ func (p *Proxy) modifyRequest(r *http.Request) {
func (p *Proxy) modifyResponse(res *http.Response) error {
fn := nopResModifier
// TODO: Make decompressing gzip formatted response bodies a configurable project setting.
if err := gunzipResponseBody(res); err != nil {
return fmt.Errorf("proxy: failed to gunzip response body: %w", err)
}
for i := len(p.resModifiers) - 1; i >= 0; i-- {
fn = p.resModifiers[i](fn)
}
@ -110,6 +164,15 @@ func (p *Proxy) modifyResponse(res *http.Response) error {
return fn(res)
}
func WithRequestID(ctx context.Context, id ulid.ULID) context.Context {
return context.WithValue(ctx, reqIDKey, id)
}
func RequestIDFromContext(ctx context.Context) (ulid.ULID, bool) {
id, ok := ctx.Value(reqIDKey).(ulid.ULID)
return id, ok
}
// handleConnect hijacks the incoming HTTP request and sets up an HTTP tunnel.
// During the TLS handshake with the client, we use the proxy's CA config to
// create a certificate on-the-fly.
@ -170,13 +233,14 @@ func (p *Proxy) clientTLSConn(conn net.Conn) (*tls.Conn, error) {
}
func (p *Proxy) errorHandler(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, context.Canceled) {
return
switch {
case !errors.Is(err, context.Canceled):
p.logger.Errorw("Failed to proxy request.",
"error", err)
case errors.Is(err, context.Canceled):
p.logger.Debugw("Proxy request was cancelled.")
}
p.logger.Errorw("Failed to proxy request.",
"error", err)
w.WriteHeader(http.StatusBadGateway)
}

View File

@ -2,16 +2,13 @@ package reqlog
import (
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net/http"
"net/url"
"time"
"github.com/oklog/ulid"
@ -23,16 +20,16 @@ import (
type contextKey int
const LogBypassedKey contextKey = 0
const (
LogBypassedKey contextKey = iota
ReqLogIDKey
)
var (
ErrRequestNotFound = errors.New("reqlog: request not found")
ErrProjectIDMustBeSet = errors.New("reqlog: project ID must be set")
)
//nolint:gosec
var ulidEntropy = rand.New(rand.NewSource(time.Now().UnixNano()))
type RequestLog struct {
ID ulid.ULID
ProjectID ulid.ULID
@ -170,8 +167,14 @@ func (svc *service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
return
}
reqID, ok := proxy.RequestIDFromContext(req.Context())
if !ok {
svc.logger.Errorw("Bypassed logging: request doesn't have an ID.")
return
}
reqLog := RequestLog{
ID: ulid.MustNew(ulid.Timestamp(time.Now()), ulidEntropy),
ID: reqID,
ProjectID: svc.activeProjectID,
Method: clone.Method,
URL: clone.URL,
@ -191,7 +194,7 @@ func (svc *service) RequestModifier(next proxy.RequestModifyFunc) proxy.RequestM
"reqLogID", reqLog.ID.String(),
"url", reqLog.URL.String())
ctx := context.WithValue(req.Context(), proxy.ReqLogIDKey, reqLog.ID)
ctx := context.WithValue(req.Context(), ReqLogIDKey, reqLog.ID)
*req = *req.WithContext(ctx)
}
}
@ -206,21 +209,23 @@ func (svc *service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
return nil
}
reqLogID, ok := res.Request.Context().Value(proxy.ReqLogIDKey).(ulid.ULID)
reqLogID, ok := res.Request.Context().Value(ReqLogIDKey).(ulid.ULID)
if !ok {
return errors.New("reqlog: request is missing ID")
}
clone := *res
// TODO: Use io.LimitReader.
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("reqlog: could not read response body: %w", err)
}
if res.Body != nil {
// TODO: Use io.LimitReader.
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("reqlog: could not read response body: %w", err)
}
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))
clone.Body = ioutil.NopCloser(bytes.NewBuffer(body))
res.Body = io.NopCloser(bytes.NewBuffer(body))
clone.Body = io.NopCloser(bytes.NewBuffer(body))
}
go func() {
if err := svc.storeResponse(context.Background(), reqLogID, &clone); err != nil {
@ -261,23 +266,6 @@ func (svc *service) BypassOutOfScopeRequests() bool {
}
func ParseHTTPResponse(res *http.Response) (ResponseLog, error) {
if res.Header.Get("Content-Encoding") == "gzip" {
gzipReader, err := gzip.NewReader(res.Body)
if err != nil {
return ResponseLog{}, fmt.Errorf("reqlog: could not create gzip reader: %w", err)
}
defer gzipReader.Close()
buf := &bytes.Buffer{}
//nolint:gosec
if _, err := io.Copy(buf, gzipReader); err != nil {
return ResponseLog{}, fmt.Errorf("reqlog: could not read gzipped response body: %w", err)
}
res.Body = io.NopCloser(buf)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return ResponseLog{}, fmt.Errorf("reqlog: could not read body: %w", err)

Some files were not shown because too many files have changed in this diff Show More