Compare commits

...

41 Commits

Author SHA1 Message Date
6889c9c183 Replace GraphQL server with Connect RPC 2025-02-05 21:54:59 +01:00
52c83a1989 Update go version matrices for CI/CD workflows 2025-01-13 23:26:31 +01:00
7ed553866b Update README and usage docs to with new default DB file path 2025-01-13 23:18:22 +01:00
fcf0e1c51e Replace BadgerDB with bbolt 2025-01-13 23:15:18 +01:00
24c2ecfa4b Remove database mocks, replace Service interface indirection 2025-01-04 00:39:40 +01:00
d23b4ed024 Update copyright footer 2025-01-01 20:52:14 +01:00
ff9e4140aa Remove Tines as sponsor 2025-01-01 20:50:52 +01:00
f7def87d0f Add HTTP header support to string literal matching 2022-03-31 15:23:56 +02:00
aa9822854d Rename search package to filter package 2022-03-31 15:12:54 +02:00
2ce4218a30 Add filter support for HTTP headers 2022-03-31 14:53:40 +02:00
fd27955e11 Sort HTTP headers 2022-03-31 12:07:35 +02:00
426a7d5f96 Add "New request" button to Sender page 2022-03-31 11:23:17 +02:00
21b679dc91 Add new sponsorship options 2022-03-30 13:02:58 +02:00
e4f468d4d2 Publish Docker image to ghcr.io and docker.io 2022-03-30 11:50:16 +02:00
d3246b0918 Add intercept feature to README 2022-03-29 14:06:51 +02:00
61fd3fcc45 Update admin dependencies 2022-03-29 13:58:38 +02:00
d34258dfd1 Add links to intercept filter docs 2022-03-29 13:52:03 +02:00
0e9fb0ac91 Add tests for search.Expression interface implementations 2022-03-23 15:39:45 +01:00
02408b5196 Add intercept module 2022-03-23 14:31:27 +01:00
6ffc55cde3 Fix Snapcraft plugs config to allow network binding 2022-03-15 10:11:53 +01:00
bdd667381a Add Snapcraft notice 2022-03-06 14:18:25 +01:00
f60202e41c Add Snapcraft and Scoop config 2022-03-03 14:31:58 +01:00
87b8b18047 Fix README badges 2022-03-03 08:29:14 +01:00
edab744d01 Remove old docs 2022-03-02 20:08:39 +01:00
3f5277e419 Fix light/dark mode logo in README 2022-03-02 20:05:37 +01:00
29550ff43b Update README 2022-03-02 19:16:53 +01:00
7afc23b3ff Add Homebrew tap to GoReleaser config 2022-03-02 14:49:24 +01:00
6aa93b782e Add "Copy to Sender" button in reqlog table 2022-03-02 08:14:44 +01:00
ed9a539ce3 Remove stray console.log calls 2022-03-02 07:52:17 +01:00
857aa0c49e Misc lint fixes 2022-02-28 16:21:01 +01:00
af26987601 Fix sort order of request logs 2022-02-28 15:40:13 +01:00
ad26478043 Add certificate management subcommands 2022-02-28 15:31:16 +01:00
ca0c085021 Use ffcli, tidy up usage message 2022-02-28 12:50:09 +01:00
d438f93ee0 Fix incorrect var names 2022-02-28 09:41:55 +01:00
fa3f24eb70 Move gql handler out of main, improve admin route matching 2022-02-28 09:23:08 +01:00
f15438e10b Fix stray outdated enum values 2022-02-28 09:21:43 +01:00
bef52d956e Add support to launch Chrome 2022-02-27 19:00:11 +01:00
8269af9478 Fix missing HTTP/1.0 proto enums 2022-02-27 17:55:41 +01:00
c5f76e1f9a Remove unused project open/close event listeners 2022-02-27 14:42:39 +01:00
2ddf2a77e8 Add logger 2022-02-27 14:28:28 +01:00
d2858a2be4 Fix input fields for key-value pair tables losing focus 2022-02-26 09:58:00 +01:00
172 changed files with 12290 additions and 24350 deletions

4
.github/FUNDING.yml vendored
View File

@ -1,3 +1,3 @@
# These are supported funding model platforms github: dstotijn
patreon: dstotijn patreon: dstotijn
custom: "https://www.paypal.com/paypalme/dstotijn"

View File

@ -5,7 +5,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
go: ["1.17", "1.16"] go: ["1.23", "1.22", "1.21"]
name: Go ${{ matrix.go }} - Build name: Go ${{ matrix.go }} - Build
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
go: ["1.17", "1.16"] go: ["1.23", "1.22", "1.21"]
name: Go ${{ matrix.go }} - Test name: Go ${{ matrix.go }} - Test
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View File

@ -12,6 +12,7 @@ linters:
- test - test
- unused - unused
disable: disable:
- dupl
- exhaustive - exhaustive
- exhaustivestruct - exhaustivestruct
- gochecknoglobals - gochecknoglobals
@ -21,9 +22,11 @@ linters:
- gomnd - gomnd
- interfacer - interfacer
- maligned - maligned
- nilnil
- nlreturn - nlreturn
- scopelint - scopelint
- testpackage - testpackage
- varnamelen
- wrapcheck - wrapcheck
linters-settings: linters-settings:
@ -31,18 +34,20 @@ linters-settings:
local-prefixes: github.com/dstotijn/hetty local-prefixes: github.com/dstotijn/hetty
godot: godot:
capital: true capital: true
ireturn:
allow: "error,empty,anon,stdlib,.*(or|er)$,github.com/dstotijn/hetty/pkg/filter.Expression"
issues: issues:
exclude-rules: exclude-rules:
- linters: - linters:
- gosec - gosec
# Ignore SHA1 usage. # Ignore SHA1 usage.
text: "G(401|505):" text: "G(401|505):"
- linters: - linters:
- wsl - wsl
# Ignore cuddled defer statements. # Ignore cuddled defer statements.
text: "only one cuddle assignment allowed before defer statement" text: "only one cuddle assignment allowed before defer statement"
- linters: - linters:
- nlreturn - nlreturn
# Ignore `break` without leading blank line. # Ignore `break` without leading blank line.
text: "break with no blank line before" text: "break with no blank line before"

View File

@ -28,6 +28,72 @@ archives:
- goos: windows - goos: windows
format: zip format: zip
brews:
- tap:
owner: hettysoft
name: homebrew-tap
folder: Formula
homepage: https://hetty.xyz
description: An HTTP toolkit for security research.
license: MIT
commit_author:
name: David Stotijn
email: dstotijn@gmail.com
test: |
system "#{bin}/hetty -v"
snapcrafts:
- publish: true
summary: An HTTP toolkit for security research.
description: |
Hetty is an HTTP toolkit for security research. It aims to become an open
source alternative to commercial software like Burp Suite Pro, with
powerful features tailored to the needs of the infosec and bug bounty
community.
grade: stable
confinement: strict
license: MIT
apps:
hetty:
command: hetty
plugs: ["network", "network-bind"]
scoop:
bucket:
owner: hettysoft
name: scoop-bucket
commit_author:
name: David Stotijn
email: dstotijn@gmail.com
homepage: https://hetty.xyz
description: An HTTP toolkit for security research.
license: MIT
dockers:
- extra_files:
- go.mod
- go.sum
- pkg
- cmd
- admin
image_templates:
- "ghcr.io/dstotijn/hetty:{{ .Version }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "ghcr.io/dstotijn/hetty:latest"
- "dstotijn/hetty:{{ .Version }}"
- "dstotijn/hetty:{{ .Major }}"
- "dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "dstotijn/hetty:latest"
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source=https://github.com/dstotijn/hetty"
- "--build-arg=HETTY_VERSION={{.Version}}"
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
@ -39,4 +105,4 @@ changelog:
filters: filters:
exclude: exclude:
- "^docs:" - "^docs:"
- "^test:" - "^test:"

274
README.md
View File

@ -1,243 +1,155 @@
<h1> <img src="https://user-images.githubusercontent.com/983924/156430531-6193e187-7400-436b-81c6-f86862783ea5.svg#gh-light-mode-only" width="240"/>
<a href="https://github.com/dstotijn/hetty"> <img src="https://user-images.githubusercontent.com/983924/156430660-9d5bd555-dcfd-47e2-ba70-54294c20c1b4.svg#gh-dark-mode-only" width="240"/>
<img src="https://hetty.xyz/assets/logo.png" width="293">
</a>
</h1>
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest) [![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https://actions-badge.atrox.dev/dstotijn/hetty/badge&style=flat-square&label=build+%26+test&logo=none&color=18BA91)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml) [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fdstotijn%2Fhetty%2Fbadge%3Fref%3Dmain&label=build&color=24ae8f)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=18BA91&style=flat-square) ![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=25ae8f)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/blob/master/LICENSE) [![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-18BA91?style=flat-square)](https://hetty.xyz/) [![Documentation](https://img.shields.io/badge/hetty-docs-25ae8f)](https://hetty.xyz/)
**Hetty** is an HTTP toolkit for security research. It aims to become an open **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 source alternative to commercial software like Burp Suite Pro, with powerful
features tailored to the needs of the infosec and bug bounty community. features tailored to the needs of the infosec and bug bounty community.
<img src="https://hetty.xyz/assets/hetty_v0.2.0_header.png"> <img src="https://hetty.xyz/img/hero.png" width="907" alt="Hetty proxy logs (screenshot)" />
## Features ## Features
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs - Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search
- Project based database storage (BadgerDB) - HTTP client for manually creating/editing requests, and replay proxied requests
- Scope support - Intercept requests and responses for manual review (edit, send/receive, cancel)
- Headless management API using GraphQL - Scope support, to help keep work organized
- Embedded web interface (Next.js) - Easy-to-use web based admin interface
- Project based database storage, to help keep work organized
Hetty is in early development. Additional features are planned 👷‍♂ Hetty is under active development. Check the <a
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a> href="https://github.com/dstotijn/hetty/projects/1">backlog</a> for the current
for details. status.
## Documentation 📣 Are you pen testing professionaly in a team? I would love to hear your
thoughts on tooling via [this 5 minute
survey](https://forms.gle/36jtgNc3TJ2imi5A8). Thank you!
📖 [Read the docs.](https://hetty.xyz/) ## Getting started
## Installation 💡 The [Getting started](https://hetty.xyz/docs/getting-started) doc has more
detailed install and usage instructions.
Hetty compiles to a self-contained binary, with an embedded BadgerDB database ### Installation
and web based admin interface.
### Install pre-built release (recommended) The quickest way to install and update Hetty is via a package manager:
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases). #### macOS
### Build from source ```sh
brew install hettysoft/tap/hetty
#### Prerequisites
- [Go 1.16](https://golang.org/)
- [Yarn](https://yarnpkg.com/)
When building from source, the static resources for the admin interface
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/). The generated
files will be embedded (using the [embed](https://golang.org/pkg/embed/)
package) when you use the `build` Makefile target.
Clone the repository and use the `build` make target to create a binary:
```
$ git clone git@github.com:dstotijn/hetty.git
$ cd hetty
$ make build
``` ```
### Docker #### Linux
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty). ```sh
For persistent storage of CA certificates and projects database, mount a volume: sudo snap install hetty
```
$ mkdir -p $HOME/.hetty
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
``` ```
## Usage #### Windows
When Hetty is run, by default it listens on `:8080` and is accessible via ```sh
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a scoop bucket add hettysoft https://github.com/hettysoft/scoop-bucket.git
MITM proxy, or it serves the API and web interface. scoop install hettysoft/hetty
By default, the projects database files and CA certificates are stored in a `.hetty`
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
on Windows).
To start, ensure `hetty` (downloaded from a release, or manually built) is in your
`$PATH` and run:
```
$ hetty
``` ```
An overview of configuration flags: #### Other
Alternatively, you can [download the latest release from
GitHub](https://github.com/dstotijn/hetty/releases/latest) for your OS and
architecture, and move the binary to a directory in your `$PATH`. If your OS is
not available for one of the package managers or not listed in the GitHub
releases, you can compile from source _(link coming soon)_.
#### Docker
Docker images are distributed via [GitHub's Container registry](https://github.com/dstotijn/hetty/pkgs/container/hetty)
and [Docker Hub](https://hub.docker.com/r/dstotijn/hetty). To run Hetty via with a volume for database and certificate
storage, and port 8080 forwarded:
``` ```
$ hetty -h docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 \
Usage of ./hetty: ghcr.io/dstotijn/hetty:latest
-addr string
TCP address to listen on, in the form "host:port" (default ":8080")
-adminPath string
File path to admin build
-cert string
CA certificate filepath. Creates a new CA certificate if file doesn't exist (default "~/.hetty/hetty_cert.pem")
-key string
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
-db string
Database directory path (default "~/.hetty/db")
``` ```
You should see: ### Usage
``` Once installed, start Hetty via:
2022/01/26 10:34:24 [INFO] Hetty (v0.3.2) is running on :8080 ...
```
Then, visit [http://localhost:8080](http://localhost:8080) to get started.
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
Hetty will need to be set up. Furthermore, the CA certificate may need to be
installed to the host for them to be trusted by your browser. The following steps
will cover how you can generate your certificate, provide them to hetty, and how
you can install them in your local CA store.
⚠️ _This process was done on a Linux machine but should_
_provide guidance on Windows and macOS as well._
### Generating CA certificates
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
```sh ```sh
hetty hetty
``` ```
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and 💡 Read the [Getting started](https://hetty.xyz/docs/getting-started) doc for
`~/.hetty/hetty_cert.pem` respectively. more details.
#### Generating CA certificates with OpenSSL To list all available options, run: `hetty --help`:
You can start off by generating a new key and CA certificate which will both expire
after a month.
```sh
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 key.pem -cert cert.pem $ hetty --help
Usage:
hetty [flags] [subcommand] [flags]
Runs an HTTP server with (MITM) proxy and a web based admin interface.
Options:
--cert Path to root CA certificate. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_cert.pem")
--key Path to root CA private key. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_key.pem")
--db Database file path. Creates file if it doesn't exist. (Default: "~/.hetty/hetty.db")
--addr TCP address for HTTP server to listen on, in the form \"host:port\". (Default: ":8080")
--chrome Launch Chrome with proxy settings applied and certificate errors ignored. (Default: false)
--verbose Enable verbose logging.
--json Encode logs as JSON, instead of pretty/human readable output.
--version, -v Output version.
--help, -h Output this usage text.
Subcommands:
- cert Certificate management
Run `hetty <subcommand> --help` for subcommand specific usage instructions.
Visit https://hetty.xyz to learn more about Hetty.
``` ```
### Trusting the CA certificate ## Documentation
In order for your browser to allow traffic to the local Hetty proxy, you may need 📖 [Read the docs](https://hetty.xyz/docs)
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:
```sh
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. You
can launch that by running the command:
```batch
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_.
_Note: 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._
## Vision and roadmap
- Fast core/engine, built with Go, with a minimal memory footprint.
- 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 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.
## Support ## Support
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions) feature requests, and
for questions and troubleshooting. [discussions](https://github.com/dstotijn/hetty/discussions) for questions and
troubleshooting.
## Community ## Community
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP). 💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP)
## Contributing ## Contributing
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md) Want to contribute? Great! Please check the [Contribution
for details. Guidelines](CONTRIBUTING.md) for details.
## Acknowledgements ## Acknowledgements
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord) - Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
for all the encouragement and feedback. for the encouragement and early feedback.
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/). - The font used in the logo and admin interface is [JetBrains
Mono](https://www.jetbrains.com/lp/mono/).
## Sponsors ## Sponsors
<a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty"> 💖 Are you enjoying Hetty? You can [sponsor me](https://github.com/sponsors/dstotijn)!
<img src="https://hetty.xyz/assets/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
</a>
## License ## License
[MIT License](LICENSE) [MIT](LICENSE)
--- © 20192025 Hetty Software
© 2021 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,20 @@
import { Alert, Box, Link, MenuItem, Snackbar } from "@mui/material"; import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import {
Alert,
Box,
IconButton,
Link,
MenuItem,
Snackbar,
styled,
TableCell,
TableCellProps,
Tooltip,
} from "@mui/material";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import Actions from "./Actions";
import LogDetail from "./LogDetail"; import LogDetail from "./LogDetail";
import Search from "./Search"; import Search from "./Search";
@ -10,6 +23,11 @@ import SplitPane from "lib/components/SplitPane";
import useContextMenu from "lib/components/useContextMenu"; import useContextMenu from "lib/components/useContextMenu";
import { useCreateSenderRequestFromHttpRequestLogMutation, useHttpRequestLogsQuery } from "lib/graphql/generated"; import { useCreateSenderRequestFromHttpRequestLogMutation, useHttpRequestLogsQuery } from "lib/graphql/generated";
const ActionsTableCell = styled(TableCell)<TableCellProps>(() => ({
paddingTop: 0,
paddingBottom: 0,
}));
export function RequestLogs(): JSX.Element { export function RequestLogs(): JSX.Element {
const router = useRouter(); const router = useRouter();
const id = router.query.id as string | undefined; const id = router.query.id as string | undefined;
@ -17,7 +35,13 @@ export function RequestLogs(): JSX.Element {
pollInterval: 1000, pollInterval: 1000,
}); });
const [createSenderReqFromLog] = useCreateSenderRequestFromHttpRequestLogMutation({}); const [createSenderReqFromLog] = useCreateSenderRequestFromHttpRequestLogMutation({
onCompleted({ createSenderRequestFromHttpRequestLog }) {
const { id } = createSenderRequestFromHttpRequestLog;
setNewSenderReqId(id);
setCopiedReqNotifOpen(true);
},
});
const [copyToSenderId, setCopyToSenderId] = useState(""); const [copyToSenderId, setCopyToSenderId] = useState("");
const [Menu, handleContextMenu, handleContextMenuClose] = useContextMenu(); const [Menu, handleContextMenu, handleContextMenuClose] = useContextMenu();
@ -27,11 +51,6 @@ export function RequestLogs(): JSX.Element {
variables: { variables: {
id: copyToSenderId, id: copyToSenderId,
}, },
onCompleted({ createSenderRequestFromHttpRequestLog }) {
const { id } = createSenderRequestFromHttpRequestLog;
setNewSenderReqId(id);
setCopiedReqNotifOpen(true);
},
}); });
handleContextMenuClose(); handleContextMenuClose();
}; };
@ -54,9 +73,36 @@ export function RequestLogs(): JSX.Element {
handleContextMenu(e); handleContextMenu(e);
}; };
const actionsCell = (id: string) => (
<ActionsTableCell>
<Tooltip title="Copy to Sender">
<IconButton
size="small"
onClick={() => {
setCopyToSenderId(id);
createSenderReqFromLog({
variables: {
id,
},
});
}}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</ActionsTableCell>
);
return ( return (
<Box display="flex" flexDirection="column" height="100%"> <Box display="flex" flexDirection="column" height="100%">
<Search /> <Box display="flex">
<Box flex="1 auto">
<Search />
</Box>
<Box pt={0.5}>
<Actions />
</Box>
</Box>
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}> <Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
<SplitPane split="horizontal" size={"40%"}> <SplitPane split="horizontal" size={"40%"}>
<Box sx={{ width: "100%", height: "100%", pb: 2 }}> <Box sx={{ width: "100%", height: "100%", pb: 2 }}>
@ -77,6 +123,7 @@ export function RequestLogs(): JSX.Element {
<RequestsTable <RequestsTable
requests={data?.httpRequestLogs || []} requests={data?.httpRequestLogs || []}
activeRowId={id} activeRowId={id}
actionsCell={actionsCell}
onRowClick={handleRowClick} onRowClick={handleRowClick}
onContextMenu={handleRowContextClick} onContextMenu={handleRowContextClick}
/> />

View File

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

View File

@ -1,100 +1,38 @@
import { import AddIcon from "@mui/icons-material/Add";
Alert, import { Alert, Box, Button, Fab, Tooltip, Typography, useTheme } from "@mui/material";
Box,
BoxProps,
Button,
InputLabel,
FormControl,
MenuItem,
Select,
TextField,
Typography,
} from "@mui/material";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useState } from "react"; import React, { useState } from "react";
import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair"; import { KeyValuePair } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs"; import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response"; import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane"; import SplitPane from "lib/components/SplitPane";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import { import {
GetSenderRequestQuery, GetSenderRequestQuery,
useCreateOrUpdateSenderRequestMutation, useCreateOrUpdateSenderRequestMutation,
HttpProtocol,
useGetSenderRequestQuery, useGetSenderRequestQuery,
useSendRequestMutation, useSendRequestMutation,
} from "lib/graphql/generated"; } from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL"; import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
enum HttpMethod { const defaultMethod = HttpMethod.Get;
Get = "GET", const defaultProto = HttpProto.Http20;
Post = "POST", const emptyKeyPair = [{ key: "", value: "" }];
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
enum HttpProto {
Http1 = "HTTP/1.1",
Http2 = "HTTP/2.0",
}
const httpProtoMap = new Map([
[HttpProto.Http1, HttpProtocol.Http1],
[HttpProto.Http2, HttpProtocol.Http2],
]);
function updateKeyPairItem(key: string, value: string, idx: number, items: KeyValuePair[]): KeyValuePair[] {
const updated = [...items];
updated[idx] = { key, value };
// Append an empty key-value pair if the last item in the array isn't blank
// anymore.
if (items.length - 1 === idx && items[idx].key === "" && items[idx].value === "") {
updated.push({ key: "", value: "" });
}
return updated;
}
function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
// Note: We don't use the `URL` interface, because we're potentially dealing
// with malformed/incorrect URLs, which would yield TypeErrors when constructed
// via `URL`.
let newURL = url;
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex !== -1) {
newURL = newURL.slice(0, questionMarkIndex);
}
const searchParams = new URLSearchParams();
for (const { key, value } of queryParams.filter(({ key }) => key !== "")) {
searchParams.append(key, value);
}
const rawQueryParams = decodeURI(searchParams.toString());
if (rawQueryParams == "") {
return newURL;
}
return newURL + "?" + rawQueryParams;
}
function EditRequest(): JSX.Element { function EditRequest(): JSX.Element {
const router = useRouter(); const router = useRouter();
const reqId = router.query.id as string | undefined; const reqId = router.query.id as string | undefined;
const [method, setMethod] = useState(HttpMethod.Get); const theme = useTheme();
const [method, setMethod] = useState(defaultMethod);
const [url, setURL] = useState(""); const [url, setURL] = useState("");
const [proto, setProto] = useState(HttpProto.Http2); const [proto, setProto] = useState(defaultProto);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]); const [queryParams, setQueryParams] = useState<KeyValuePair[]>(emptyKeyPair);
const [headers, setHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]); const [headers, setHeaders] = useState<KeyValuePair[]>(emptyKeyPair);
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => { const handleQueryParamChange = (key: string, value: string, idx: number) => {
@ -152,9 +90,8 @@ function EditRequest(): JSX.Element {
newQueryParams.push({ key: "", value: "" }); newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams); setQueryParams(newQueryParams);
const newHeaders = sortKeyValuePairs(senderRequest.headers || []); const newHeaders = senderRequest.headers || [];
setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]); setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
console.log(senderRequest.response);
setResponse(senderRequest.response); setResponse(senderRequest.response);
}, },
}); });
@ -201,8 +138,26 @@ function EditRequest(): JSX.Element {
createOrUpdateRequestAndSend(); createOrUpdateRequestAndSend();
}; };
const handleNewRequest = () => {
setURL("");
setMethod(defaultMethod);
setProto(defaultProto);
setQueryParams(emptyKeyPair);
setHeaders(emptyKeyPair);
setBody("");
setResponse(null);
router.push(`/sender`);
};
return ( return (
<Box display="flex" flexDirection="column" height="100%" gap={2}> <Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box sx={{ position: "absolute", bottom: theme.spacing(2), right: theme.spacing(2) }}>
<Tooltip title="New request">
<Fab color="primary" onClick={handleNewRequest}>
<AddIcon />
</Fab>
</Tooltip>
</Box>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}> <Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar <UrlBar
@ -262,94 +217,4 @@ function EditRequest(): JSX.Element {
); );
} }
interface UrlBarProps extends BoxProps {
method: HttpMethod;
onMethodChange: (method: HttpMethod) => void;
url: string;
onUrlChange: (url: string) => void;
proto: HttpProto;
onProtoChange: (proto: HttpProto) => void;
}
function UrlBar(props: UrlBarProps) {
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
return (
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
<FormControl>
<InputLabel id="req-method-label">Method</InputLabel>
<Select
labelId="req-method-label"
id="req-method"
value={method}
label="Method"
onChange={(e) => onMethodChange(e.target.value as HttpMethod)}
sx={{
width: "8rem",
".MuiOutlinedInput-notchedOutline": {
borderRightWidth: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderRightWidth: 1,
},
}}
>
{Object.values(HttpMethod).map((method) => (
<MenuItem key={method} value={method}>
{method}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="URL"
placeholder="E.g. “https://example.com/foobar”"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
required
variant="outlined"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: {
".MuiOutlinedInput-notchedOutline": {
borderRadius: 0,
},
},
}}
sx={{ flexGrow: 1 }}
/>
<FormControl>
<InputLabel id="req-proto-label">Protocol</InputLabel>
<Select
labelId="req-proto-label"
id="req-proto"
value={proto}
label="Protocol"
onChange={(e) => onProtoChange(e.target.value as HttpProto)}
sx={{
".MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 1,
},
}}
>
{Object.values(HttpProto).map((proto) => (
<MenuItem key={proto} value={proto}>
{proto}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
export default EditRequest; export default EditRequest;

View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,6 @@ interface Props {
} }
function Editor({ content, contentType, monacoOptions, onChange }: Props): JSX.Element { function Editor({ content, contentType, monacoOptions, onChange }: Props): JSX.Element {
console.log(content);
return ( return (
<MonacoEditor <MonacoEditor
language={languageForContentType(contentType)} language={languageForContentType(contentType)}

View File

@ -9,7 +9,6 @@ import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableCellProps,
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
@ -74,19 +73,6 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
setCopyConfOpen(false); setCopyConfOpen(false);
}; };
const baseCellStyle = {
"&:hover": {
cursor: "copy",
},
};
const KeyTableCell = styled(TableCell)<TableCellProps>(() => (!onChange ? baseCellStyle : {}));
const ValueTableCell = styled(TableCell)<TableCellProps>(() => ({
...(!onChange && baseCellStyle),
width: "60%",
wordBreak: "break-all",
}));
return ( return (
<div> <div>
<Snackbar open={copyConfOpen} autoHideDuration={3000} onClose={handleCopyConfClose}> <Snackbar open={copyConfOpen} autoHideDuration={3000} onClose={handleCopyConfClose}>
@ -118,12 +104,19 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
> >
{items.map(({ key, value }, idx) => ( {items.map(({ key, value }, idx) => (
<StyledTableRow key={idx} hover> <StyledTableRow key={idx} hover>
<KeyTableCell <TableCell
component="th" component="th"
scope="row" scope="row"
onClick={(e) => { onClick={(e) => {
!onChange && handleCellClick(e); !onChange && handleCellClick(e);
}} }}
sx={{
...(!onChange && {
"&:hover": {
cursor: "copy",
},
}),
}}
> >
{!onChange && <span>{key}</span>} {!onChange && <span>{key}</span>}
{onChange && ( {onChange && (
@ -137,11 +130,20 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
}} }}
/> />
)} )}
</KeyTableCell> </TableCell>
<ValueTableCell <TableCell
onClick={(e) => { onClick={(e) => {
!onChange && handleCellClick(e); !onChange && handleCellClick(e);
}} }}
sx={{
width: "60%",
wordBreak: "break-all",
...(!onChange && {
"&:hover": {
cursor: "copy",
},
}),
}}
> >
{!onChange && value} {!onChange && value}
{onChange && ( {onChange && (
@ -155,7 +157,7 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
}} }}
/> />
)} )}
</ValueTableCell> </TableCell>
{onDelete && ( {onDelete && (
<TableCell> <TableCell>
<div className="delete-button"> <div className="delete-button">
@ -182,20 +184,4 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
); );
} }
export function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
const sorted = [...items];
sorted.sort((a, b) => {
if (a.key < b.key) {
return -1;
}
if (a.key > b.key) {
return 1;
}
return 0;
});
return sorted;
}
export default KeyValuePairTable; export default KeyValuePairTable;

View File

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

View File

@ -62,12 +62,13 @@ interface HttpResponse {
interface Props { interface Props {
requests: HttpRequest[]; requests: HttpRequest[];
activeRowId?: string; activeRowId?: string;
actionsCell?: (id: string) => JSX.Element;
onRowClick?: (id: string) => void; onRowClick?: (id: string) => void;
onContextMenu?: (e: React.MouseEvent, id: string) => void; onContextMenu?: (e: React.MouseEvent, id: string) => void;
} }
export default function RequestsTable(props: Props): JSX.Element { export default function RequestsTable(props: Props): JSX.Element {
const { requests, activeRowId, onRowClick, onContextMenu } = props; const { requests, activeRowId, actionsCell, onRowClick, onContextMenu } = props;
return ( return (
<TableContainer sx={{ overflowX: "initial" }}> <TableContainer sx={{ overflowX: "initial" }}>
@ -78,6 +79,7 @@ export default function RequestsTable(props: Props): JSX.Element {
<TableCell>Origin</TableCell> <TableCell>Origin</TableCell>
<TableCell>Path</TableCell> <TableCell>Path</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
{actionsCell && <TableCell padding="checkbox"></TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -104,6 +106,7 @@ export default function RequestsTable(props: Props): JSX.Element {
<StatusTableCell> <StatusTableCell>
{response && <Status code={response.statusCode} reason={response.statusReason} />} {response && <Status code={response.statusCode} reason={response.statusReason} />}
</StatusTableCell> </StatusTableCell>
{actionsCell && actionsCell(id)}
</RequestTableRow> </RequestTableRow>
); );
})} })}

View File

@ -1,6 +1,5 @@
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { sortKeyValuePairs } from "./KeyValuePair";
import ResponseTabs from "./ResponseTabs"; import ResponseTabs from "./ResponseTabs";
import ResponseStatus from "lib/components/ResponseStatus"; import ResponseStatus from "lib/components/ResponseStatus";
@ -29,7 +28,7 @@ function Response({ response }: ResponseProps): JSX.Element {
</Box> </Box>
<ResponseTabs <ResponseTabs
body={response?.body} body={response?.body}
headers={sortKeyValuePairs(response?.headers || [])} headers={response?.headers || []}
hasResponse={response !== undefined && response !== null} hasResponse={response !== undefined && response !== null}
/> />
</Box> </Box>

View File

@ -12,9 +12,11 @@ type ResponseStatusProps = {
function mapProto(proto: HttpProtocol): string { function mapProto(proto: HttpProtocol): string {
switch (proto) { switch (proto) {
case HttpProtocol.Http1: case HttpProtocol.Http10:
return "HTTP/1.0";
case HttpProtocol.Http11:
return "HTTP/1.1"; return "HTTP/1.1";
case HttpProtocol.Http2: case HttpProtocol.Http20:
return "HTTP/2.0"; return "HTTP/2.0";
default: default:
return proto; return proto;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

10
buf.gen.yaml Normal file
View File

@ -0,0 +1,10 @@
version: v2
plugins:
- local: protoc-gen-go
out: pkg
opt: paths=source_relative
- local: protoc-gen-connect-go
out: pkg
opt:
- paths=source_relative
- package_suffix # Generate `*.connect.go` files next to `*.pb.go` files.

12
buf.yaml Normal file
View File

@ -0,0 +1,12 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
- PACKAGE_DIRECTORY_MATCH
- PACKAGE_VERSION_SUFFIX
breaking:
use:
- PACKAGE

212
cmd/hetty/cert.go Normal file
View File

@ -0,0 +1,212 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/mitchellh/go-homedir"
"github.com/peterbourgon/ff/v3/ffcli"
"github.com/smallstep/truststore"
)
var certUsage = `
Usage:
hetty cert <subcommand> [flags]
Certificate management tools.
Options:
--help, -h Output this usage text.
Subcommands:
- install Installs a certificate to the system trust store, and
(optionally) to the Firefox and Java trust stores.
- uninstall Uninstalls a certificate from the system trust store, and
(optionally) from the Firefox and Java trust stores.
Run ` + "`hetty cert <subcommand> --help`" + ` for subcommand specific usage instructions.
Visit https://hetty.xyz to learn more about Hetty.
`
var certInstallUsage = `
Usage:
hetty cert install [flags]
Installs a certificate to the system trust store, and (optionally) to the Firefox
and Java trust stores.
Options:
--cert Path to certificate. (Default: "~/.hetty/hetty_cert.pem")
--firefox Install certificate to Firefox trust store. (Default: false)
--java Install certificate to Java trust store. (Default: false)
--skip-system Skip installing certificate to system trust store (Default: false)
--help, -h Output this usage text.
Visit https://hetty.xyz to learn more about Hetty.
`
var certUninstallUsage = `
Usage:
hetty cert uninstall [flags]
Uninstalls a certificate from the system trust store, and (optionally) from the Firefox
and Java trust stores.
Options:
--cert Path to certificate. (Default: "~/.hetty/hetty_cert.pem")
--firefox Uninstall certificate from Firefox trust store. (Default: false)
--java Uninstall certificate from Java trust store. (Default: false)
--skip-system Skip uninstalling certificate from system trust store (Default: false)
--help, -h Output this usage text.
Visit https://hetty.xyz to learn more about Hetty.
`
type CertInstallCommand struct {
config *Config
cert string
firefox bool
java bool
skipSystem bool
}
type CertUninstallCommand struct {
config *Config
cert string
firefox bool
java bool
skipSystem bool
}
func NewCertCommand(rootConfig *Config) *ffcli.Command {
return &ffcli.Command{
Name: "cert",
Subcommands: []*ffcli.Command{
NewCertInstallCommand(rootConfig),
NewCertUninstallCommand(rootConfig),
},
Exec: func(context.Context, []string) error {
return flag.ErrHelp
},
UsageFunc: func(*ffcli.Command) string {
return certUsage
},
}
}
func NewCertInstallCommand(rootConfig *Config) *ffcli.Command {
cmd := CertInstallCommand{
config: rootConfig,
}
fs := flag.NewFlagSet("hetty cert install", flag.ExitOnError)
fs.StringVar(&cmd.cert, "cert", "~/.hetty/hetty_cert.pem", "Path to certificate.")
fs.BoolVar(&cmd.firefox, "firefox", false, "Install certificate to Firefox trust store. (Default: false)")
fs.BoolVar(&cmd.java, "java", false, "Install certificate to Java trust store. (Default: false)")
fs.BoolVar(&cmd.skipSystem, "skip-system", false, "Skip installing certificate to system trust store (Default: false)")
cmd.config.RegisterFlags(fs)
return &ffcli.Command{
Name: "install",
FlagSet: fs,
Exec: cmd.Exec,
UsageFunc: func(*ffcli.Command) string {
return certInstallUsage
},
}
}
func (cmd *CertInstallCommand) Exec(_ context.Context, _ []string) error {
caCertFile, err := homedir.Expand(cmd.cert)
if err != nil {
return fmt.Errorf("failed to parse certificate filepath: %w", err)
}
opts := []truststore.Option{}
if cmd.skipSystem {
opts = append(opts, truststore.WithNoSystem())
}
if cmd.firefox {
opts = append(opts, truststore.WithFirefox())
}
if cmd.java {
opts = append(opts, truststore.WithJava())
}
if !cmd.skipSystem {
cmd.config.logger.Info(
"To install the certificate in the system trust store, you might be prompted for your password.")
}
if err := truststore.InstallFile(caCertFile, opts...); err != nil {
return fmt.Errorf("failed to install certificate: %w", err)
}
cmd.config.logger.Info("Finished installing certificate.")
return nil
}
func NewCertUninstallCommand(rootConfig *Config) *ffcli.Command {
cmd := CertUninstallCommand{
config: rootConfig,
}
fs := flag.NewFlagSet("hetty cert uninstall", flag.ExitOnError)
fs.StringVar(&cmd.cert, "cert", "~/.hetty/hetty_cert.pem", "Path to certificate.")
fs.BoolVar(&cmd.firefox, "firefox", false, "Uninstall certificate from Firefox trust store. (Default: false)")
fs.BoolVar(&cmd.java, "java", false, "Uninstall certificate from Java trust store. (Default: false)")
fs.BoolVar(&cmd.skipSystem, "skip-system", false,
"Skip uninstalling certificate from system trust store (Default: false)")
cmd.config.RegisterFlags(fs)
return &ffcli.Command{
Name: "uninstall",
FlagSet: fs,
Exec: cmd.Exec,
UsageFunc: func(*ffcli.Command) string {
return certUninstallUsage
},
}
}
func (cmd *CertUninstallCommand) Exec(_ context.Context, _ []string) error {
caCertFile, err := homedir.Expand(cmd.cert)
if err != nil {
return fmt.Errorf("failed to parse certificate filepath: %w", err)
}
opts := []truststore.Option{}
if cmd.skipSystem {
opts = append(opts, truststore.WithNoSystem())
}
if cmd.firefox {
opts = append(opts, truststore.WithFirefox())
}
if cmd.java {
opts = append(opts, truststore.WithJava())
}
if !cmd.skipSystem {
cmd.config.logger.Info(
"To uninstall the certificate from the system trust store, you might be prompted for your password.")
}
if err := truststore.UninstallFile(caCertFile, opts...); err != nil {
return fmt.Errorf("failed to uninstall certificate: %w", err)
}
cmd.config.logger.Info("Finished uninstalling certificate.")
return nil
}

23
cmd/hetty/config.go Normal file
View File

@ -0,0 +1,23 @@
package main
import (
"flag"
"go.uber.org/zap"
)
// Config represents the global configuration shared amongst all commands.
type Config struct {
verbose bool
jsonLogs bool
logger *zap.Logger
}
// RegisterFlags registers the flag fields into the provided flag.FlagSet. This
// helper function allows subcommands to register the root flags into their
// flagsets, creating "global" flags that can be passed after any subcommand at
// the commandline.
func (cfg *Config) RegisterFlags(fs *flag.FlagSet) {
fs.BoolVar(&cfg.verbose, "verbose", false, "Enable verbose logging.")
fs.BoolVar(&cfg.jsonLogs, "json", false, "Encode logs as JSON, instead of pretty/human readable output.")
}

298
cmd/hetty/hetty.go Normal file
View File

@ -0,0 +1,298 @@
package main
import (
"context"
"crypto/tls"
"embed"
"errors"
"flag"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"strings"
"github.com/chromedp/chromedp"
"github.com/gorilla/mux"
"github.com/mitchellh/go-homedir"
"github.com/peterbourgon/ff/v3/ffcli"
"go.etcd.io/bbolt"
"go.uber.org/zap"
"github.com/dstotijn/hetty/pkg/chrome"
"github.com/dstotijn/hetty/pkg/db/bolt"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/proxy/intercept"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/sender"
)
var version = "0.0.0"
//go:embed admin
//go:embed admin/_next/static
//go:embed admin/_next/static/chunks/pages/*.js
//go:embed admin/_next/static/*/*.js
var adminContent embed.FS
var hettyUsage = `
Usage:
hetty [flags] [subcommand] [flags]
Runs an HTTP server with (MITM) proxy and a web based admin interface.
Options:
--cert Path to root CA certificate. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_cert.pem")
--key Path to root CA private key. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_key.pem")
--db Database file path. Creates file if it doesn't exist. (Default: "~/.hetty/hetty.db")
--addr TCP address for HTTP server to listen on, in the form \"host:port\". (Default: ":8080")
--chrome Launch Chrome with proxy settings applied and certificate errors ignored. (Default: false)
--verbose Enable verbose logging.
--json Encode logs as JSON, instead of pretty/human readable output.
--version, -v Output version.
--help, -h Output this usage text.
Subcommands:
- cert Certificate management
Run ` + "`hetty <subcommand> --help`" + ` for subcommand specific usage instructions.
Visit https://hetty.xyz to learn more about Hetty.
`
type HettyCommand struct {
config *Config
cert string
key string
db string
addr string
chrome bool
version bool
}
func NewHettyCommand() (*ffcli.Command, *Config) {
cmd := HettyCommand{
config: &Config{},
}
fs := flag.NewFlagSet("hetty", flag.ExitOnError)
fs.StringVar(&cmd.cert, "cert", "~/.hetty/hetty_cert.pem",
"Path to root CA certificate. Creates a new certificate if file doesn't exist.")
fs.StringVar(&cmd.key, "key", "~/.hetty/hetty_key.pem",
"Path to root CA private key. Creates a new private key if file doesn't exist.")
fs.StringVar(&cmd.db, "db", "~/.hetty/hetty.db", "Database file path. Creates file if it doesn't exist.")
fs.StringVar(&cmd.addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\".")
fs.BoolVar(&cmd.chrome, "chrome", false, "Launch Chrome with proxy settings applied and certificate errors ignored.")
fs.BoolVar(&cmd.version, "version", false, "Output version.")
fs.BoolVar(&cmd.version, "v", false, "Output version.")
cmd.config.RegisterFlags(fs)
return &ffcli.Command{
Name: "hetty",
FlagSet: fs,
Subcommands: []*ffcli.Command{
NewCertCommand(cmd.config),
},
Exec: cmd.Exec,
UsageFunc: func(*ffcli.Command) string {
return hettyUsage
},
}, cmd.config
}
func (cmd *HettyCommand) Exec(ctx context.Context, _ []string) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt)
defer stop()
if cmd.version {
fmt.Fprint(os.Stdout, version+"\n")
return nil
}
mainLogger := cmd.config.logger.Named("main")
listenHost, listenPort, err := net.SplitHostPort(cmd.addr)
if err != nil {
mainLogger.Fatal("Failed to parse listening address.", zap.Error(err))
}
url := fmt.Sprintf("http://%v:%v", listenHost, listenPort)
if listenHost == "" || listenHost == "0.0.0.0" || listenHost == "127.0.0.1" || listenHost == "::1" {
url = fmt.Sprintf("http://localhost:%v", listenPort)
}
// Expand `~` in filepaths.
caCertFile, err := homedir.Expand(cmd.cert)
if err != nil {
cmd.config.logger.Fatal("Failed to parse CA certificate filepath.", zap.Error(err))
}
caKeyFile, err := homedir.Expand(cmd.key)
if err != nil {
cmd.config.logger.Fatal("Failed to parse CA private key filepath.", zap.Error(err))
}
dbPath, err := homedir.Expand(cmd.db)
if err != nil {
cmd.config.logger.Fatal("Failed to parse database path.", zap.Error(err))
}
// Load existing CA certificate and key from disk, or generate and write
// to disk if no files exist yet.
caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile)
if err != nil {
cmd.config.logger.Fatal("Failed to load or create CA key pair.", zap.Error(err))
}
dbLogger := cmd.config.logger.Named("boltdb").Sugar()
boltOpts := *bbolt.DefaultOptions
boltOpts.Logger = &bolt.Logger{SugaredLogger: dbLogger}
boltDB, err := bolt.OpenDatabase(dbPath, &boltOpts)
if err != nil {
cmd.config.logger.Fatal("Failed to open database.", zap.Error(err))
}
defer boltDB.Close()
scope := &scope.Scope{}
reqLogService := reqlog.NewService(reqlog.Config{
Scope: scope,
Repository: boltDB,
Logger: cmd.config.logger.Named("reqlog").Sugar(),
})
interceptService := intercept.NewService(intercept.Config{
Logger: cmd.config.logger.Named("intercept").Sugar(),
})
senderService := sender.NewService(sender.Config{
Repository: boltDB,
ReqLogService: reqLogService,
})
projService, err := proj.NewService(proj.Config{
Repository: boltDB,
InterceptService: interceptService,
ReqLogService: reqLogService,
SenderService: senderService,
Scope: scope,
})
if err != nil {
cmd.config.logger.Fatal("Failed to create new projects service.", zap.Error(err))
}
proxy, err := proxy.NewProxy(proxy.Config{
CACert: caCert,
CAKey: caKey,
Logger: cmd.config.logger.Named("proxy").Sugar(),
})
if err != nil {
cmd.config.logger.Fatal("Failed to create new proxy.", zap.Error(err))
}
proxy.UseRequestModifier(reqLogService.RequestModifier)
proxy.UseResponseModifier(reqLogService.ResponseModifier)
proxy.UseRequestModifier(interceptService.RequestModifier)
proxy.UseResponseModifier(interceptService.ResponseModifier)
fsSub, err := fs.Sub(adminContent, "admin")
if err != nil {
cmd.config.logger.Fatal("Failed to construct file system subtree from admin dir.", zap.Error(err))
}
adminHandler := http.FileServer(http.FS(fsSub))
router := mux.NewRouter().SkipClean(true)
adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
hostname, _ := os.Hostname()
host, _, _ := net.SplitHostPort(req.Host)
// Serve local admin routes when either:
// - The `Host` is well-known, e.g. `hetty.proxy`, `localhost:[port]`
// or the listen addr `[host]:[port]`.
// - The request is not for TLS proxying (e.g. no `CONNECT`) and not
// for proxying an external URL. E.g. Request-Line (RFC 7230, Section 3.1.1)
// has no scheme.
return strings.EqualFold(host, hostname) ||
req.Host == "hetty.proxy" ||
req.Host == fmt.Sprintf("%v:%v", "localhost", listenPort) ||
req.Host == fmt.Sprintf("%v:%v", listenHost, listenPort) ||
req.Method != http.MethodConnect && !strings.HasPrefix(req.RequestURI, "http://")
}).Subrouter().StrictSlash(true)
// Connect RPC server.
projPath, projHandler := proj.NewProjectServiceHandler(projService)
adminRouter.PathPrefix(projPath).Handler(projHandler)
reqlogPath, reqlogHandler := reqlog.NewHttpRequestLogServiceHandler(reqLogService)
adminRouter.PathPrefix(reqlogPath).Handler(reqlogHandler)
// Admin interface.
adminRouter.PathPrefix("").Handler(adminHandler)
// Fallback (default) is the Proxy handler.
router.PathPrefix("").Handler(proxy)
httpServer := &http.Server{
Addr: cmd.addr,
Handler: router,
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2
ErrorLog: zap.NewStdLog(cmd.config.logger.Named("http")),
}
go func() {
mainLogger.Info(fmt.Sprintf("Hetty (v%v) is running on %v ...", version, cmd.addr))
mainLogger.Info(fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(32), "Get started at "+url))
err := httpServer.ListenAndServe()
if err != http.ErrServerClosed {
mainLogger.Fatal("HTTP server closed unexpected.", zap.Error(err))
}
}()
if cmd.chrome {
ctx, cancel := chrome.NewExecAllocator(ctx, chrome.Config{
ProxyServer: url,
ProxyBypassHosts: []string{url},
})
defer cancel()
taskCtx, cancel := chromedp.NewContext(ctx)
defer cancel()
err = chromedp.Run(taskCtx, chromedp.Navigate(url))
switch {
case errors.Is(err, exec.ErrNotFound):
mainLogger.Info("Chrome executable not found.")
case err != nil:
mainLogger.Error(fmt.Sprintf("Failed to navigate to %v.", url), zap.Error(err))
default:
mainLogger.Info("Launched Chrome.")
}
}
// Wait for interrupt signal.
<-ctx.Done()
// Restore signal, allowing "force quit".
stop()
mainLogger.Info("Shutting down HTTP server. Press Ctrl+C to force quit.")
// Note: We expect httpServer.Handler to handle timeouts, thus, we don't
// need a context value with deadline here.
//nolint:contextcheck
err = httpServer.Shutdown(context.Background())
if err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %w", err)
}
return nil
}

View File

@ -1,163 +1,35 @@
package main package main
import ( import (
"crypto/tls" "context"
"embed"
"errors" "errors"
"flag" "flag"
"fmt" llog "log"
"io/fs"
"log"
"net"
"net/http"
"os" "os"
"strings"
"github.com/99designs/gqlgen/graphql/handler" "go.uber.org/zap"
"github.com/99designs/gqlgen/graphql/playground"
badgerdb "github.com/dgraph-io/badger/v3"
"github.com/gorilla/mux"
"github.com/mitchellh/go-homedir"
"github.com/dstotijn/hetty/pkg/api" "github.com/dstotijn/hetty/pkg/log"
"github.com/dstotijn/hetty/pkg/db/badger"
"github.com/dstotijn/hetty/pkg/proj"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/dstotijn/hetty/pkg/scope"
"github.com/dstotijn/hetty/pkg/sender"
) )
var version = "0.0.0"
// Flag variables.
var (
caCertFile string
caKeyFile string
dbPath string
addr string
)
//go:embed admin
//go:embed admin/_next/static
//go:embed admin/_next/static/chunks/pages/*.js
//go:embed admin/_next/static/*/*.js
var adminContent embed.FS
func main() { func main() {
if err := run(); err != nil { hettyCmd, cfg := NewHettyCommand()
log.Fatalf("[ERROR]: %v", err)
if err := hettyCmd.Parse(os.Args[1:]); err != nil {
llog.Fatalf("Failed to parse command line arguments: %v", err)
}
logger, err := log.NewZapLogger(cfg.verbose, cfg.jsonLogs)
if err != nil {
llog.Fatal(err)
}
//nolint:errcheck
defer logger.Sync()
cfg.logger = logger
err = hettyCmd.Run(context.Background())
if err != nil && !errors.Is(err, flag.ErrHelp) {
logger.Fatal("Command failed.", zap.Error(err))
} }
} }
func run() error {
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem",
"CA certificate filepath. Creates a new CA certificate if file doesn't exist")
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem",
"CA private key filepath. Creates a new CA private key if file doesn't exist")
flag.StringVar(&dbPath, "db", "~/.hetty/db", "Database directory path")
flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"")
flag.Parse()
// Expand `~` in filepaths.
caCertFile, err := homedir.Expand(caCertFile)
if err != nil {
return fmt.Errorf("could not parse CA certificate filepath: %w", err)
}
caKeyFile, err := homedir.Expand(caKeyFile)
if err != nil {
return fmt.Errorf("could not parse CA private key filepath: %w", err)
}
dbPath, err := homedir.Expand(dbPath)
if err != nil {
return fmt.Errorf("could not parse projects filepath: %w", err)
}
// Load existing CA certificate and key from disk, or generate and write
// to disk if no files exist yet.
caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile)
if err != nil {
return fmt.Errorf("could not create/load CA key pair: %w", err)
}
badger, err := badger.OpenDatabase(badgerdb.DefaultOptions(dbPath))
if err != nil {
return fmt.Errorf("could not open badger database: %w", err)
}
defer badger.Close()
scope := &scope.Scope{}
reqLogService := reqlog.NewService(reqlog.Config{
Scope: scope,
Repository: badger,
})
senderService := sender.NewService(sender.Config{
Repository: badger,
ReqLogService: reqLogService,
})
projService, err := proj.NewService(proj.Config{
Repository: badger,
ReqLogService: reqLogService,
SenderService: senderService,
Scope: scope,
})
if err != nil {
return fmt.Errorf("could not create new project service: %w", err)
}
p, err := proxy.NewProxy(caCert, caKey)
if err != nil {
return fmt.Errorf("could not create proxy: %w", err)
}
p.UseRequestModifier(reqLogService.RequestModifier)
p.UseResponseModifier(reqLogService.ResponseModifier)
fsSub, err := fs.Sub(adminContent, "admin")
if err != nil {
return fmt.Errorf("could not prepare subtree file system: %w", err)
}
adminHandler := http.FileServer(http.FS(fsSub))
router := mux.NewRouter().SkipClean(true)
adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
hostname, _ := os.Hostname()
host, _, _ := net.SplitHostPort(req.Host)
return strings.EqualFold(host, hostname) || (req.Host == "hetty.proxy" || req.Host == "localhost:8080")
}).Subrouter().StrictSlash(true)
// GraphQL server.
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/"))
adminRouter.Path("/api/graphql/").Handler(
handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
ProjectService: projService,
RequestLogService: reqLogService,
SenderService: senderService,
}})))
// Admin interface.
adminRouter.PathPrefix("").Handler(adminHandler)
// Fallback (default) is the Proxy handler.
router.PathPrefix("").Handler(p)
s := &http.Server{
Addr: addr,
Handler: router,
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2
}
log.Printf("[INFO] Hetty (v%v) is running on %v ...", version, addr)
err = s.ListenAndServe()
if err != nil && errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("http server closed unexpected: %w", err)
}
return nil
}

12
docs/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

View File

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

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