Compare commits

...

68 Commits

Author SHA1 Message Date
565c370bb8 Fix GoReleaser config, update moq dep 2022-01-31 15:48:44 +01:00
2dc6538a3b Add build-test Github Action 2022-01-31 14:37:56 +01:00
aa8ddf4122 Update Next.js, Material UI 2022-01-28 20:20:15 +01:00
73ebb89863 Update Dockerfile 2022-01-26 11:35:47 +01:00
1489cb16bf Update GoReleaser config 2022-01-25 13:20:16 +01:00
d84d2d0905 Replace SQLite with BadgerDB 2022-01-21 11:45:54 +01:00
8a3b3cbf02 Bump copyright year 2022-01-01 16:15:03 +01:00
b3225bfb99 Add initial tests for reqlog package 2022-01-01 16:11:49 +01:00
4e2eaea499 Add sponsor 2021-12-30 11:40:14 +01:00
8122b2552d Remove dead code 2021-05-13 13:35:11 +02:00
569f7bc76f Use embed instead of rice 2021-04-26 09:24:45 +02:00
ca3a729c36 Add linter, fix linting issue 2021-04-25 16:23:53 +02:00
ad3dc0da70 Bump ini from 1.3.5 to 1.3.8 in /docs (#57)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:36 +02:00
49547f535f Bump prismjs from 1.22.0 to 1.23.0 in /docs (#64)
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.22.0...v1.23.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:23 +02:00
e42e1c212b Bump elliptic from 6.5.3 to 6.5.4 in /admin (#65)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:17 +02:00
6e38b16cf2 Bump elliptic from 6.5.3 to 6.5.4 in /docs (#66)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:10 +02:00
078bf303be Bump ssri from 6.0.1 to 6.0.2 in /docs (#71)
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:17:30 +02:00
a42f003919 Bump y18n from 4.0.0 to 4.0.1 in /admin (#68)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:52 +02:00
50c2eac42d Bump y18n from 4.0.0 to 4.0.1 in /docs (#69)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:39 +02:00
4ead501f53 Bump ssri from 6.0.1 to 6.0.2 in /admin (#70)
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:11 +02:00
d2e97f2acc Fix typo "is" > "if" in CLI cert help text (#63)
* Fix typo "is" > "if" in main.go

* Fix typo "is" > "if" in documentation

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

* Add UI for clearing all HTTP request logs

* Use consistent naming

* Explicitly delete only from http_requests

* Check if datebase is open

* Add confirmation dialog
2020-11-28 15:48:19 +01:00
efc115e961 Fix docs dir 2020-11-09 21:20:49 +01:00
471fa212ef Add links to docs website in README 2020-11-09 21:16:56 +01:00
07ef2f9090 Add OG meta tags 2020-11-09 21:06:40 +01:00
f7550d649a Add static asset for header image for README 2020-11-09 20:50:56 +01:00
dbc25774c2 Small doc fixes for mobile/lighthouse 2020-11-09 20:44:01 +01:00
430670ab54 Add docs 2020-11-09 20:28:10 +01:00
f6789fa245 Tidy up manual build process 2020-11-01 19:01:07 +01:00
81fbfe4cb3 Tidy up .gitignore 2020-11-01 18:31:08 +01:00
6931d63250 Remove GitHub workflows 2020-11-01 17:19:17 +01:00
71e87d3cd3 Remove modd.conf 2020-11-01 17:13:13 +01:00
0ffbb618fa Update README 2020-11-01 17:05:36 +01:00
c01f190fc8 Use Go instead of C for regexp sqlite func
While less performant, this (for now) simplifies
compiling and building the project.
2020-10-31 15:19:17 +01:00
ad98dd7f01 Add .release-env and .vscode to .gitignore 2020-10-29 21:21:47 +01:00
0d04996f06 Add scope support 2020-10-29 20:55:22 +01:00
98dacbe849 sqlite: fix nil deref on missing response (#32) 2020-10-11 19:12:04 +02:00
fedb425381 Add project management 2020-10-11 17:09:39 +02:00
ca707d17ea Bump next from 9.5.3 to 9.5.4 in /admin (#36)
Bumps [next](https://github.com/vercel/next.js) from 9.5.3 to 9.5.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v9.5.3...v9.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-10-09 18:10:27 +02:00
Tom
13240109b6 Documenting CA certificates setup for hetty (#21)
* Documenting CA certificates setup for hetty

- Provides more accessible instructions on CA certificates
- New sections provided for "Certificate Setup and Installation"
- Configuring certificates for hetty using defaults and direct CLI
  arguments
- Trusting CA certificates on Windows, macOS, and Ubuntu
- Mention for additional Linux distros not covered by Ubuntu

Ref: #12

* Updated README to show instructions for generating CA cert/key with both Hetty and OpenSSL

* Update README.md

Co-authored-by: David Stotijn <dstotijn@gmail.com>

* Apply suggestions from code review

Co-authored-by: David Stotijn <dstotijn@gmail.com>

* Removed suggestion on possible HSTS error from README as it could be irrelevant or misleading

Co-authored-by: David Stotijn <dstotijn@gmail.com>
2020-10-08 19:04:57 +02:00
fa41e9c46c pkg/scope: add mutexes around scope (#33) 2020-10-08 17:49:36 +02:00
5f4bff0155 Selectively query DB based on GraphQL query field collection
Fixes #5
2020-10-05 18:40:37 +02:00
073bcea565 Add db & pem's to gitignore (#29) 2020-10-04 16:32:33 +02:00
6244d4aa74 Link to discussions via issue templates 2020-10-04 14:08:08 +02:00
cf687f0bd3 Update issue templates 2020-10-04 13:26:37 +02:00
6fad74c0a5 Add contributing guidelines 2020-10-04 13:21:43 +02:00
248001ec8a Create CODE_OF_CONDUCT.md 2020-10-04 12:33:53 +02:00
ba7d88dfc5 Replace Cayley with SQLite3 2020-10-04 11:50:03 +02:00
d48f1f058d Add scaffolding for scope package 2020-10-01 21:46:35 +02:00
46caa05d20 Add initial GitHub actions
Update .github/workflows/build_run.yml

Co-authored-by: David Stotijn <dstotijn@gmail.com>

Update .github/workflows/go.yml

Co-authored-by: David Stotijn <dstotijn@gmail.com>

Update .github/workflows/build_run.yml

Co-authored-by: David Stotijn <dstotijn@gmail.com>

Update .github/workflows/go.yml

Co-authored-by: David Stotijn <dstotijn@gmail.com>

Update .github/workflows/go.yml

Co-authored-by: David Stotijn <dstotijn@gmail.com>

Co-authored-by: David Stotijn <dstotijn@gmail.com>
2020-09-28 22:56:26 +02:00
c5bfb96454 Fix link to Hacker101 Discord 2020-09-28 22:01:19 +02:00
f97e0526d7 Fix usage snippet in README 2020-09-28 21:58:25 +02:00
ce4805452f Add support for ~ expansion for DB filepath 2020-09-28 21:53:51 +02:00
b17c70bc0a Update README, .gitignore (#10)
* update readme

* update gitignore (test binary, built with go test -c)

* update readme
2020-09-28 21:07:24 +02:00
8712151e8f Change default location of DB 2020-09-28 20:43:43 +02:00
05b08f7097 Remove stray file 2020-09-28 20:42:13 +02:00
5c7165ebf3 Change default port to :8080 2020-09-28 20:41:43 +02:00
8b04747855 Add support for CA key and certificate generation 2020-09-28 20:37:25 +02:00
81ae8f55da Create FUNDING.yml 2020-09-27 21:05:30 +02:00
5159c860d1 Update README.md 2020-09-27 20:45:00 +02:00
76b78d43e2 Update README.md 2020-09-27 20:43:53 +02:00
83f1439a6a Add README and LICENSE 2020-09-27 20:38:30 +02:00
145 changed files with 21997 additions and 6898 deletions

View File

@ -1,5 +1,8 @@
**/rice-box.go
/admin/.env /admin/.env
/admin/.next /admin/.next
/admin/dist /admin/dist
/admin/node_modules /admin/node_modules
/dist
/docs
/hetty
/cmd/hetty/admin

3
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,3 @@
# These are supported funding model platforms
patreon: dstotijn

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Ask a question
url: https://github.com/dstotijn/hetty/discussions
about: Ask questions and discuss with other community members

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

52
.github/workflows/build-test.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
name: Go ${{ matrix.go }} - Build
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- uses: actions/setup-node@v2
with:
node-version: "14"
- uses: actions/cache@v2
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- uses: actions/cache@v2
with:
path: ${{ github.workspace }}/admin/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: make build
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
name: Go ${{ matrix.go }} - Test
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- uses: actions/cache@v2
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./pkg/...

10
.gitignore vendored
View File

@ -1,4 +1,6 @@
**/rice-box.go /.vscode
dist /dist
hetty /hetty
hetty.bolt /cmd/hetty/admin
*.pem
*.test

48
.golangci.yml Normal file
View File

@ -0,0 +1,48 @@
linters:
presets:
- bugs
- comment
- error
- format
- import
- metalinter
- module
- performance
- style
- test
- unused
disable:
- exhaustive
- exhaustivestruct
- gochecknoglobals
- gochecknoinits
- godox
- goerr113
- gomnd
- interfacer
- maligned
- nlreturn
- scopelint
- testpackage
- wrapcheck
linters-settings:
gci:
local-prefixes: github.com/dstotijn/hetty
godot:
capital: true
issues:
exclude-rules:
- linters:
- gosec
# Ignore SHA1 usage.
text: "G(401|505):"
- linters:
- wsl
# Ignore cuddled defer statements.
text: "only one cuddle assignment allowed before defer statement"
- linters:
- nlreturn
# Ignore `break` without leading blank line.
text: "break with no blank line before"

View File

@ -1,29 +1,39 @@
before: before:
hooks: hooks:
- make clean - make clean
- go mod download - sh -c "NEXT_PUBLIC_VERSION={{ .Version}} make build-admin"
- go generate ./... - go mod tidy
builds: builds:
- main: ./cmd/hetty - env:
env:
- CGO_ENABLED=0 - CGO_ENABLED=0
main: ./cmd/hetty
ldflags:
- -s -w -X main.version={{.Version}}
goos: goos:
- linux - linux
- windows - windows
- darwin - darwin
hooks: goarch:
pre: make embed - amd64
- arm64
archives: archives:
- replacements: - replacements:
darwin: Darwin darwin: macOS
linux: Linux linux: Linux
windows: Windows windows: Windows
386: i386
amd64: x86_64 amd64: x86_64
format_overrides:
- goos: windows
format: zip
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
snapshot: snapshot:
name_template: "{{ .Tag }}-next" name_template: "{{ incpatch .Version }}-next"
changelog: changelog:
sort: asc sort: asc
filters: filters:

76
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at dstotijn@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

35
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,35 @@
# Contribution Guidelines
Thank you for taking an interest in Hetty! If you want to contribute to the
project, please read the guidelines below to ensure a smooth develop experience.
## Code of conduct
Please first read the [code of conduct](CODE_OF_CONDUCT.md), and abide to it
whenever you interact with the community.
## Issues
Use [issues](https://github.com/dstotijn/hetty/issues) for reporting bugs,
adding feature requests and giving context to PRs you submit. Please use [labels](https://github.com/dstotijn/hetty/labels)
in favor of category prefixes in issue titles. To keep the issue tracker
focused on development, use [discussions](https://github.com/dstotijn/hetty/discussions)
for usage questions and non-code related discourse.
Before submitting new feature requests, check out the Kanban board for the
status of on-going work. There might already be a card/issue.
## Pull requests
Before submitting a pull request that introduces a new feature or significantly
changes the behavior of Hetty, please consider first using [discussions](https://github.com/dstotijn/hetty/discussions)
or commenting on a relevant existing issue to share what you have in mind.
Because the project is in an early stage, this is especially important; there
are still a lot of major design decisions to be made. Until the foundation has
solidified, design and implementation leading up to the first milestone (v1.0)
is highly in flux, and your work might not align/be applicable for what the
maintainers have envisioned.
## Development
_Todo: Write steps for setting up local development environment._

View File

@ -1,14 +1,6 @@
ARG GO_VERSION=1.15 ARG GO_VERSION=1.17
ARG CGO_ENABLED=0 ARG NODE_VERSION=16.13
ARG NODE_VERSION=14.11 ARG ALPINE_VERSION=3.15
FROM golang:${GO_VERSION}-alpine AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
RUN go build -o hetty ./cmd/hetty
FROM node:${NODE_VERSION}-alpine AS node-builder FROM node:${NODE_VERSION}-alpine AS node-builder
WORKDIR /app WORKDIR /app
@ -18,11 +10,21 @@ COPY admin/ .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn run export RUN yarn run export
FROM alpine:3.12 FROM golang:${GO_VERSION}-alpine AS go-builder
ARG HETTY_VERSION=0.0.0
ENV CGO_ENABLED=0
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
COPY --from=node-builder /app/dist ./cmd/hetty/admin
RUN go build -ldflags="-s -w -X main.version=${HETTY_VERSION}" ./cmd/hetty
FROM alpine:${ALPINE_VERSION}
WORKDIR /app WORKDIR /app
COPY --from=go-builder /app/hetty . COPY --from=go-builder /app/hetty .
COPY --from=node-builder /app/dist admin
ENTRYPOINT ["./hetty", "-adminPath=./admin"] ENTRYPOINT ["./hetty"]
EXPOSE 80 EXPOSE 8080

21
LICENSE Normal file
View File

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

View File

@ -1,20 +1,21 @@
setup: export CGO_ENABLED = 0
go mod download export NEXT_TELEMETRY_DISABLED = 1
go generate ./...
.PHONY: setup
embed:
go install github.com/GeertJohan/go.rice/rice
cd cmd/hetty && rice embed-go
.PHONY: embed
build: embed
go build ./cmd/hetty
.PHONY: build
clean:
rm -rf cmd/hetty/rice-box.go
.PHONY: clean .PHONY: clean
clean:
rm -f hetty
rm -rf ./cmd/hetty/admin
rm -rf ./admin/node_modules
rm -rf ./admin/dist
rm -rf ./admin/.next
release: .PHONY: build-admin
goreleaser -p 1 build-admin:
cd admin && \
yarn install --frozen-lockfile && \
yarn run export && \
mv dist ../cmd/hetty/admin
.PHONY: build
build: build-admin
go build ./cmd/hetty

243
README.md Normal file
View File

@ -0,0 +1,243 @@
<h1>
<a href="https://github.com/dstotijn/hetty">
<img src="https://hetty.xyz/assets/logo.png" width="293">
</a>
</h1>
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https://actions-badge.atrox.dev/dstotijn/hetty/badge&style=flat-square&label=build+%26+test&logo=none&color=18BA91)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=18BA91&style=flat-square)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-18BA91?style=flat-square)](https://hetty.xyz/)
**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">
## Features
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
- Project based database storage (SQLite)
- Scope support
- Headless management API using GraphQL
- Embedded web interface (Next.js)
Hetty is in early development. Additional features are planned
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a>
for details.
## Documentation
📖 [Read the docs.](https://hetty.xyz/)
## Installation
Hetty compiles to a self-contained binary, with an embedded BadgerDB database
and web based 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 certificates and projects database, mount a volume:
```
$ mkdir -p $HOME/.hetty
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
```
## Usage
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
```
An overview of 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")
```
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.
Detailed documentation is under development and will be available soon.
## Certificate Setup and Installation
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
Hetty will need to be set up. Furthermore, the CA certificate may need to be
installed to the host for them to be trusted by your browser. The following steps
will cover how you can generate your certificate, provide them to hetty, and how
you can install them in your local CA store.
⚠️ _This process was done on a Linux machine but should_
_provide guidance on Windows and macOS as well._
### Generating CA certificates
You can generate a CA keypair two different ways. The first is bundled directly
with Hetty, and simplifies the process immensely. The alternative is using OpenSSL
to generate them, which provides more control over expiration time and cryptography
used, but requires you install the OpenSSL tooling. The first is suggested for any
beginners trying to get started.
#### Generating CA certificates with hetty
Hetty will generate the default key and certificate on its own if none are supplied
or found in `~/.hetty/` when first running the CLI. To generate a default key and
certificate with hetty, simply run the command with no arguments
```sh
hetty
```
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
You can start off by generating a new key and CA certificate which will both expire
after a month.
```sh
mkdir ~/.hetty
openssl req -newkey rsa:2048 -new -nodes -x509 -days 31 -keyout ~/.hetty/hetty_key.pem -out ~/.hetty/hetty_cert.pem
```
The default location which `hetty` will check for the key and CA certificate is under
`~/.hetty/`, at `hetty_key.pem` and `hetty_cert.pem` respectively. You can move them
here and `hetty` will detect them automatically. Otherwise, you can specify the
location of these as arguments to `hetty`.
```
hetty -key key.pem -cert cert.pem
```
### 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:
```sh
sudo cp ~/.hetty/hetty_cert.pem /usr/local/share/ca-certificates/hetty.crt
sudo update-ca-certificates
```
On Windows, you would add your certificate by using the Certificate Manager. You
can launch that by running the command:
```batch
certmgr.msc
```
On macOS, you can add your certificate by using the Keychain Access program. This
can be found under `Application/Utilities/Keychain Access.app`. After opening this,
drag the certificate into the app. Next, open the certificate in the app, enter the
_Trust_ section, and under _When using this certificate_ select _Always Trust_.
_Note: Various Linux distributions may require other steps or commands for updating_
_their certificate authority. See the documentation relevant to your distribution for_
_more information on how to update the system to trust your self-signed certificate._
## Vision and roadmap
- Fast core/engine, built with Go, with a minimal memory footprint.
- Easy to use admin interface, built with Next.js and Material UI.
- Headless management, via GraphQL API.
- Extensibility is top of mind. All modules are written as Go packages, to
be used by Hetty, but also as libraries by other software.
- Pluggable architecture for MITM proxy, projects, scope. It should be possible.
to build a plugin system in the (near) future.
- Based on feedback and real-world usage of pentesters and bug bounty hunters.
- Aim for a relatively small core feature set that the majority of security researchers need.
## Support
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions)
for questions and troubleshooting.
## Community
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP).
## Contributing
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/).
## 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>
## License
[MIT License](LICENSE)
---
© 2021 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)

6
admin/.eslintrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-css-tags": "off"
}
}

4
admin/.prettierignore Normal file
View File

@ -0,0 +1,4 @@
/.next/
/out/
/build
/coverage

3
admin/.prettierrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"printWidth": 120
}

5
admin/next-env.d.ts vendored
View File

@ -1,2 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,7 +1,10 @@
const withCSS = require("@zeit/next-css"); // @ts-check
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
module.exports = withCSS({ /**
* @type {import('next').NextConfig}
**/
const nextConfig = {
reactStrictMode: true,
trailingSlash: true, trailingSlash: true,
async rewrites() { async rewrites() {
return [ return [
@ -11,24 +14,6 @@ module.exports = withCSS({
}, },
]; ];
}, },
webpack: (config) => { };
config.module.rules.push({
test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/,
use: {
loader: "url-loader",
options: {
limit: 100000,
},
},
});
config.plugins.push( module.exports = nextConfig;
new MonacoWebpackPlugin({
languages: ["html", "json", "javascript"],
filename: "static/[name].worker.js",
})
);
return config;
},
});

View File

@ -6,31 +6,38 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint",
"export": "next build && next export -o dist" "export": "next build && next export -o dist"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.2.0", "@apollo/client": "^3.2.0",
"@material-ui/core": "^4.11.0", "@emotion/react": "^11.7.1",
"@material-ui/icons": "^4.9.1", "@emotion/server": "^11.4.0",
"@material-ui/lab": "^4.0.0-alpha.56", "@emotion/styled": "^11.6.0",
"@zeit/next-css": "^1.0.1", "@monaco-editor/react": "^4.3.1",
"graphql": "^15.3.0", "@mui/icons-material": "^5.3.1",
"monaco-editor": "^0.20.0", "@mui/lab": "^5.0.0-alpha.66",
"monaco-editor-webpack-plugin": "^1.9.0", "@mui/material": "^5.3.1",
"next": "^9.5.3", "deepmerge": "^4.2.2",
"graphql": "^16.2.0",
"lodash": "^4.17.21",
"monaco-editor": "^0.31.1",
"next": "^12.0.8",
"next-fonts": "^1.0.3", "next-fonts": "^1.0.3",
"react": "^16.13.1", "react": "^17.0.2",
"react-dom": "^16.13.1", "react-dom": "^17.0.2",
"react-monaco-editor": "^0.34.0",
"react-syntax-highlighter": "^13.5.3",
"typescript": "^4.0.3" "typescript": "^4.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^14.11.1", "@babel/core": "^7.0.0",
"@types/react": "^16.9.49", "@types/lodash": "^4.14.178",
"eslint": "^7.9.0", "@types/node": "^17.0.12",
"eslint-config-prettier": "^6.11.0", "@types/react": "^17.0.38",
"eslint-plugin-prettier": "^3.1.4", "eslint": "^8.7.0",
"prettier": "^2.1.2" "eslint-config-next": "12.0.8",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.1.2",
"webpack": "^5.67.0"
} }
} }

View File

@ -1,10 +1,6 @@
import { Paper } from "@material-ui/core"; import { Paper } from "@mui/material";
function CenteredPaper({ function CenteredPaper({ children }: { children: React.ReactNode }): JSX.Element {
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return ( return (
<div> <div>
<Paper <Paper

View File

@ -1,118 +1,129 @@
import React from "react"; import React from "react";
import { import {
makeStyles,
Theme, Theme,
createStyles,
useTheme, useTheme,
AppBar,
Toolbar, Toolbar,
IconButton, IconButton,
Typography, Typography,
Drawer,
Divider, Divider,
List, List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip, Tooltip,
} from "@material-ui/core"; styled,
CSSObject,
Box,
ListItemText,
} from "@mui/material";
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
import MuiDrawer from "@mui/material/Drawer";
import MuiListItemButton, { ListItemButtonProps } from "@mui/material/ListItemButton";
import MuiListItemIcon, { ListItemIconProps } from "@mui/material/ListItemIcon";
import Link from "next/link"; import Link from "next/link";
import MenuIcon from "@material-ui/icons/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import HomeIcon from "@material-ui/icons/Home"; import HomeIcon from "@mui/icons-material/Home";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet"; import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send"; import SendIcon from "@mui/icons-material/Send";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; import FolderIcon from "@mui/icons-material/Folder";
import ChevronRightIcon from "@material-ui/icons/ChevronRight"; import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import clsx from "clsx"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
export enum Page { export enum Page {
Home, Home,
GetStarted,
Projects,
ProxySetup, ProxySetup,
ProxyLogs, ProxyLogs,
Sender, Sender,
Scope,
} }
const drawerWidth = 240; const drawerWidth = 240;
const useStyles = makeStyles((theme: Theme) => const openedMixin = (theme: Theme): CSSObject => ({
createStyles({ width: drawerWidth,
root: { transition: theme.transitions.create("width", {
display: "flex", easing: theme.transitions.easing.sharp,
width: "100%", duration: theme.transitions.duration.enteringScreen,
}, }),
appBar: { overflowX: "hidden",
zIndex: theme.zIndex.drawer + 1, });
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp, const closedMixin = (theme: Theme): CSSObject => ({
duration: theme.transitions.duration.leavingScreen, transition: theme.transitions.create("width", {
}), easing: theme.transitions.easing.sharp,
}, duration: theme.transitions.duration.leavingScreen,
appBarShift: { }),
marginLeft: drawerWidth, overflowX: "hidden",
width: `calc(100% - ${drawerWidth}px)`, width: 56,
transition: theme.transitions.create(["width", "margin"], { });
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen, const DrawerHeader = styled("div")(({ theme }) => ({
}), display: "flex",
}, alignItems: "center",
menuButton: { justifyContent: "flex-start",
marginRight: 28, padding: theme.spacing(0, 1),
}, // necessary for content to be below app bar
hide: { ...theme.mixins.toolbar,
display: "none", }));
},
drawer: { interface AppBarProps extends MuiAppBarProps {
width: drawerWidth, open?: boolean;
flexShrink: 0, }
whiteSpace: "nowrap",
}, const AppBar = styled(MuiAppBar, {
drawerOpen: { shouldForwardProp: (prop) => prop !== "open",
width: drawerWidth, })<AppBarProps>(({ theme, open }) => ({
transition: theme.transitions.create("width", { backgroundColor: theme.palette.secondary.dark,
easing: theme.transitions.easing.sharp, zIndex: theme.zIndex.drawer + 1,
duration: theme.transitions.duration.enteringScreen, transition: theme.transitions.create(["width", "margin"], {
}), easing: theme.transitions.easing.sharp,
}, duration: theme.transitions.duration.leavingScreen,
drawerClose: { }),
transition: theme.transitions.create("width", { ...(open && {
easing: theme.transitions.easing.sharp, marginLeft: drawerWidth,
duration: theme.transitions.duration.leavingScreen, width: `calc(100% - ${drawerWidth}px)`,
}), transition: theme.transitions.create(["width", "margin"], {
overflowX: "hidden", easing: theme.transitions.easing.sharp,
width: theme.spacing(7) + 1, duration: theme.transitions.duration.enteringScreen,
[theme.breakpoints.up("sm")]: { }),
width: theme.spacing(7) + 8, }),
}));
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== "open" })(({ theme, open }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: "nowrap",
boxSizing: "border-box",
...(open && {
...openedMixin(theme),
"& .MuiDrawer-paper": openedMixin(theme),
}),
...(!open && {
...closedMixin(theme),
"& .MuiDrawer-paper": closedMixin(theme),
}),
}));
const ListItemButton = styled(MuiListItemButton)<ListItemButtonProps>(({ theme }) => ({
[theme.breakpoints.up("sm")]: {
px: 1,
},
"&.MuiListItemButton-root": {
"&.Mui-selected": {
backgroundColor: theme.palette.primary.main,
"& .MuiListItemIcon-root": {
color: theme.palette.secondary.dark,
},
"& .MuiListItemText-root": {
color: theme.palette.secondary.dark,
}, },
}, },
toolbar: { },
display: "flex", }));
alignItems: "center",
justifyContent: "flex-end", const ListItemIcon = styled(MuiListItemIcon)<ListItemIconProps>(() => ({
padding: theme.spacing(0, 1), minWidth: 42,
// necessary for content to be below app bar }));
...theme.mixins.toolbar,
},
content: {
flexGrow: 1,
padding: theme.spacing(3),
},
listItem: {
paddingLeft: 16,
paddingRight: 16,
[theme.breakpoints.up("sm")]: {
paddingLeft: 20,
paddingRight: 20,
},
},
listItemIcon: {
minWidth: 42,
},
titleHighlight: {
color: theme.palette.secondary.main,
marginRight: 4,
},
})
);
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
@ -121,7 +132,6 @@ interface Props {
} }
export function Layout({ title, page, children }: Props): JSX.Element { export function Layout({ title, page, children }: Props): JSX.Element {
const classes = useStyles();
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@ -133,113 +143,109 @@ export function Layout({ title, page, children }: Props): JSX.Element {
setOpen(false); setOpen(false);
}; };
const SiteTitle = styled("span")({
...(title !== "" && {
color: theme.palette.primary.main,
marginRight: 4,
}),
});
return ( return (
<div className={classes.root}> <Box sx={{ display: "flex" }}>
<AppBar <AppBar position="fixed" open={open}>
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Toolbar> <Toolbar>
<IconButton <IconButton
color="inherit" color="inherit"
aria-label="open drawer" aria-label="Open drawer"
onClick={handleDrawerOpen} onClick={handleDrawerOpen}
edge="start" edge="start"
className={clsx(classes.menuButton, { sx={{
[classes.hide]: open, mr: 5,
})} ...(open && { display: "none" }),
}}
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Typography variant="h5" noWrap> <Box
<span className={title !== "" && classes.titleHighlight}> sx={{
Hetty:// display: "flex",
</span> justifyContent: "space-around",
{title} width: "100%",
</Typography> }}
>
<Typography variant="h5" noWrap sx={{ width: "100%" }}>
<SiteTitle>Hetty://</SiteTitle>
{title}
</Typography>
<Box sx={{ flexShrink: 0, pt: 0.75 }}>v{process.env.NEXT_PUBLIC_VERSION || "0.0"}</Box>
</Box>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer <Drawer variant="permanent" open={open}>
variant="permanent" <DrawerHeader>
className={clsx(classes.drawer, {
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
})}
classes={{
paper: clsx({
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
}),
}}
>
<div className={classes.toolbar}>
<IconButton onClick={handleDrawerClose}> <IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? ( {theme.direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton> </IconButton>
</div> </DrawerHeader>
<Divider /> <Divider />
<List> <List sx={{ p: 0 }}>
<Link href="/" passHref> <Link href="/" passHref>
<ListItem <ListItemButton key="home" selected={page === Page.Home}>
button
component="a"
key="home"
selected={page === Page.Home}
className={classes.listItem}
>
<Tooltip title="Home"> <Tooltip title="Home">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<HomeIcon /> <HomeIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Home" /> <ListItemText primary="Home" />
</ListItem> </ListItemButton>
</Link> </Link>
<Link href="/proxy/logs" passHref> <Link href="/proxy/logs" passHref>
<ListItem <ListItemButton key="proxyLogs" selected={page === Page.ProxyLogs}>
button
component="a"
key="proxyLogs"
selected={page === Page.ProxyLogs}
className={classes.listItem}
>
<Tooltip title="Proxy"> <Tooltip title="Proxy">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<SettingsEthernetIcon /> <SettingsEthernetIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Proxy" /> <ListItemText primary="Proxy" />
</ListItem> </ListItemButton>
</Link> </Link>
<Link href="/sender" passHref> <Link href="/sender" passHref>
<ListItem <ListItemButton key="sender" selected={page === Page.Sender}>
button
component="a"
key="sender"
selected={page === Page.Sender}
className={classes.listItem}
>
<Tooltip title="Sender"> <Tooltip title="Sender">
<ListItemIcon className={classes.listItemIcon}> <ListItemIcon>
<SendIcon /> <SendIcon />
</ListItemIcon> </ListItemIcon>
</Tooltip> </Tooltip>
<ListItemText primary="Sender" /> <ListItemText primary="Sender" />
</ListItem> </ListItemButton>
</Link>
<Link href="/scope" passHref>
<ListItemButton key="scope" selected={page === Page.Scope}>
<Tooltip title="Scope">
<ListItemIcon>
<LocationSearchingIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Scope" />
</ListItemButton>
</Link>
<Link href="/projects" passHref>
<ListItemButton key="projects" selected={page === Page.Projects}>
<Tooltip title="Projects">
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Projects" />
</ListItemButton>
</Link> </Link>
</List> </List>
</Drawer> </Drawer>
<main className={classes.content}> <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<div className={classes.toolbar} /> <DrawerHeader />
{children} {children}
</main> </Box>
</div> </Box>
); );
} }

View File

@ -0,0 +1,117 @@
import { gql, useMutation } from "@apollo/client";
import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import React, { useState } from "react";
const CREATE_PROJECT = gql`
mutation CreateProject($name: String!) {
createProject(name: $name) {
id
name
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($id: ID!) {
openProject(id: $id) {
id
name
isActive
}
}
`;
function NewProject(): JSX.Element {
const [name, setName] = useState("");
const [createProject, { error: createProjErr, loading: createProjLoading }] = useMutation(CREATE_PROJECT, {
onError: () => {},
onCompleted(data) {
setName("");
openProject({ variables: { id: data.createProject.id } });
},
});
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation(OPEN_PROJECT, {
onError: () => {},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.id,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
id
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.id,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
id
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleCreateAndOpenProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
createProject({ variables: { name } });
};
return (
<div>
<Box mb={3}>
<Typography variant="h6">New project</Typography>
</Box>
<form onSubmit={handleCreateAndOpenProjectForm} autoComplete="off">
<TextField
sx={{
mr: 2,
}}
color="primary"
size="small"
label="Project name"
placeholder="Project name…"
onChange={(e) => setName(e.target.value)}
error={Boolean(createProjErr || openProjErr)}
helperText={(createProjErr && createProjErr.message) || (openProjErr && openProjErr.message)}
/>
<Button
type="submit"
variant="contained"
color="primary"
size="large"
sx={{
pt: 0.9,
pb: 0.7,
}}
disabled={createProjLoading || openProjLoading}
startIcon={createProjLoading || openProjLoading ? <CircularProgress size={22} /> : <AddIcon />}
>
Create & open project
</Button>
</form>
</div>
);
}
export default NewProject;

View File

@ -0,0 +1,292 @@
import { gql, useMutation, useQuery } from "@apollo/client";
import {
Avatar,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
Paper,
Snackbar,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import DescriptionIcon from "@mui/icons-material/Description";
import DeleteIcon from "@mui/icons-material/Delete";
import LaunchIcon from "@mui/icons-material/Launch";
import { Alert } from "@mui/lab";
import React, { useState } from "react";
import { Project } from "../../lib/Project";
const PROJECTS = gql`
query Projects {
projects {
id
name
isActive
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($id: ID!) {
openProject(id: $id) {
id
name
isActive
}
}
`;
const CLOSE_PROJECT = gql`
mutation CloseProject {
closeProject {
success
}
}
`;
const DELETE_PROJECT = gql`
mutation DeleteProject($id: ID!) {
deleteProject(id: $id) {
success
}
}
`;
function ProjectList(): JSX.Element {
const theme = useTheme();
const { loading: projLoading, error: projErr, data: projData } = useQuery<{ projects: Project[] }>(PROJECTS);
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation<{ openProject: Project }>(
OPEN_PROJECT,
{
errorPolicy: "all",
onError: () => {},
update(cache, { data }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: data?.openProject,
fragment: gql`
fragment ActiveProject on Project {
id
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: data?.openProject.id,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
id
name
isActive
type
}
`,
});
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
}
);
const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
activeProject() {
return null;
},
projects(_, { DELETE }) {
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
});
const [deleteProject, { loading: deleteProjLoading, error: deleteProjErr }] = useMutation(DELETE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
projects(_, { DELETE }) {
return DELETE;
},
},
});
setDeleteDiagOpen(false);
setDeleteNotifOpen(true);
},
});
const [deleteProj, setDeleteProj] = useState<Project>();
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (project: any) => {
setDeleteProj(project);
setDeleteDiagOpen(true);
};
const handleDeleteConfirm = () => {
deleteProject({ variables: { id: deleteProj?.id } });
};
const handleDeleteCancel = () => {
setDeleteDiagOpen(false);
};
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
const handleCloseDeleteNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setDeleteNotifOpen(false);
};
return (
<div>
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
<DialogTitle>
Delete project <strong>{deleteProj?.name}</strong>?
</DialogTitle>
<DialogContent>
<DialogContentText>
Deleting a project permanently removes all its data from the database. This action is irreversible.
</DialogContentText>
{deleteProjErr && <Alert severity="error">Error closing project: {deleteProjErr.message}</Alert>}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} autoFocus color="secondary" variant="contained">
Cancel
</Button>
<Button
sx={{
color: "white",
backgroundColor: "error.main",
"&:hover": {
backgroundColor: "error.dark",
},
}}
onClick={handleDeleteConfirm}
disabled={deleteProjLoading}
variant="contained"
>
Delete
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={deleteNotifOpen}
autoHideDuration={3000}
onClose={handleCloseDeleteNotif}
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
>
<Alert onClose={handleCloseDeleteNotif} severity="info">
Project <strong>{deleteProj?.name}</strong> was deleted.
</Alert>
</Snackbar>
<Box mb={3}>
<Typography variant="h6">Manage projects</Typography>
</Box>
<Box mb={4}>
{projLoading && <CircularProgress />}
{projErr && <Alert severity="error">Error fetching projects: {projErr.message}</Alert>}
{openProjErr && <Alert severity="error">Error opening project: {openProjErr.message}</Alert>}
{closeProjErr && <Alert severity="error">Error closing project: {closeProjErr.message}</Alert>}
</Box>
{projData && projData.projects.length > 0 && (
<Paper>
<List>
{projData.projects.map((project) => (
<ListItem key={project.id}>
<ListItemAvatar>
<Avatar
sx={{
...(project.isActive && {
color: theme.palette.secondary.dark,
backgroundColor: theme.palette.primary.main,
}),
}}
>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText>
{project.name} {project.isActive && <em>(Active)</em>}
</ListItemText>
<ListItemSecondaryAction>
{project.isActive && (
<Tooltip title="Close project">
<IconButton onClick={() => closeProject()}>
<CloseIcon />
</IconButton>
</Tooltip>
)}
{!project.isActive && (
<Tooltip title="Open project">
<span>
<IconButton
disabled={openProjLoading || projLoading}
onClick={() =>
openProject({
variables: { id: project.id },
})
}
>
<LaunchIcon />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Delete project">
<span>
<IconButton onClick={() => handleDeleteButtonClick(project)} disabled={project.isActive}>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</Paper>
)}
{projData?.projects.length === 0 && (
<Alert severity="info">There are no projects. Create one to get started.</Alert>
)}
</div>
);
}
export default ProjectList;

View File

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

View File

@ -1,7 +1,7 @@
import dynamic from "next/dynamic"; import MonacoEditor from "@monaco-editor/react";
const MonacoEditor = dynamic(import("react-monaco-editor"), { ssr: false }); import monaco from "monaco-editor/esm/vs/editor/editor.api";
const monacoOptions = { const monacoOptions: monaco.editor.IEditorOptions = {
readOnly: true, readOnly: true,
wordWrap: "on", wordWrap: "on",
minimap: { minimap: {
@ -11,20 +11,7 @@ const monacoOptions = {
type language = "html" | "typescript" | "json"; type language = "html" | "typescript" | "json";
function editorDidMount() { function languageForContentType(contentType?: string): language | undefined {
return ((window as any).MonacoEnvironment.getWorkerUrl = (
moduleId,
label
) => {
if (label === "json") return "/_next/static/json.worker.js";
if (label === "html") return "/_next/static/html.worker.js";
if (label === "javascript") return "/_next/static/ts.worker.js";
return "/_next/static/editor.worker.js";
});
}
function languageForContentType(contentType: string): language {
switch (contentType) { switch (contentType) {
case "text/html": case "text/html":
return "html"; return "html";
@ -41,7 +28,7 @@ function languageForContentType(contentType: string): language {
interface Props { interface Props {
content: string; content: string;
contentType: string; contentType?: string;
} }
function Editor({ content, contentType }: Props): JSX.Element { function Editor({ content, contentType }: Props): JSX.Element {
@ -50,8 +37,7 @@ function Editor({ content, contentType }: Props): JSX.Element {
height={"600px"} height={"600px"}
language={languageForContentType(contentType)} language={languageForContentType(contentType)}
theme="vs-dark" theme="vs-dark"
editorDidMount={editorDidMount} options={monacoOptions}
options={monacoOptions as any}
value={content} value={content}
/> />
); );

View File

@ -1,83 +1,66 @@
import { import { Table, TableBody, TableCell, TableContainer, TableRow, Snackbar } from "@mui/material";
makeStyles, import { Alert } from "@mui/lab";
Theme,
createStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Snackbar,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import React, { useState } from "react"; import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) => { const baseCellStyle = {
const paddingX = 0; px: 0,
const paddingY = theme.spacing(1) / 3; py: 0.33,
const tableCell = { verticalAlign: "top",
paddingLeft: paddingX, border: "none",
paddingRight: paddingX, whiteSpace: "nowrap" as any,
paddingTop: paddingY, overflow: "hidden",
paddingBottom: paddingY, textOverflow: "ellipsis",
verticalAlign: "top", "&:hover": {
border: "none", color: "primary.main",
whiteSpace: "nowrap" as any, whiteSpace: "inherit" as any,
overflow: "hidden", overflow: "inherit",
textOverflow: "ellipsis", textOverflow: "inherit",
"&:hover": { cursor: "copy",
color: theme.palette.secondary.main, },
whiteSpace: "inherit" as any, };
overflow: "inherit",
textOverflow: "inherit", const keyCellStyle = {
cursor: "copy", ...baseCellStyle,
}, pr: 1,
}; width: "40%",
return createStyles({ fontWeight: "bold",
root: {}, fontSize: ".75rem",
table: { };
tableLayout: "fixed",
width: "100%", const valueCellStyle = {
}, ...baseCellStyle,
keyCell: { width: "60%",
...tableCell, border: "none",
paddingRight: theme.spacing(1), fontSize: ".75rem",
width: "40%", };
fontWeight: "bold",
fontSize: ".75rem",
},
valueCell: {
...tableCell,
width: "60%",
border: "none",
fontSize: ".75rem",
},
});
});
interface Props { interface Props {
headers: Array<{ key: string; value: string }>; headers: Array<{ key: string; value: string }>;
} }
function HttpHeadersTable({ headers }: Props): JSX.Element { function HttpHeadersTable({ headers }: Props): JSX.Element {
const classes = useStyles();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
const windowSel = window.getSelection();
if (!windowSel || !document) {
return;
}
const r = document.createRange(); const r = document.createRange();
r.selectNode(e.currentTarget); r.selectNode(e.currentTarget);
window.getSelection().removeAllRanges(); windowSel.removeAllRanges();
window.getSelection().addRange(r); windowSel.addRange(r);
document.execCommand("copy"); document.execCommand("copy");
window.getSelection().removeAllRanges(); windowSel.removeAllRanges();
setOpen(true); setOpen(true);
}; };
const handleClose = (event?: React.SyntheticEvent, reason?: string) => { const handleClose = (event: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") { if (reason === "clickaway") {
return; return;
} }
@ -92,20 +75,21 @@ function HttpHeadersTable({ headers }: Props): JSX.Element {
Copied to clipboard. Copied to clipboard.
</Alert> </Alert>
</Snackbar> </Snackbar>
<TableContainer className={classes.root}> <TableContainer>
<Table className={classes.table} size="small"> <Table
sx={{
tableLayout: "fixed",
width: "100%",
}}
size="small"
>
<TableBody> <TableBody>
{headers.map(({ key, value }, index) => ( {headers.map(({ key, value }, index) => (
<TableRow key={index}> <TableRow key={index}>
<TableCell <TableCell component="th" scope="row" sx={keyCellStyle} onClick={handleClick}>
component="th"
scope="row"
className={classes.keyCell}
onClick={handleClick}
>
<code>{key}:</code> <code>{key}:</code>
</TableCell> </TableCell>
<TableCell className={classes.valueCell} onClick={handleClick}> <TableCell sx={valueCellStyle} onClick={handleClick}>
<code>{value}</code> <code>{value}</code>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@ -1,31 +1,25 @@
import { Theme, withTheme } from "@material-ui/core"; import { SvgIconTypeMap } from "@mui/material";
import { orange, red } from "@material-ui/core/colors"; import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import FiberManualRecordIcon from "@material-ui/icons/FiberManualRecord";
interface Props { interface Props {
status: number; status: number;
theme: Theme;
} }
function HttpStatusIcon({ status, theme }: Props): JSX.Element { export default function HttpStatusIcon({ status }: Props): JSX.Element {
const style = { marginTop: "-.25rem", verticalAlign: "middle" }; let color: SvgIconTypeMap["props"]["color"] = "inherit";
switch (Math.floor(status / 100)) { switch (Math.floor(status / 100)) {
case 2: case 2:
case 3: case 3:
return ( color = "primary";
<FiberManualRecordIcon break;
style={{ ...style, color: theme.palette.secondary.main }}
/>
);
case 4: case 4:
return ( color = "warning";
<FiberManualRecordIcon style={{ ...style, color: orange["A400"] }} /> break;
);
case 5: case 5:
return <FiberManualRecordIcon style={{ ...style, color: red["A400"] }} />; color = "error";
default: break;
return <FiberManualRecordIcon style={style} />;
} }
}
export default withTheme(HttpStatusIcon); return <FiberManualRecordIcon sx={{ marginTop: "-.25rem", verticalAlign: "middle" }} color={color} />;
}

View File

@ -1,9 +1,9 @@
import { gql, useQuery } from "@apollo/client"; import { gql, useQuery } from "@apollo/client";
import { Box, Grid, Paper, CircularProgress } from "@material-ui/core"; import { Box, Grid, Paper, CircularProgress } from "@mui/material";
import ResponseDetail from "./ResponseDetail"; import ResponseDetail from "./ResponseDetail";
import RequestDetail from "./RequestDetail"; import RequestDetail from "./RequestDetail";
import Alert from "@material-ui/lab/Alert"; import Alert from "@mui/lab/Alert";
const HTTP_REQUEST_LOG = gql` const HTTP_REQUEST_LOG = gql`
query HttpRequestLog($id: ID!) { query HttpRequestLog($id: ID!) {
@ -23,8 +23,8 @@ const HTTP_REQUEST_LOG = gql`
key key
value value
} }
status
statusCode statusCode
statusReason
body body
} }
} }
@ -44,11 +44,7 @@ function LogDetail({ requestId: id }: Props): JSX.Element {
return <CircularProgress />; return <CircularProgress />;
} }
if (error) { if (error) {
return ( return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
<Alert severity="error">
Error fetching logs details: {error.message}
</Alert>
);
} }
if (!data.httpRequestLog) { if (!data.httpRequestLog) {

View File

@ -1,36 +1,17 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { gql, useQuery } from "@apollo/client"; import Link from "next/link";
import { useState } from "react"; import { Box, CircularProgress, Link as MaterialLink, Typography } from "@mui/material";
import { Box, Typography, CircularProgress } from "@material-ui/core"; import Alert from "@mui/lab/Alert";
import Alert from "@material-ui/lab/Alert";
import RequestList from "./RequestList"; import RequestList from "./RequestList";
import LogDetail from "./LogDetail"; import LogDetail from "./LogDetail";
import CenteredPaper from "../CenteredPaper"; import CenteredPaper from "../CenteredPaper";
import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
const HTTP_REQUEST_LOGS = gql`
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
status
statusCode
}
}
}
`;
function LogsOverview(): JSX.Element { function LogsOverview(): JSX.Element {
const router = useRouter(); const router = useRouter();
const detailReqLogId = router.query.id as string; const detailReqLogId = router.query.id as string | undefined;
console.log(detailReqLogId); const { loading, error, data } = useHttpRequestLogs();
const { loading, error, data } = useQuery(HTTP_REQUEST_LOGS, {
pollInterval: 1000,
});
const handleLogClick = (reqId: string) => { const handleLogClick = (reqId: string) => {
router.push("/proxy/logs?id=" + reqId, undefined, { router.push("/proxy/logs?id=" + reqId, undefined, {
@ -42,6 +23,17 @@ function LogsOverview(): JSX.Element {
return <CircularProgress />; return <CircularProgress />;
} }
if (error) { if (error) {
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
return (
<Alert severity="info">
There is no project active.{" "}
<Link href="/projects" passHref>
<MaterialLink color="primary">Create or open</MaterialLink>
</Link>{" "}
one first.
</Alert>
);
}
return <Alert severity="error">Error fetching logs: {error.message}</Alert>; return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
} }
@ -50,11 +42,7 @@ function LogsOverview(): JSX.Element {
return ( return (
<div> <div>
<Box mb={2}> <Box mb={2}>
<RequestList <RequestList logs={logs || []} selectedReqLogId={detailReqLogId} onLogClick={handleLogClick} />
logs={logs}
selectedReqLogId={detailReqLogId}
onLogClick={handleLogClick}
/>
</Box> </Box>
<Box> <Box>
{detailReqLogId && <LogDetail requestId={detailReqLogId} />} {detailReqLogId && <LogDetail requestId={detailReqLogId} />}

View File

@ -1,42 +1,9 @@
import React from "react"; import React from "react";
import { import { Typography, Box, Divider } from "@mui/material";
Typography,
Box,
createStyles,
makeStyles,
Theme,
Divider,
} from "@material-ui/core";
import HttpHeadersTable from "./HttpHeadersTable"; import HttpHeadersTable from "./HttpHeadersTable";
import Editor from "./Editor"; import Editor from "./Editor";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
requestTitle: {
width: "calc(100% - 80px)",
fontSize: "1rem",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
headersTable: {
tableLayout: "fixed",
width: "100%",
},
headerKeyCell: {
verticalAlign: "top",
width: "30%",
fontWeight: "bold",
},
headerValueCell: {
width: "70%",
verticalAlign: "top",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
})
);
interface Props { interface Props {
request: { request: {
method: string; method: string;
@ -49,29 +16,27 @@ interface Props {
function RequestDetail({ request }: Props): JSX.Element { function RequestDetail({ request }: Props): JSX.Element {
const { method, url, proto, headers, body } = request; const { method, url, proto, headers, body } = request;
const classes = useStyles();
const contentType = headers.find((header) => header.key === "Content-Type") const contentType = headers.find((header) => header.key === "Content-Type")?.value;
?.value;
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
return ( return (
<div> <div>
<Box p={2}> <Box p={2}>
<Typography <Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
Request Request
</Typography> </Typography>
<Typography className={classes.requestTitle} variant="h6"> <Typography
sx={{
width: "calc(100% - 80px)",
fontSize: "1rem",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
}}
variant="h6"
>
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "} {method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
<Typography <Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{proto} {proto}
</Typography> </Typography>
</Typography> </Typography>

View File

@ -8,48 +8,23 @@ import {
TableBody, TableBody,
Typography, Typography,
Box, Box,
createStyles, useTheme,
makeStyles, } from "@mui/material";
Theme,
withTheme,
} from "@material-ui/core";
import HttpStatusIcon from "./HttpStatusCode"; import HttpStatusIcon from "./HttpStatusCode";
import CenteredPaper from "../CenteredPaper"; import CenteredPaper from "../CenteredPaper";
import { RequestLog } from "../../lib/requestLogs";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
row: {
"&:hover": {
cursor: "pointer",
},
},
/* Pseudo-class applied to the root element if `hover={true}`. */
hover: {},
})
);
interface Props { interface Props {
logs: Array<any>; logs: RequestLog[];
selectedReqLogId?: string; selectedReqLogId?: string;
onLogClick(requestId: string): void; onLogClick(requestId: string): void;
theme: Theme;
} }
function RequestList({ export default function RequestList({ logs, onLogClick, selectedReqLogId }: Props): JSX.Element {
logs,
onLogClick,
selectedReqLogId,
theme,
}: Props): JSX.Element {
return ( return (
<div> <div>
<RequestListTable <RequestListTable onLogClick={onLogClick} logs={logs} selectedReqLogId={selectedReqLogId} />
onLogClick={onLogClick}
logs={logs}
selectedReqLogId={selectedReqLogId}
theme={theme}
/>
{logs.length === 0 && ( {logs.length === 0 && (
<Box my={1}> <Box my={1}>
<CenteredPaper> <CenteredPaper>
@ -62,19 +37,14 @@ function RequestList({
} }
interface RequestListTableProps { interface RequestListTableProps {
logs?: any; logs: RequestLog[];
selectedReqLogId?: string; selectedReqLogId?: string;
onLogClick(requestId: string): void; onLogClick(requestId: string): void;
theme: Theme;
} }
function RequestListTable({ function RequestListTable({ logs, selectedReqLogId, onLogClick }: RequestListTableProps): JSX.Element {
logs, const theme = useTheme();
selectedReqLogId,
onLogClick,
theme,
}: RequestListTableProps): JSX.Element {
const classes = useStyles();
return ( return (
<TableContainer <TableContainer
component={Paper} component={Paper}
@ -102,33 +72,34 @@ function RequestListTable({
textOverflow: "ellipsis", textOverflow: "ellipsis",
} as any; } as any;
const rowStyle = {
backgroundColor:
id === selectedReqLogId && theme.palette.action.selected,
};
return ( return (
<TableRow <TableRow
key={id} key={id}
className={classes.row} sx={{
style={rowStyle} "&:hover": {
cursor: "pointer",
},
...(id === selectedReqLogId && {
bgcolor: theme.palette.action.selected,
}),
}}
hover hover
onClick={() => onLogClick(id)} onClick={() => onLogClick(id)}
> >
<TableCell style={{ ...cellStyle, width: "100px" }}> <TableCell style={{ ...cellStyle, width: "100px" }}>
<code>{method}</code> <code>{method}</code>
</TableCell> </TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "100px" }}> <TableCell sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
{origin} <TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
</TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "200px" }}>
{decodeURIComponent(pathname + search + hash)} {decodeURIComponent(pathname + search + hash)}
</TableCell> </TableCell>
<TableCell style={{ maxWidth: "100px" }}> <TableCell style={{ maxWidth: "100px" }}>
{response && ( {response && (
<div> <div>
<HttpStatusIcon status={response.statusCode} />{" "} <HttpStatusIcon status={response.statusCode} />{" "}
<code>{response.status}</code> <code>
{response.statusCode} {response.statusReason}
</code>
</div> </div>
)} )}
</TableCell> </TableCell>
@ -140,5 +111,3 @@ function RequestListTable({
</TableContainer> </TableContainer>
); );
} }
export default withTheme(RequestList);

View File

@ -1,4 +1,4 @@
import { Typography, Box, Divider } from "@material-ui/core"; import { Typography, Box, Divider } from "@mui/material";
import HttpStatusIcon from "./HttpStatusCode"; import HttpStatusIcon from "./HttpStatusCode";
import Editor from "./Editor"; import Editor from "./Editor";
@ -8,41 +8,28 @@ interface Props {
response: { response: {
proto: string; proto: string;
statusCode: number; statusCode: number;
status: string; statusReason: string;
headers: Array<{ key: string; value: string }>; headers: Array<{ key: string; value: string }>;
body?: string; body?: string;
}; };
} }
function ResponseDetail({ response }: Props): JSX.Element { function ResponseDetail({ response }: Props): JSX.Element {
const contentType = response.headers.find( const contentType = response.headers.find((header) => header.key === "Content-Type")?.value;
(header) => header.key === "Content-Type"
)?.value;
return ( return (
<div> <div>
<Box p={2}> <Box p={2}>
<Typography <Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
Response Response
</Typography> </Typography>
<Typography <Typography variant="h6" style={{ fontSize: "1rem", whiteSpace: "nowrap" }}>
variant="h6"
style={{ fontSize: "1rem", whiteSpace: "nowrap" }}
>
<HttpStatusIcon status={response.statusCode} />{" "} <HttpStatusIcon status={response.statusCode} />{" "}
<Typography component="span" color="textSecondary"> <Typography component="span" color="textSecondary">
<Typography <Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{response.proto} {response.proto}
</Typography> </Typography>
</Typography>{" "} </Typography>{" "}
{response.status} {response.statusCode} {response.statusReason}
</Typography> </Typography>
</Box> </Box>
@ -52,9 +39,7 @@ function ResponseDetail({ response }: Props): JSX.Element {
<HttpHeadersTable headers={response.headers} /> <HttpHeadersTable headers={response.headers} />
</Box> </Box>
{response.body && ( {response.body && <Editor content={response.body} contentType={contentType} />}
<Editor content={response.body} contentType={contentType} />
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,216 @@
import {
Box,
Checkbox,
CircularProgress,
ClickAwayListener,
FormControlLabel,
InputBase,
Paper,
Popper,
Tooltip,
useTheme,
} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import SearchIcon from "@mui/icons-material/Search";
import FilterListIcon from "@mui/icons-material/FilterList";
import DeleteIcon from "@mui/icons-material/Delete";
import React, { useRef, useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { withoutTypename } from "../../lib/omitTypename";
import { Alert } from "@mui/lab";
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
import { ConfirmationDialog, useConfirmationDialog } from "./ConfirmationDialog";
const FILTER = gql`
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
searchExpression
}
}
`;
const SET_FILTER = gql`
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
searchExpression
}
}
`;
export interface SearchFilter {
onlyInScope: boolean;
searchExpression: string;
}
function Search(): JSX.Element {
const theme = useTheme();
const [searchExpr, setSearchExpr] = useState("");
const {
loading: filterLoading,
error: filterErr,
data: filter,
} = useQuery(FILTER, {
onCompleted: (data) => {
setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
},
});
const [setFilterMutate, { error: setFilterErr, loading: setFilterLoading }] = useMutation<{
setHttpRequestLogFilter: SearchFilter | null;
}>(SET_FILTER, {
update(cache, { data }) {
cache.writeQuery({
query: FILTER,
data: {
httpRequestLogFilter: data?.setHttpRequestLogFilter,
},
});
},
onError: () => {},
});
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHTTPRequestLog();
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLFormElement>(null);
const [filterOpen, setFilterOpen] = useState(false);
const handleSubmit = (e: React.SyntheticEvent) => {
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
searchExpression: searchExpr,
},
},
});
setFilterOpen(false);
e.preventDefault();
};
const handleClickAway = (event: MouseEvent | TouchEvent) => {
if (filterRef?.current && filterRef.current.contains(event.target as HTMLElement)) {
return;
}
setFilterOpen(false);
};
return (
<Box>
<Error prefix="Error fetching filter" error={filterErr} />
<Error prefix="Error setting filter" error={setFilterErr} />
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
onSubmit={handleSubmit}
ref={filterRef}
sx={{
padding: "2px 4px",
display: "flex",
alignItems: "center",
width: 400,
}}
>
<Tooltip title="Toggle filter options">
<IconButton
onClick={() => setFilterOpen(!filterOpen)}
sx={{
p: 1,
color: filter?.httpRequestLogFilter?.onlyInScope ? "primary.main" : "inherit",
}}
>
{filterLoading || setFilterLoading ? (
<CircularProgress sx={{ color: theme.palette.text.primary }} size={23} />
) : (
<FilterListIcon />
)}
</IconButton>
</Tooltip>
<InputBase
sx={{
ml: 1,
flex: 1,
}}
placeholder="Search proxy logs…"
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">
<IconButton type="submit" sx={{ padding: 1.25 }}>
<SearchIcon />
</IconButton>
</Tooltip>
<Popper
open={filterOpen}
anchorEl={filterRef.current}
placement="bottom"
style={{ zIndex: theme.zIndex.appBar }}
>
<Paper
sx={{
width: 400,
marginTop: 0.5,
p: 1.5,
}}
>
<FormControlLabel
control={
<Checkbox
checked={filter?.httpRequestLogFilter?.onlyInScope ? true : false}
disabled={filterLoading || setFilterLoading}
onChange={(e) =>
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
onlyInScope: e.target.checked,
},
},
})
}
/>
}
label="Only show in-scope requests"
/>
</Paper>
</Popper>
</Paper>
</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>
);
}
function Error(props: { prefix: string; error?: Error }) {
if (!props.error) return null;
return (
<Box mb={4}>
<Alert severity="error">
{props.prefix}: {props.error.message}
</Alert>
</Box>
);
}
export default Search;

View File

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

View File

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

View File

@ -0,0 +1,119 @@
import { gql, useApolloClient, useMutation } from "@apollo/client";
import {
Box,
Button,
CircularProgress,
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup,
TextField,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { Alert } from "@mui/lab";
import React from "react";
import { SCOPE } from "./Rules";
import { ScopeRule } from "../../lib/scope";
const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}
`;
function AddRule(): JSX.Element {
const [ruleType, setRuleType] = React.useState("url");
const [expression, setExpression] = React.useState("");
const client = useApolloClient();
const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
onError() {},
onCompleted() {
setExpression("");
},
update(_, { data: { setScope } }) {
client.writeQuery({
query: SCOPE,
data: { scope: setScope },
});
},
});
const handleTypeChange = (e: React.ChangeEvent, value: string) => {
setRuleType(value);
};
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
let scope: ScopeRule[] = [];
try {
const data = client.readQuery<{ scope: ScopeRule[] }>({
query: SCOPE,
});
if (data) {
scope = data.scope;
}
} catch (e) {}
setScope({
variables: {
scope: [...scope.map(({ url }) => ({ url })), { url: expression }],
},
});
};
return (
<div>
{error && (
<Box mb={4}>
<Alert severity="error">Error adding rule: {error.message}</Alert>
</Box>
)}
<form onSubmit={handleSubmit} autoComplete="off">
<FormControl fullWidth>
<FormLabel color="primary" component="legend">
Rule Type
</FormLabel>
<RadioGroup row name="ruleType" value={ruleType} onChange={handleTypeChange}>
<FormControlLabel value="url" control={<Radio />} label="URL" />
</RadioGroup>
</FormControl>
<FormControl fullWidth>
<TextField
label="Expression"
placeholder="^https:\/\/(.*)example.com(.*)"
helperText="Regular expression to match on."
color="primary"
variant="outlined"
required
value={expression}
onChange={(e) => setExpression(e.target.value)}
InputProps={{
sx: { fontFamily: "'JetBrains Mono', monospace" },
}}
InputLabelProps={{
shrink: true,
}}
margin="normal"
/>
</FormControl>
<Box my={2}>
<Button
type="submit"
variant="contained"
color="primary"
disabled={loading}
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
>
Add rule
</Button>
</Box>
</form>
</div>
);
}
export default AddRule;

View File

@ -0,0 +1,97 @@
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
import {
Avatar,
Chip,
IconButton,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
Tooltip,
} from "@mui/material";
import CodeIcon from "@mui/icons-material/Code";
import DeleteIcon from "@mui/icons-material/Delete";
import React from "react";
import { SCOPE } from "./Rules";
import { ScopeRule } from "../../lib/scope";
const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}
`;
type RuleListItemProps = {
scope: ScopeRule[];
rule: ScopeRule;
index: number;
};
function RuleListItem({ scope, rule, index }: RuleListItemProps): JSX.Element {
const client = useApolloClient();
const [setScope, { loading }] = useMutation(SET_SCOPE, {
update(_, { data: { setScope } }) {
client.writeQuery({
query: SCOPE,
data: { scope: setScope },
});
},
});
const handleDelete = (index: number) => {
const clone = [...scope];
clone.splice(index, 1);
setScope({
variables: {
scope: clone.map(({ url }) => ({ url })),
},
});
};
return (
<ListItem>
<ListItemAvatar>
<Avatar>
<CodeIcon />
</Avatar>
</ListItemAvatar>
<RuleListItemText rule={rule} />
<ListItemSecondaryAction>
<RuleTypeChip rule={rule} />
<Tooltip title="Delete rule">
<span style={{ marginLeft: 8 }}>
<IconButton onClick={() => handleDelete(index)} disabled={loading}>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
function RuleListItemText({ rule }: { rule: ScopeRule }): JSX.Element {
let text: JSX.Element = <div></div>;
if (rule.url) {
text = <code>{rule.url}</code>;
}
// TODO: Parse and handle rule.header and rule.body.
return <ListItemText>{text}</ListItemText>;
}
function RuleTypeChip({ rule }: { rule: ScopeRule }): JSX.Element {
let label = "Unknown";
if (rule.url) {
label = "URL";
}
return <Chip label={label} variant="outlined" />;
}
export default RuleListItem;

View File

@ -0,0 +1,38 @@
import { gql, useQuery } from "@apollo/client";
import { CircularProgress, List } from "@mui/material";
import { Alert } from "@mui/lab";
import React from "react";
import RuleListItem from "./RuleListItem";
import { ScopeRule } from "../../lib/scope";
export const SCOPE = gql`
query Scope {
scope {
url
}
}
`;
function Rules(): JSX.Element {
const { loading, error, data } = useQuery<{ scope: ScopeRule[] }>(SCOPE);
return (
<div>
{loading && <CircularProgress />}
{error && <Alert severity="error">Error fetching scope: {error.message}</Alert>}
{data && data.scope.length > 0 && (
<List
sx={{
bgcolor: "background.paper",
}}
>
{data.scope.map((rule, index) => (
<RuleListItem key={index} rule={rule} scope={data.scope} index={index} />
))}
</List>
)}
</div>
);
}
export default Rules;

5
admin/src/lib/Project.ts Normal file
View File

@ -0,0 +1,5 @@
export type Project = {
id: string
name: string
isActive: boolean
}

View File

@ -0,0 +1,7 @@
import createCache from "@emotion/cache";
// prepend: true moves MUI styles to the top of the <head> so they're loaded first.
// It allows developers to easily override MUI styles with other styling solutions, like CSS modules.
export default function createEmotionCache() {
return createCache({ key: "css", prepend: true });
}

View File

@ -1,8 +1,11 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
import { concatPagination } from "@apollo/client/utilities"; import merge from "deepmerge";
import isEqual from "lodash/isEqual";
let apolloClient; export const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__";
let apolloClient: ApolloClient<NormalizedCacheObject>;
function createApolloClient() { function createApolloClient() {
return new ApolloClient({ return new ApolloClient({
@ -10,15 +13,7 @@ function createApolloClient() {
link: new HttpLink({ link: new HttpLink({
uri: "/api/graphql/", uri: "/api/graphql/",
}), }),
cache: new InMemoryCache({ cache: new InMemoryCache(),
typePolicies: {
Query: {
fields: {
allPosts: concatPagination(),
},
},
},
}),
}); });
} }
@ -30,9 +25,18 @@ export function initializeApollo(initialState = null) {
if (initialState) { if (initialState) {
// Get existing cache, loaded during client side data fetching // Get existing cache, loaded during client side data fetching
const existingCache = _apolloClient.extract(); const existingCache = _apolloClient.extract();
// Restore the cache using the data passed from getStaticProps/getServerSideProps
// combined with the existing cached data // Merge the existing cache into data passed from getStaticProps/getServerSideProps
_apolloClient.cache.restore({ ...existingCache, ...initialState }); const data = merge(initialState, existingCache, {
// combine arrays using object equality (like in sets)
arrayMerge: (destinationArray, sourceArray) => [
...sourceArray,
...destinationArray.filter((d) => sourceArray.every((s) => !isEqual(d, s))),
],
});
// Restore the cache with the merged data
_apolloClient.cache.restore(data);
} }
// For SSG and SSR always create a new Apollo Client // For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient; if (typeof window === "undefined") return _apolloClient;
@ -42,7 +46,16 @@ export function initializeApollo(initialState = null) {
return _apolloClient; return _apolloClient;
} }
export function useApollo(initialState) { export function addApolloState(client: ApolloClient<NormalizedCacheObject>, pageProps: any) {
const store = useMemo(() => initializeApollo(initialState), [initialState]); if (pageProps?.props) {
pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
}
return pageProps;
}
export function useApollo(pageProps: any) {
const state = pageProps[APOLLO_STATE_PROP_NAME];
const store = useMemo(() => initializeApollo(state), [state]);
return store; return store;
} }

View File

@ -0,0 +1,5 @@
const omitTypename = (key: string, value: any) => (key === "__typename" ? undefined : value);
export function withoutTypename(input: any): any {
return JSON.parse(JSON.stringify(input), omitTypename);
}

View File

@ -0,0 +1,24 @@
export type RequestLog = {
id: string
url: string
method: string
proto: string
headers: HTTPHeader[]
body?: string
timestamp: string
response?: ResponseLog
}
export type ResponseLog = {
proto: string
statusCode: number
statusReason: string
body?: string
headers: HTTPHeader[]
}
export type HTTPHeader = {
key: string
value: string
}

3
admin/src/lib/scope.ts Normal file
View File

@ -0,0 +1,3 @@
export type ScopeRule = {
url?: string
}

View File

@ -1,43 +1,51 @@
import { createMuiTheme } from "@material-ui/core/styles"; import { createTheme } from "@mui/material/styles";
import grey from "@material-ui/core/colors/grey"; import * as colors from "@mui/material/colors";
import teal from "@material-ui/core/colors/teal";
const theme = createMuiTheme({ const heading = {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
};
let theme = createTheme({
palette: { palette: {
type: "dark", mode: "dark",
primary: { primary: {
main: grey[900], main: colors.teal["A400"],
}, },
secondary: { secondary: {
main: teal["A400"], main: colors.grey[900],
light: "#333",
dark: colors.common.black,
}, },
}, },
typography: { typography: {
h2: { h2: heading,
fontFamily: "'JetBrains Mono', monospace", h3: heading,
fontWeight: 600, h4: heading,
h5: heading,
h6: heading,
},
});
theme = createTheme(theme, {
palette: {
background: {
default: theme.palette.secondary.main,
paper: theme.palette.secondary.light,
}, },
h3: { info: {
fontFamily: "'JetBrains Mono', monospace", main: theme.palette.primary.main,
fontWeight: 600,
}, },
h4: { success: {
fontFamily: "'JetBrains Mono', monospace", main: theme.palette.primary.main,
fontWeight: 600,
},
h5: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
h6: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
}, },
}, },
overrides: { components: {
MuiTableCell: { MuiTableCell: {
stickyHeader: { styleOverrides: {
backgroundColor: grey[900], stickyHeader: {
backgroundColor: theme.palette.secondary.dark,
},
}, },
}, },
}, },

View File

@ -1,32 +1,31 @@
import React from "react"; import * as React from "react";
import Head from "next/head";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import { ApolloProvider } from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
import Head from "next/head"; import { ThemeProvider } from "@mui/material/styles";
import { ThemeProvider } from "@material-ui/core/styles"; import CssBaseline from "@mui/material/CssBaseline";
import CssBaseline from "@material-ui/core/CssBaseline"; import { CacheProvider, EmotionCache } from "@emotion/react";
import createEmotionCache from "../lib/createEmotionCache";
import theme from "../lib/theme"; import theme from "../lib/theme";
import { useApollo } from "../lib/graphql"; import { useApollo } from "../lib/graphql";
function App({ Component, pageProps }: AppProps): JSX.Element { // Client-side cache, shared for the whole session of the user in the browser.
const apolloClient = useApollo(pageProps.initialApolloState); const clientSideEmotionCache = createEmotionCache();
React.useEffect(() => { interface MyAppProps extends AppProps {
// Remove the server-side injected CSS. emotionCache?: EmotionCache;
const jssStyles = document.querySelector("#jss-server-side"); }
if (jssStyles) {
jssStyles.parentElement.removeChild(jssStyles); export default function MyApp(props: MyAppProps) {
} const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
}, []); const apolloClient = useApollo(pageProps);
return ( return (
<React.Fragment> <CacheProvider value={emotionCache}>
<Head> <Head>
<title>Hetty://</title> <title>Hetty://</title>
<meta <meta name="viewport" content="initial-scale=1, width=device-width" />
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
</Head> </Head>
<ApolloProvider client={apolloClient}> <ApolloProvider client={apolloClient}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
@ -34,8 +33,6 @@ function App({ Component, pageProps }: AppProps): JSX.Element {
<Component {...pageProps} /> <Component {...pageProps} />
</ThemeProvider> </ThemeProvider>
</ApolloProvider> </ApolloProvider>
</React.Fragment> </CacheProvider>
); );
} }
export default App;

View File

@ -1,7 +1,8 @@
import React from "react"; import * as React from "react";
import Document, { Html, Head, Main, NextScript } from "next/document"; import Document, { Html, Head, Main, NextScript } from "next/document";
import { ServerStyleSheets } from "@material-ui/core/styles"; import createEmotionServer from "@emotion/server/create-instance";
import createEmotionCache from "../lib/createEmotionCache";
import theme from "../lib/theme"; import theme from "../lib/theme";
export default class MyDocument extends Document { export default class MyDocument extends Document {
@ -11,14 +12,9 @@ export default class MyDocument extends Document {
<Head> <Head>
<meta name="theme-color" content={theme.palette.primary.main} /> <meta name="theme-color" content={theme.palette.primary.main} />
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
<link <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
rel="stylesheet" <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" />
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" {(this.props as any).emotionStyleTags}
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
/>
</Head> </Head>
<body> <body>
<Main /> <Main />
@ -30,25 +26,60 @@ export default class MyDocument extends Document {
} }
// `getInitialProps` belongs to `_document` (instead of `_app`), // `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side generation (SSG). // it's compatible with static-site generation (SSG).
MyDocument.getInitialProps = async (ctx) => { MyDocument.getInitialProps = async (ctx) => {
// Render app and page and get the context of the page with collected side effects. // Resolution order
const sheets = new ServerStyleSheets(); //
// On the server:
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. document.getInitialProps
// 4. app.render
// 5. page.render
// 6. document.render
//
// On the server with error:
// 1. document.getInitialProps
// 2. app.render
// 3. page.render
// 4. document.render
//
// On the client
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. app.render
// 4. page.render
const originalRenderPage = ctx.renderPage; const originalRenderPage = ctx.renderPage;
// You can consider sharing the same emotion cache between all the SSR requests to speed up performance.
// However, be aware that it can have global side effects.
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
ctx.renderPage = () => ctx.renderPage = () =>
originalRenderPage({ originalRenderPage({
enhanceApp: (App) => (props) => sheets.collect(<App {...props} />), enhanceApp: (App: any) =>
function EnhanceApp(props) {
return <App emotionCache={cache} {...props} />;
},
}); });
const initialProps = await Document.getInitialProps(ctx); const initialProps = await Document.getInitialProps(ctx);
// This is important. It prevents emotion to render invalid HTML.
// See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
const emotionStyles = extractCriticalToChunks(initialProps.html);
const emotionStyleTags = emotionStyles.styles.map((style) => (
<style
data-emotion={`${style.key} ${style.ids.join(" ")}`}
key={style.key}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: style.css }}
/>
));
return { return {
...initialProps, ...initialProps,
// Styles fragment is rendered after the app and page rendering finish. emotionStyleTags,
styles: [
...React.Children.toArray(initialProps.styles),
sheets.getStyleElement(),
],
}; };
}; };

View File

@ -0,0 +1,32 @@
import { Box, Link as MaterialLink, Typography } from "@mui/material";
import Link from "next/link";
import React from "react";
import Layout, { Page } from "../../components/Layout";
function Index(): JSX.Element {
return (
<Layout page={Page.GetStarted} title="Get started">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Get started</Typography>
</Box>
<Typography paragraph>
Youve loaded a (new) project. Whats next? You can now use the MITM proxy and review HTTP requests and
responses via the{" "}
<Link href="/proxy/logs" passHref>
<MaterialLink color="primary">Proxy logs</MaterialLink>
</Link>
. Stuck? Ask for help on the{" "}
<MaterialLink href="https://github.com/dstotijn/hetty/discussions" color="primary" target="_blank">
Discussions forum
</MaterialLink>
.
</Typography>
</Box>
</Layout>
);
}
export default Index;

View File

@ -1,79 +1,53 @@
import { import { Box, Button, Typography } from "@mui/material";
Box, import FolderIcon from "@mui/icons-material/Folder";
Button,
createStyles,
IconButton,
makeStyles,
Theme,
Typography,
} from "@material-ui/core";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send";
import Link from "next/link"; import Link from "next/link";
import Layout, { Page } from "../components/Layout"; import Layout, { Page } from "../components/Layout";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
titleHighlight: {
color: theme.palette.secondary.main,
},
subtitle: {
fontSize: "1.6rem",
width: "60%",
lineHeight: 2,
marginBottom: theme.spacing(5),
},
button: {
marginRight: theme.spacing(2),
},
})
);
function Index(): JSX.Element { function Index(): JSX.Element {
const classes = useStyles(); const highlightSx = { color: "primary.main" };
return ( return (
<Layout page={Page.Home} title=""> <Layout page={Page.Home} title="">
<Box p={4}> <Box p={4}>
<Box mb={4} width="60%"> <Box mb={4} width="60%">
<Typography variant="h2"> <Typography variant="h2">
<span className={classes.titleHighlight}>Hetty://</span> <Box component="span" sx={highlightSx}>
Hetty://
</Box>
<br /> <br />
The simple HTTP toolkit for security research. The simple HTTP toolkit for security research.
</Typography> </Typography>
</Box> </Box>
<Typography className={classes.subtitle} paragraph>
What if security testing was intuitive, powerful, and good looking? <Typography
What if it was <strong>free</strong>, instead of $400 per year?{" "} paragraph
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "} sx={{
<code>:8080</code> fontSize: "1.6rem",
width: "60%",
lineHeight: 2,
mb: 5,
}}
>
Welcome to{" "}
<Box component="span" sx={highlightSx}>
Hetty
</Box>
. Get started by creating a project.
</Typography> </Typography>
<Box>
<Link href="/proxy" passHref> <Link href="/projects" passHref>
<Button <Button
className={classes.button} sx={{ mr: 2 }}
variant="contained" variant="contained"
color="secondary" color="primary"
component="a" component="a"
size="large" size="large"
startIcon={<SettingsEthernetIcon />} startIcon={<FolderIcon />}
> >
Setup proxy Manage projects
</Button> </Button>
</Link> </Link>
<Link href="/proxy" passHref>
<Button
className={classes.button}
variant="contained"
color="primary"
component="a"
size="large"
startIcon={<SendIcon />}
>
Send HTTP requests
</Button>
</Link>
</Box>
</Box> </Box>
</Layout> </Layout>
); );

View File

@ -0,0 +1,32 @@
import { Box, Divider, Grid, Typography } from "@mui/material";
import Layout, { Page } from "../../components/Layout";
import NewProject from "../../components/projects/NewProject";
import ProjectList from "../../components/projects/ProjectList";
function Index(): JSX.Element {
return (
<Layout page={Page.Projects} title="Projects">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Projects</Typography>
</Box>
<Typography paragraph>
Projects contain settings and data generated/processed by Hetty. They are stored in a single database on disk.
</Typography>
<Box my={4}>
<Divider />
</Box>
<Box mb={8}>
<NewProject />
</Box>
<Grid container>
<Grid item xs={12} sm={8} md={6} lg={6}>
<ProjectList />
</Grid>
</Grid>
</Box>
</Layout>
);
}
export default Index;

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Button, Typography } from "@material-ui/core"; import { Button, Typography } from "@mui/material";
import ListIcon from "@material-ui/icons/List"; import ListIcon from "@mui/icons-material/List";
import Link from "next/link"; import Link from "next/link";
import Layout, { Page } from "../../components/Layout"; import Layout, { Page } from "../../components/Layout";
@ -10,13 +10,7 @@ function Index(): JSX.Element {
<Layout page={Page.ProxySetup} title="Proxy setup"> <Layout page={Page.ProxySetup} title="Proxy setup">
<Typography paragraph>Coming soon</Typography> <Typography paragraph>Coming soon</Typography>
<Link href="/proxy/logs" passHref> <Link href="/proxy/logs" passHref>
<Button <Button variant="contained" color="primary" component="a" size="large" startIcon={<ListIcon />}>
variant="contained"
color="secondary"
component="a"
size="large"
startIcon={<ListIcon />}
>
View logs View logs
</Button> </Button>
</Link> </Link>

View File

@ -1,9 +1,15 @@
import { Box } from "@mui/material";
import LogsOverview from "../../../components/reqlog/LogsOverview"; import LogsOverview from "../../../components/reqlog/LogsOverview";
import Layout, { Page } from "../../../components/Layout"; import Layout, { Page } from "../../../components/Layout";
import Search from "../../../components/reqlog/Search";
function ProxyLogs(): JSX.Element { function ProxyLogs(): JSX.Element {
return ( return (
<Layout page={Page.ProxyLogs} title="Proxy logs"> <Layout page={Page.ProxyLogs} title="Proxy logs">
<Box mb={2}>
<Search />
</Box>
<LogsOverview /> <LogsOverview />
</Layout> </Layout>
); );

View File

@ -0,0 +1,37 @@
import { Box, Divider, Grid, Typography } from "@mui/material";
import React from "react";
import Layout, { Page } from "../../components/Layout";
import AddRule from "../../components/scope/AddRule";
import Rules from "../../components/scope/Rules";
function Index(): JSX.Element {
return (
<Layout page={Page.Scope} title="Scope">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Scope</Typography>
</Box>
<Typography paragraph>
Scope rules are used by various modules in Hetty and can influence their behavior. For example: the Proxy logs
module can match incoming requests against scope rules and decide its behavior (e.g. log or bypass) based on
the outcome of the match. All scope configuration is stored per project.
</Typography>
<Box my={4}>
<Divider />
</Box>
<Grid container>
<Grid item xs={12} sm={12} md={8} lg={6}>
<AddRule />
<Box my={4}>
<Divider />
</Box>
<Rules />
</Grid>
</Grid>
</Box>
</Layout>
);
}
export default Index;

View File

@ -1,4 +1,4 @@
import { Box, Typography } from "@material-ui/core"; import { Box, Typography } from "@mui/material";
import Layout, { Page } from "../../components/Layout"; import Layout, { Page } from "../../components/Layout";

View File

@ -8,7 +8,7 @@
], ],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": false, "strict": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -16,7 +16,8 @@
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve" "jsx": "preserve",
"incremental": true
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",

File diff suppressed because it is too large Load Diff

View File

@ -2,81 +2,122 @@ package main
import ( import (
"crypto/tls" "crypto/tls"
"crypto/x509" "embed"
"errors"
"flag" "flag"
"fmt"
"io/fs"
"log" "log"
"net" "net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
rice "github.com/GeertJohan/go.rice"
"github.com/dstotijn/hetty/pkg/api"
"github.com/dstotijn/hetty/pkg/db/cayley"
"github.com/dstotijn/hetty/pkg/proxy"
"github.com/dstotijn/hetty/pkg/reqlog"
"github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground" "github.com/99designs/gqlgen/graphql/playground"
badgerdb "github.com/dgraph-io/badger/v3"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/mitchellh/go-homedir"
"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"
) )
var version = "0.0.0"
// Flag variables.
var ( var (
caCertFile string caCertFile string
caKeyFile string caKeyFile string
dbFile string dbPath string
addr string addr string
adminPath string
) )
//go:embed admin
//go:embed admin/_next/static
//go:embed admin/_next/static/chunks/pages/*.js
//go:embed admin/_next/static/*/*.js
var adminContent embed.FS
func main() { func main() {
flag.StringVar(&caCertFile, "cert", "", "CA certificate file path") if err := run(); err != nil {
flag.StringVar(&caKeyFile, "key", "", "CA private key file path") log.Fatalf("[ERROR]: %v", err)
flag.StringVar(&dbFile, "db", "hetty.db", "Database file path") }
flag.StringVar(&addr, "addr", ":80", "TCP address to listen on, in the form \"host:port\"") }
flag.StringVar(&adminPath, "adminPath", "", "File path to admin build")
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() flag.Parse()
tlsCA, err := tls.LoadX509KeyPair(caCertFile, caKeyFile) // Expand `~` in filepaths.
caCertFile, err := homedir.Expand(caCertFile)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not load CA key pair: %v", err) return fmt.Errorf("could not parse CA certificate filepath: %w", err)
} }
caCert, err := x509.ParseCertificate(tlsCA.Certificate[0]) caKeyFile, err := homedir.Expand(caKeyFile)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not parse CA: %v", err) return fmt.Errorf("could not parse CA private key filepath: %w", err)
} }
db, err := cayley.NewDatabase(dbFile) dbPath, err := homedir.Expand(dbPath)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not initialize database: %v", err) return fmt.Errorf("could not parse projects filepath: %w", err)
} }
defer db.Close()
reqLogService := reqlog.NewService(db) // Load existing CA certificate and key from disk, or generate and write
// to disk if no files exist yet.
p, err := proxy.NewProxy(caCert, tlsCA.PrivateKey) caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile)
if err != nil { if err != nil {
log.Fatalf("[FATAL] Could not create Proxy: %v", err) 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,
})
projService, err := proj.NewService(proj.Config{
Repository: badger,
ReqLogService: reqLogService,
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.UseRequestModifier(reqLogService.RequestModifier)
p.UseResponseModifier(reqLogService.ResponseModifier) p.UseResponseModifier(reqLogService.ResponseModifier)
var adminHandler http.Handler fsSub, err := fs.Sub(adminContent, "admin")
if adminPath == "" { if err != nil {
// Used for embedding with `rice`. return fmt.Errorf("could not prepare subtree file system: %w", err)
box, err := rice.FindBox("../../admin/dist")
if err != nil {
log.Fatalf("[FATAL] Could not find embedded admin resources: %v", err)
}
adminHandler = http.FileServer(box.HTTPBox())
} else {
adminHandler = http.FileServer(http.Dir(adminPath))
} }
adminHandler := http.FileServer(http.FS(fsSub))
router := mux.NewRouter().SkipClean(true) router := mux.NewRouter().SkipClean(true)
adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool { adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
host, _, _ := net.SplitHostPort(req.Host) host, _, _ := net.SplitHostPort(req.Host)
@ -85,9 +126,11 @@ func main() {
// GraphQL server. // GraphQL server.
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/")) 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{ adminRouter.Path("/api/graphql/").Handler(
RequestLogService: reqLogService, handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
}}))) RequestLogService: reqLogService,
ProjectService: projService,
}})))
// Admin interface. // Admin interface.
adminRouter.PathPrefix("").Handler(adminHandler) adminRouter.PathPrefix("").Handler(adminHandler)
@ -101,9 +144,12 @@ func main() {
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2 TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2
} }
log.Printf("[INFO] Running server on %v ...", addr) log.Printf("[INFO] Hetty (v%v) is running on %v ...", version, addr)
err = s.ListenAndServe() err = s.ListenAndServe()
if err != nil && err != http.ErrServerClosed { if err != nil && errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[FATAL] HTTP server closed: %v", err) return fmt.Errorf("http server closed unexpected: %w", err)
} }
return nil
} }

12
docs/.gitignore vendored Executable file
View File

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

21
docs/package.json Executable file
View File

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

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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