Compare commits

...

19 Commits

Author SHA1 Message Date
ad3fa7d379 Handle req log filter I/O when no project is set 2020-12-29 20:46:42 +01:00
8c2efdb285 Add support for string literal expressions in sqlite pkg 2020-12-24 18:58:04 +01:00
194d727f4f Add search expression support to admin interface 2020-12-22 12:35:14 +01:00
8ab65fb55f Support implicit boolean expression nested in groups 2020-12-20 13:13:12 +01:00
5bce912e89 Replace lexer, add parser 2020-12-20 13:13:12 +01:00
16910bb637 Add lexer for reqlog search 2020-12-20 13:13:12 +01:00
e59b9d6663 Clear all HTTP request logs (#49)
* Create mutation to clear request logs

* Add UI for clearing all HTTP request logs

* Use consistent naming

* Explicitly delete only from http_requests

* Check if datebase is open

* Add confirmation dialog
2020-11-28 15:48:19 +01:00
efc115e961 Fix docs dir 2020-11-09 21:20:49 +01:00
471fa212ef Add links to docs website in README 2020-11-09 21:16:56 +01:00
07ef2f9090 Add OG meta tags 2020-11-09 21:06:40 +01:00
f7550d649a Add static asset for header image for README 2020-11-09 20:50:56 +01:00
dbc25774c2 Small doc fixes for mobile/lighthouse 2020-11-09 20:44:01 +01:00
430670ab54 Add docs 2020-11-09 20:28:10 +01:00
f6789fa245 Tidy up manual build process 2020-11-01 19:01:07 +01:00
81fbfe4cb3 Tidy up .gitignore 2020-11-01 18:31:08 +01:00
6931d63250 Remove GitHub workflows 2020-11-01 17:19:17 +01:00
71e87d3cd3 Remove modd.conf 2020-11-01 17:13:13 +01:00
0ffbb618fa Update README 2020-11-01 17:05:36 +01:00
c01f190fc8 Use Go instead of C for regexp sqlite func
While less performant, this (for now) simplifies
compiling and building the project.
2020-10-31 15:19:17 +01:00
84 changed files with 13378 additions and 1114 deletions

View File

@ -1,4 +1,3 @@
**/rice-box.go
/admin/.env
/admin/.next
/admin/dist

View File

@ -1,31 +0,0 @@
name: Build Frontend, Backend and Run
on: [pull_request]
jobs:
build:
name: Build Images and Deploy Example WebApp
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
submodules: true
- name: Test Yarn
uses:
node_version: '14.x'
run: |-
cd admin && \
yarn install && \
yarn export
- name: Test make
run: |-
cd .. && \
make build
- name: Run
run: |-
./hetty

View File

@ -1,14 +0,0 @@
name: Docker Image CI
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build the Docker image
run: docker build . --file Dockerfile --tag hetty:$(date +%s)

View File

@ -1,24 +0,0 @@
name: Go
on: [pull_request]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.15
uses: actions/setup-go@v1
with:
go-version: 1.15
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get -v -t -d ./...
- name: Build
run: go build -v ./cmd/hetty

3
.gitignore vendored
View File

@ -1,10 +1,7 @@
.release-env
.vscode
**/rice-box.go
sqlite3_mod_regexp.dylib
dist
hetty
hetty.bolt
hetty.db
*.pem
*.test

View File

@ -1,11 +1,7 @@
env:
- GO111MODULE=on
- CGO_ENABLED=1
- CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
before:
hooks:
- make clean
- make embed
builds:
- id: hetty-darwin-amd64
main: ./cmd/hetty
@ -16,48 +12,50 @@ builds:
env:
- CC=o64-clang
- CXX=o64-clang++
- CGO_LDFLAGS=-Wl,-undefined,dynamic_lookup
flags:
- -mod=readonly
ldflags:
- id: hetty-linux-amd64
main: ./cmd/hetty
goarch:
- amd64
goos:
- linux
flags:
- -mod=readonly
- id: hetty-windows-amd64
main: ./cmd/hetty
goarch:
- amd64
goos:
- windows
env:
- CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
- CGO_LDFLAGS=-Wl,--unresolved-symbols=ignore-in-object-files
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
flags:
- -mod=readonly
ldflags:
# - id: hetty-windows-amd64
# main: ./cmd/hetty
# goarch:
# - amd64
# goos:
# - windows
# env:
# - CC=x86_64-w64-mingw32-gcc
# - CXX=x86_64-w64-mingw32-g++
# - CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
# - CGO_LDFLAGS=-Wl,--unresolved-symbols=ignore-in-object-files # Not working :(
# flags:
# - -mod=readonly
# ldflags:
# - -buildmode=exe
- -buildmode=exe
archives:
- replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
-
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:

View File

@ -2,17 +2,15 @@ ARG GO_VERSION=1.15
ARG CGO_ENABLED=1
ARG NODE_VERSION=14.11
FROM golang:${GO_VERSION} AS go-builder
FROM golang:${GO_VERSION}-alpine AS go-builder
WORKDIR /app
RUN apt-get update && \
apt-get install -y build-essential
RUN apk add --no-cache build-base
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
ENV CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
ENV CGO_LDFLAGS=-Wl,--unresolved-symbols=ignore-in-object-files
RUN go build -o hetty ./cmd/hetty
RUN rm -f cmd/hetty/rice-box.go
RUN go build ./cmd/hetty
FROM node:${NODE_VERSION}-alpine AS node-builder
WORKDIR /app
@ -22,7 +20,7 @@ COPY admin/ .
ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn run export
FROM debian:buster-slim
FROM alpine:3.12
WORKDIR /app
COPY --from=go-builder /app/hetty .
COPY --from=node-builder /app/dist admin

View File

@ -1,36 +1,26 @@
PACKAGE_NAME := github.com/dstotijn/hetty
GOLANG_CROSS_VERSION ?= v1.15.2
setup:
go mod download
go generate ./...
.PHONY: setup
embed:
go install github.com/GeertJohan/go.rice/rice
cd cmd/hetty && rice embed-go
.PHONY: embed
embed:
NEXT_TELEMETRY_DISABLED=1 cd admin && yarn install && yarn run export
cd cmd/hetty && rice embed-go
build: embed
env CGO_ENABLED=1 CGO_CFLAGS="-DUSE_LIBSQLITE3" CGO_LDFLAGS="-Wl,-undefined,dynamic_lookup" \
go build -tags libsqlite3 ./cmd/hetty
.PHONY: build
build: embed
CGO_ENABLED=1 go build ./cmd/hetty
clean:
rm -rf cmd/hetty/rice-box.go
.PHONY: clean
release-dry-run:
.PHONY: release-dry-run
release-dry-run: embed
@docker run \
--rm \
-v `pwd`:/go/src/$(PACKAGE_NAME) \
-v `pwd`/admin/dist:/go/src/$(PACKAGE_NAME)/admin/dist \
-w /go/src/$(PACKAGE_NAME) \
troian/golang-cross:${GOLANG_CROSS_VERSION} \
--rm-dist --skip-validate --skip-publish
.PHONY: release-dry-run
release:
.PHONY: release
release: embed
@if [ ! -f ".release-env" ]; then \
echo "\033[91mFile \`.release-env\` is missing.\033[0m";\
exit 1;\
@ -38,9 +28,7 @@ release:
@docker run \
--rm \
-v `pwd`:/go/src/$(PACKAGE_NAME) \
-v `pwd`/admin/dist:/go/src/$(PACKAGE_NAME)/admin/dist \
-w /go/src/$(PACKAGE_NAME) \
--env-file .release-env \
troian/golang-cross:${GOLANG_CROSS_VERSION} \
release --rm-dist
.PHONY: release

166
README.md
View File

@ -1,70 +1,94 @@
<img src="https://i.imgur.com/AT71SBq.png" width="346" />
<h1>
<a href="https://github.com/dstotijn/hetty">
<img src="https://hetty.xyz/assets/logo.png" width="293">
</a>
</h1>
> 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.
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest)
![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)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-18BA91?style=flat-square)](https://hetty.xyz/)
<img src="https://i.imgur.com/ZZ6o83X.png">
**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.
## Features/to do
<img src="https://hetty.xyz/assets/hetty_v0.2.0_header.png">
- [x] HTTP man-in-the-middle (MITM) proxy and GraphQL server.
- [x] Web interface (Next.js) with proxy log viewer.
- [x] Add scope support to the proxy.
- [ ] Full text search (with regex) in proxy log viewer.
- [x] Project management.
- [ ] Sender module for sending manual HTTP requests, either from scratch or based
off requests from the proxy log.
- [ ] Attacker module for automated sending of HTTP requests. Leverage the concurrency
features of Go and its `net/http` package to make it blazingly fast.
## Features
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
- Project based database storage (SQLite)
- Scope support
- Headless management API using GraphQL
- Embedded web interface (Next.js)
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.
## Documentation
📖 [Read the docs.](https://hetty.xyz/)
## Installation
Hetty is packaged on GitHub as a single binary, with the web interface resources
embedded.
Hetty compiles to a self-contained binary, with an embedded SQLite database
and web based admin interface.
👉 You can find downloads for Linux, macOS and Windows on the [releases page](https://github.com/dstotijn/hetty/releases).
### Install pre-built release (recommended)
### Alternatives:
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
**Build from source**
### Build from source
#### Prerequisites
- [Go](https://golang.org/)
- [Yarn](https://yarnpkg.com/)
- [go.rice](https://github.com/GeertJohan/go.rice)
Hetty depends on SQLite (via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3))
and needs `cgo` to compile. Additionally, the static resources for the admin interface
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand.
Clone the repository and use the `build` make target to create a binary:
```
$ GO111MODULE=auto go get -u -v github.com/dstotijn/hetty/cmd/hetty
$ git clone git@github.com:dstotijn/hetty.git
$ cd hetty
$ make build
```
Then export the Next.js frontend app:
### Docker
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
For persistent storage of CA certificates and project databases, mount a volume:
```
$ cd admin
$ yarn install
$ yarn export
```
This will ensure a folder `./admin/dist` exists.
Then, you can bundle the frontend app using `rice`.
The easiest way to do this is via a supplied `Makefile` command in the root of
the project:
```
make build
```
**Docker**
Alternatively, you can run Hetty via Docker. See: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty)
on Docker Hub.
```
$ docker run -v $HOME/.hetty:/root/.hetty -p 127.0.0.1:8080:8080 dstotijn/hetty
$ mkdir -p $HOME/.hetty
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
```
## Usage
Hetty is packaged as a single binary, with the web interface resources embedded.
When the program is run, it listens by default on `:8080` and is accessible via
When Hetty is run, by default it listens on `:8080` and is accessible via
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
MITM proxy, or it serves the GraphQL API and web interface (Next.js).
MITM proxy, or it serves the API and web interface.
By default, project 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
```
An overview of configuration flags:
```
$ hetty -h
@ -81,6 +105,16 @@ Usage of ./hetty:
Projects directory path (default "~/.hetty/projects")
```
You should see:
```
2020/11/01 14:47:10 [INFO] Running server on :8080 ...
```
Then, visit [http://localhost:8080](http://localhost:8080) to get started.
Detailed documentation is under development and will be available soon.
## Certificate Setup and Installation
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
@ -163,38 +197,40 @@ _more information on how to update the system to trust your self-signed certific
## Vision and roadmap
The project has just gotten underway, and as such I havent had time yet to do a
write-up on its mission and roadmap. A short summary/braindump:
- Fast core/engine, built with Go, with a minimal memory footprint.
- GraphQL server to interact with the backend.
- Easy to use web interface, built with Next.js and Material UI.
- Easy to use admin interface, built with Next.js and Material UI.
- Headless management, via GraphQL API.
- Extensibility is top of mind. All modules are written as Go packages, to
be used by the main `hetty` program, but also usable as libraries for other software.
Aside from the GraphQL server, it should (eventually) be possible to also use
it as a CLI tool.
- Pluggable architecture for the MITM proxy and future modules, making it
possible for hook into the core engine.
- Talk to the community, and focus on the features that the majority.
Less features means less code to maintain.
be used by Hetty, but also as libraries by other software.
- Pluggable architecture for MITM proxy, projects, scope. It should be possible.
to build a plugin system in the (near) future.
- Based on feedback and real-world usage of pentesters and bug bounty hunters.
- Aim for a relatively small core feature set that the majority of security researchers need.
## Status
## Support
The project is currently under active development. Please star/follow and check
back soon. 🤗
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions)
for questions and troubleshooting.
## Community
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP).
## Contributing
Please see the [Contribution Guidelines](CONTRIBUTING.md) for details.
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md)
for details.
## Acknowledgements
Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
for all the encouragement to actually start building this thing!
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
for all the encouragement and feedback.
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
## License
[MIT](LICENSE)
[MIT License](LICENSE)
---

View File

@ -0,0 +1,53 @@
import React, { useState } from "react";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
export function useConfirmationDialog() {
const [isOpen, setIsOpen] = useState(false);
const close = () => setIsOpen(false);
const open = () => setIsOpen(true);
return { open, close, isOpen };
}
interface ConfirmationDialog {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
children: React.ReactNode;
}
export function ConfirmationDialog(props: ConfirmationDialog) {
const { onClose, onConfirm, isOpen, children } = props;
function confirm() {
onConfirm();
onClose();
}
return (
<Dialog
open={isOpen}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{children}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abort</Button>
<Button onClick={confirm} autoFocus>
Confirm
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -1,41 +1,24 @@
import { useRouter } from "next/router";
import { gql, useQuery } from "@apollo/client";
import Link from "next/link";
import {
Box,
Typography,
CircularProgress,
Link as MaterialLink,
Typography,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import RequestList from "./RequestList";
import LogDetail from "./LogDetail";
import CenteredPaper from "../CenteredPaper";
const HTTP_REQUEST_LOGS = gql`
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}
`;
import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
function LogsOverview(): JSX.Element {
const router = useRouter();
const detailReqLogId =
router.query.id && parseInt(router.query.id as string, 10);
const { loading, error, data } = useQuery(HTTP_REQUEST_LOGS, {
pollInterval: 1000,
});
const { loading, error, data } = useHttpRequestLogs();
const handleLogClick = (reqId: number) => {
router.push("/proxy/logs?id=" + reqId, undefined, {

View File

@ -16,15 +16,22 @@ import {
import IconButton from "@material-ui/core/IconButton";
import SearchIcon from "@material-ui/icons/Search";
import FilterListIcon from "@material-ui/icons/FilterList";
import DeleteIcon from "@material-ui/icons/Delete";
import React, { useRef, useState } from "react";
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
import { gql, useMutation, useQuery } from "@apollo/client";
import { withoutTypename } from "../../lib/omitTypename";
import { Alert } from "@material-ui/lab";
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
import {
ConfirmationDialog,
useConfirmationDialog,
} from "./ConfirmationDialog";
const FILTER = gql`
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
searchExpression
}
}
`;
@ -33,6 +40,7 @@ const SET_FILTER = gql`
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
searchExpression
}
}
`;
@ -69,37 +77,59 @@ const useStyles = makeStyles((theme: Theme) =>
export interface SearchFilter {
onlyInScope: boolean;
searchExpression: string;
}
function Search(): JSX.Element {
const classes = useStyles();
const theme = useTheme();
const [searchExpr, setSearchExpr] = useState("");
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
FILTER
FILTER,
{
onCompleted: (data) => {
setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
},
}
);
const client = useApolloClient();
const [
setFilterMutate,
{ error: setFilterErr, loading: setFilterLoading },
] = useMutation<{
setHttpRequestLogFilter: SearchFilter | null;
}>(SET_FILTER, {
update(_, { data: { setHttpRequestLogFilter } }) {
client.writeQuery({
update(cache, { data: { setHttpRequestLogFilter } }) {
cache.writeQuery({
query: FILTER,
data: {
httpRequestLogFilter: setHttpRequestLogFilter,
},
});
},
onError: () => {},
});
const [
clearHTTPRequestLog,
clearHTTPRequestLogResult,
] = useClearHTTPRequestLog();
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLElement | null>();
const [filterOpen, setFilterOpen] = useState(false);
const handleSubmit = (e: React.SyntheticEvent) => {
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
searchExpression: searchExpr,
},
},
});
setFilterOpen(false);
e.preventDefault();
};
@ -111,90 +141,113 @@ function Search(): JSX.Element {
};
return (
<ClickAwayListener onClickAway={handleClickAway}>
<Box style={{ display: "inline-block" }}>
{filterErr && (
<Box mb={4}>
<Alert severity="error">
Error fetching filter: {filterErr.message}
</Alert>
</Box>
)}
{setFilterErr && (
<Box mb={4}>
<Alert severity="error">
Error setting filter: {setFilterErr.message}
</Alert>
</Box>
)}
<Paper
component="form"
onSubmit={handleSubmit}
ref={filterRef}
className={classes.root}
>
<Tooltip title="Toggle filter options">
<IconButton
className={classes.iconButton}
onClick={() => setFilterOpen(!filterOpen)}
style={{
color:
filter?.httpRequestLogFilter !== null
<Box>
<Error prefix="Error fetching filter" error={filterErr} />
<Error prefix="Error setting filter" error={setFilterErr} />
<Error
prefix="Error clearing all HTTP logs"
error={clearHTTPRequestLogResult.error}
/>
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
onSubmit={handleSubmit}
ref={filterRef}
className={classes.root}
>
<Tooltip title="Toggle filter options">
<IconButton
className={classes.iconButton}
onClick={() => setFilterOpen(!filterOpen)}
style={{
color: filter?.httpRequestLogFilter?.onlyInScope
? theme.palette.secondary.main
: "inherit",
}}
>
{filterLoading || setFilterLoading ? (
<CircularProgress className={classes.filterLoading} size={23} />
) : (
<FilterListIcon />
)}
</IconButton>
</Tooltip>
<InputBase
className={classes.input}
placeholder="Search proxy logs…"
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">
<IconButton type="submit" className={classes.iconButton}>
<SearchIcon />
</IconButton>
</Tooltip>
</Paper>
<Popper
className={classes.filterPopper}
open={filterOpen}
anchorEl={filterRef.current}
placement="bottom-start"
>
<Paper className={classes.filterOptions}>
<FormControlLabel
control={
<Checkbox
checked={
filter?.httpRequestLogFilter?.onlyInScope ? true : false
}
disabled={filterLoading || setFilterLoading}
onChange={(e) =>
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
onlyInScope: e.target.checked,
},
},
})
}
/>
}
label="Only show in-scope requests"
}}
>
{filterLoading || setFilterLoading ? (
<CircularProgress
className={classes.filterLoading}
size={23}
/>
) : (
<FilterListIcon />
)}
</IconButton>
</Tooltip>
<InputBase
className={classes.input}
placeholder="Search proxy logs…"
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">
<IconButton type="submit" className={classes.iconButton}>
<SearchIcon />
</IconButton>
</Tooltip>
<Popper
className={classes.filterPopper}
open={filterOpen}
anchorEl={filterRef.current}
placement="bottom-start"
>
<Paper className={classes.filterOptions}>
<FormControlLabel
control={
<Checkbox
checked={
filter?.httpRequestLogFilter?.onlyInScope ? true : false
}
disabled={filterLoading || setFilterLoading}
onChange={(e) =>
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
onlyInScope: e.target.checked,
},
},
})
}
/>
}
label="Only show in-scope requests"
/>
</Paper>
</Popper>
</Paper>
</Popper>
</ClickAwayListener>
<Box style={{ marginLeft: "auto" }}>
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
</ClickAwayListener>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
</Box>
);
}
function Error(props: { prefix: string; error?: Error }) {
if (!props.error) return null;
return (
<Box mb={4}>
<Alert severity="error">
{props.prefix}: {props.error.message}
</Alert>
</Box>
);
}

View File

@ -0,0 +1,16 @@
import { gql, useMutation } from "@apollo/client";
import { HTTP_REQUEST_LOGS } from "./useHttpRequestLogs";
const CLEAR_HTTP_REQUEST_LOG = gql`
mutation ClearHTTPRequestLog {
clearHTTPRequestLog {
success
}
}
`;
export function useClearHTTPRequestLog() {
return useMutation(CLEAR_HTTP_REQUEST_LOG, {
refetchQueries: [{ query: HTTP_REQUEST_LOGS }],
});
}

View File

@ -0,0 +1,22 @@
import { gql, useQuery } from "@apollo/client";
export const HTTP_REQUEST_LOGS = gql`
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}
`;
export function useHttpRequestLogs() {
return useQuery(HTTP_REQUEST_LOGS, {
pollInterval: 1000,
});
}

12
docs/.gitignore vendored Executable file
View File

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

21
docs/package.json Executable file
View File

@ -0,0 +1,21 @@
{
"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": {}
}

77
docs/src/.vuepress/config.js Executable file
View File

@ -0,0 +1,77 @@
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

@ -0,0 +1,14 @@
/**
* 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.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

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

View File

@ -0,0 +1,21 @@
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

@ -0,0 +1,171 @@
<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

@ -0,0 +1,252 @@
<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

@ -0,0 +1,33 @@
<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

@ -0,0 +1,197 @@
<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

@ -0,0 +1,90 @@
<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

@ -0,0 +1,156 @@
<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

@ -0,0 +1,162 @@
<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

@ -0,0 +1,31 @@
<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

@ -0,0 +1,155 @@
<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

@ -0,0 +1,163 @@
<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

@ -0,0 +1,64 @@
<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

@ -0,0 +1,40 @@
<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

@ -0,0 +1,141 @@
<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

@ -0,0 +1,133 @@
<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

@ -0,0 +1,103 @@
<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

@ -0,0 +1,44 @@
<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

@ -0,0 +1,36 @@
<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

@ -0,0 +1,105 @@
<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

@ -0,0 +1,59 @@
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

@ -0,0 +1,30 @@
<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

@ -0,0 +1,137 @@
<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

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

View File

@ -0,0 +1,22 @@
@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

@ -0,0 +1,137 @@
{$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

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

View File

@ -0,0 +1,44 @@
.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

@ -0,0 +1,200 @@
@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

@ -0,0 +1,37 @@
@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

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

View File

@ -0,0 +1,9 @@
$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

@ -0,0 +1,244 @@
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

@ -0,0 +1,45 @@
---
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) 2020 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.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,85 @@
# Getting Started
## Installation
Hetty compiles to a static binary, with an embedded SQLite 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](https://golang.org/)
- [Yarn](https://yarnpkg.com/)
- [go.rice](https://github.com/GeertJohan/go.rice)
Hetty depends on SQLite (via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3))
and needs `cgo` to compile. Additionally, the static resources for the web admin interface
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand.
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, project 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:
```
2020/11/01 14:47:10 [INFO] Running server 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 is 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")
-projects string
Projects directory path (default "~/.hetty/projects")
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

29
docs/src/guide/index.md Executable file
View File

@ -0,0 +1,29 @@
---
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 (SQLite)
- 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.
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

238
docs/src/guide/modules.md Normal file
View File

@ -0,0 +1,238 @@
---
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 self-contained (SQLite) database files that contain module data.
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 create a new project. Give it a name (alphanumeric and space character)
and click the create button:
![Creating a project](./create_project.png =417x)
The project name will become the base for the database file on disk. For example,
if you name your project `My first project`, the file on disk will be
`My first project.db`.
::: tip INFO
Project database files by default are stored in `$HOME/.hetty/projects` on Linux
and macOS, and `%USERPROFILE%/.hetty` on Windows. You can override this path with
the `-projects` 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.

After

Width:  |  Height:  |  Size: 410 KiB

6
docs/src/index.md Executable file
View File

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

7869
docs/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

9
go.sum
View File

@ -1,5 +1,3 @@
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -37,10 +35,6 @@ github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
@ -107,8 +101,6 @@ github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8W
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -128,6 +120,7 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtD
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@ -1,4 +0,0 @@
@env = CGO_CFLAGS=-DUSE_LIBSQLITE3 CGO_LDFLAGS=-Wl,-undefined,dynamic_lookup
**/*.go {
daemon +sigterm: @env go run -tags libsqlite3 ./cmd/hetty
}

View File

@ -43,6 +43,10 @@ type DirectiveRoot struct {
}
type ComplexityRoot struct {
ClearHTTPRequestLogResult struct {
Success func(childComplexity int) int
}
CloseProjectResult struct {
Success func(childComplexity int) int
}
@ -68,7 +72,8 @@ type ComplexityRoot struct {
}
HTTPRequestLogFilter struct {
OnlyInScope func(childComplexity int) int
OnlyInScope func(childComplexity int) int
SearchExpression func(childComplexity int) int
}
HTTPResponseLog struct {
@ -81,6 +86,7 @@ type ComplexityRoot struct {
}
Mutation struct {
ClearHTTPRequestLog func(childComplexity int) int
CloseProject func(childComplexity int) int
DeleteProject func(childComplexity int, name string) int
OpenProject func(childComplexity int, name string) int
@ -118,6 +124,7 @@ type MutationResolver interface {
OpenProject(ctx context.Context, name string) (*Project, error)
CloseProject(ctx context.Context) (*CloseProjectResult, error)
DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error)
ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error)
SetScope(ctx context.Context, scope []ScopeRuleInput) ([]ScopeRule, error)
SetHTTPRequestLogFilter(ctx context.Context, filter *HTTPRequestLogFilterInput) (*HTTPRequestLogFilter, error)
}
@ -145,6 +152,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
_ = ec
switch typeName + "." + field {
case "ClearHTTPRequestLogResult.success":
if e.complexity.ClearHTTPRequestLogResult.Success == nil {
break
}
return e.complexity.ClearHTTPRequestLogResult.Success(childComplexity), true
case "CloseProjectResult.success":
if e.complexity.CloseProjectResult.Success == nil {
break
@ -236,6 +250,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.HTTPRequestLogFilter.OnlyInScope(childComplexity), true
case "HttpRequestLogFilter.searchExpression":
if e.complexity.HTTPRequestLogFilter.SearchExpression == nil {
break
}
return e.complexity.HTTPRequestLogFilter.SearchExpression(childComplexity), true
case "HttpResponseLog.body":
if e.complexity.HTTPResponseLog.Body == nil {
break
@ -278,6 +299,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.HTTPResponseLog.StatusReason(childComplexity), true
case "Mutation.clearHTTPRequestLog":
if e.complexity.Mutation.ClearHTTPRequestLog == nil {
break
}
return e.complexity.Mutation.ClearHTTPRequestLog(childComplexity), true
case "Mutation.closeProject":
if e.complexity.Mutation.CloseProject == nil {
break
@ -553,12 +581,18 @@ type DeleteProjectResult {
success: Boolean!
}
type ClearHTTPRequestLogResult {
success: Boolean!
}
input HttpRequestLogFilterInput {
onlyInScope: Boolean
searchExpression: String
}
type HttpRequestLogFilter {
onlyInScope: Boolean!
searchExpression: String
}
type Query {
@ -574,6 +608,7 @@ type Mutation {
openProject(name: String!): Project
closeProject: CloseProjectResult!
deleteProject(name: String!): DeleteProjectResult!
clearHTTPRequestLog: ClearHTTPRequestLogResult!
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
setHttpRequestLogFilter(
filter: HttpRequestLogFilterInput
@ -730,6 +765,41 @@ func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArg
// region **************************** field.gotpl *****************************
func (ec *executionContext) _ClearHTTPRequestLogResult_success(ctx context.Context, field graphql.CollectedField, obj *ClearHTTPRequestLogResult) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "ClearHTTPRequestLogResult",
Field: field,
Args: nil,
IsMethod: false,
IsResolver: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Success, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(bool)
fc.Result = res
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _CloseProjectResult_success(ctx context.Context, field graphql.CollectedField, obj *CloseProjectResult) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -1179,6 +1249,38 @@ func (ec *executionContext) _HttpRequestLogFilter_onlyInScope(ctx context.Contex
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
}
func (ec *executionContext) _HttpRequestLogFilter_searchExpression(ctx context.Context, field graphql.CollectedField, obj *HTTPRequestLogFilter) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "HttpRequestLogFilter",
Field: field,
Args: nil,
IsMethod: false,
IsResolver: false,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.SearchExpression, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
return graphql.Null
}
res := resTmp.(*string)
fc.Result = res
return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
}
func (ec *executionContext) _HttpResponseLog_requestId(ctx context.Context, field graphql.CollectedField, obj *HTTPResponseLog) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -1502,6 +1604,41 @@ func (ec *executionContext) _Mutation_deleteProject(ctx context.Context, field g
return ec.marshalNDeleteProjectResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐDeleteProjectResult(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_clearHTTPRequestLog(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
fc := &graphql.FieldContext{
Object: "Mutation",
Field: field,
Args: nil,
IsMethod: true,
IsResolver: true,
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().ClearHTTPRequestLog(rctx)
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*ClearHTTPRequestLogResult)
fc.Result = res
return ec.marshalNClearHTTPRequestLogResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐClearHTTPRequestLogResult(ctx, field.Selections, res)
}
func (ec *executionContext) _Mutation_setScope(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
@ -3193,6 +3330,14 @@ func (ec *executionContext) unmarshalInputHttpRequestLogFilterInput(ctx context.
if err != nil {
return it, err
}
case "searchExpression":
var err error
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("searchExpression"))
it.SearchExpression, err = ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
}
}
@ -3271,6 +3416,33 @@ func (ec *executionContext) unmarshalInputScopeRuleInput(ctx context.Context, ob
// region **************************** object.gotpl ****************************
var clearHTTPRequestLogResultImplementors = []string{"ClearHTTPRequestLogResult"}
func (ec *executionContext) _ClearHTTPRequestLogResult(ctx context.Context, sel ast.SelectionSet, obj *ClearHTTPRequestLogResult) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, clearHTTPRequestLogResultImplementors)
out := graphql.NewFieldSet(fields)
var invalids uint32
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("ClearHTTPRequestLogResult")
case "success":
out.Values[i] = ec._ClearHTTPRequestLogResult_success(ctx, field, obj)
if out.Values[i] == graphql.Null {
invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch()
if invalids > 0 {
return graphql.Null
}
return out
}
var closeProjectResultImplementors = []string{"CloseProjectResult"}
func (ec *executionContext) _CloseProjectResult(ctx context.Context, sel ast.SelectionSet, obj *CloseProjectResult) graphql.Marshaler {
@ -3429,6 +3601,8 @@ func (ec *executionContext) _HttpRequestLogFilter(ctx context.Context, sel ast.S
if out.Values[i] == graphql.Null {
invalids++
}
case "searchExpression":
out.Values[i] = ec._HttpRequestLogFilter_searchExpression(ctx, field, obj)
default:
panic("unknown field " + strconv.Quote(field.Name))
}
@ -3516,6 +3690,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
invalids++
}
case "clearHTTPRequestLog":
out.Values[i] = ec._Mutation_clearHTTPRequestLog(ctx, field)
if out.Values[i] == graphql.Null {
invalids++
}
case "setScope":
out.Values[i] = ec._Mutation_setScope(ctx, field)
if out.Values[i] == graphql.Null {
@ -3985,6 +4164,20 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se
return res
}
func (ec *executionContext) marshalNClearHTTPRequestLogResult2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐClearHTTPRequestLogResult(ctx context.Context, sel ast.SelectionSet, v ClearHTTPRequestLogResult) graphql.Marshaler {
return ec._ClearHTTPRequestLogResult(ctx, sel, &v)
}
func (ec *executionContext) marshalNClearHTTPRequestLogResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐClearHTTPRequestLogResult(ctx context.Context, sel ast.SelectionSet, v *ClearHTTPRequestLogResult) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
return ec._ClearHTTPRequestLogResult(ctx, sel, v)
}
func (ec *executionContext) marshalNCloseProjectResult2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐCloseProjectResult(ctx context.Context, sel ast.SelectionSet, v CloseProjectResult) graphql.Marshaler {
return ec._CloseProjectResult(ctx, sel, &v)
}

View File

@ -9,6 +9,10 @@ import (
"time"
)
type ClearHTTPRequestLogResult struct {
Success bool `json:"success"`
}
type CloseProjectResult struct {
Success bool `json:"success"`
}
@ -34,11 +38,13 @@ type HTTPRequestLog struct {
}
type HTTPRequestLogFilter struct {
OnlyInScope bool `json:"onlyInScope"`
OnlyInScope bool `json:"onlyInScope"`
SearchExpression *string `json:"searchExpression"`
}
type HTTPRequestLogFilterInput struct {
OnlyInScope *bool `json:"onlyInScope"`
OnlyInScope *bool `json:"onlyInScope"`
SearchExpression *string `json:"searchExpression"`
}
type HTTPResponseLog struct {

View File

@ -12,6 +12,7 @@ import (
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
"github.com/vektah/gqlparser/v2/gqlerror"
)
@ -30,13 +31,7 @@ func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
reqs, err := r.RequestLogService.FindRequests(ctx)
if err == proj.ErrNoProject {
return nil, &gqlerror.Error{
Path: graphql.GetPath(ctx),
Message: "No active project.",
Extensions: map[string]interface{}{
"code": "no_active_project",
},
}
return nil, noActiveProjectErr(ctx)
}
if err != nil {
return nil, fmt.Errorf("could not query repository for requests: %v", err)
@ -209,6 +204,13 @@ func (r *mutationResolver) DeleteProject(ctx context.Context, name string) (*Del
}, nil
}
func (r *mutationResolver) ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error) {
if err := r.RequestLogService.ClearRequests(ctx); err != nil {
return nil, fmt.Errorf("could not clear request log: %v", err)
}
return &ClearHTTPRequestLogResult{true}, nil
}
func (r *mutationResolver) SetScope(ctx context.Context, input []ScopeRuleInput) ([]ScopeRule, error) {
rules := make([]scope.Rule, len(input))
for i, rule := range input {
@ -256,15 +258,18 @@ func (r *mutationResolver) SetHTTPRequestLogFilter(
ctx context.Context,
input *HTTPRequestLogFilterInput,
) (*HTTPRequestLogFilter, error) {
filter := findRequestsFilterFromInput(input)
if err := r.RequestLogService.SetRequestLogFilter(ctx, filter); err != nil {
filter, err := findRequestsFilterFromInput(input)
if err != nil {
return nil, fmt.Errorf("could not parse request log filter: %v", err)
}
err = r.RequestLogService.SetRequestLogFilter(ctx, filter)
if err == proj.ErrNoProject {
return nil, noActiveProjectErr(ctx)
}
if err != nil {
return nil, fmt.Errorf("could not set request log filter: %v", err)
}
empty := reqlog.FindRequestsFilter{}
if filter == empty {
return nil, nil
}
return findReqFilterToHTTPReqLogFilter(filter), nil
}
@ -290,13 +295,21 @@ func scopeToScopeRules(rules []scope.Rule) []ScopeRule {
return scopeRules
}
func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter) {
func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter, err error) {
if input == nil {
return
}
if input.OnlyInScope != nil {
filter.OnlyInScope = *input.OnlyInScope
}
if input.SearchExpression != nil && *input.SearchExpression != "" {
expr, err := search.ParseQuery(*input.SearchExpression)
if err != nil {
return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %v", err)
}
filter.RawSearchExpr = *input.SearchExpression
filter.SearchExpr = expr
}
return
}
@ -310,5 +323,19 @@ func findReqFilterToHTTPReqLogFilter(findReqFilter reqlog.FindRequestsFilter) *H
OnlyInScope: findReqFilter.OnlyInScope,
}
if findReqFilter.RawSearchExpr != "" {
httpReqLogFilter.SearchExpression = &findReqFilter.RawSearchExpr
}
return httpReqLogFilter
}
func noActiveProjectErr(ctx context.Context) error {
return &gqlerror.Error{
Path: graphql.GetPath(ctx),
Message: "No active project.",
Extensions: map[string]interface{}{
"code": "no_active_project",
},
}
}

View File

@ -58,12 +58,18 @@ type DeleteProjectResult {
success: Boolean!
}
type ClearHTTPRequestLogResult {
success: Boolean!
}
input HttpRequestLogFilterInput {
onlyInScope: Boolean
searchExpression: String
}
type HttpRequestLogFilter {
onlyInScope: Boolean!
searchExpression: String
}
type Query {
@ -79,6 +85,7 @@ type Mutation {
openProject(name: String!): Project
closeProject: CloseProjectResult!
deleteProject(name: String!): DeleteProjectResult!
clearHTTPRequestLog: ClearHTTPRequestLogResult!
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
setHttpRequestLogFilter(
filter: HttpRequestLogFilterInput

View File

@ -1,759 +0,0 @@
/*
** 2012-11-13
**
** The author disclaims copyright to this source code. In place of
** a legal notice, here is a blessing:
**
** May you do good and not evil.
** May you find forgiveness for yourself and forgive others.
** May you share freely, never taking more than you give.
**
******************************************************************************
**
** The code in this file implements a compact but reasonably
** efficient regular-expression matcher for posix extended regular
** expressions against UTF8 text.
**
** This file is an SQLite extension. It registers a single function
** named "regexp(A,B)" where A is the regular expression and B is the
** string to be matched. By registering this function, SQLite will also
** then implement the "B regexp A" operator. Note that with the function
** the regular expression comes first, but with the operator it comes
** second.
**
** The following regular expression syntax is supported:
**
** X* zero or more occurrences of X
** X+ one or more occurrences of X
** X? zero or one occurrences of X
** X{p,q} between p and q occurrences of X
** (X) match X
** X|Y X or Y
** ^X X occurring at the beginning of the string
** X$ X occurring at the end of the string
** . Match any single character
** \c Character c where c is one of \{}()[]|*+?.
** \c C-language escapes for c in afnrtv. ex: \t or \n
** \uXXXX Where XXXX is exactly 4 hex digits, unicode value XXXX
** \xXX Where XX is exactly 2 hex digits, unicode value XX
** [abc] Any single character from the set abc
** [^abc] Any single character not in the set abc
** [a-z] Any single character in the range a-z
** [^a-z] Any single character not in the range a-z
** \b Word boundary
** \w Word character. [A-Za-z0-9_]
** \W Non-word character
** \d Digit
** \D Non-digit
** \s Whitespace character
** \S Non-whitespace character
**
** A nondeterministic finite automaton (NFA) is used for matching, so the
** performance is bounded by O(N*M) where N is the size of the regular
** expression and M is the size of the input string. The matcher never
** exhibits exponential behavior. Note that the X{p,q} operator expands
** to p copies of X following by q-p copies of X? and that the size of the
** regular expression in the O(N*M) performance bound is computed after
** this expansion.
*/
#include <string.h>
#include <stdlib.h>
#include "sqlite3ext.h"
SQLITE_EXTENSION_INIT1
/*
** The following #defines change the names of some functions implemented in
** this file to prevent name collisions with C-library functions of the
** same name.
*/
#define re_match sqlite3re_match
#define re_compile sqlite3re_compile
#define re_free sqlite3re_free
/* The end-of-input character */
#define RE_EOF 0 /* End of input */
/* The NFA is implemented as sequence of opcodes taken from the following
** set. Each opcode has a single integer argument.
*/
#define RE_OP_MATCH 1 /* Match the one character in the argument */
#define RE_OP_ANY 2 /* Match any one character. (Implements ".") */
#define RE_OP_ANYSTAR 3 /* Special optimized version of .* */
#define RE_OP_FORK 4 /* Continue to both next and opcode at iArg */
#define RE_OP_GOTO 5 /* Jump to opcode at iArg */
#define RE_OP_ACCEPT 6 /* Halt and indicate a successful match */
#define RE_OP_CC_INC 7 /* Beginning of a [...] character class */
#define RE_OP_CC_EXC 8 /* Beginning of a [^...] character class */
#define RE_OP_CC_VALUE 9 /* Single value in a character class */
#define RE_OP_CC_RANGE 10 /* Range of values in a character class */
#define RE_OP_WORD 11 /* Perl word character [A-Za-z0-9_] */
#define RE_OP_NOTWORD 12 /* Not a perl word character */
#define RE_OP_DIGIT 13 /* digit: [0-9] */
#define RE_OP_NOTDIGIT 14 /* Not a digit */
#define RE_OP_SPACE 15 /* space: [ \t\n\r\v\f] */
#define RE_OP_NOTSPACE 16 /* Not a digit */
#define RE_OP_BOUNDARY 17 /* Boundary between word and non-word */
/* Each opcode is a "state" in the NFA */
typedef unsigned short ReStateNumber;
/* Because this is an NFA and not a DFA, multiple states can be active at
** once. An instance of the following object records all active states in
** the NFA. The implementation is optimized for the common case where the
** number of actives states is small.
*/
typedef struct ReStateSet {
unsigned nState; /* Number of current states */
ReStateNumber *aState; /* Current states */
} ReStateSet;
/* An input string read one character at a time.
*/
typedef struct ReInput ReInput;
struct ReInput {
const unsigned char *z; /* All text */
int i; /* Next byte to read */
int mx; /* EOF when i>=mx */
};
/* A compiled NFA (or an NFA that is in the process of being compiled) is
** an instance of the following object.
*/
typedef struct ReCompiled ReCompiled;
struct ReCompiled {
ReInput sIn; /* Regular expression text */
const char *zErr; /* Error message to return */
char *aOp; /* Operators for the virtual machine */
int *aArg; /* Arguments to each operator */
unsigned (*xNextChar)(ReInput*); /* Next character function */
unsigned char zInit[12]; /* Initial text to match */
int nInit; /* Number of characters in zInit */
unsigned nState; /* Number of entries in aOp[] and aArg[] */
unsigned nAlloc; /* Slots allocated for aOp[] and aArg[] */
};
/* Add a state to the given state set if it is not already there */
static void re_add_state(ReStateSet *pSet, int newState){
unsigned i;
for(i=0; i<pSet->nState; i++) if( pSet->aState[i]==newState ) return;
pSet->aState[pSet->nState++] = (ReStateNumber)newState;
}
/* Extract the next unicode character from *pzIn and return it. Advance
** *pzIn to the first byte past the end of the character returned. To
** be clear: this routine converts utf8 to unicode. This routine is
** optimized for the common case where the next character is a single byte.
*/
static unsigned re_next_char(ReInput *p){
unsigned c;
if( p->i>=p->mx ) return 0;
c = p->z[p->i++];
if( c>=0x80 ){
if( (c&0xe0)==0xc0 && p->i<p->mx && (p->z[p->i]&0xc0)==0x80 ){
c = (c&0x1f)<<6 | (p->z[p->i++]&0x3f);
if( c<0x80 ) c = 0xfffd;
}else if( (c&0xf0)==0xe0 && p->i+1<p->mx && (p->z[p->i]&0xc0)==0x80
&& (p->z[p->i+1]&0xc0)==0x80 ){
c = (c&0x0f)<<12 | ((p->z[p->i]&0x3f)<<6) | (p->z[p->i+1]&0x3f);
p->i += 2;
if( c<=0x7ff || (c>=0xd800 && c<=0xdfff) ) c = 0xfffd;
}else if( (c&0xf8)==0xf0 && p->i+3<p->mx && (p->z[p->i]&0xc0)==0x80
&& (p->z[p->i+1]&0xc0)==0x80 && (p->z[p->i+2]&0xc0)==0x80 ){
c = (c&0x07)<<18 | ((p->z[p->i]&0x3f)<<12) | ((p->z[p->i+1]&0x3f)<<6)
| (p->z[p->i+2]&0x3f);
p->i += 3;
if( c<=0xffff || c>0x10ffff ) c = 0xfffd;
}else{
c = 0xfffd;
}
}
return c;
}
static unsigned re_next_char_nocase(ReInput *p){
unsigned c = re_next_char(p);
if( c>='A' && c<='Z' ) c += 'a' - 'A';
return c;
}
/* Return true if c is a perl "word" character: [A-Za-z0-9_] */
static int re_word_char(int c){
return (c>='0' && c<='9') || (c>='a' && c<='z')
|| (c>='A' && c<='Z') || c=='_';
}
/* Return true if c is a "digit" character: [0-9] */
static int re_digit_char(int c){
return (c>='0' && c<='9');
}
/* Return true if c is a perl "space" character: [ \t\r\n\v\f] */
static int re_space_char(int c){
return c==' ' || c=='\t' || c=='\n' || c=='\r' || c=='\v' || c=='\f';
}
/* Run a compiled regular expression on the zero-terminated input
** string zIn[]. Return true on a match and false if there is no match.
*/
static int re_match(ReCompiled *pRe, const unsigned char *zIn, int nIn){
ReStateSet aStateSet[2], *pThis, *pNext;
ReStateNumber aSpace[100];
ReStateNumber *pToFree;
unsigned int i = 0;
unsigned int iSwap = 0;
int c = RE_EOF+1;
int cPrev = 0;
int rc = 0;
ReInput in;
in.z = zIn;
in.i = 0;
in.mx = nIn>=0 ? nIn : (int)strlen((char const*)zIn);
/* Look for the initial prefix match, if there is one. */
if( pRe->nInit ){
unsigned char x = pRe->zInit[0];
while( in.i+pRe->nInit<=in.mx
&& (zIn[in.i]!=x ||
strncmp((const char*)zIn+in.i, (const char*)pRe->zInit, pRe->nInit)!=0)
){
in.i++;
}
if( in.i+pRe->nInit>in.mx ) return 0;
}
if( pRe->nState<=(sizeof(aSpace)/(sizeof(aSpace[0])*2)) ){
pToFree = 0;
aStateSet[0].aState = aSpace;
}else{
pToFree = sqlite3_malloc64( sizeof(ReStateNumber)*2*pRe->nState );
if( pToFree==0 ) return -1;
aStateSet[0].aState = pToFree;
}
aStateSet[1].aState = &aStateSet[0].aState[pRe->nState];
pNext = &aStateSet[1];
pNext->nState = 0;
re_add_state(pNext, 0);
while( c!=RE_EOF && pNext->nState>0 ){
cPrev = c;
c = pRe->xNextChar(&in);
pThis = pNext;
pNext = &aStateSet[iSwap];
iSwap = 1 - iSwap;
pNext->nState = 0;
for(i=0; i<pThis->nState; i++){
int x = pThis->aState[i];
switch( pRe->aOp[x] ){
case RE_OP_MATCH: {
if( pRe->aArg[x]==c ) re_add_state(pNext, x+1);
break;
}
case RE_OP_ANY: {
re_add_state(pNext, x+1);
break;
}
case RE_OP_WORD: {
if( re_word_char(c) ) re_add_state(pNext, x+1);
break;
}
case RE_OP_NOTWORD: {
if( !re_word_char(c) ) re_add_state(pNext, x+1);
break;
}
case RE_OP_DIGIT: {
if( re_digit_char(c) ) re_add_state(pNext, x+1);
break;
}
case RE_OP_NOTDIGIT: {
if( !re_digit_char(c) ) re_add_state(pNext, x+1);
break;
}
case RE_OP_SPACE: {
if( re_space_char(c) ) re_add_state(pNext, x+1);
break;
}
case RE_OP_NOTSPACE: {
if( !re_space_char(c) ) re_add_state(pNext, x+1);
break;
}
case RE_OP_BOUNDARY: {
if( re_word_char(c)!=re_word_char(cPrev) ) re_add_state(pThis, x+1);
break;
}
case RE_OP_ANYSTAR: {
re_add_state(pNext, x);
re_add_state(pThis, x+1);
break;
}
case RE_OP_FORK: {
re_add_state(pThis, x+pRe->aArg[x]);
re_add_state(pThis, x+1);
break;
}
case RE_OP_GOTO: {
re_add_state(pThis, x+pRe->aArg[x]);
break;
}
case RE_OP_ACCEPT: {
rc = 1;
goto re_match_end;
}
case RE_OP_CC_INC:
case RE_OP_CC_EXC: {
int j = 1;
int n = pRe->aArg[x];
int hit = 0;
for(j=1; j>0 && j<n; j++){
if( pRe->aOp[x+j]==RE_OP_CC_VALUE ){
if( pRe->aArg[x+j]==c ){
hit = 1;
j = -1;
}
}else{
if( pRe->aArg[x+j]<=c && pRe->aArg[x+j+1]>=c ){
hit = 1;
j = -1;
}else{
j++;
}
}
}
if( pRe->aOp[x]==RE_OP_CC_EXC ) hit = !hit;
if( hit ) re_add_state(pNext, x+n);
break;
}
}
}
}
for(i=0; i<pNext->nState; i++){
if( pRe->aOp[pNext->aState[i]]==RE_OP_ACCEPT ){ rc = 1; break; }
}
re_match_end:
sqlite3_free(pToFree);
return rc;
}
/* Resize the opcode and argument arrays for an RE under construction.
*/
static int re_resize(ReCompiled *p, int N){
char *aOp;
int *aArg;
aOp = sqlite3_realloc64(p->aOp, N*sizeof(p->aOp[0]));
if( aOp==0 ) return 1;
p->aOp = aOp;
aArg = sqlite3_realloc64(p->aArg, N*sizeof(p->aArg[0]));
if( aArg==0 ) return 1;
p->aArg = aArg;
p->nAlloc = N;
return 0;
}
/* Insert a new opcode and argument into an RE under construction. The
** insertion point is just prior to existing opcode iBefore.
*/
static int re_insert(ReCompiled *p, int iBefore, int op, int arg){
int i;
if( p->nAlloc<=p->nState && re_resize(p, p->nAlloc*2) ) return 0;
for(i=p->nState; i>iBefore; i--){
p->aOp[i] = p->aOp[i-1];
p->aArg[i] = p->aArg[i-1];
}
p->nState++;
p->aOp[iBefore] = (char)op;
p->aArg[iBefore] = arg;
return iBefore;
}
/* Append a new opcode and argument to the end of the RE under construction.
*/
static int re_append(ReCompiled *p, int op, int arg){
return re_insert(p, p->nState, op, arg);
}
/* Make a copy of N opcodes starting at iStart onto the end of the RE
** under construction.
*/
static void re_copy(ReCompiled *p, int iStart, int N){
if( p->nState+N>=p->nAlloc && re_resize(p, p->nAlloc*2+N) ) return;
memcpy(&p->aOp[p->nState], &p->aOp[iStart], N*sizeof(p->aOp[0]));
memcpy(&p->aArg[p->nState], &p->aArg[iStart], N*sizeof(p->aArg[0]));
p->nState += N;
}
/* Return true if c is a hexadecimal digit character: [0-9a-fA-F]
** If c is a hex digit, also set *pV = (*pV)*16 + valueof(c). If
** c is not a hex digit *pV is unchanged.
*/
static int re_hex(int c, int *pV){
if( c>='0' && c<='9' ){
c -= '0';
}else if( c>='a' && c<='f' ){
c -= 'a' - 10;
}else if( c>='A' && c<='F' ){
c -= 'A' - 10;
}else{
return 0;
}
*pV = (*pV)*16 + (c & 0xff);
return 1;
}
/* A backslash character has been seen, read the next character and
** return its interpretation.
*/
static unsigned re_esc_char(ReCompiled *p){
static const char zEsc[] = "afnrtv\\()*.+?[$^{|}]";
static const char zTrans[] = "\a\f\n\r\t\v";
int i, v = 0;
char c;
if( p->sIn.i>=p->sIn.mx ) return 0;
c = p->sIn.z[p->sIn.i];
if( c=='u' && p->sIn.i+4<p->sIn.mx ){
const unsigned char *zIn = p->sIn.z + p->sIn.i;
if( re_hex(zIn[1],&v)
&& re_hex(zIn[2],&v)
&& re_hex(zIn[3],&v)
&& re_hex(zIn[4],&v)
){
p->sIn.i += 5;
return v;
}
}
if( c=='x' && p->sIn.i+2<p->sIn.mx ){
const unsigned char *zIn = p->sIn.z + p->sIn.i;
if( re_hex(zIn[1],&v)
&& re_hex(zIn[2],&v)
){
p->sIn.i += 3;
return v;
}
}
for(i=0; zEsc[i] && zEsc[i]!=c; i++){}
if( zEsc[i] ){
if( i<6 ) c = zTrans[i];
p->sIn.i++;
}else{
p->zErr = "unknown \\ escape";
}
return c;
}
/* Forward declaration */
static const char *re_subcompile_string(ReCompiled*);
/* Peek at the next byte of input */
static unsigned char rePeek(ReCompiled *p){
return p->sIn.i<p->sIn.mx ? p->sIn.z[p->sIn.i] : 0;
}
/* Compile RE text into a sequence of opcodes. Continue up to the
** first unmatched ")" character, then return. If an error is found,
** return a pointer to the error message string.
*/
static const char *re_subcompile_re(ReCompiled *p){
const char *zErr;
int iStart, iEnd, iGoto;
iStart = p->nState;
zErr = re_subcompile_string(p);
if( zErr ) return zErr;
while( rePeek(p)=='|' ){
iEnd = p->nState;
re_insert(p, iStart, RE_OP_FORK, iEnd + 2 - iStart);
iGoto = re_append(p, RE_OP_GOTO, 0);
p->sIn.i++;
zErr = re_subcompile_string(p);
if( zErr ) return zErr;
p->aArg[iGoto] = p->nState - iGoto;
}
return 0;
}
/* Compile an element of regular expression text (anything that can be
** an operand to the "|" operator). Return NULL on success or a pointer
** to the error message if there is a problem.
*/
static const char *re_subcompile_string(ReCompiled *p){
int iPrev = -1;
int iStart;
unsigned c;
const char *zErr;
while( (c = p->xNextChar(&p->sIn))!=0 ){
iStart = p->nState;
switch( c ){
case '|':
case '$':
case ')': {
p->sIn.i--;
return 0;
}
case '(': {
zErr = re_subcompile_re(p);
if( zErr ) return zErr;
if( rePeek(p)!=')' ) return "unmatched '('";
p->sIn.i++;
break;
}
case '.': {
if( rePeek(p)=='*' ){
re_append(p, RE_OP_ANYSTAR, 0);
p->sIn.i++;
}else{
re_append(p, RE_OP_ANY, 0);
}
break;
}
case '*': {
if( iPrev<0 ) return "'*' without operand";
re_insert(p, iPrev, RE_OP_GOTO, p->nState - iPrev + 1);
re_append(p, RE_OP_FORK, iPrev - p->nState + 1);
break;
}
case '+': {
if( iPrev<0 ) return "'+' without operand";
re_append(p, RE_OP_FORK, iPrev - p->nState);
break;
}
case '?': {
if( iPrev<0 ) return "'?' without operand";
re_insert(p, iPrev, RE_OP_FORK, p->nState - iPrev+1);
break;
}
case '{': {
int m = 0, n = 0;
int sz, j;
if( iPrev<0 ) return "'{m,n}' without operand";
while( (c=rePeek(p))>='0' && c<='9' ){ m = m*10 + c - '0'; p->sIn.i++; }
n = m;
if( c==',' ){
p->sIn.i++;
n = 0;
while( (c=rePeek(p))>='0' && c<='9' ){ n = n*10 + c-'0'; p->sIn.i++; }
}
if( c!='}' ) return "unmatched '{'";
if( n>0 && n<m ) return "n less than m in '{m,n}'";
p->sIn.i++;
sz = p->nState - iPrev;
if( m==0 ){
if( n==0 ) return "both m and n are zero in '{m,n}'";
re_insert(p, iPrev, RE_OP_FORK, sz+1);
n--;
}else{
for(j=1; j<m; j++) re_copy(p, iPrev, sz);
}
for(j=m; j<n; j++){
re_append(p, RE_OP_FORK, sz+1);
re_copy(p, iPrev, sz);
}
if( n==0 && m>0 ){
re_append(p, RE_OP_FORK, -sz);
}
break;
}
case '[': {
int iFirst = p->nState;
if( rePeek(p)=='^' ){
re_append(p, RE_OP_CC_EXC, 0);
p->sIn.i++;
}else{
re_append(p, RE_OP_CC_INC, 0);
}
while( (c = p->xNextChar(&p->sIn))!=0 ){
if( c=='[' && rePeek(p)==':' ){
return "POSIX character classes not supported";
}
if( c=='\\' ) c = re_esc_char(p);
if( rePeek(p)=='-' ){
re_append(p, RE_OP_CC_RANGE, c);
p->sIn.i++;
c = p->xNextChar(&p->sIn);
if( c=='\\' ) c = re_esc_char(p);
re_append(p, RE_OP_CC_RANGE, c);
}else{
re_append(p, RE_OP_CC_VALUE, c);
}
if( rePeek(p)==']' ){ p->sIn.i++; break; }
}
if( c==0 ) return "unclosed '['";
p->aArg[iFirst] = p->nState - iFirst;
break;
}
case '\\': {
int specialOp = 0;
switch( rePeek(p) ){
case 'b': specialOp = RE_OP_BOUNDARY; break;
case 'd': specialOp = RE_OP_DIGIT; break;
case 'D': specialOp = RE_OP_NOTDIGIT; break;
case 's': specialOp = RE_OP_SPACE; break;
case 'S': specialOp = RE_OP_NOTSPACE; break;
case 'w': specialOp = RE_OP_WORD; break;
case 'W': specialOp = RE_OP_NOTWORD; break;
}
if( specialOp ){
p->sIn.i++;
re_append(p, specialOp, 0);
}else{
c = re_esc_char(p);
re_append(p, RE_OP_MATCH, c);
}
break;
}
default: {
re_append(p, RE_OP_MATCH, c);
break;
}
}
iPrev = iStart;
}
return 0;
}
/* Free and reclaim all the memory used by a previously compiled
** regular expression. Applications should invoke this routine once
** for every call to re_compile() to avoid memory leaks.
*/
static void re_free(ReCompiled *pRe){
if( pRe ){
sqlite3_free(pRe->aOp);
sqlite3_free(pRe->aArg);
sqlite3_free(pRe);
}
}
/*
** Compile a textual regular expression in zIn[] into a compiled regular
** expression suitable for us by re_match() and return a pointer to the
** compiled regular expression in *ppRe. Return NULL on success or an
** error message if something goes wrong.
*/
static const char *re_compile(ReCompiled **ppRe, const char *zIn, int noCase){
ReCompiled *pRe;
const char *zErr;
int i, j;
*ppRe = 0;
pRe = sqlite3_malloc( sizeof(*pRe) );
if( pRe==0 ){
return "out of memory";
}
memset(pRe, 0, sizeof(*pRe));
pRe->xNextChar = noCase ? re_next_char_nocase : re_next_char;
if( re_resize(pRe, 30) ){
re_free(pRe);
return "out of memory";
}
if( zIn[0]=='^' ){
zIn++;
}else{
re_append(pRe, RE_OP_ANYSTAR, 0);
}
pRe->sIn.z = (unsigned char*)zIn;
pRe->sIn.i = 0;
pRe->sIn.mx = (int)strlen(zIn);
zErr = re_subcompile_re(pRe);
if( zErr ){
re_free(pRe);
return zErr;
}
if( rePeek(pRe)=='$' && pRe->sIn.i+1>=pRe->sIn.mx ){
re_append(pRe, RE_OP_MATCH, RE_EOF);
re_append(pRe, RE_OP_ACCEPT, 0);
*ppRe = pRe;
}else if( pRe->sIn.i>=pRe->sIn.mx ){
re_append(pRe, RE_OP_ACCEPT, 0);
*ppRe = pRe;
}else{
re_free(pRe);
return "unrecognized character";
}
/* The following is a performance optimization. If the regex begins with
** ".*" (if the input regex lacks an initial "^") and afterwards there are
** one or more matching characters, enter those matching characters into
** zInit[]. The re_match() routine can then search ahead in the input
** string looking for the initial match without having to run the whole
** regex engine over the string. Do not worry able trying to match
** unicode characters beyond plane 0 - those are very rare and this is
** just an optimization. */
if( pRe->aOp[0]==RE_OP_ANYSTAR ){
for(j=0, i=1; j<sizeof(pRe->zInit)-2 && pRe->aOp[i]==RE_OP_MATCH; i++){
unsigned x = pRe->aArg[i];
if( x<=127 ){
pRe->zInit[j++] = (unsigned char)x;
}else if( x<=0xfff ){
pRe->zInit[j++] = (unsigned char)(0xc0 | (x>>6));
pRe->zInit[j++] = 0x80 | (x&0x3f);
}else if( x<=0xffff ){
pRe->zInit[j++] = (unsigned char)(0xd0 | (x>>12));
pRe->zInit[j++] = 0x80 | ((x>>6)&0x3f);
pRe->zInit[j++] = 0x80 | (x&0x3f);
}else{
break;
}
}
if( j>0 && pRe->zInit[j-1]==0 ) j--;
pRe->nInit = j;
}
return pRe->zErr;
}
/*
** Implementation of the regexp() SQL function. This function implements
** the build-in REGEXP operator. The first argument to the function is the
** pattern and the second argument is the string. So, the SQL statements:
**
** A REGEXP B
**
** is implemented as regexp(B,A).
*/
static void re_sql_func(
sqlite3_context *context,
int argc,
sqlite3_value **argv
){
ReCompiled *pRe; /* Compiled regular expression */
const char *zPattern; /* The regular expression */
const unsigned char *zStr;/* String being searched */
const char *zErr; /* Compile error message */
int setAux = 0; /* True to invoke sqlite3_set_auxdata() */
pRe = sqlite3_get_auxdata(context, 0);
if( pRe==0 ){
zPattern = (const char*)sqlite3_value_text(argv[0]);
if( zPattern==0 ) return;
zErr = re_compile(&pRe, zPattern, 0);
if( zErr ){
re_free(pRe);
sqlite3_result_error(context, zErr, -1);
return;
}
if( pRe==0 ){
sqlite3_result_error_nomem(context);
return;
}
setAux = 1;
}
zStr = (const unsigned char*)sqlite3_value_text(argv[1]);
if( zStr!=0 ){
sqlite3_result_int(context, re_match(pRe, zStr, -1));
}
if( setAux ){
sqlite3_set_auxdata(context, 0, pRe, (void(*)(void*))re_free);
}
}
/*
** Invoke this routine to register the regexp() function with the
** SQLite database connection.
*/
#ifdef _WIN32
__declspec(dllexport)
#endif
int sqlite3_regexp_init(
sqlite3 *db,
char **pzErrMsg,
const sqlite3_api_routines *pApi
){
int rc = SQLITE_OK;
SQLITE_EXTENSION_INIT2(pApi);
rc = sqlite3_create_function(db, "regexp", 2, SQLITE_UTF8, 0, re_sql_func, 0, 0);
return rc;
}

View File

@ -1,16 +0,0 @@
package regexp
// #ifndef USE_LIBSQLITE3
// #include <sqlite3-binding.h>
// #else
// #include <sqlite3.h>
// #endif
//
// // Extension function defined in regexp.c.
// extern int sqlite3_regexp_init(sqlite3*, char**, const sqlite3_api_routines*);
//
// // Use constructor to register extension function with sqlite.
// void __attribute__((constructor)) init(void) {
// sqlite3_auto_extension((void*) sqlite3_regexp_init);
// }
import "C"

126
pkg/db/sqlite/search.go Normal file
View File

@ -0,0 +1,126 @@
package sqlite
import (
"errors"
"fmt"
"sort"
sq "github.com/Masterminds/squirrel"
"github.com/dstotijn/hetty/pkg/search"
)
var stringLiteralMap = map[string]string{
// http_requests
"req.id": "req.id",
"req.proto": "req.proto",
"req.url": "req.url",
"req.method": "req.method",
"req.body": "req.body",
"req.timestamp": "req.timestamp",
// http_responses
"res.id": "res.id",
"res.proto": "res.proto",
"res.statusCode": "res.status_code",
"res.statusReason": "res.status_reason",
"res.body": "res.body",
"res.timestamp": "res.timestamp",
// TODO: http_headers
}
func parseSearchExpr(expr search.Expression) (sq.Sqlizer, error) {
switch e := expr.(type) {
case *search.PrefixExpression:
return parsePrefixExpr(e)
case *search.InfixExpression:
return parseInfixExpr(e)
case *search.StringLiteral:
return parseStringLiteral(e)
default:
return nil, fmt.Errorf("expression type (%v) not supported", expr)
}
}
func parsePrefixExpr(expr *search.PrefixExpression) (sq.Sqlizer, error) {
switch expr.Operator {
case search.TokOpNot:
// TODO: Find a way to prefix an `sq.Sqlizer` with "NOT".
return nil, errors.New("not implemented")
default:
return nil, errors.New("operator is not supported")
}
}
func parseInfixExpr(expr *search.InfixExpression) (sq.Sqlizer, error) {
switch expr.Operator {
case search.TokOpAnd:
left, err := parseSearchExpr(expr.Left)
if err != nil {
return nil, err
}
right, err := parseSearchExpr(expr.Right)
if err != nil {
return nil, err
}
return sq.And{left, right}, nil
case search.TokOpOr:
left, err := parseSearchExpr(expr.Left)
if err != nil {
return nil, err
}
right, err := parseSearchExpr(expr.Right)
if err != nil {
return nil, err
}
return sq.Or{left, right}, nil
}
left, ok := expr.Left.(*search.StringLiteral)
if !ok {
return nil, errors.New("left operand must be a string literal")
}
right, ok := expr.Right.(*search.StringLiteral)
if !ok {
return nil, errors.New("right operand must be a string literal")
}
mappedLeft, ok := stringLiteralMap[left.Value]
if !ok {
return nil, fmt.Errorf("invalid string literal: %v", left)
}
switch expr.Operator {
case search.TokOpEq:
return sq.Eq{mappedLeft: right.Value}, nil
case search.TokOpNotEq:
return sq.NotEq{mappedLeft: right.Value}, nil
case search.TokOpGt:
return sq.Gt{mappedLeft: right.Value}, nil
case search.TokOpLt:
return sq.Lt{mappedLeft: right.Value}, nil
case search.TokOpGtEq:
return sq.GtOrEq{mappedLeft: right.Value}, nil
case search.TokOpLtEq:
return sq.LtOrEq{mappedLeft: right.Value}, nil
case search.TokOpRe:
return sq.Expr(fmt.Sprintf("regexp(?, %v)", mappedLeft), right.Value), nil
case search.TokOpNotRe:
return sq.Expr(fmt.Sprintf("NOT regexp(?, %v)", mappedLeft), right.Value), nil
default:
return nil, errors.New("unsupported operator")
}
}
func parseStringLiteral(strLiteral *search.StringLiteral) (sq.Sqlizer, error) {
// Sorting is not necessary, but makes it easier to do assertions in tests.
sortedKeys := make([]string, 0, len(stringLiteralMap))
for _, v := range stringLiteralMap {
sortedKeys = append(sortedKeys, v)
}
sort.Strings(sortedKeys)
or := make(sq.Or, len(stringLiteralMap))
for i, value := range sortedKeys {
or[i] = sq.Like{value: "%" + strLiteral.Value + "%"}
}
return or, nil
}

View File

@ -0,0 +1,217 @@
package sqlite
import (
"reflect"
"testing"
sq "github.com/Masterminds/squirrel"
"github.com/dstotijn/hetty/pkg/search"
)
func TestParseSearchExpr(t *testing.T) {
tests := []struct {
name string
searchExpr search.Expression
expectedSqlizer sq.Sqlizer
expectedError error
}{
{
name: "req.body = bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.Eq{"req.body": "bar"},
expectedError: nil,
},
{
name: "req.body != bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpNotEq,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.NotEq{"req.body": "bar"},
expectedError: nil,
},
{
name: "req.body > bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpGt,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.Gt{"req.body": "bar"},
expectedError: nil,
},
{
name: "req.body < bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpLt,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.Lt{"req.body": "bar"},
expectedError: nil,
},
{
name: "req.body >= bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpGtEq,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.GtOrEq{"req.body": "bar"},
expectedError: nil,
},
{
name: "req.body <= bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpLtEq,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.LtOrEq{"req.body": "bar"},
expectedError: nil,
},
{
name: "req.body =~ bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpRe,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.Expr("regexp(?, req.body)", "bar"),
expectedError: nil,
},
{
name: "req.body !~ bar",
searchExpr: &search.InfixExpression{
Operator: search.TokOpNotRe,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
expectedSqlizer: sq.Expr("NOT regexp(?, req.body)", "bar"),
expectedError: nil,
},
{
name: "req.body = bar AND res.body = yolo",
searchExpr: &search.InfixExpression{
Operator: search.TokOpAnd,
Left: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
Right: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "res.body"},
Right: &search.StringLiteral{Value: "yolo"},
},
},
expectedSqlizer: sq.And{
sq.Eq{"req.body": "bar"},
sq.Eq{"res.body": "yolo"},
},
expectedError: nil,
},
{
name: "req.body = bar AND res.body = yolo AND req.method = POST",
searchExpr: &search.InfixExpression{
Operator: search.TokOpAnd,
Left: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
Right: &search.InfixExpression{
Operator: search.TokOpAnd,
Left: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "res.body"},
Right: &search.StringLiteral{Value: "yolo"},
},
Right: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "req.method"},
Right: &search.StringLiteral{Value: "POST"},
},
},
},
expectedSqlizer: sq.And{
sq.Eq{"req.body": "bar"},
sq.And{
sq.Eq{"res.body": "yolo"},
sq.Eq{"req.method": "POST"},
},
},
expectedError: nil,
},
{
name: "req.body = bar OR res.body = yolo",
searchExpr: &search.InfixExpression{
Operator: search.TokOpOr,
Left: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "req.body"},
Right: &search.StringLiteral{Value: "bar"},
},
Right: &search.InfixExpression{
Operator: search.TokOpEq,
Left: &search.StringLiteral{Value: "res.body"},
Right: &search.StringLiteral{Value: "yolo"},
},
},
expectedSqlizer: sq.Or{
sq.Eq{"req.body": "bar"},
sq.Eq{"res.body": "yolo"},
},
expectedError: nil,
},
{
name: "foo",
searchExpr: &search.StringLiteral{
Value: "foo",
},
expectedSqlizer: sq.Or{
sq.Like{"req.body": "%foo%"},
sq.Like{"req.id": "%foo%"},
sq.Like{"req.method": "%foo%"},
sq.Like{"req.proto": "%foo%"},
sq.Like{"req.timestamp": "%foo%"},
sq.Like{"req.url": "%foo%"},
sq.Like{"res.body": "%foo%"},
sq.Like{"res.id": "%foo%"},
sq.Like{"res.proto": "%foo%"},
sq.Like{"res.status_code": "%foo%"},
sq.Like{"res.status_reason": "%foo%"},
sq.Like{"res.timestamp": "%foo%"},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := parseSearchExpr(tt.searchExpr)
assertError(t, tt.expectedError, err)
if !reflect.DeepEqual(tt.expectedSqlizer, got) {
t.Errorf("expected: %#v, got: %#v", tt.expectedSqlizer, got)
}
})
}
}
func assertError(t *testing.T, exp, got error) {
switch {
case exp == nil && got != nil:
t.Fatalf("expected: nil, got: %v", got)
case exp != nil && got == nil:
t.Fatalf("expected: %v, got: nil", exp.Error())
case exp != nil && got != nil && exp.Error() != got.Error():
t.Fatalf("expected: %v, got: %v", exp.Error(), got.Error())
}
}

View File

@ -11,6 +11,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
@ -21,14 +22,22 @@ import (
"github.com/99designs/gqlgen/graphql"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
// Register `sqlite3` driver.
_ "github.com/mattn/go-sqlite3"
// Register `regexp()` function.
_ "github.com/dstotijn/hetty/pkg/db/sqlite/regexp"
"github.com/mattn/go-sqlite3"
)
var regexpFn = func(pattern string, value interface{}) (bool, error) {
switch v := value.(type) {
case string:
return regexp.MatchString(pattern, v)
case int64:
return regexp.MatchString(pattern, fmt.Sprintf("%v", v))
case []byte:
return regexp.Match(pattern, v)
default:
return false, fmt.Errorf("unsupported type %T", v)
}
}
// Client implements reqlog.Repository.
type Client struct {
db *sqlx.DB
@ -43,6 +52,17 @@ type httpRequestLogsQuery struct {
joinResponse bool
}
func init() {
sql.Register("sqlite3_with_regexp", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
if err := conn.RegisterFunc("regexp", regexpFn, false); err != nil {
return err
}
return nil
},
})
}
func New(dbPath string) (*Client, error) {
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
if err := os.MkdirAll(dbPath, 0755); err != nil {
@ -65,7 +85,7 @@ func (c *Client) OpenProject(name string) error {
dbPath := filepath.Join(c.dbPath, name+".db")
dsn := fmt.Sprintf("file:%v?%v", dbPath, opts.Encode())
db, err := sqlx.Open("sqlite3", dsn)
db, err := sqlx.Open("sqlite3_with_regexp", dsn)
if err != nil {
return fmt.Errorf("sqlite: could not open database: %v", err)
}
@ -195,6 +215,17 @@ var headerFieldToColumnMap = map[string]string{
"value": "value",
}
func (c *Client) ClearRequestLogs(ctx context.Context) error {
if c.db == nil {
return proj.ErrNoProject
}
_, err := c.db.Exec("DELETE FROM http_requests")
if err != nil {
return fmt.Errorf("sqlite: could not delete requests: %v", err)
}
return nil
}
func (c *Client) FindRequestLogs(
ctx context.Context,
filter reqlog.FindRequestsFilter,
@ -218,7 +249,7 @@ func (c *Client) FindRequestLogs(
var ruleExpr []sq.Sqlizer
for _, rule := range scope.Rules() {
if rule.URL != nil {
ruleExpr = append(ruleExpr, sq.Expr("req.url regexp ?", rule.URL.String()))
ruleExpr = append(ruleExpr, sq.Expr("regexp(?, req.url)", rule.URL.String()))
}
}
if len(ruleExpr) > 0 {
@ -226,6 +257,14 @@ func (c *Client) FindRequestLogs(
}
}
if filter.SearchExpr != nil {
sqlizer, err := parseSearchExpr(filter.SearchExpr)
if err != nil {
return nil, fmt.Errorf("sqlite: could not parse search expression: %v", err)
}
reqQuery = reqQuery.Where(sqlizer)
}
sql, args, err := reqQuery.ToSql()
if err != nil {
return nil, fmt.Errorf("sqlite: could not parse query: %v", err)

View File

@ -17,6 +17,7 @@ type Repository interface {
FindRequestLogByID(ctx context.Context, id int64) (Request, error)
AddRequestLog(ctx context.Context, req http.Request, body []byte, timestamp time.Time) (*Request, error)
AddResponseLog(ctx context.Context, reqID int64, res http.Response, body []byte, timestamp time.Time) (*Response, error)
ClearRequestLogs(ctx context.Context) error
UpsertSettings(ctx context.Context, module string, settings interface{}) error
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error
}

View File

@ -4,6 +4,7 @@ import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
@ -14,6 +15,7 @@ import (
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/search"
)
type contextKey int
@ -51,7 +53,9 @@ type Service struct {
}
type FindRequestsFilter struct {
OnlyInScope bool
OnlyInScope bool
SearchExpr search.Expression `json:"-"`
RawSearchExpr string
}
type Config struct {
@ -99,6 +103,10 @@ func (svc *Service) SetRequestLogFilter(ctx context.Context, filter FindRequests
return svc.repo.UpsertSettings(ctx, "reqlog", svc)
}
func (svc *Service) ClearRequests(ctx context.Context) error {
return svc.repo.ClearRequestLogs(ctx)
}
func (svc *Service) addRequest(
ctx context.Context,
req http.Request,
@ -206,6 +214,34 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
}
}
// UnmarshalJSON implements json.Unmarshaler.
func (f *FindRequestsFilter) UnmarshalJSON(b []byte) error {
var dto struct {
OnlyInScope bool
RawSearchExpr string
}
if err := json.Unmarshal(b, &dto); err != nil {
return err
}
filter := FindRequestsFilter{
OnlyInScope: dto.OnlyInScope,
RawSearchExpr: dto.RawSearchExpr,
}
if dto.RawSearchExpr != "" {
expr, err := search.ParseQuery(dto.RawSearchExpr)
if err != nil {
return err
}
filter.SearchExpr = expr
}
*f = filter
return nil
}
func (svc *Service) loadSettings() error {
return svc.repo.FindSettingsByModule(context.Background(), moduleName, svc)
}

53
pkg/search/ast.go Normal file
View File

@ -0,0 +1,53 @@
package search
import "strings"
type Expression interface {
String() string
}
type PrefixExpression struct {
Operator TokenType
Right Expression
}
func (pe *PrefixExpression) expressionNode() {}
func (pe *PrefixExpression) String() string {
b := strings.Builder{}
b.WriteString("(")
b.WriteString(pe.Operator.String())
b.WriteString(" ")
b.WriteString(pe.Right.String())
b.WriteString(")")
return b.String()
}
type InfixExpression struct {
Operator TokenType
Left Expression
Right Expression
}
func (ie *InfixExpression) expressionNode() {}
func (ie *InfixExpression) String() string {
b := strings.Builder{}
b.WriteString("(")
b.WriteString(ie.Left.String())
b.WriteString(" ")
b.WriteString(ie.Operator.String())
b.WriteString(" ")
b.WriteString(ie.Right.String())
b.WriteString(")")
return b.String()
}
type StringLiteral struct {
Value string
}
func (sl *StringLiteral) expressionNode() {}
func (sl *StringLiteral) String() string {
return sl.Value
}

264
pkg/search/lexer.go Normal file
View File

@ -0,0 +1,264 @@
package search
import (
"fmt"
"unicode"
"unicode/utf8"
)
type TokenType int
type Token struct {
Type TokenType
Literal string
}
const eof = 0
// Token types.
const (
// Flow
TokInvalid TokenType = iota
TokEOF
TokParenOpen
TokParenClose
// Literals
TokString
// Boolean operators
TokOpNot
TokOpAnd
TokOpOr
// Comparison operators
TokOpEq
TokOpNotEq
TokOpGt
TokOpLt
TokOpGtEq
TokOpLtEq
TokOpRe
TokOpNotRe
)
var (
keywords = map[string]TokenType{
"NOT": TokOpNot,
"AND": TokOpAnd,
"OR": TokOpOr,
}
reservedRunes = []rune{'=', '!', '<', '>', '(', ')'}
tokenTypeStrings = map[TokenType]string{
TokInvalid: "INVALID",
TokEOF: "EOF",
TokParenOpen: "(",
TokParenClose: ")",
TokString: "STRING",
TokOpNot: "NOT",
TokOpAnd: "AND",
TokOpOr: "OR",
TokOpEq: "=",
TokOpNotEq: "!=",
TokOpGt: ">",
TokOpLt: "<",
TokOpGtEq: ">=",
TokOpLtEq: "<=",
TokOpRe: "=~",
TokOpNotRe: "!~",
}
)
type stateFn func(*Lexer) stateFn
type Lexer struct {
input string
pos int
start int
width int
tokens chan Token
}
func NewLexer(input string) *Lexer {
l := &Lexer{
input: input,
tokens: make(chan Token),
}
go l.run(begin)
return l
}
func (l *Lexer) Next() Token {
return <-l.tokens
}
func (tt TokenType) String() string {
if typeString, ok := tokenTypeStrings[tt]; ok {
return typeString
}
return "<unknown>"
}
func (l *Lexer) run(init stateFn) {
for nextState := init; nextState != nil; {
nextState = nextState(l)
}
close(l.tokens)
}
func (l *Lexer) read() (r rune) {
if l.pos >= len(l.input) {
l.width = 0
return eof
}
r, l.width = utf8.DecodeRuneInString(l.input[l.pos:])
l.pos += l.width
return
}
func (l *Lexer) emit(tokenType TokenType) {
l.tokens <- Token{
Type: tokenType,
Literal: l.input[l.start:l.pos],
}
l.start = l.pos
}
func (l *Lexer) ignore() {
l.start = l.pos
}
func (l *Lexer) skip() {
l.pos += l.width
l.start = l.pos
}
func (l *Lexer) backup() {
l.pos -= l.width
}
func (l *Lexer) errorf(format string, args ...interface{}) stateFn {
l.tokens <- Token{
Type: TokInvalid,
Literal: fmt.Sprintf(format, args...),
}
return nil
}
func begin(l *Lexer) stateFn {
r := l.read()
switch r {
case '=':
if next := l.read(); next == '~' {
l.emit(TokOpRe)
} else {
l.backup()
l.emit(TokOpEq)
}
return begin
case '!':
switch next := l.read(); next {
case '=':
l.emit(TokOpNotEq)
case '~':
l.emit(TokOpNotRe)
default:
return l.errorf("invalid rune %v", r)
}
return begin
case '<':
if next := l.read(); next == '=' {
l.emit(TokOpLtEq)
} else {
l.backup()
l.emit(TokOpLt)
}
return begin
case '>':
if next := l.read(); next == '=' {
l.emit(TokOpGtEq)
} else {
l.backup()
l.emit(TokOpGt)
}
return begin
case '(':
l.emit(TokParenOpen)
return begin
case ')':
l.emit(TokParenClose)
return begin
case '"':
return l.delimString(r)
case eof:
l.emit(TokEOF)
return nil
}
if unicode.IsSpace(r) {
l.ignore()
return begin
}
return unquotedString
}
func (l *Lexer) delimString(delim rune) stateFn {
// Ignore the start delimiter rune.
l.ignore()
for r := l.read(); r != delim; r = l.read() {
if r == eof {
return l.errorf("unexpected EOF, unclosed delimiter")
}
}
// Don't include the end delimiter in emitted token.
l.backup()
l.emit(TokString)
// Skip end delimiter.
l.skip()
return begin
}
func unquotedString(l *Lexer) stateFn {
for r := l.read(); ; r = l.read() {
switch {
case r == eof:
l.backup()
l.emitUnquotedString()
return begin
case unicode.IsSpace(r):
l.backup()
l.emitUnquotedString()
l.skip()
return begin
case isReserved(r):
l.backup()
l.emitUnquotedString()
return begin
}
}
}
func (l *Lexer) emitUnquotedString() {
str := l.input[l.start:l.pos]
if tokType, ok := keywords[str]; ok {
l.emit(tokType)
return
}
l.emit(TokString)
}
func isReserved(r rune) bool {
for _, v := range reservedRunes {
if r == v {
return true
}
}
return false
}

89
pkg/search/lexer_test.go Normal file
View File

@ -0,0 +1,89 @@
package search
import "testing"
func TestNextToken(t *testing.T) {
tests := []struct {
name string
input string
expected []Token
}{
{
name: "unquoted string",
input: "foo bar",
expected: []Token{
{TokString, "foo"},
{TokString, "bar"},
{TokEOF, ""},
},
},
{
name: "quoted string",
input: `"foo bar" "baz"`,
expected: []Token{
{TokString, "foo bar"},
{TokString, "baz"},
{TokEOF, ""},
},
},
{
name: "boolean operator token types",
input: "NOT AND OR",
expected: []Token{
{TokOpNot, "NOT"},
{TokOpAnd, "AND"},
{TokOpOr, "OR"},
{TokEOF, ""},
},
},
{
name: "comparison operator token types",
input: `= != < > <= >= =~ !~`,
expected: []Token{
{TokOpEq, "="},
{TokOpNotEq, "!="},
{TokOpLt, "<"},
{TokOpGt, ">"},
{TokOpLtEq, "<="},
{TokOpGtEq, ">="},
{TokOpRe, "=~"},
{TokOpNotRe, "!~"},
{TokEOF, ""},
},
},
{
name: "with parentheses",
input: "(foo AND bar) OR baz",
expected: []Token{
{TokParenOpen, "("},
{TokString, "foo"},
{TokOpAnd, "AND"},
{TokString, "bar"},
{TokParenClose, ")"},
{TokOpOr, "OR"},
{TokString, "baz"},
{TokEOF, ""},
},
},
}
for i, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
l := NewLexer(tt.input)
for _, exp := range tt.expected {
got := l.Next()
if got.Type != exp.Type {
t.Errorf("invalid type (idx: %v, expected: %v, got: %v)",
i, exp.Type, got.Type)
}
if got.Literal != exp.Literal {
t.Errorf("invalid literal (idx: %v, expected: %v, got: %v)",
i, exp.Literal, got.Literal)
}
}
})
}
}

237
pkg/search/parser.go Normal file
View File

@ -0,0 +1,237 @@
package search
import (
"fmt"
)
type precedence int
const (
_ precedence = iota
precLowest
precAnd
precOr
precNot
precEq
precLessGreater
precPrefix
precGroup
)
type prefixParser func(*Parser) (Expression, error)
type infixParser func(*Parser, Expression) (Expression, error)
var (
prefixParsers = map[TokenType]prefixParser{}
infixParsers = map[TokenType]infixParser{}
)
var tokenPrecedences = map[TokenType]precedence{
TokParenOpen: precGroup,
TokOpNot: precNot,
TokOpAnd: precAnd,
TokOpOr: precOr,
TokOpEq: precEq,
TokOpNotEq: precEq,
TokOpGt: precLessGreater,
TokOpLt: precLessGreater,
TokOpGtEq: precLessGreater,
TokOpLtEq: precLessGreater,
TokOpRe: precEq,
TokOpNotRe: precEq,
}
func init() {
// Populate maps in `init`, because package global variables would cause an
// initialization cycle.
infixOperators := []TokenType{
TokOpAnd,
TokOpOr,
TokOpEq,
TokOpNotEq,
TokOpGt,
TokOpLt,
TokOpGtEq,
TokOpLtEq,
TokOpRe,
TokOpNotRe,
}
for _, op := range infixOperators {
infixParsers[op] = parseInfixExpression
}
prefixParsers[TokOpNot] = parsePrefixExpression
prefixParsers[TokString] = parseStringLiteral
prefixParsers[TokParenOpen] = parseGroupedExpression
}
type Parser struct {
l *Lexer
cur Token
peek Token
}
func NewParser(l *Lexer) *Parser {
p := &Parser{l: l}
p.nextToken()
p.nextToken()
return p
}
func ParseQuery(input string) (expr Expression, err error) {
p := &Parser{l: NewLexer(input)}
p.nextToken()
p.nextToken()
if p.curTokenIs(TokEOF) {
return nil, fmt.Errorf("search: unexpected EOF")
}
for !p.curTokenIs(TokEOF) {
right, err := p.parseExpression(precLowest)
if err != nil {
return nil, fmt.Errorf("search: could not parse expression: %v", err)
}
if expr == nil {
expr = right
} else {
expr = &InfixExpression{
Operator: TokOpAnd,
Left: expr,
Right: right,
}
}
p.nextToken()
}
return
}
func (p *Parser) nextToken() {
p.cur = p.peek
p.peek = p.l.Next()
}
func (p *Parser) curTokenIs(t TokenType) bool {
return p.cur.Type == t
}
func (p *Parser) peekTokenIs(t TokenType) bool {
return p.peek.Type == t
}
func (p *Parser) expectPeek(t TokenType) error {
if !p.peekTokenIs(t) {
return fmt.Errorf("expected next token to be %v, got %v", t, p.peek.Type)
}
p.nextToken()
return nil
}
func (p *Parser) curPrecedence() precedence {
if p, ok := tokenPrecedences[p.cur.Type]; ok {
return p
}
return precLowest
}
func (p *Parser) peekPrecedence() precedence {
if p, ok := tokenPrecedences[p.peek.Type]; ok {
return p
}
return precLowest
}
func (p *Parser) parseExpression(prec precedence) (Expression, error) {
prefixParser, ok := prefixParsers[p.cur.Type]
if !ok {
return nil, fmt.Errorf("no prefix parse function for %v found", p.cur.Type)
}
expr, err := prefixParser(p)
if err != nil {
return nil, fmt.Errorf("could not parse expression prefix: %v", err)
}
for !p.peekTokenIs(eof) && prec < p.peekPrecedence() {
infixParser, ok := infixParsers[p.peek.Type]
if !ok {
break
}
p.nextToken()
expr, err = infixParser(p, expr)
if err != nil {
return nil, fmt.Errorf("could not parse infix expression: %v", err)
}
}
return expr, nil
}
func parsePrefixExpression(p *Parser) (Expression, error) {
expr := &PrefixExpression{
Operator: p.cur.Type,
}
p.nextToken()
right, err := p.parseExpression(precPrefix)
if err != nil {
return nil, fmt.Errorf("could not parse expression for right operand: %v", err)
}
expr.Right = right
return expr, nil
}
func parseInfixExpression(p *Parser, left Expression) (Expression, error) {
expr := &InfixExpression{
Operator: p.cur.Type,
Left: left,
}
prec := p.curPrecedence()
p.nextToken()
right, err := p.parseExpression(prec)
if err != nil {
return nil, fmt.Errorf("could not parse expression for right operand: %v", err)
}
expr.Right = right
return expr, nil
}
func parseStringLiteral(p *Parser) (Expression, error) {
return &StringLiteral{Value: p.cur.Literal}, nil
}
func parseGroupedExpression(p *Parser) (Expression, error) {
p.nextToken()
expr, err := p.parseExpression(precLowest)
if err != nil {
return nil, fmt.Errorf("could not parse grouped expression: %v", err)
}
for p.nextToken(); !p.curTokenIs(TokParenClose); p.nextToken() {
if p.curTokenIs(TokEOF) {
return nil, fmt.Errorf("unexpected EOF: unmatched parentheses")
}
right, err := p.parseExpression(precLowest)
if err != nil {
return nil, fmt.Errorf("could not parse expression: %v", err)
}
expr = &InfixExpression{
Operator: TokOpAnd,
Left: expr,
Right: right,
}
}
return expr, nil
}

244
pkg/search/parser_test.go Normal file
View File

@ -0,0 +1,244 @@
package search
import (
"errors"
"reflect"
"testing"
)
func TestParseQuery(t *testing.T) {
tests := []struct {
name string
input string
expectedExpression Expression
expectedError error
}{
{
name: "empty query",
input: "",
expectedExpression: nil,
expectedError: errors.New("search: unexpected EOF"),
},
{
name: "string literal expression",
input: "foobar",
expectedExpression: &StringLiteral{Value: "foobar"},
expectedError: nil,
},
{
name: "boolean expression with equal operator",
input: "foo = bar",
expectedExpression: &InfixExpression{
Operator: TokOpEq,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with not equal operator",
input: "foo != bar",
expectedExpression: &InfixExpression{
Operator: TokOpNotEq,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with greater than operator",
input: "foo > bar",
expectedExpression: &InfixExpression{
Operator: TokOpGt,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with less than operator",
input: "foo < bar",
expectedExpression: &InfixExpression{
Operator: TokOpLt,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with greater than or equal operator",
input: "foo >= bar",
expectedExpression: &InfixExpression{
Operator: TokOpGtEq,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with less than or equal operator",
input: "foo <= bar",
expectedExpression: &InfixExpression{
Operator: TokOpLtEq,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with regular expression operator",
input: "foo =~ bar",
expectedExpression: &InfixExpression{
Operator: TokOpRe,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with not regular expression operator",
input: "foo !~ bar",
expectedExpression: &InfixExpression{
Operator: TokOpNotRe,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "boolean expression with AND, OR and NOT operators",
input: "foo AND bar OR NOT baz",
expectedExpression: &InfixExpression{
Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"},
Right: &InfixExpression{
Operator: TokOpOr,
Left: &StringLiteral{Value: "bar"},
Right: &PrefixExpression{
Operator: TokOpNot,
Right: &StringLiteral{Value: "baz"},
},
},
},
expectedError: nil,
},
{
name: "boolean expression with nested group",
input: "(foo AND bar) OR NOT baz",
expectedExpression: &InfixExpression{
Operator: TokOpOr,
Left: &InfixExpression{
Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
Right: &PrefixExpression{
Operator: TokOpNot,
Right: &StringLiteral{Value: "baz"},
},
},
expectedError: nil,
},
{
name: "implicit boolean expression with string literal operands",
input: "foo bar baz",
expectedExpression: &InfixExpression{
Operator: TokOpAnd,
Left: &InfixExpression{
Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
Right: &StringLiteral{Value: "baz"},
},
expectedError: nil,
},
{
name: "implicit boolean expression nested in group",
input: "(foo bar)",
expectedExpression: &InfixExpression{
Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
expectedError: nil,
},
{
name: "implicit and explicit boolean expression with string literal operands",
input: "foo bar OR baz yolo",
expectedExpression: &InfixExpression{
Operator: TokOpAnd,
Left: &InfixExpression{
Operator: TokOpAnd,
Left: &StringLiteral{Value: "foo"},
Right: &InfixExpression{
Operator: TokOpOr,
Left: &StringLiteral{Value: "bar"},
Right: &StringLiteral{Value: "baz"},
},
},
Right: &StringLiteral{Value: "yolo"},
},
expectedError: nil,
},
{
name: "implicit boolean expression with comparison operands",
input: "foo=bar baz=~yolo",
expectedExpression: &InfixExpression{
Operator: TokOpAnd,
Left: &InfixExpression{
Operator: TokOpEq,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
Right: &InfixExpression{
Operator: TokOpRe,
Left: &StringLiteral{Value: "baz"},
Right: &StringLiteral{Value: "yolo"},
},
},
expectedError: nil,
},
{
name: "eq operator takes precedence over boolean ops",
input: "foo=bar OR baz=yolo",
expectedExpression: &InfixExpression{
Operator: TokOpOr,
Left: &InfixExpression{
Operator: TokOpEq,
Left: &StringLiteral{Value: "foo"},
Right: &StringLiteral{Value: "bar"},
},
Right: &InfixExpression{
Operator: TokOpEq,
Left: &StringLiteral{Value: "baz"},
Right: &StringLiteral{Value: "yolo"},
},
},
expectedError: nil,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ParseQuery(tt.input)
assertError(t, tt.expectedError, err)
if !reflect.DeepEqual(tt.expectedExpression, got) {
t.Errorf("expected: %v, got: %v", tt.expectedExpression, got)
}
})
}
}
func assertError(t *testing.T, exp, got error) {
switch {
case exp == nil && got != nil:
t.Fatalf("expected: nil, got: %v", got)
case exp != nil && got == nil:
t.Fatalf("expected: %v, got: nil", exp.Error())
case exp != nil && got != nil && exp.Error() != got.Error():
t.Fatalf("expected: %v, got: %v", exp.Error(), got.Error())
}
}