Compare commits
35 Commits
v0.5.0
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
508829f2a3 | |||
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 | |||
d2858a2be4 |
4
.github/FUNDING.yml
vendored
@ -1,3 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: dstotijn
|
||||
patreon: dstotijn
|
||||
custom: "https://www.paypal.com/paypalme/dstotijn"
|
||||
|
@ -12,6 +12,7 @@ linters:
|
||||
- test
|
||||
- unused
|
||||
disable:
|
||||
- dupl
|
||||
- exhaustive
|
||||
- exhaustivestruct
|
||||
- gochecknoglobals
|
||||
@ -21,9 +22,11 @@ linters:
|
||||
- gomnd
|
||||
- interfacer
|
||||
- maligned
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- scopelint
|
||||
- testpackage
|
||||
- varnamelen
|
||||
- wrapcheck
|
||||
|
||||
linters-settings:
|
||||
@ -31,6 +34,8 @@ linters-settings:
|
||||
local-prefixes: github.com/dstotijn/hetty
|
||||
godot:
|
||||
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:
|
||||
exclude-rules:
|
||||
|
@ -28,6 +28,72 @@ archives:
|
||||
- goos: windows
|
||||
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:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
@ -39,4 +105,4 @@ changelog:
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^test:"
|
278
README.md
@ -1,243 +1,159 @@
|
||||
<h1>
|
||||
<a href="https://github.com/dstotijn/hetty">
|
||||
<img src="https://hetty.xyz/assets/logo.png" width="293">
|
||||
</a>
|
||||
</h1>
|
||||
<img src="https://user-images.githubusercontent.com/983924/156430531-6193e187-7400-436b-81c6-f86862783ea5.svg#gh-light-mode-only" width="240"/>
|
||||
<img src="https://user-images.githubusercontent.com/983924/156430660-9d5bd555-dcfd-47e2-ba70-54294c20c1b4.svg#gh-dark-mode-only" width="240"/>
|
||||
|
||||
[](https://github.com/dstotijn/hetty/releases/latest)
|
||||
[](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
|
||||

|
||||
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
||||
[](https://hetty.xyz/)
|
||||
[](https://github.com/dstotijn/hetty/releases/latest)
|
||||
[](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
|
||||

|
||||
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
||||
[](https://hetty.xyz/)
|
||||
|
||||
**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.
|
||||
|
||||
<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
|
||||
|
||||
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
|
||||
- Project based database storage (BadgerDB)
|
||||
- Scope support
|
||||
- Headless management API using GraphQL
|
||||
- Embedded web interface (Next.js)
|
||||
- Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search
|
||||
- HTTP client for manually creating/editing requests, and replay proxied requests
|
||||
- Intercept requests and responses for manual review (edit, send/receive, cancel)
|
||||
- Scope support, to help keep work organized
|
||||
- Easy-to-use web based admin interface
|
||||
- Project based database storage, to help keep work organized
|
||||
|
||||
ℹ️ 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.
|
||||
👷♂️ Hetty is under active development. Check the <a
|
||||
href="https://github.com/dstotijn/hetty/projects/1">backlog</a> for the current
|
||||
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
|
||||
and web based admin interface.
|
||||
### Installation
|
||||
|
||||
### 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
|
||||
|
||||
#### 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
|
||||
```sh
|
||||
brew install hettysoft/tap/hetty
|
||||
```
|
||||
|
||||
### Docker
|
||||
#### Linux
|
||||
|
||||
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
|
||||
For persistent storage of CA certificates and projects database, mount a volume:
|
||||
|
||||
```
|
||||
$ mkdir -p $HOME/.hetty
|
||||
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
|
||||
```sh
|
||||
sudo snap install hetty
|
||||
```
|
||||
|
||||
## Usage
|
||||
#### Windows
|
||||
|
||||
When Hetty is run, by default it listens on `:8080` and is accessible via
|
||||
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
|
||||
MITM proxy, or it serves the 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
|
||||
```sh
|
||||
scoop bucket add hettysoft https://github.com/hettysoft/scoop-bucket.git
|
||||
scoop install hettysoft/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
|
||||
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")
|
||||
docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 \
|
||||
ghcr.io/dstotijn/hetty:latest
|
||||
```
|
||||
|
||||
You should see:
|
||||
### Usage
|
||||
|
||||
```
|
||||
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
|
||||
Once installed, start Hetty via:
|
||||
|
||||
```sh
|
||||
hetty
|
||||
```
|
||||
|
||||
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
|
||||
`~/.hetty/hetty_cert.pem` respectively.
|
||||
💡 Read the [Getting started](https://hetty.xyz/docs/getting-started) doc for
|
||||
more details.
|
||||
|
||||
#### Generating CA certificates with OpenSSL
|
||||
|
||||
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`.
|
||||
To list all available options, run: `hetty --help`:
|
||||
|
||||
```
|
||||
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 directory path. (Default: "~/.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
|
||||
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.
|
||||
📖 [Read the docs](https://hetty.xyz/docs)
|
||||
|
||||
## Support
|
||||
|
||||
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
|
||||
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions)
|
||||
for questions and troubleshooting.
|
||||
feature requests, and
|
||||
[discussions](https://github.com/dstotijn/hetty/discussions) for questions and
|
||||
troubleshooting.
|
||||
|
||||
## Community
|
||||
|
||||
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP).
|
||||
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP)
|
||||
|
||||
## Contributing
|
||||
|
||||
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md)
|
||||
for details.
|
||||
Want to contribute? Great! Please check the [Contribution
|
||||
Guidelines](CONTRIBUTING.md) for details.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
|
||||
for all the encouragement and feedback.
|
||||
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
|
||||
for the encouragement and early feedback.
|
||||
- The font used in the logo and admin interface is [JetBrains
|
||||
Mono](https://www.jetbrains.com/lp/mono/).
|
||||
|
||||
## Sponsors
|
||||
|
||||
<a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
|
||||
<img src="https://hetty.xyz/assets/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
|
||||
</a>
|
||||
<p><a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
|
||||
<img src="https://hetty.xyz/img/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
|
||||
</a></p>
|
||||
|
||||
💖 Are you enjoying Hetty? You can [sponsor me](https://github.com/sponsors/dstotijn)!
|
||||
|
||||
## License
|
||||
|
||||
[MIT License](LICENSE)
|
||||
[MIT](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
© 2021 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)
|
||||
© 2022 Hetty Software
|
||||
|
@ -17,7 +17,12 @@
|
||||
"prettier/prettier": ["error"],
|
||||
"@next/next/no-css-tags": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
|
||||
"import/default": "off",
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import AltRouteIcon from "@mui/icons-material/AltRoute";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import SendIcon from "@mui/icons-material/Send";
|
||||
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
||||
import {
|
||||
Theme,
|
||||
useTheme,
|
||||
@ -19,6 +20,7 @@ import {
|
||||
CSSObject,
|
||||
Box,
|
||||
ListItemText,
|
||||
Badge,
|
||||
} from "@mui/material";
|
||||
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
|
||||
import MuiDrawer from "@mui/material/Drawer";
|
||||
@ -28,15 +30,18 @@ import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useActiveProject } from "lib/ActiveProjectContext";
|
||||
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
|
||||
|
||||
export enum Page {
|
||||
Home,
|
||||
GetStarted,
|
||||
Intercept,
|
||||
Projects,
|
||||
ProxySetup,
|
||||
ProxyLogs,
|
||||
Sender,
|
||||
Scope,
|
||||
Settings,
|
||||
}
|
||||
|
||||
const drawerWidth = 240;
|
||||
@ -135,6 +140,7 @@ interface Props {
|
||||
|
||||
export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
const activeProject = useActiveProject();
|
||||
const interceptedRequests = useInterceptedRequests();
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@ -204,12 +210,24 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
||||
</Link>
|
||||
<Link href="/proxy/logs" passHref>
|
||||
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
|
||||
<Tooltip title="Proxy">
|
||||
<Tooltip title="Proxy logs">
|
||||
<ListItemIcon>
|
||||
<SettingsEthernetIcon />
|
||||
<FormatListBulletedIcon />
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Proxy" />
|
||||
<ListItemText primary="Logs" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/proxy/intercept" passHref>
|
||||
<ListItemButton key="proxyIntercept" disabled={!activeProject} selected={page === Page.Intercept}>
|
||||
<Tooltip title="Proxy intercept">
|
||||
<ListItemIcon>
|
||||
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
|
||||
<AltRouteIcon />
|
||||
</Badge>
|
||||
</ListItemIcon>
|
||||
</Tooltip>
|
||||
<ListItemText primary="Intercept" />
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
<Link href="/sender" passHref>
|
||||
|
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 DescriptionIcon from "@mui/icons-material/Description";
|
||||
import LaunchIcon from "@mui/icons-material/Launch";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import { Alert } from "@mui/lab";
|
||||
import {
|
||||
Avatar,
|
||||
@ -29,6 +30,7 @@ import React, { useState } from "react";
|
||||
|
||||
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
|
||||
|
||||
import Link, { NextLinkComposed } from "lib/components/Link";
|
||||
import {
|
||||
ProjectsQuery,
|
||||
useCloseProjectMutation,
|
||||
@ -179,6 +181,11 @@ function ProjectList(): JSX.Element {
|
||||
{project.name} {project.isActive && <em>(Active)</em>}
|
||||
</ListItemText>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Project settings">
|
||||
<IconButton LinkComponent={Link} href="/settings" disabled={!project.isActive}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{project.isActive && (
|
||||
<Tooltip title="Close project">
|
||||
<IconButton onClick={() => closeProject()}>
|
||||
|
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,8 +1,20 @@
|
||||
import { ContentCopy } from "@mui/icons-material";
|
||||
import { Alert, Box, IconButton, Link, MenuItem, Snackbar, Tooltip } 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 { useState } from "react";
|
||||
|
||||
import Actions from "./Actions";
|
||||
import LogDetail from "./LogDetail";
|
||||
import Search from "./Search";
|
||||
|
||||
@ -11,6 +23,11 @@ import SplitPane from "lib/components/SplitPane";
|
||||
import useContextMenu from "lib/components/useContextMenu";
|
||||
import { useCreateSenderRequestFromHttpRequestLogMutation, useHttpRequestLogsQuery } from "lib/graphql/generated";
|
||||
|
||||
const ActionsTableCell = styled(TableCell)<TableCellProps>(() => ({
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}));
|
||||
|
||||
export function RequestLogs(): JSX.Element {
|
||||
const router = useRouter();
|
||||
const id = router.query.id as string | undefined;
|
||||
@ -56,26 +73,36 @@ export function RequestLogs(): JSX.Element {
|
||||
handleContextMenu(e);
|
||||
};
|
||||
|
||||
const handleCopyToSenderActionClick = (id: string) => {
|
||||
setCopyToSenderId(id);
|
||||
createSenderReqFromLog({
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const rowActions = (id: string): JSX.Element => (
|
||||
<Tooltip title="Copy to Sender">
|
||||
<IconButton size="small" onClick={() => handleCopyToSenderActionClick(id)}>
|
||||
<ContentCopy fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
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 (
|
||||
<Box display="flex" flexDirection="column" height="100%">
|
||||
<Search />
|
||||
<Box display="flex">
|
||||
<Box flex="1 auto">
|
||||
<Search />
|
||||
</Box>
|
||||
<Box pt={0.5}>
|
||||
<Actions />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
|
||||
<SplitPane split="horizontal" size={"40%"}>
|
||||
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>
|
||||
@ -96,9 +123,9 @@ export function RequestLogs(): JSX.Element {
|
||||
<RequestsTable
|
||||
requests={data?.httpRequestLogs || []}
|
||||
activeRowId={id}
|
||||
actionsCell={actionsCell}
|
||||
onRowClick={handleRowClick}
|
||||
onContextMenu={handleRowContextClick}
|
||||
rowActions={rowActions}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { Alert } from "@mui/lab";
|
||||
@ -17,11 +16,8 @@ import {
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
|
||||
import {
|
||||
HttpRequestLogFilterDocument,
|
||||
HttpRequestLogsDocument,
|
||||
useClearHttpRequestLogMutation,
|
||||
useHttpRequestLogFilterQuery,
|
||||
useSetHttpRequestLogFilterMutation,
|
||||
} from "lib/graphql/generated";
|
||||
@ -49,11 +45,6 @@ function Search(): JSX.Element {
|
||||
},
|
||||
});
|
||||
|
||||
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHttpRequestLogMutation({
|
||||
refetchQueries: [{ query: HttpRequestLogsDocument }],
|
||||
});
|
||||
const clearHTTPConfirmationDialog = useConfirmationDialog();
|
||||
|
||||
const filterRef = useRef<HTMLFormElement>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
|
||||
@ -81,11 +72,11 @@ function Search(): JSX.Element {
|
||||
<Box>
|
||||
<Error prefix="Error fetching filter" error={filterResult.error} />
|
||||
<Error prefix="Error setting filter" error={setFilterResult.error} />
|
||||
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
|
||||
<Box style={{ display: "flex", flex: 1 }}>
|
||||
<ClickAwayListener onClickAway={handleClickAway}>
|
||||
<Paper
|
||||
component="form"
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit}
|
||||
ref={filterRef}
|
||||
sx={{
|
||||
@ -119,6 +110,8 @@ function Search(): JSX.Element {
|
||||
value={searchExpr}
|
||||
onChange={(e) => setSearchExpr(e.target.value)}
|
||||
onFocus={() => setFilterOpen(true)}
|
||||
autoCorrect="false"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<Tooltip title="Search">
|
||||
<IconButton type="submit" sx={{ padding: 1.25 }}>
|
||||
@ -161,21 +154,7 @@ function Search(): JSX.Element {
|
||||
</Popper>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
<Box style={{ marginLeft: "auto" }}>
|
||||
<Tooltip title="Clear all">
|
||||
<IconButton onClick={clearHTTPConfirmationDialog.open}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<ConfirmationDialog
|
||||
isOpen={clearHTTPConfirmationDialog.isOpen}
|
||||
onClose={clearHTTPConfirmationDialog.close}
|
||||
onConfirm={clearHTTPRequestLog}
|
||||
>
|
||||
All proxy logs are going to be removed. This action cannot be undone.
|
||||
</ConfirmationDialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -1,100 +1,38 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
BoxProps,
|
||||
Button,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { Alert, Box, Button, Fab, Tooltip, Typography, useTheme } from "@mui/material";
|
||||
import { useRouter } from "next/router";
|
||||
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 Response from "lib/components/Response";
|
||||
import SplitPane from "lib/components/SplitPane";
|
||||
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
|
||||
import {
|
||||
GetSenderRequestQuery,
|
||||
useCreateOrUpdateSenderRequestMutation,
|
||||
HttpProtocol,
|
||||
useGetSenderRequestQuery,
|
||||
useSendRequestMutation,
|
||||
} from "lib/graphql/generated";
|
||||
import { queryParamsFromURL } from "lib/queryParamsFromURL";
|
||||
import updateKeyPairItem from "lib/updateKeyPairItem";
|
||||
import updateURLQueryParams from "lib/updateURLQueryParams";
|
||||
|
||||
enum HttpMethod {
|
||||
Get = "GET",
|
||||
Post = "POST",
|
||||
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;
|
||||
}
|
||||
const defaultMethod = HttpMethod.Get;
|
||||
const defaultProto = HttpProto.Http20;
|
||||
const emptyKeyPair = [{ key: "", value: "" }];
|
||||
|
||||
function EditRequest(): JSX.Element {
|
||||
const router = useRouter();
|
||||
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 [proto, setProto] = useState(HttpProto.Http2);
|
||||
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
|
||||
const [headers, setHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
|
||||
const [proto, setProto] = useState(defaultProto);
|
||||
const [queryParams, setQueryParams] = useState<KeyValuePair[]>(emptyKeyPair);
|
||||
const [headers, setHeaders] = useState<KeyValuePair[]>(emptyKeyPair);
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
const handleQueryParamChange = (key: string, value: string, idx: number) => {
|
||||
@ -152,7 +90,7 @@ function EditRequest(): JSX.Element {
|
||||
newQueryParams.push({ key: "", value: "" });
|
||||
setQueryParams(newQueryParams);
|
||||
|
||||
const newHeaders = sortKeyValuePairs(senderRequest.headers || []);
|
||||
const newHeaders = senderRequest.headers || [];
|
||||
setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
|
||||
setResponse(senderRequest.response);
|
||||
},
|
||||
@ -200,8 +138,26 @@ function EditRequest(): JSX.Element {
|
||||
createOrUpdateRequestAndSend();
|
||||
};
|
||||
|
||||
const handleNewRequest = () => {
|
||||
setURL("");
|
||||
setMethod(defaultMethod);
|
||||
setProto(defaultProto);
|
||||
setQueryParams(emptyKeyPair);
|
||||
setHeaders(emptyKeyPair);
|
||||
setBody("");
|
||||
setResponse(null);
|
||||
router.push(`/sender`);
|
||||
};
|
||||
|
||||
return (
|
||||
<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 sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<UrlBar
|
||||
@ -261,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;
|
||||
|
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 { Project, useProjectsQuery } from "./graphql/generated";
|
||||
import { Project, useActiveProjectQuery } from "./graphql/generated";
|
||||
|
||||
const ActiveProjectContext = createContext<Project | null>(null);
|
||||
|
||||
@ -9,8 +9,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ActiveProjectProvider({ children }: Props): JSX.Element {
|
||||
const { data } = useProjectsQuery();
|
||||
const project = data?.projects.find((project) => project.isActive) || null;
|
||||
const { data } = useActiveProjectQuery();
|
||||
const project = data?.activeProject || null;
|
||||
|
||||
return <ActiveProjectContext.Provider value={project}>{children}</ActiveProjectContext.Provider>;
|
||||
}
|
||||
|
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);
|
||||
}
|
@ -9,7 +9,6 @@ import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellProps,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
@ -74,19 +73,6 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
|
||||
setCopyConfOpen(false);
|
||||
};
|
||||
|
||||
const baseCellStyle = {
|
||||
"&:hover": {
|
||||
cursor: "copy",
|
||||
},
|
||||
};
|
||||
|
||||
const KeyTableCell = styled(TableCell)<TableCellProps>(() => (!onChange ? baseCellStyle : {}));
|
||||
const ValueTableCell = styled(TableCell)<TableCellProps>(() => ({
|
||||
...(!onChange && baseCellStyle),
|
||||
width: "60%",
|
||||
wordBreak: "break-all",
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Snackbar open={copyConfOpen} autoHideDuration={3000} onClose={handleCopyConfClose}>
|
||||
@ -118,12 +104,19 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
|
||||
>
|
||||
{items.map(({ key, value }, idx) => (
|
||||
<StyledTableRow key={idx} hover>
|
||||
<KeyTableCell
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
onClick={(e) => {
|
||||
!onChange && handleCellClick(e);
|
||||
}}
|
||||
sx={{
|
||||
...(!onChange && {
|
||||
"&:hover": {
|
||||
cursor: "copy",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{!onChange && <span>{key}</span>}
|
||||
{onChange && (
|
||||
@ -137,11 +130,20 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</KeyTableCell>
|
||||
<ValueTableCell
|
||||
</TableCell>
|
||||
<TableCell
|
||||
onClick={(e) => {
|
||||
!onChange && handleCellClick(e);
|
||||
}}
|
||||
sx={{
|
||||
width: "60%",
|
||||
wordBreak: "break-all",
|
||||
...(!onChange && {
|
||||
"&:hover": {
|
||||
cursor: "copy",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{!onChange && value}
|
||||
{onChange && (
|
||||
@ -155,7 +157,7 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ValueTableCell>
|
||||
</TableCell>
|
||||
{onDelete && (
|
||||
<TableCell>
|
||||
<div className="delete-button">
|
||||
@ -182,20 +184,4 @@ export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTab
|
||||
);
|
||||
}
|
||||
|
||||
export function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
|
||||
const sorted = [...items];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
if (a.key < b.key) {
|
||||
return -1;
|
||||
}
|
||||
if (a.key > b.key) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export default KeyValuePairTable;
|
||||
|
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,13 +62,13 @@ interface HttpResponse {
|
||||
interface Props {
|
||||
requests: HttpRequest[];
|
||||
activeRowId?: string;
|
||||
actionsCell?: (id: string) => JSX.Element;
|
||||
onRowClick?: (id: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, id: string) => void;
|
||||
rowActions?: (id: string) => JSX.Element;
|
||||
}
|
||||
|
||||
export default function RequestsTable(props: Props): JSX.Element {
|
||||
const { requests, activeRowId, onRowClick, onContextMenu, rowActions } = props;
|
||||
const { requests, activeRowId, actionsCell, onRowClick, onContextMenu } = props;
|
||||
|
||||
return (
|
||||
<TableContainer sx={{ overflowX: "initial" }}>
|
||||
@ -79,7 +79,7 @@ export default function RequestsTable(props: Props): JSX.Element {
|
||||
<TableCell>Origin</TableCell>
|
||||
<TableCell>Path</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
{rowActions && <TableCell padding="checkbox" />}
|
||||
{actionsCell && <TableCell padding="checkbox"></TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@ -106,7 +106,7 @@ export default function RequestsTable(props: Props): JSX.Element {
|
||||
<StatusTableCell>
|
||||
{response && <Status code={response.statusCode} reason={response.statusReason} />}
|
||||
</StatusTableCell>
|
||||
{rowActions && <TableCell sx={{ py: 0 }}>{rowActions(id)}</TableCell>}
|
||||
{actionsCell && actionsCell(id)}
|
||||
</RequestTableRow>
|
||||
);
|
||||
})}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
import { sortKeyValuePairs } from "./KeyValuePair";
|
||||
import ResponseTabs from "./ResponseTabs";
|
||||
|
||||
import ResponseStatus from "lib/components/ResponseStatus";
|
||||
@ -29,7 +28,7 @@ function Response({ response }: ResponseProps): JSX.Element {
|
||||
</Box>
|
||||
<ResponseTabs
|
||||
body={response?.body}
|
||||
headers={sortKeyValuePairs(response?.headers || [])}
|
||||
headers={response?.headers || []}
|
||||
hasResponse={response !== undefined && response !== null}
|
||||
/>
|
||||
</Box>
|
||||
|
@ -12,9 +12,11 @@ type ResponseStatusProps = {
|
||||
|
||||
function mapProto(proto: HttpProtocol): string {
|
||||
switch (proto) {
|
||||
case HttpProtocol.Http1:
|
||||
case HttpProtocol.Http10:
|
||||
return "HTTP/1.0";
|
||||
case HttpProtocol.Http11:
|
||||
return "HTTP/1.1";
|
||||
case HttpProtocol.Http2:
|
||||
case HttpProtocol.Http20:
|
||||
return "HTTP/2.0";
|
||||
default:
|
||||
return proto;
|
||||
|
@ -2,13 +2,16 @@ import { TabContext, TabList, TabPanel } from "@mui/lab";
|
||||
import { Box, Paper, Tab, Typography } from "@mui/material";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
|
||||
|
||||
import Editor from "lib/components/Editor";
|
||||
import { KeyValuePairTable } from "lib/components/KeyValuePair";
|
||||
import { HttpResponseLog } from "lib/graphql/generated";
|
||||
|
||||
interface ResponseTabsProps {
|
||||
headers: HttpResponseLog["headers"];
|
||||
body: HttpResponseLog["body"];
|
||||
headers: KeyValuePair[];
|
||||
onHeaderChange?: KeyValuePairTableProps["onChange"];
|
||||
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
|
||||
body?: string | null;
|
||||
onBodyChange?: (value: string) => void;
|
||||
hasResponse: boolean;
|
||||
}
|
||||
|
||||
@ -24,7 +27,7 @@ const reqNotSent = (
|
||||
);
|
||||
|
||||
function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||
const { headers, body, hasResponse } = props;
|
||||
const { headers, onHeaderChange, onHeaderDelete, body, onBodyChange, hasResponse } = props;
|
||||
const [tabValue, setTabValue] = useState(TabValue.Body);
|
||||
|
||||
const contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
|
||||
@ -33,6 +36,8 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||
textTransform: "none",
|
||||
};
|
||||
|
||||
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
|
||||
|
||||
return (
|
||||
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<TabContext value={tabValue}>
|
||||
@ -43,20 +48,25 @@ function ResponseTabs(props: ResponseTabsProps): JSX.Element {
|
||||
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
|
||||
sx={tabSx}
|
||||
/>
|
||||
<Tab
|
||||
value={TabValue.Headers}
|
||||
label={"Headers" + (headers.length ? ` (${headers.length})` : "")}
|
||||
sx={tabSx}
|
||||
/>
|
||||
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
|
||||
</TabList>
|
||||
</Box>
|
||||
<Box flex="1 auto" overflow="hidden">
|
||||
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
|
||||
{body && <Editor content={body} contentType={contentType} />}
|
||||
{hasResponse && (
|
||||
<Editor
|
||||
content={body || ""}
|
||||
onChange={(value) => {
|
||||
onBodyChange && onBodyChange(value || "");
|
||||
}}
|
||||
monacoOptions={{ readOnly: onBodyChange === undefined }}
|
||||
contentType={contentType}
|
||||
/>
|
||||
)}
|
||||
{!hasResponse && reqNotSent}
|
||||
</TabPanel>
|
||||
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
|
||||
{headers.length > 0 && <KeyValuePairTable items={headers} />}
|
||||
{hasResponse && <KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />}
|
||||
{!hasResponse && reqNotSent}
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
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;
|
||||
};
|
||||
|
||||
export type CancelRequestResult = {
|
||||
__typename?: 'CancelRequestResult';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type CancelResponseResult = {
|
||||
__typename?: 'CancelResponseResult';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type ClearHttpRequestLogResult = {
|
||||
__typename?: 'ClearHTTPRequestLogResult';
|
||||
success: Scalars['Boolean'];
|
||||
@ -62,10 +72,22 @@ export enum HttpMethod {
|
||||
}
|
||||
|
||||
export enum HttpProtocol {
|
||||
Http1 = 'HTTP1',
|
||||
Http2 = 'HTTP2'
|
||||
Http10 = 'HTTP10',
|
||||
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 = {
|
||||
__typename?: 'HttpRequestLog';
|
||||
body?: Maybe<Scalars['String']>;
|
||||
@ -89,6 +111,17 @@ export type HttpRequestLogFilterInput = {
|
||||
searchExpression?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type HttpResponse = {
|
||||
__typename?: 'HttpResponse';
|
||||
body?: Maybe<Scalars['String']>;
|
||||
headers: Array<HttpHeader>;
|
||||
/** Will be the same ID as its related request ID. */
|
||||
id: Scalars['ID'];
|
||||
proto: HttpProtocol;
|
||||
statusCode: Scalars['Int'];
|
||||
statusReason: Scalars['String'];
|
||||
};
|
||||
|
||||
export type HttpResponseLog = {
|
||||
__typename?: 'HttpResponseLog';
|
||||
body?: Maybe<Scalars['String']>;
|
||||
@ -100,8 +133,47 @@ export type HttpResponseLog = {
|
||||
statusReason: Scalars['String'];
|
||||
};
|
||||
|
||||
export type InterceptSettings = {
|
||||
__typename?: 'InterceptSettings';
|
||||
requestFilter?: Maybe<Scalars['String']>;
|
||||
requestsEnabled: Scalars['Boolean'];
|
||||
responseFilter?: Maybe<Scalars['String']>;
|
||||
responsesEnabled: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type ModifyRequestInput = {
|
||||
body?: InputMaybe<Scalars['String']>;
|
||||
headers?: InputMaybe<Array<HttpHeaderInput>>;
|
||||
id: Scalars['ID'];
|
||||
method: HttpMethod;
|
||||
modifyResponse?: InputMaybe<Scalars['Boolean']>;
|
||||
proto: HttpProtocol;
|
||||
url: Scalars['URL'];
|
||||
};
|
||||
|
||||
export type ModifyRequestResult = {
|
||||
__typename?: 'ModifyRequestResult';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type ModifyResponseInput = {
|
||||
body?: InputMaybe<Scalars['String']>;
|
||||
headers?: InputMaybe<Array<HttpHeaderInput>>;
|
||||
proto: HttpProtocol;
|
||||
requestID: Scalars['ID'];
|
||||
statusCode: Scalars['Int'];
|
||||
statusReason: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ModifyResponseResult = {
|
||||
__typename?: 'ModifyResponseResult';
|
||||
success: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
cancelRequest: CancelRequestResult;
|
||||
cancelResponse: CancelResponseResult;
|
||||
clearHTTPRequestLog: ClearHttpRequestLogResult;
|
||||
closeProject: CloseProjectResult;
|
||||
createOrUpdateSenderRequest: SenderRequest;
|
||||
@ -109,11 +181,24 @@ export type Mutation = {
|
||||
createSenderRequestFromHttpRequestLog: SenderRequest;
|
||||
deleteProject: DeleteProjectResult;
|
||||
deleteSenderRequests: DeleteSenderRequestsResult;
|
||||
modifyRequest: ModifyRequestResult;
|
||||
modifyResponse: ModifyResponseResult;
|
||||
openProject?: Maybe<Project>;
|
||||
sendRequest: SenderRequest;
|
||||
setHttpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
||||
setScope: Array<ScopeRule>;
|
||||
setSenderRequestFilter?: Maybe<SenderRequestFilter>;
|
||||
updateInterceptSettings: InterceptSettings;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCancelRequestArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationCancelResponseArgs = {
|
||||
requestID: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
@ -137,6 +222,16 @@ export type MutationDeleteProjectArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationModifyRequestArgs = {
|
||||
request: ModifyRequestInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationModifyResponseArgs = {
|
||||
response: ModifyResponseInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationOpenProjectArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
@ -161,11 +256,22 @@ export type MutationSetSenderRequestFilterArgs = {
|
||||
filter?: InputMaybe<SenderRequestFilterInput>;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateInterceptSettingsArgs = {
|
||||
input: UpdateInterceptSettingsInput;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
id: Scalars['ID'];
|
||||
isActive: Scalars['Boolean'];
|
||||
name: Scalars['String'];
|
||||
settings: ProjectSettings;
|
||||
};
|
||||
|
||||
export type ProjectSettings = {
|
||||
__typename?: 'ProjectSettings';
|
||||
intercept: InterceptSettings;
|
||||
};
|
||||
|
||||
export type Query = {
|
||||
@ -174,6 +280,8 @@ export type Query = {
|
||||
httpRequestLog?: Maybe<HttpRequestLog>;
|
||||
httpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
|
||||
httpRequestLogs: Array<HttpRequestLog>;
|
||||
interceptedRequest?: Maybe<HttpRequest>;
|
||||
interceptedRequests: Array<HttpRequest>;
|
||||
projects: Array<Project>;
|
||||
scope: Array<ScopeRule>;
|
||||
senderRequest?: Maybe<SenderRequest>;
|
||||
@ -186,6 +294,11 @@ export type QueryHttpRequestLogArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryInterceptedRequestArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type QuerySenderRequestArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
@ -247,6 +360,53 @@ export type SenderRequestInput = {
|
||||
url: Scalars['URL'];
|
||||
};
|
||||
|
||||
export type UpdateInterceptSettingsInput = {
|
||||
requestFilter?: InputMaybe<Scalars['String']>;
|
||||
requestsEnabled: Scalars['Boolean'];
|
||||
responseFilter?: InputMaybe<Scalars['String']>;
|
||||
responsesEnabled: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type CancelRequestMutationVariables = Exact<{
|
||||
id: Scalars['ID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type CancelRequestMutation = { __typename?: 'Mutation', cancelRequest: { __typename?: 'CancelRequestResult', success: boolean } };
|
||||
|
||||
export type CancelResponseMutationVariables = Exact<{
|
||||
requestID: Scalars['ID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type CancelResponseMutation = { __typename?: 'Mutation', cancelResponse: { __typename?: 'CancelResponseResult', success: boolean } };
|
||||
|
||||
export type GetInterceptedRequestQueryVariables = Exact<{
|
||||
id: Scalars['ID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetInterceptedRequestQuery = { __typename?: 'Query', interceptedRequest?: { __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod, proto: HttpProtocol, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }>, response?: { __typename?: 'HttpResponse', id: string, proto: HttpProtocol, statusCode: number, statusReason: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null } | null };
|
||||
|
||||
export type ModifyRequestMutationVariables = Exact<{
|
||||
request: ModifyRequestInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type ModifyRequestMutation = { __typename?: 'Mutation', modifyRequest: { __typename?: 'ModifyRequestResult', success: boolean } };
|
||||
|
||||
export type ModifyResponseMutationVariables = Exact<{
|
||||
response: ModifyResponseInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type ModifyResponseMutation = { __typename?: 'Mutation', modifyResponse: { __typename?: 'ModifyResponseResult', success: boolean } };
|
||||
|
||||
export type ActiveProjectQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type ActiveProjectQuery = { __typename?: 'Query', activeProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean, settings: { __typename?: 'ProjectSettings', intercept: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } } } | null };
|
||||
|
||||
export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@ -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 UpdateInterceptSettingsMutationVariables = Exact<{
|
||||
input: UpdateInterceptSettingsInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateInterceptSettingsMutation = { __typename?: 'Mutation', updateInterceptSettings: { __typename?: 'InterceptSettings', requestsEnabled: boolean, responsesEnabled: boolean, requestFilter?: string | null, responseFilter?: string | null } };
|
||||
|
||||
export type GetInterceptedRequestsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetInterceptedRequestsQuery = { __typename?: 'Query', interceptedRequests: Array<{ __typename?: 'HttpRequest', id: string, url: any, method: HttpMethod, response?: { __typename?: 'HttpResponse', statusCode: number, statusReason: string } | null }> };
|
||||
|
||||
|
||||
export const CancelRequestDocument = gql`
|
||||
mutation CancelRequest($id: ID!) {
|
||||
cancelRequest(id: $id) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CancelRequestMutationFn = Apollo.MutationFunction<CancelRequestMutation, CancelRequestMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCancelRequestMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCancelRequestMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCancelRequestMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [cancelRequestMutation, { data, loading, error }] = useCancelRequestMutation({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCancelRequestMutation(baseOptions?: Apollo.MutationHookOptions<CancelRequestMutation, CancelRequestMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CancelRequestMutation, CancelRequestMutationVariables>(CancelRequestDocument, options);
|
||||
}
|
||||
export type CancelRequestMutationHookResult = ReturnType<typeof useCancelRequestMutation>;
|
||||
export type CancelRequestMutationResult = Apollo.MutationResult<CancelRequestMutation>;
|
||||
export type CancelRequestMutationOptions = Apollo.BaseMutationOptions<CancelRequestMutation, CancelRequestMutationVariables>;
|
||||
export const CancelResponseDocument = gql`
|
||||
mutation CancelResponse($requestID: ID!) {
|
||||
cancelResponse(requestID: $requestID) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CancelResponseMutationFn = Apollo.MutationFunction<CancelResponseMutation, CancelResponseMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCancelResponseMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCancelResponseMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCancelResponseMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [cancelResponseMutation, { data, loading, error }] = useCancelResponseMutation({
|
||||
* variables: {
|
||||
* requestID: // value for 'requestID'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCancelResponseMutation(baseOptions?: Apollo.MutationHookOptions<CancelResponseMutation, CancelResponseMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CancelResponseMutation, CancelResponseMutationVariables>(CancelResponseDocument, options);
|
||||
}
|
||||
export type CancelResponseMutationHookResult = ReturnType<typeof useCancelResponseMutation>;
|
||||
export type CancelResponseMutationResult = Apollo.MutationResult<CancelResponseMutation>;
|
||||
export type CancelResponseMutationOptions = Apollo.BaseMutationOptions<CancelResponseMutation, CancelResponseMutationVariables>;
|
||||
export const GetInterceptedRequestDocument = gql`
|
||||
query GetInterceptedRequest($id: ID!) {
|
||||
interceptedRequest(id: $id) {
|
||||
id
|
||||
url
|
||||
method
|
||||
proto
|
||||
headers {
|
||||
key
|
||||
value
|
||||
}
|
||||
body
|
||||
response {
|
||||
id
|
||||
proto
|
||||
statusCode
|
||||
statusReason
|
||||
headers {
|
||||
key
|
||||
value
|
||||
}
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetInterceptedRequestQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetInterceptedRequestQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetInterceptedRequestQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetInterceptedRequestQuery({
|
||||
* variables: {
|
||||
* id: // value for 'id'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetInterceptedRequestQuery(baseOptions: Apollo.QueryHookOptions<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>(GetInterceptedRequestDocument, options);
|
||||
}
|
||||
export function useGetInterceptedRequestLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>(GetInterceptedRequestDocument, options);
|
||||
}
|
||||
export type GetInterceptedRequestQueryHookResult = ReturnType<typeof useGetInterceptedRequestQuery>;
|
||||
export type GetInterceptedRequestLazyQueryHookResult = ReturnType<typeof useGetInterceptedRequestLazyQuery>;
|
||||
export type GetInterceptedRequestQueryResult = Apollo.QueryResult<GetInterceptedRequestQuery, GetInterceptedRequestQueryVariables>;
|
||||
export const ModifyRequestDocument = gql`
|
||||
mutation ModifyRequest($request: ModifyRequestInput!) {
|
||||
modifyRequest(request: $request) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type ModifyRequestMutationFn = Apollo.MutationFunction<ModifyRequestMutation, ModifyRequestMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useModifyRequestMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useModifyRequestMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useModifyRequestMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [modifyRequestMutation, { data, loading, error }] = useModifyRequestMutation({
|
||||
* variables: {
|
||||
* request: // value for 'request'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useModifyRequestMutation(baseOptions?: Apollo.MutationHookOptions<ModifyRequestMutation, ModifyRequestMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<ModifyRequestMutation, ModifyRequestMutationVariables>(ModifyRequestDocument, options);
|
||||
}
|
||||
export type ModifyRequestMutationHookResult = ReturnType<typeof useModifyRequestMutation>;
|
||||
export type ModifyRequestMutationResult = Apollo.MutationResult<ModifyRequestMutation>;
|
||||
export type ModifyRequestMutationOptions = Apollo.BaseMutationOptions<ModifyRequestMutation, ModifyRequestMutationVariables>;
|
||||
export const ModifyResponseDocument = gql`
|
||||
mutation ModifyResponse($response: ModifyResponseInput!) {
|
||||
modifyResponse(response: $response) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type ModifyResponseMutationFn = Apollo.MutationFunction<ModifyResponseMutation, ModifyResponseMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useModifyResponseMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useModifyResponseMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useModifyResponseMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [modifyResponseMutation, { data, loading, error }] = useModifyResponseMutation({
|
||||
* variables: {
|
||||
* response: // value for 'response'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useModifyResponseMutation(baseOptions?: Apollo.MutationHookOptions<ModifyResponseMutation, ModifyResponseMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<ModifyResponseMutation, ModifyResponseMutationVariables>(ModifyResponseDocument, options);
|
||||
}
|
||||
export type ModifyResponseMutationHookResult = ReturnType<typeof useModifyResponseMutation>;
|
||||
export type ModifyResponseMutationResult = Apollo.MutationResult<ModifyResponseMutation>;
|
||||
export type ModifyResponseMutationOptions = Apollo.BaseMutationOptions<ModifyResponseMutation, ModifyResponseMutationVariables>;
|
||||
export const ActiveProjectDocument = gql`
|
||||
query ActiveProject {
|
||||
activeProject {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
settings {
|
||||
intercept {
|
||||
requestsEnabled
|
||||
responsesEnabled
|
||||
requestFilter
|
||||
responseFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useActiveProjectQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useActiveProjectQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useActiveProjectQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useActiveProjectQuery({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useActiveProjectQuery(baseOptions?: Apollo.QueryHookOptions<ActiveProjectQuery, ActiveProjectQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<ActiveProjectQuery, ActiveProjectQueryVariables>(ActiveProjectDocument, options);
|
||||
}
|
||||
export function useActiveProjectLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ActiveProjectQuery, ActiveProjectQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<ActiveProjectQuery, ActiveProjectQueryVariables>(ActiveProjectDocument, options);
|
||||
}
|
||||
export type ActiveProjectQueryHookResult = ReturnType<typeof useActiveProjectQuery>;
|
||||
export type ActiveProjectLazyQueryHookResult = ReturnType<typeof useActiveProjectLazyQuery>;
|
||||
export type ActiveProjectQueryResult = Apollo.QueryResult<ActiveProjectQuery, ActiveProjectQueryVariables>;
|
||||
export const CloseProjectDocument = gql`
|
||||
mutation CloseProject {
|
||||
closeProject {
|
||||
@ -981,4 +1383,80 @@ export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHook
|
||||
}
|
||||
export type GetSenderRequestsQueryHookResult = ReturnType<typeof useGetSenderRequestsQuery>;
|
||||
export type GetSenderRequestsLazyQueryHookResult = ReturnType<typeof useGetSenderRequestsLazyQuery>;
|
||||
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
|
||||
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;
|
||||
export const UpdateInterceptSettingsDocument = gql`
|
||||
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
|
||||
updateInterceptSettings(input: $input) {
|
||||
requestsEnabled
|
||||
responsesEnabled
|
||||
requestFilter
|
||||
responseFilter
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type UpdateInterceptSettingsMutationFn = Apollo.MutationFunction<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useUpdateInterceptSettingsMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useUpdateInterceptSettingsMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useUpdateInterceptSettingsMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [updateInterceptSettingsMutation, { data, loading, error }] = useUpdateInterceptSettingsMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useUpdateInterceptSettingsMutation(baseOptions?: Apollo.MutationHookOptions<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>(UpdateInterceptSettingsDocument, options);
|
||||
}
|
||||
export type UpdateInterceptSettingsMutationHookResult = ReturnType<typeof useUpdateInterceptSettingsMutation>;
|
||||
export type UpdateInterceptSettingsMutationResult = Apollo.MutationResult<UpdateInterceptSettingsMutation>;
|
||||
export type UpdateInterceptSettingsMutationOptions = Apollo.BaseMutationOptions<UpdateInterceptSettingsMutation, UpdateInterceptSettingsMutationVariables>;
|
||||
export const GetInterceptedRequestsDocument = gql`
|
||||
query GetInterceptedRequests {
|
||||
interceptedRequests {
|
||||
id
|
||||
url
|
||||
method
|
||||
response {
|
||||
statusCode
|
||||
statusReason
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* __useGetInterceptedRequestsQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useGetInterceptedRequestsQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useGetInterceptedRequestsQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useGetInterceptedRequestsQuery({
|
||||
* variables: {
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useGetInterceptedRequestsQuery(baseOptions?: Apollo.QueryHookOptions<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>(GetInterceptedRequestsDocument, options);
|
||||
}
|
||||
export function useGetInterceptedRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>(GetInterceptedRequestsDocument, options);
|
||||
}
|
||||
export type GetInterceptedRequestsQueryHookResult = ReturnType<typeof useGetInterceptedRequestsQuery>;
|
||||
export type GetInterceptedRequestsLazyQueryHookResult = ReturnType<typeof useGetInterceptedRequestsLazyQuery>;
|
||||
export type GetInterceptedRequestsQueryResult = Apollo.QueryResult<GetInterceptedRequestsQuery, GetInterceptedRequestsQueryVariables>;
|
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({
|
||||
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 { ActiveProjectProvider } from "lib/ActiveProjectContext";
|
||||
import { InterceptedRequestsProvider } from "lib/InterceptedRequestsContext";
|
||||
import { useApollo } from "lib/graphql/useApollo";
|
||||
import createEmotionCache from "lib/mui/createEmotionCache";
|
||||
import theme from "lib/mui/theme";
|
||||
@ -32,10 +33,12 @@ export default function MyApp(props: MyAppProps) {
|
||||
</Head>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ActiveProjectProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
<InterceptedRequestsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</InterceptedRequestsProvider>
|
||||
</ActiveProjectProvider>
|
||||
</ApolloProvider>
|
||||
</CacheProvider>
|
||||
|
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;
|
1698
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.")
|
||||
}
|
307
cmd/hetty/hetty.go
Normal file
@ -0,0 +1,307 @@
|
||||
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"
|
||||
badgerdb "github.com/dgraph-io/badger/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/api"
|
||||
"github.com/dstotijn/hetty/pkg/chrome"
|
||||
"github.com/dstotijn/hetty/pkg/db/badger"
|
||||
"github.com/dstotijn/hetty/pkg/proj"
|
||||
"github.com/dstotijn/hetty/pkg/proxy"
|
||||
"github.com/dstotijn/hetty/pkg/proxy/intercept"
|
||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||
"github.com/dstotijn/hetty/pkg/scope"
|
||||
"github.com/dstotijn/hetty/pkg/sender"
|
||||
)
|
||||
|
||||
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 directory path. (Default: "~/.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/db", "Database directory path.")
|
||||
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))
|
||||
}
|
||||
|
||||
// BadgerDB logs some verbose entries with `INFO` level, so unless
|
||||
// we're running in debug mode, bump the minimal level to `WARN`.
|
||||
dbLogger := cmd.config.logger.Named("badgerdb").WithOptions(zap.IncreaseLevel(zapcore.WarnLevel))
|
||||
|
||||
dbSugaredLogger := dbLogger.Sugar()
|
||||
|
||||
badger, err := badger.OpenDatabase(
|
||||
badgerdb.DefaultOptions(dbPath).WithLogger(badger.NewLogger(dbSugaredLogger)),
|
||||
)
|
||||
if err != nil {
|
||||
cmd.config.logger.Fatal("Failed to open database.", zap.Error(err))
|
||||
}
|
||||
defer badger.Close()
|
||||
|
||||
scope := &scope.Scope{}
|
||||
|
||||
reqLogService := reqlog.NewService(reqlog.Config{
|
||||
Scope: scope,
|
||||
Repository: badger,
|
||||
Logger: cmd.config.logger.Named("reqlog").Sugar(),
|
||||
})
|
||||
|
||||
interceptService := intercept.NewService(intercept.Config{
|
||||
Logger: cmd.config.logger.Named("intercept").Sugar(),
|
||||
})
|
||||
|
||||
senderService := sender.NewService(sender.Config{
|
||||
Repository: badger,
|
||||
ReqLogService: reqLogService,
|
||||
})
|
||||
|
||||
projService, err := proj.NewService(proj.Config{
|
||||
Repository: badger,
|
||||
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
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
llog "log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
"github.com/99designs/gqlgen/graphql/playground"
|
||||
badgerdb "github.com/dgraph-io/badger/v3"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/dstotijn/hetty/pkg/api"
|
||||
"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"
|
||||
"github.com/dstotijn/hetty/pkg/log"
|
||||
)
|
||||
|
||||
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() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatalf("[ERROR]: %v", err)
|
||||
hettyCmd, cfg := NewHettyCommand()
|
||||
|
||||
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
24
go.mod
@ -4,22 +4,31 @@ go 1.17
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.14.0
|
||||
github.com/chromedp/chromedp v0.7.8
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2
|
||||
github.com/google/go-cmp v0.5.6
|
||||
github.com/gorilla/mux v1.7.4
|
||||
github.com/matryer/moq v0.2.5
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
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
|
||||
go.uber.org/zap v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/agnivade/levenshtein v1.1.0 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.1 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20220217222649-d8c14a5c6edf // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.1.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect
|
||||
@ -28,7 +37,9 @@ require (
|
||||
github.com/google/flatbuffers v1.12.1 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.12.3 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.0.1 // indirect
|
||||
@ -36,10 +47,13 @@ require (
|
||||
github.com/urfave/cli/v2 v2.1.1 // indirect
|
||||
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e // indirect
|
||||
go.opencensus.io v0.22.5 // indirect
|
||||
golang.org/x/mod v0.3.0 // indirect
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
golang.org/x/mod v0.4.2 // indirect
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 // indirect
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
|
||||
golang.org/x/tools v0.1.5 // 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
|
||||
)
|
||||
|
68
go.sum
@ -12,10 +12,18 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20220217222649-d8c14a5c6edf h1:1omDWNUsWxn2HpiMiMuyRmzjl9uG7RP3IE6GTlpgJWU=
|
||||
github.com/chromedp/cdproto v0.0.0-20220217222649-d8c14a5c6edf/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
|
||||
github.com/chromedp/chromedp v0.7.8 h1:JFPIFb28LPjcx6l6mUUzLOTD/TgswcTtg7KrDn8S/2I=
|
||||
github.com/chromedp/chromedp v0.7.8/go.mod h1:HcIUFBa5vA+u2QI3+xljiU59llUQ8lgGoLzYSCBfmUA=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
@ -38,6 +46,12 @@ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
|
||||
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=
|
||||
@ -67,6 +81,9 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
|
||||
@ -78,6 +95,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ=
|
||||
github.com/matryer/moq v0.2.5 h1:BGQISyhl7Gc9W/gMYmAJONh9mT6AYeyeTjNupNPknMs=
|
||||
github.com/matryer/moq v0.2.5/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE=
|
||||
@ -93,7 +112,12 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
|
||||
github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5 h1:1SoBaSPudixRecmlHXb/GxmaD3fLMtHIDN13QujwQuc=
|
||||
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
|
||||
github.com/peterbourgon/ff/v3 v3.1.2 h1:0GNhbRhO9yHA4CC27ymskOsuRpmX0YQxwxM9UPiP6JM=
|
||||
github.com/peterbourgon/ff/v3 v3.1.2/go.mod h1:XNJLY8EIl6MjMVjBS4F0+G0LYoAqs0DTa4rmHHukKDE=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@ -110,6 +134,8 @@ github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJ
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
|
||||
github.com/smallstep/truststore v0.11.0 h1:JUTkQ4oHr40jHTS/A2t0usEhteMWG+45CDD2iJA/dIk=
|
||||
github.com/smallstep/truststore v0.11.0/go.mod h1:HwHKRcBi0RUxxw1LYDpTRhYC4jZUuxPpkHdVonlkoDM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
@ -122,8 +148,10 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
|
||||
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||
@ -135,8 +163,17 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@ -145,9 +182,11 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@ -156,8 +195,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -166,6 +206,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@ -175,8 +216,14 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@ -188,8 +235,9 @@ golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a h1:CB3a9Nez8M13wwlr/E2YtwoU+qYHKfC+JrDa45RXXoQ=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@ -202,11 +250,19 @@ google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRn
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
howett.net/plist v0.0.0-20181124034731-591f970eefbb h1:jhnBjNi9UFpfpl8YZhA9CrOqpnJdvzuiHsl/dnxl11M=
|
||||
howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0=
|
||||
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
|
||||
sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k=
|
||||
|