Compare commits
39 Commits
Author | SHA1 | Date | |
---|---|---|---|
52c83a1989 | |||
7ed553866b | |||
fcf0e1c51e | |||
24c2ecfa4b | |||
d23b4ed024 | |||
ff9e4140aa | |||
f7def87d0f | |||
aa9822854d | |||
2ce4218a30 | |||
fd27955e11 | |||
426a7d5f96 | |||
21b679dc91 | |||
e4f468d4d2 | |||
d3246b0918 | |||
61fd3fcc45 | |||
d34258dfd1 | |||
0e9fb0ac91 | |||
02408b5196 | |||
6ffc55cde3 | |||
bdd667381a | |||
f60202e41c | |||
87b8b18047 | |||
edab744d01 | |||
3f5277e419 | |||
29550ff43b | |||
7afc23b3ff | |||
6aa93b782e | |||
ed9a539ce3 | |||
857aa0c49e | |||
af26987601 | |||
ad26478043 | |||
ca0c085021 | |||
d438f93ee0 | |||
fa3f24eb70 | |||
f15438e10b | |||
bef52d956e | |||
8269af9478 | |||
c5f76e1f9a | |||
2ddf2a77e8 |
4
.github/FUNDING.yml
vendored
@ -1,3 +1,3 @@
|
|||||||
# These are supported funding model platforms
|
github: dstotijn
|
||||||
|
|
||||||
patreon: dstotijn
|
patreon: dstotijn
|
||||||
|
custom: "https://www.paypal.com/paypalme/dstotijn"
|
||||||
|
4
.github/workflows/build-test.yml
vendored
@ -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
|
||||||
|
@ -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,6 +34,8 @@ 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/99designs/gqlgen/graphql.Marshaler,github.com/dstotijn/hetty/pkg/api.QueryResolver,github.com/dstotijn/hetty/pkg/filter.Expression"
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-rules:
|
exclude-rules:
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
274
README.md
@ -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>
|
|
||||||
|
|
||||||
[](https://github.com/dstotijn/hetty/releases/latest)
|
[](https://github.com/dstotijn/hetty/releases/latest)
|
||||||
[](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
|
[](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
|
||||||

|

|
||||||
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
||||||
[](https://hetty.xyz/)
|
[](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, GraphQL service, 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)
|
||||||
|
|
||||||
---
|
© 2019–2025 Hetty Software
|
||||||
|
|
||||||
© 2021 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)
|
|
||||||
|
@ -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",
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
366
admin/src/features/intercept/components/EditRequest.tsx
Normal 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;
|
21
admin/src/features/intercept/components/Intercept.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
33
admin/src/features/intercept/components/Requests.tsx
Normal 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;
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation CancelRequest($id: ID!) {
|
||||||
|
cancelRequest(id: $id) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation CancelResponse($requestID: ID!) {
|
||||||
|
cancelResponse(requestID: $requestID) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation ModifyRequest($request: ModifyRequestInput!) {
|
||||||
|
modifyRequest(request: $request) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation ModifyResponse($response: ModifyResponseInput!) {
|
||||||
|
modifyResponse(response: $response) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -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()}>
|
||||||
|
15
admin/src/features/projects/graphql/activeProject.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
query ActiveProject {
|
||||||
|
activeProject {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
settings {
|
||||||
|
intercept {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
admin/src/features/reqlog/components/Actions.tsx
Normal 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;
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
307
admin/src/features/settings/components/Settings.tsx
Normal 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 Hetty’s 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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
|
||||||
|
updateInterceptSettings(input: $input) {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
22
admin/src/lib/InterceptedRequestsContext.tsx
Normal 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);
|
||||||
|
}
|
@ -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)}
|
||||||
|
@ -184,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;
|
||||||
|
94
admin/src/lib/components/Link.tsx
Normal 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;
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
|
122
admin/src/lib/components/UrlBar.tsx
Normal 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;
|
@ -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 {
|
||||||
@ -982,3 +1384,79 @@ 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>;
|
11
admin/src/lib/graphql/interceptedRequests.graphql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
query GetInterceptedRequests {
|
||||||
|
interceptedRequests {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
method
|
||||||
|
response {
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
16
admin/src/lib/updateKeyPairItem.ts
Normal 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;
|
28
admin/src/lib/updateURLQueryParams.ts
Normal 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;
|
@ -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>
|
||||||
|
12
admin/src/pages/proxy/intercept/index.tsx
Normal 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;
|
12
admin/src/pages/settings/index.tsx
Normal 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;
|
1692
admin/yarn.lock
212
cmd/hetty/cert.go
Normal 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
@ -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.")
|
||||||
|
}
|
302
cmd/hetty/hetty.go
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
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/api"
|
||||||
|
"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, GraphQL service, 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)
|
||||||
|
|
||||||
|
// GraphQL server.
|
||||||
|
gqlEndpoint := "/api/graphql/"
|
||||||
|
adminRouter.Path(gqlEndpoint).Handler(api.HTTPHandler(&api.Resolver{
|
||||||
|
ProjectService: projService,
|
||||||
|
RequestLogService: reqLogService,
|
||||||
|
InterceptService: interceptService,
|
||||||
|
SenderService: senderService,
|
||||||
|
}, gqlEndpoint))
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
@ -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
@ -1,12 +0,0 @@
|
|||||||
pids
|
|
||||||
logs
|
|
||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
coverage/
|
|
||||||
run
|
|
||||||
dist
|
|
||||||
.DS_Store
|
|
||||||
.nyc_output
|
|
||||||
.basement
|
|
||||||
config.local.js
|
|
||||||
basement_dist
|
|
@ -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": {}
|
|
||||||
}
|
|
@ -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"));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
@ -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.
|
|
||||||
}
|
|
Before Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 12 KiB |
@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom Styles here.
|
|
||||||
*
|
|
||||||
* ref:https://v1.vuepress.vuejs.org/config/#index-styl
|
|
||||||
*/
|
|
||||||
|
|
||||||
.home .hero img
|
|
||||||
width 450px
|
|
||||||
max-width 100%!important
|
|
@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Custom palette here.
|
|
||||||
*
|
|
||||||
* ref:https://v1.vuepress.vuejs.org/zh/config/#palette-styl
|
|
||||||
*/
|
|
||||||
|
|
||||||
$accentColor = #2CC09B
|
|
||||||
$textColor = #2c3e50
|
|
||||||
$borderColor = #eaecef
|
|
||||||
$codeBgColor = #282c34
|
|
||||||
$badgeTipColor = #2CC09B
|
|
@ -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.
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -1 +0,0 @@
|
|||||||
export default {}
|
|
@ -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
|
|
@ -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'
|
|
@ -1 +0,0 @@
|
|||||||
$contentClass = '.theme-default-content'
|
|
@ -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
|
|
@ -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'
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||||||
.table-of-contents
|
|
||||||
.badge
|
|
||||||
vertical-align middle
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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.
|
|
Before Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 27 KiB |
@ -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")
|
|
||||||
```
|
|
Before Width: | Height: | Size: 144 KiB |
@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
sidebarDepth: 0
|
|
||||||
---
|
|
||||||
|
|
||||||
# Introduction
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
**Hetty** is an HTTP toolkit for security research. It aims to become an open
|
|
||||||
source alternative to commercial software like Burp Suite Pro, with powerful
|
|
||||||
features tailored to the needs of the infosec and bug bounty community.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
[](https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty)
|
|
Before Width: | Height: | Size: 49 KiB |
@ -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, you’ll 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:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
::: 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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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, you’ll 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”:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
_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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 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”:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
::: tip INFO
|
|
||||||
At the moment of writing (`v0.2.0`), text based search is not implemented yet.
|
|
||||||
:::
|
|
Before Width: | Height: | Size: 410 KiB |
@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
home: true
|
|
||||||
heroImage: https://hetty.xyz/assets/logo.png
|
|
||||||
actionText: Read the docs →
|
|
||||||
actionLink: /guide/
|
|
||||||
---
|
|
7869
docs/yarn.lock
43
go.mod
@ -1,45 +1,48 @@
|
|||||||
module github.com/dstotijn/hetty
|
module github.com/dstotijn/hetty
|
||||||
|
|
||||||
go 1.17
|
go 1.23
|
||||||
|
|
||||||
|
toolchain go1.23.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/gqlgen v0.14.0
|
github.com/99designs/gqlgen v0.14.0
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.2
|
github.com/chromedp/chromedp v0.7.8
|
||||||
github.com/google/go-cmp v0.5.6
|
github.com/google/go-cmp v0.5.6
|
||||||
github.com/gorilla/mux v1.7.4
|
github.com/gorilla/mux v1.7.4
|
||||||
github.com/matryer/moq v0.2.5
|
|
||||||
github.com/mitchellh/go-homedir v1.1.0
|
github.com/mitchellh/go-homedir v1.1.0
|
||||||
github.com/oklog/ulid v1.3.1
|
github.com/oklog/ulid v1.3.1
|
||||||
|
github.com/peterbourgon/ff/v3 v3.1.2
|
||||||
|
github.com/smallstep/truststore v0.11.0
|
||||||
github.com/vektah/gqlparser/v2 v2.2.0
|
github.com/vektah/gqlparser/v2 v2.2.0
|
||||||
|
go.etcd.io/bbolt v1.4.0-beta.0
|
||||||
|
go.uber.org/zap v1.21.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/agnivade/levenshtein v1.1.0 // indirect
|
github.com/agnivade/levenshtein v1.1.0 // indirect
|
||||||
github.com/cespare/xxhash v1.1.0 // indirect
|
github.com/chromedp/cdproto v0.0.0-20220217222649-d8c14a5c6edf // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
||||||
github.com/dgraph-io/ristretto v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gobwas/ws v1.1.0 // indirect
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
|
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect
|
|
||||||
github.com/golang/protobuf v1.3.1 // indirect
|
|
||||||
github.com/golang/snappy v0.0.3 // indirect
|
|
||||||
github.com/google/flatbuffers v1.12.1 // indirect
|
|
||||||
github.com/gorilla/websocket v1.4.2 // indirect
|
github.com/gorilla/websocket v1.4.2 // indirect
|
||||||
github.com/hashicorp/golang-lru v0.5.1 // indirect
|
github.com/hashicorp/golang-lru v0.5.1 // indirect
|
||||||
github.com/klauspost/compress v1.12.3 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/matryer/moq v0.2.5 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/urfave/cli/v2 v2.1.1 // indirect
|
github.com/urfave/cli/v2 v2.1.1 // indirect
|
||||||
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e // indirect
|
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e // indirect
|
||||||
go.opencensus.io v0.22.5 // indirect
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
golang.org/x/mod v0.3.0 // indirect
|
go.uber.org/multierr v1.6.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect
|
golang.org/x/mod v0.4.2 // indirect
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
golang.org/x/sys v0.26.0 // indirect
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a // indirect
|
golang.org/x/tools v0.1.5 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.2.4 // indirect
|
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||||
|
howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect
|
||||||
)
|
)
|
||||||
|