Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
ad3fa7d379 | |||
8c2efdb285 | |||
194d727f4f | |||
8ab65fb55f | |||
5bce912e89 | |||
16910bb637 | |||
e59b9d6663 | |||
efc115e961 | |||
471fa212ef | |||
07ef2f9090 | |||
f7550d649a | |||
dbc25774c2 | |||
430670ab54 | |||
f6789fa245 | |||
81fbfe4cb3 | |||
6931d63250 | |||
71e87d3cd3 | |||
0ffbb618fa | |||
c01f190fc8 |
@ -1,4 +1,3 @@
|
|||||||
**/rice-box.go
|
|
||||||
/admin/.env
|
/admin/.env
|
||||||
/admin/.next
|
/admin/.next
|
||||||
/admin/dist
|
/admin/dist
|
||||||
|
31
.github/workflows/build_run.yml
vendored
@ -1,31 +0,0 @@
|
|||||||
name: Build Frontend, Backend and Run
|
|
||||||
|
|
||||||
on: [pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build Images and Deploy Example WebApp
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: Test Yarn
|
|
||||||
uses:
|
|
||||||
node_version: '14.x'
|
|
||||||
run: |-
|
|
||||||
cd admin && \
|
|
||||||
yarn install && \
|
|
||||||
yarn export
|
|
||||||
|
|
||||||
- name: Test make
|
|
||||||
run: |-
|
|
||||||
cd .. && \
|
|
||||||
make build
|
|
||||||
|
|
||||||
- name: Run
|
|
||||||
run: |-
|
|
||||||
./hetty
|
|
14
.github/workflows/dockerimage.yml
vendored
@ -1,14 +0,0 @@
|
|||||||
name: Docker Image CI
|
|
||||||
|
|
||||||
on: [pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Build the Docker image
|
|
||||||
run: docker build . --file Dockerfile --tag hetty:$(date +%s)
|
|
24
.github/workflows/go.yml
vendored
@ -1,24 +0,0 @@
|
|||||||
name: Go
|
|
||||||
on: [pull_request]
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- name: Set up Go 1.15
|
|
||||||
uses: actions/setup-go@v1
|
|
||||||
with:
|
|
||||||
go-version: 1.15
|
|
||||||
id: go
|
|
||||||
|
|
||||||
- name: Check out code into the Go module directory
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Get dependencies
|
|
||||||
run: |
|
|
||||||
go get -v -t -d ./...
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: go build -v ./cmd/hetty
|
|
3
.gitignore
vendored
@ -1,10 +1,7 @@
|
|||||||
.release-env
|
.release-env
|
||||||
.vscode
|
.vscode
|
||||||
**/rice-box.go
|
**/rice-box.go
|
||||||
sqlite3_mod_regexp.dylib
|
|
||||||
dist
|
dist
|
||||||
hetty
|
hetty
|
||||||
hetty.bolt
|
|
||||||
hetty.db
|
|
||||||
*.pem
|
*.pem
|
||||||
*.test
|
*.test
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
env:
|
env:
|
||||||
- GO111MODULE=on
|
- GO111MODULE=on
|
||||||
- CGO_ENABLED=1
|
- CGO_ENABLED=1
|
||||||
- CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
- make clean
|
|
||||||
- make embed
|
|
||||||
builds:
|
builds:
|
||||||
- id: hetty-darwin-amd64
|
- id: hetty-darwin-amd64
|
||||||
main: ./cmd/hetty
|
main: ./cmd/hetty
|
||||||
@ -16,48 +12,50 @@ builds:
|
|||||||
env:
|
env:
|
||||||
- CC=o64-clang
|
- CC=o64-clang
|
||||||
- CXX=o64-clang++
|
- CXX=o64-clang++
|
||||||
- CGO_LDFLAGS=-Wl,-undefined,dynamic_lookup
|
|
||||||
flags:
|
flags:
|
||||||
- -mod=readonly
|
- -mod=readonly
|
||||||
ldflags:
|
|
||||||
- id: hetty-linux-amd64
|
- id: hetty-linux-amd64
|
||||||
main: ./cmd/hetty
|
main: ./cmd/hetty
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
|
flags:
|
||||||
|
- -mod=readonly
|
||||||
|
|
||||||
|
- id: hetty-windows-amd64
|
||||||
|
main: ./cmd/hetty
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
goos:
|
||||||
|
- windows
|
||||||
env:
|
env:
|
||||||
- CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
|
- CC=x86_64-w64-mingw32-gcc
|
||||||
- CGO_LDFLAGS=-Wl,--unresolved-symbols=ignore-in-object-files
|
- CXX=x86_64-w64-mingw32-g++
|
||||||
flags:
|
flags:
|
||||||
- -mod=readonly
|
- -mod=readonly
|
||||||
ldflags:
|
ldflags:
|
||||||
# - id: hetty-windows-amd64
|
- -buildmode=exe
|
||||||
# main: ./cmd/hetty
|
|
||||||
# goarch:
|
|
||||||
# - amd64
|
|
||||||
# goos:
|
|
||||||
# - windows
|
|
||||||
# env:
|
|
||||||
# - CC=x86_64-w64-mingw32-gcc
|
|
||||||
# - CXX=x86_64-w64-mingw32-g++
|
|
||||||
# - CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
|
|
||||||
# - CGO_LDFLAGS=-Wl,--unresolved-symbols=ignore-in-object-files # Not working :(
|
|
||||||
# flags:
|
|
||||||
# - -mod=readonly
|
|
||||||
# ldflags:
|
|
||||||
# - -buildmode=exe
|
|
||||||
archives:
|
archives:
|
||||||
- replacements:
|
-
|
||||||
|
replacements:
|
||||||
darwin: macOS
|
darwin: macOS
|
||||||
linux: Linux
|
linux: Linux
|
||||||
windows: Windows
|
windows: Windows
|
||||||
386: i386
|
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: "{{ .Tag }}-next"
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
12
Dockerfile
@ -2,17 +2,15 @@ ARG GO_VERSION=1.15
|
|||||||
ARG CGO_ENABLED=1
|
ARG CGO_ENABLED=1
|
||||||
ARG NODE_VERSION=14.11
|
ARG NODE_VERSION=14.11
|
||||||
|
|
||||||
FROM golang:${GO_VERSION} AS go-builder
|
FROM golang:${GO_VERSION}-alpine AS go-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && \
|
RUN apk add --no-cache build-base
|
||||||
apt-get install -y build-essential
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY cmd ./cmd
|
COPY cmd ./cmd
|
||||||
COPY pkg ./pkg
|
COPY pkg ./pkg
|
||||||
ENV CGO_CFLAGS=-I/go/pkg/mod/github.com/mattn/go-sqlite3@v1.14.4
|
RUN rm -f cmd/hetty/rice-box.go
|
||||||
ENV CGO_LDFLAGS=-Wl,--unresolved-symbols=ignore-in-object-files
|
RUN go build ./cmd/hetty
|
||||||
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
|
||||||
@ -22,7 +20,7 @@ COPY admin/ .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN yarn run export
|
RUN yarn run export
|
||||||
|
|
||||||
FROM debian:buster-slim
|
FROM alpine:3.12
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go-builder /app/hetty .
|
COPY --from=go-builder /app/hetty .
|
||||||
COPY --from=node-builder /app/dist admin
|
COPY --from=node-builder /app/dist admin
|
||||||
|
30
Makefile
@ -1,36 +1,26 @@
|
|||||||
PACKAGE_NAME := github.com/dstotijn/hetty
|
PACKAGE_NAME := github.com/dstotijn/hetty
|
||||||
GOLANG_CROSS_VERSION ?= v1.15.2
|
GOLANG_CROSS_VERSION ?= v1.15.2
|
||||||
|
|
||||||
setup:
|
|
||||||
go mod download
|
|
||||||
go generate ./...
|
|
||||||
.PHONY: setup
|
|
||||||
|
|
||||||
embed:
|
|
||||||
go install github.com/GeertJohan/go.rice/rice
|
|
||||||
cd cmd/hetty && rice embed-go
|
|
||||||
.PHONY: embed
|
.PHONY: embed
|
||||||
|
embed:
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 cd admin && yarn install && yarn run export
|
||||||
|
cd cmd/hetty && rice embed-go
|
||||||
|
|
||||||
build: embed
|
|
||||||
env CGO_ENABLED=1 CGO_CFLAGS="-DUSE_LIBSQLITE3" CGO_LDFLAGS="-Wl,-undefined,dynamic_lookup" \
|
|
||||||
go build -tags libsqlite3 ./cmd/hetty
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
build: embed
|
||||||
|
CGO_ENABLED=1 go build ./cmd/hetty
|
||||||
|
|
||||||
clean:
|
.PHONY: release-dry-run
|
||||||
rm -rf cmd/hetty/rice-box.go
|
release-dry-run: embed
|
||||||
.PHONY: clean
|
|
||||||
|
|
||||||
release-dry-run:
|
|
||||||
@docker run \
|
@docker run \
|
||||||
--rm \
|
--rm \
|
||||||
-v `pwd`:/go/src/$(PACKAGE_NAME) \
|
-v `pwd`:/go/src/$(PACKAGE_NAME) \
|
||||||
-v `pwd`/admin/dist:/go/src/$(PACKAGE_NAME)/admin/dist \
|
|
||||||
-w /go/src/$(PACKAGE_NAME) \
|
-w /go/src/$(PACKAGE_NAME) \
|
||||||
troian/golang-cross:${GOLANG_CROSS_VERSION} \
|
troian/golang-cross:${GOLANG_CROSS_VERSION} \
|
||||||
--rm-dist --skip-validate --skip-publish
|
--rm-dist --skip-validate --skip-publish
|
||||||
.PHONY: release-dry-run
|
|
||||||
|
|
||||||
release:
|
.PHONY: release
|
||||||
|
release: embed
|
||||||
@if [ ! -f ".release-env" ]; then \
|
@if [ ! -f ".release-env" ]; then \
|
||||||
echo "\033[91mFile \`.release-env\` is missing.\033[0m";\
|
echo "\033[91mFile \`.release-env\` is missing.\033[0m";\
|
||||||
exit 1;\
|
exit 1;\
|
||||||
@ -38,9 +28,7 @@ release:
|
|||||||
@docker run \
|
@docker run \
|
||||||
--rm \
|
--rm \
|
||||||
-v `pwd`:/go/src/$(PACKAGE_NAME) \
|
-v `pwd`:/go/src/$(PACKAGE_NAME) \
|
||||||
-v `pwd`/admin/dist:/go/src/$(PACKAGE_NAME)/admin/dist \
|
|
||||||
-w /go/src/$(PACKAGE_NAME) \
|
-w /go/src/$(PACKAGE_NAME) \
|
||||||
--env-file .release-env \
|
--env-file .release-env \
|
||||||
troian/golang-cross:${GOLANG_CROSS_VERSION} \
|
troian/golang-cross:${GOLANG_CROSS_VERSION} \
|
||||||
release --rm-dist
|
release --rm-dist
|
||||||
.PHONY: release
|
|
166
README.md
@ -1,70 +1,94 @@
|
|||||||
<img src="https://i.imgur.com/AT71SBq.png" width="346" />
|
<h1>
|
||||||
|
<a href="https://github.com/dstotijn/hetty">
|
||||||
|
<img src="https://hetty.xyz/assets/logo.png" width="293">
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
> Hetty is an HTTP toolkit for security research. It aims to become an open source
|
[](https://github.com/dstotijn/hetty/releases/latest)
|
||||||
> alternative to commercial software like Burp Suite Pro, with powerful features
|

|
||||||
> tailored to the needs of the infosec and bug bounty community.
|
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
||||||
|
[](https://hetty.xyz/)
|
||||||
|
|
||||||
<img src="https://i.imgur.com/ZZ6o83X.png">
|
**Hetty** is an HTTP toolkit for security research. It aims to become an open
|
||||||
|
source alternative to commercial software like Burp Suite Pro, with powerful
|
||||||
|
features tailored to the needs of the infosec and bug bounty community.
|
||||||
|
|
||||||
## Features/to do
|
<img src="https://hetty.xyz/assets/hetty_v0.2.0_header.png">
|
||||||
|
|
||||||
- [x] HTTP man-in-the-middle (MITM) proxy and GraphQL server.
|
## Features
|
||||||
- [x] Web interface (Next.js) with proxy log viewer.
|
|
||||||
- [x] Add scope support to the proxy.
|
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
|
||||||
- [ ] Full text search (with regex) in proxy log viewer.
|
- Project based database storage (SQLite)
|
||||||
- [x] Project management.
|
- Scope support
|
||||||
- [ ] Sender module for sending manual HTTP requests, either from scratch or based
|
- Headless management API using GraphQL
|
||||||
off requests from the proxy log.
|
- Embedded web interface (Next.js)
|
||||||
- [ ] Attacker module for automated sending of HTTP requests. Leverage the concurrency
|
|
||||||
features of Go and its `net/http` package to make it blazingly fast.
|
ℹ️ 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
|
## Installation
|
||||||
|
|
||||||
Hetty is packaged on GitHub as a single binary, with the web interface resources
|
Hetty compiles to a self-contained binary, with an embedded SQLite database
|
||||||
embedded.
|
and web based admin interface.
|
||||||
|
|
||||||
👉 You can find downloads for Linux, macOS and Windows on the [releases page](https://github.com/dstotijn/hetty/releases).
|
### Install pre-built release (recommended)
|
||||||
|
|
||||||
### Alternatives:
|
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
|
||||||
|
|
||||||
**Build from source**
|
### Build from source
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- [Go](https://golang.org/)
|
||||||
|
- [Yarn](https://yarnpkg.com/)
|
||||||
|
- [go.rice](https://github.com/GeertJohan/go.rice)
|
||||||
|
|
||||||
|
Hetty depends on SQLite (via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3))
|
||||||
|
and needs `cgo` to compile. Additionally, the static resources for the admin interface
|
||||||
|
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in
|
||||||
|
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand.
|
||||||
|
|
||||||
|
Clone the repository and use the `build` make target to create a binary:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ GO111MODULE=auto go get -u -v github.com/dstotijn/hetty/cmd/hetty
|
$ git clone git@github.com:dstotijn/hetty.git
|
||||||
|
$ cd hetty
|
||||||
|
$ make build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then export the Next.js frontend app:
|
### Docker
|
||||||
|
|
||||||
|
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
|
||||||
|
For persistent storage of CA certificates and project databases, mount a volume:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ cd admin
|
$ mkdir -p $HOME/.hetty
|
||||||
$ yarn install
|
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
|
||||||
$ yarn export
|
|
||||||
```
|
|
||||||
|
|
||||||
This will ensure a folder `./admin/dist` exists.
|
|
||||||
Then, you can bundle the frontend app using `rice`.
|
|
||||||
The easiest way to do this is via a supplied `Makefile` command in the root of
|
|
||||||
the project:
|
|
||||||
|
|
||||||
```
|
|
||||||
make build
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker**
|
|
||||||
|
|
||||||
Alternatively, you can run Hetty via Docker. See: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty)
|
|
||||||
on Docker Hub.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ docker run -v $HOME/.hetty:/root/.hetty -p 127.0.0.1:8080:8080 dstotijn/hetty
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Hetty is packaged as a single binary, with the web interface resources embedded.
|
When Hetty is run, by default it listens on `:8080` and is accessible via
|
||||||
When the program is run, it listens by default on `:8080` and is accessible via
|
|
||||||
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
|
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
|
||||||
MITM proxy, or it serves the GraphQL API and web interface (Next.js).
|
MITM proxy, or it serves the API and web interface.
|
||||||
|
|
||||||
|
By default, project database files and CA certificates are stored in a `.hetty`
|
||||||
|
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
|
||||||
|
on Windows).
|
||||||
|
|
||||||
|
To start, ensure `hetty` (downloaded from a release, or manually built) is in your
|
||||||
|
`$PATH` and run:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hetty
|
||||||
|
```
|
||||||
|
|
||||||
|
An overview of configuration flags:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ hetty -h
|
$ hetty -h
|
||||||
@ -81,6 +105,16 @@ Usage of ./hetty:
|
|||||||
Projects directory path (default "~/.hetty/projects")
|
Projects directory path (default "~/.hetty/projects")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
|
||||||
|
```
|
||||||
|
2020/11/01 14:47:10 [INFO] Running server on :8080 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, visit [http://localhost:8080](http://localhost:8080) to get started.
|
||||||
|
|
||||||
|
ℹ️ Detailed documentation is under development and will be available soon.
|
||||||
|
|
||||||
## Certificate Setup and Installation
|
## Certificate Setup and Installation
|
||||||
|
|
||||||
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
|
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
|
||||||
@ -163,38 +197,40 @@ _more information on how to update the system to trust your self-signed certific
|
|||||||
|
|
||||||
## Vision and roadmap
|
## Vision and roadmap
|
||||||
|
|
||||||
The project has just gotten underway, and as such I haven’t had time yet to do a
|
|
||||||
write-up on its mission and roadmap. A short summary/braindump:
|
|
||||||
|
|
||||||
- Fast core/engine, built with Go, with a minimal memory footprint.
|
- Fast core/engine, built with Go, with a minimal memory footprint.
|
||||||
- GraphQL server to interact with the backend.
|
- Easy to use admin interface, built with Next.js and Material UI.
|
||||||
- Easy to use web 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
|
- Extensibility is top of mind. All modules are written as Go packages, to
|
||||||
be used by the main `hetty` program, but also usable as libraries for other software.
|
be used by Hetty, but also as libraries by other software.
|
||||||
Aside from the GraphQL server, it should (eventually) be possible to also use
|
- Pluggable architecture for MITM proxy, projects, scope. It should be possible.
|
||||||
it as a CLI tool.
|
to build a plugin system in the (near) future.
|
||||||
- Pluggable architecture for the MITM proxy and future modules, making it
|
- Based on feedback and real-world usage of pentesters and bug bounty hunters.
|
||||||
possible for hook into the core engine.
|
- Aim for a relatively small core feature set that the majority of security researchers need.
|
||||||
- Talk to the community, and focus on the features that the majority.
|
|
||||||
Less features means less code to maintain.
|
|
||||||
|
|
||||||
## Status
|
## Support
|
||||||
|
|
||||||
The project is currently under active development. Please star/follow and check
|
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
|
||||||
back soon. 🤗
|
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
|
## Contributing
|
||||||
|
|
||||||
Please see the [Contribution Guidelines](CONTRIBUTING.md) for details.
|
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md)
|
||||||
|
for details.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
|
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
|
||||||
for all the encouragement to actually start building this thing!
|
for all the encouragement and feedback.
|
||||||
|
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE)
|
[MIT License](LICENSE)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
53
admin/src/components/reqlog/ConfirmationDialog.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import Dialog from "@material-ui/core/Dialog";
|
||||||
|
import DialogActions from "@material-ui/core/DialogActions";
|
||||||
|
import DialogContent from "@material-ui/core/DialogContent";
|
||||||
|
import DialogContentText from "@material-ui/core/DialogContentText";
|
||||||
|
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||||
|
|
||||||
|
export function useConfirmationDialog() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const close = () => setIsOpen(false);
|
||||||
|
const open = () => setIsOpen(true);
|
||||||
|
|
||||||
|
return { open, close, isOpen };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmationDialog {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmationDialog(props: ConfirmationDialog) {
|
||||||
|
const { onClose, onConfirm, isOpen, children } = props;
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
>
|
||||||
|
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText id="alert-dialog-description">
|
||||||
|
{children}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Abort</Button>
|
||||||
|
<Button onClick={confirm} autoFocus>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -1,41 +1,24 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { gql, useQuery } from "@apollo/client";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Link as MaterialLink,
|
Link as MaterialLink,
|
||||||
|
Typography,
|
||||||
} from "@material-ui/core";
|
} from "@material-ui/core";
|
||||||
import Alert from "@material-ui/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 {
|
|
||||||
statusCode
|
|
||||||
statusReason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function LogsOverview(): JSX.Element {
|
function LogsOverview(): JSX.Element {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const detailReqLogId =
|
const detailReqLogId =
|
||||||
router.query.id && parseInt(router.query.id as string, 10);
|
router.query.id && parseInt(router.query.id as string, 10);
|
||||||
|
|
||||||
const { loading, error, data } = useQuery(HTTP_REQUEST_LOGS, {
|
const { loading, error, data } = useHttpRequestLogs();
|
||||||
pollInterval: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogClick = (reqId: number) => {
|
const handleLogClick = (reqId: number) => {
|
||||||
router.push("/proxy/logs?id=" + reqId, undefined, {
|
router.push("/proxy/logs?id=" + reqId, undefined, {
|
||||||
|
@ -16,15 +16,22 @@ import {
|
|||||||
import IconButton from "@material-ui/core/IconButton";
|
import IconButton from "@material-ui/core/IconButton";
|
||||||
import SearchIcon from "@material-ui/icons/Search";
|
import SearchIcon from "@material-ui/icons/Search";
|
||||||
import FilterListIcon from "@material-ui/icons/FilterList";
|
import FilterListIcon from "@material-ui/icons/FilterList";
|
||||||
|
import DeleteIcon from "@material-ui/icons/Delete";
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
|
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||||
import { withoutTypename } from "../../lib/omitTypename";
|
import { withoutTypename } from "../../lib/omitTypename";
|
||||||
import { Alert } from "@material-ui/lab";
|
import { Alert } from "@material-ui/lab";
|
||||||
|
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
|
||||||
|
import {
|
||||||
|
ConfirmationDialog,
|
||||||
|
useConfirmationDialog,
|
||||||
|
} from "./ConfirmationDialog";
|
||||||
|
|
||||||
const FILTER = gql`
|
const FILTER = gql`
|
||||||
query HttpRequestLogFilter {
|
query HttpRequestLogFilter {
|
||||||
httpRequestLogFilter {
|
httpRequestLogFilter {
|
||||||
onlyInScope
|
onlyInScope
|
||||||
|
searchExpression
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@ -33,6 +40,7 @@ const SET_FILTER = gql`
|
|||||||
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
|
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
|
||||||
setHttpRequestLogFilter(filter: $filter) {
|
setHttpRequestLogFilter(filter: $filter) {
|
||||||
onlyInScope
|
onlyInScope
|
||||||
|
searchExpression
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@ -69,37 +77,59 @@ const useStyles = makeStyles((theme: Theme) =>
|
|||||||
|
|
||||||
export interface SearchFilter {
|
export interface SearchFilter {
|
||||||
onlyInScope: boolean;
|
onlyInScope: boolean;
|
||||||
|
searchExpression: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Search(): JSX.Element {
|
function Search(): JSX.Element {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const [searchExpr, setSearchExpr] = useState("");
|
||||||
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
|
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
|
||||||
FILTER
|
FILTER,
|
||||||
|
{
|
||||||
|
onCompleted: (data) => {
|
||||||
|
setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const client = useApolloClient();
|
|
||||||
const [
|
const [
|
||||||
setFilterMutate,
|
setFilterMutate,
|
||||||
{ error: setFilterErr, loading: setFilterLoading },
|
{ error: setFilterErr, loading: setFilterLoading },
|
||||||
] = useMutation<{
|
] = useMutation<{
|
||||||
setHttpRequestLogFilter: SearchFilter | null;
|
setHttpRequestLogFilter: SearchFilter | null;
|
||||||
}>(SET_FILTER, {
|
}>(SET_FILTER, {
|
||||||
update(_, { data: { setHttpRequestLogFilter } }) {
|
update(cache, { data: { setHttpRequestLogFilter } }) {
|
||||||
client.writeQuery({
|
cache.writeQuery({
|
||||||
query: FILTER,
|
query: FILTER,
|
||||||
data: {
|
data: {
|
||||||
httpRequestLogFilter: setHttpRequestLogFilter,
|
httpRequestLogFilter: setHttpRequestLogFilter,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onError: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [
|
||||||
|
clearHTTPRequestLog,
|
||||||
|
clearHTTPRequestLogResult,
|
||||||
|
] = useClearHTTPRequestLog();
|
||||||
|
const clearHTTPConfirmationDialog = useConfirmationDialog();
|
||||||
|
|
||||||
const filterRef = useRef<HTMLElement | null>();
|
const filterRef = useRef<HTMLElement | null>();
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (e: React.SyntheticEvent) => {
|
const handleSubmit = (e: React.SyntheticEvent) => {
|
||||||
|
setFilterMutate({
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
...withoutTypename(filter?.httpRequestLogFilter),
|
||||||
|
searchExpression: searchExpr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setFilterOpen(false);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -111,22 +141,15 @@ function Search(): JSX.Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<ClickAwayListener onClickAway={handleClickAway}>
|
||||||
<Box style={{ display: "inline-block" }}>
|
|
||||||
{filterErr && (
|
|
||||||
<Box mb={4}>
|
|
||||||
<Alert severity="error">
|
|
||||||
Error fetching filter: {filterErr.message}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{setFilterErr && (
|
|
||||||
<Box mb={4}>
|
|
||||||
<Alert severity="error">
|
|
||||||
Error setting filter: {setFilterErr.message}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Paper
|
<Paper
|
||||||
component="form"
|
component="form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@ -138,14 +161,16 @@ function Search(): JSX.Element {
|
|||||||
className={classes.iconButton}
|
className={classes.iconButton}
|
||||||
onClick={() => setFilterOpen(!filterOpen)}
|
onClick={() => setFilterOpen(!filterOpen)}
|
||||||
style={{
|
style={{
|
||||||
color:
|
color: filter?.httpRequestLogFilter?.onlyInScope
|
||||||
filter?.httpRequestLogFilter !== null
|
|
||||||
? theme.palette.secondary.main
|
? theme.palette.secondary.main
|
||||||
: "inherit",
|
: "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filterLoading || setFilterLoading ? (
|
{filterLoading || setFilterLoading ? (
|
||||||
<CircularProgress className={classes.filterLoading} size={23} />
|
<CircularProgress
|
||||||
|
className={classes.filterLoading}
|
||||||
|
size={23}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FilterListIcon />
|
<FilterListIcon />
|
||||||
)}
|
)}
|
||||||
@ -154,6 +179,8 @@ function Search(): JSX.Element {
|
|||||||
<InputBase
|
<InputBase
|
||||||
className={classes.input}
|
className={classes.input}
|
||||||
placeholder="Search proxy logs…"
|
placeholder="Search proxy logs…"
|
||||||
|
value={searchExpr}
|
||||||
|
onChange={(e) => setSearchExpr(e.target.value)}
|
||||||
onFocus={() => setFilterOpen(true)}
|
onFocus={() => setFilterOpen(true)}
|
||||||
/>
|
/>
|
||||||
<Tooltip title="Search">
|
<Tooltip title="Search">
|
||||||
@ -161,8 +188,6 @@ function Search(): JSX.Element {
|
|||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Popper
|
<Popper
|
||||||
className={classes.filterPopper}
|
className={classes.filterPopper}
|
||||||
open={filterOpen}
|
open={filterOpen}
|
||||||
@ -193,8 +218,36 @@ function Search(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Popper>
|
</Popper>
|
||||||
</Box>
|
</Paper>
|
||||||
</ClickAwayListener>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
16
admin/src/components/reqlog/hooks/useClearHTTPRequestLog.ts
Normal 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 }],
|
||||||
|
});
|
||||||
|
}
|
22
admin/src/components/reqlog/hooks/useHttpRequestLogs.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { gql, useQuery } from "@apollo/client";
|
||||||
|
|
||||||
|
export const HTTP_REQUEST_LOGS = gql`
|
||||||
|
query HttpRequestLogs {
|
||||||
|
httpRequestLogs {
|
||||||
|
id
|
||||||
|
method
|
||||||
|
url
|
||||||
|
timestamp
|
||||||
|
response {
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function useHttpRequestLogs() {
|
||||||
|
return useQuery(HTTP_REQUEST_LOGS, {
|
||||||
|
pollInterval: 1000,
|
||||||
|
});
|
||||||
|
}
|
12
docs/.gitignore
vendored
Executable file
@ -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
@ -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
@ -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"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
14
docs/src/.vuepress/enhanceApp.js
Executable 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.
|
||||||
|
}
|
BIN
docs/src/.vuepress/public/assets/hetty_v0.2.0_header.png
Normal file
After Width: | Height: | Size: 144 KiB |
BIN
docs/src/.vuepress/public/assets/logo.png
Normal file
After Width: | Height: | Size: 13 KiB |
9
docs/src/.vuepress/styles/index.styl
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Custom Styles here.
|
||||||
|
*
|
||||||
|
* ref:https://v1.vuepress.vuejs.org/config/#index-styl
|
||||||
|
*/
|
||||||
|
|
||||||
|
.home .hero img
|
||||||
|
width 450px
|
||||||
|
max-width 100%!important
|
11
docs/src/.vuepress/styles/palette.styl
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Custom palette here.
|
||||||
|
*
|
||||||
|
* ref:https://v1.vuepress.vuejs.org/zh/config/#palette-styl
|
||||||
|
*/
|
||||||
|
|
||||||
|
$accentColor = #2CC09B
|
||||||
|
$textColor = #2c3e50
|
||||||
|
$borderColor = #eaecef
|
||||||
|
$codeBgColor = #282c34
|
||||||
|
$badgeTipColor = #2CC09B
|
21
docs/src/.vuepress/theme/LICENSE
Normal 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.
|
171
docs/src/.vuepress/theme/components/AlgoliaSearchBox.vue
Normal 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>
|
252
docs/src/.vuepress/theme/components/DropdownLink.vue
Normal 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>
|
33
docs/src/.vuepress/theme/components/DropdownTransition.vue
Normal 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>
|
197
docs/src/.vuepress/theme/components/Home.vue
Normal 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>
|
90
docs/src/.vuepress/theme/components/NavLink.vue
Normal 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>
|
156
docs/src/.vuepress/theme/components/NavLinks.vue
Normal 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>
|
162
docs/src/.vuepress/theme/components/Navbar.vue
Normal 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>
|
31
docs/src/.vuepress/theme/components/Page.vue
Normal 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>
|
155
docs/src/.vuepress/theme/components/PageEdit.vue
Normal 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>
|
163
docs/src/.vuepress/theme/components/PageNav.vue
Normal 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>
|
64
docs/src/.vuepress/theme/components/Sidebar.vue
Normal 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>
|
40
docs/src/.vuepress/theme/components/SidebarButton.vue
Normal 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>
|
141
docs/src/.vuepress/theme/components/SidebarGroup.vue
Normal 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>
|
133
docs/src/.vuepress/theme/components/SidebarLink.vue
Normal 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>
|
103
docs/src/.vuepress/theme/components/SidebarLinks.vue
Normal 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>
|
44
docs/src/.vuepress/theme/global-components/Badge.vue
Normal 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>
|
36
docs/src/.vuepress/theme/global-components/CodeBlock.vue
Normal 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>
|
105
docs/src/.vuepress/theme/global-components/CodeGroup.vue
Normal 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>
|
59
docs/src/.vuepress/theme/index.js
Normal 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]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
30
docs/src/.vuepress/theme/layouts/404.vue
Normal 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>
|
137
docs/src/.vuepress/theme/layouts/Layout.vue
Normal 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>
|
1
docs/src/.vuepress/theme/noopModule.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {}
|
22
docs/src/.vuepress/theme/styles/arrow.styl
Normal 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
|
137
docs/src/.vuepress/theme/styles/code.styl
Normal 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'
|
1
docs/src/.vuepress/theme/styles/config.styl
Normal file
@ -0,0 +1 @@
|
|||||||
|
$contentClass = '.theme-default-content'
|
44
docs/src/.vuepress/theme/styles/custom-blocks.styl
Normal 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
|
200
docs/src/.vuepress/theme/styles/index.styl
Normal 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'
|
37
docs/src/.vuepress/theme/styles/mobile.styl
Normal 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
|
3
docs/src/.vuepress/theme/styles/toc.styl
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.table-of-contents
|
||||||
|
.badge
|
||||||
|
vertical-align middle
|
9
docs/src/.vuepress/theme/styles/wrapper.styl
Normal 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
|
||||||
|
|
244
docs/src/.vuepress/theme/util/index.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
docs/src/appendix/index.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
sidebarDepth: 1
|
||||||
|
sidebar: auto
|
||||||
|
---
|
||||||
|
|
||||||
|
# Appendix
|
||||||
|
|
||||||
|
## GraphQL API
|
||||||
|
|
||||||
|
Hetty exposes a GraphQL API over HTTP for managing all its features. This API is
|
||||||
|
used by the web admin interface; a Next.js app using Apollo Client.
|
||||||
|
|
||||||
|
### Playground
|
||||||
|
|
||||||
|
You can also introspect and manually experiment with the API via the included GraphQL Playground. To access it, start Hetty and visit: [http://localhost:8080/api/playground](http://localhost:8080/api/playground).
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
<<< @/../pkg/api/schema.graphql
|
||||||
|
|
||||||
|
Source: [pkg/api/schema.graphql](https://github.com/dstotijn/hetty/blob/master/pkg/api/schema.graphql)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 David Stotijn
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
BIN
docs/src/guide/add_scope_rule.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
docs/src/guide/create_project.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
docs/src/guide/filter_in_scope.png
Normal file
After Width: | Height: | Size: 27 KiB |
85
docs/src/guide/getting-started.md
Executable file
@ -0,0 +1,85 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Hetty compiles to a static binary, with an embedded SQLite database and web
|
||||||
|
admin interface.
|
||||||
|
|
||||||
|
### Install pre-built release (recommended)
|
||||||
|
|
||||||
|
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- [Go](https://golang.org/)
|
||||||
|
- [Yarn](https://yarnpkg.com/)
|
||||||
|
- [go.rice](https://github.com/GeertJohan/go.rice)
|
||||||
|
|
||||||
|
Hetty depends on SQLite (via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3))
|
||||||
|
and needs `cgo` to compile. Additionally, the static resources for the web admin interface
|
||||||
|
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in
|
||||||
|
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand.
|
||||||
|
|
||||||
|
Clone the repository and use the `build` make target to create a binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone git@github.com:dstotijn/hetty.git
|
||||||
|
$ cd hetty
|
||||||
|
$ make build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
|
||||||
|
For persistent storage of CA certificate and project databases, mount a volume:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ mkdir -p $HOME/.hetty
|
||||||
|
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When Hetty is started, by default it listens on `:8080` and is accessible via
|
||||||
|
[http://localhost:8080](http://localhost:8080). Depending on incoming HTTP
|
||||||
|
requests, it either acts as a MITM proxy, or it serves the API and web interface.
|
||||||
|
|
||||||
|
By default, project database files and CA certificates are stored in a `.hetty`
|
||||||
|
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
|
||||||
|
on Windows).
|
||||||
|
|
||||||
|
To start, ensure `hetty` (downloaded from a release, or manually built) is in your
|
||||||
|
`$PATH` and run:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hetty
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
|
||||||
|
```
|
||||||
|
2020/11/01 14:47:10 [INFO] Running server on :8080 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, visit [http://localhost:8080](http://localhost:8080) to get started.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
An overview of available configuration flags:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hetty -h
|
||||||
|
Usage of ./hetty:
|
||||||
|
-addr string
|
||||||
|
TCP address to listen on, in the form "host:port" (default ":8080")
|
||||||
|
-adminPath string
|
||||||
|
File path to admin build
|
||||||
|
-cert string
|
||||||
|
CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem")
|
||||||
|
-key string
|
||||||
|
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
|
||||||
|
-projects string
|
||||||
|
Projects directory path (default "~/.hetty/projects")
|
||||||
|
```
|
BIN
docs/src/guide/hetty_v0.2.0_header.png
Normal file
After Width: | Height: | Size: 144 KiB |
29
docs/src/guide/index.md
Executable file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
sidebarDepth: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

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

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Machine-in-the-middle (MITM) HTTP/1.1 proxy with logs
|
||||||
|
- Project based database storage (SQLite)
|
||||||
|
- Scope support
|
||||||
|
- Headless management API using GraphQL
|
||||||
|
- Embedded web admin interface (Next.js)
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
Hetty is in early development. Additional features are planned
|
||||||
|
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a>
|
||||||
|
for details.
|
||||||
|
:::
|
BIN
docs/src/guide/manage_projects.png
Normal file
After Width: | Height: | Size: 49 KiB |
238
docs/src/guide/modules.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
---
|
||||||
|
sidebarDepth: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Modules
|
||||||
|
|
||||||
|
Hetty consists of various _modules_ that together form an HTTP toolkit. They
|
||||||
|
typically are managed via the web admin interface. Some modules expose settings
|
||||||
|
and behavior that is leveraged by other modules.
|
||||||
|
|
||||||
|
The available modules:
|
||||||
|
|
||||||
|
[[toc]]
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
Projects are self-contained (SQLite) database files that contain module data.
|
||||||
|
They allow you organize your work, for example to split your work between research
|
||||||
|
targets.
|
||||||
|
|
||||||
|
You can create multiple projects, but only one can be open at a time. Most other
|
||||||
|
modules are useful only if you have a project opened, so creating a project is
|
||||||
|
typically the first thing you do when you start using Hetty.
|
||||||
|
|
||||||
|
### Creating a new project
|
||||||
|
|
||||||
|
When you open the Hetty admin interface after starting the program, you’ll be prompted
|
||||||
|
on the homepage to create a new project. Give it a name (alphanumeric and space character)
|
||||||
|
and click the create button:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The project name will become the base for the database file on disk. For example,
|
||||||
|
if you name your project `My first project`, the file on disk will be
|
||||||
|
`My first project.db`.
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
Project database files by default are stored in `$HOME/.hetty/projects` on Linux
|
||||||
|
and macOS, and `%USERPROFILE%/.hetty` on Windows. You can override this path with
|
||||||
|
the `-projects` flag. See: [Usage](/guide/getting-started.md#usage).
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Managing projects
|
||||||
|
|
||||||
|
You can open and delete existing projects on the “Projects” page, available via
|
||||||
|
the folder icon in the menu bar.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
An opened (_active_) project is listed in green. You can close it using the “X”
|
||||||
|
button. To delete a project, use the trash bin icon.
|
||||||
|
|
||||||
|
::: danger
|
||||||
|
Deleting a project is irreversible.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Proxy
|
||||||
|
|
||||||
|
Hetty features a HTTP/1.1 proxy server with machine-in-the-middle (MITM) behavior.
|
||||||
|
For now, its only configuration is done via command line flags.
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
Support for HTTP/2 and WebSockets are currently not supported, but this will
|
||||||
|
likely be addressed in the (near) future.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Network address
|
||||||
|
|
||||||
|
To configure the network address that the proxy listens on, use the `-addr` flag
|
||||||
|
when starting Hetty. The address needs to be in the format `[host]:port`. E.g.
|
||||||
|
`localhost:3000` or `:3000`. If the host in the address is empty or a literal
|
||||||
|
unspecified IP address, Hetty listens on all available unicast and anycast IP
|
||||||
|
addresses of the local system.
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
When not specified with `-addr`, Hetty by default listens on `:8080`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Example of starting Hetty, binding to port `3000` on all IPs of the local system:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hetty -addr :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the proxy
|
||||||
|
|
||||||
|
To use Hetty as an HTTP proxy server, you’ll need to configure your HTTP client (e.g.
|
||||||
|
your browser or mobile OS). Refer to your client documentation or use a search
|
||||||
|
engine to find instructions for setting a HTTP proxy.
|
||||||
|
|
||||||
|
### Certificate Authority (CA)
|
||||||
|
|
||||||
|
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
|
||||||
|
Hetty will need to be set up. Furthermore, the CA certificate needs to be
|
||||||
|
installed to the host for them to be trusted by your browser. The following steps
|
||||||
|
will cover how you can generate a certificate, provide it to Hetty, and how
|
||||||
|
you can install it in your local CA store.
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
Certificate management features (e.g. automated installing of a root CA to your local
|
||||||
|
OS or browser trust store) are planned for a future release. In the meantime, please
|
||||||
|
use the instructions below.
|
||||||
|
:::
|
||||||
|
|
||||||
|
#### Generating a CA certificate
|
||||||
|
|
||||||
|
You can generate a CA keypair two different ways. The first is bundled directly
|
||||||
|
with Hetty, and simplifies the process immensely. The alternative is using OpenSSL
|
||||||
|
to generate them, which provides more control over expiration time and cryptography
|
||||||
|
used, but requires you install the OpenSSL tooling. The first is suggested for any
|
||||||
|
beginners trying to get started.
|
||||||
|
|
||||||
|
#### Generating CA certificates with hetty
|
||||||
|
|
||||||
|
Hetty will generate the default key and certificate on its own if none are supplied
|
||||||
|
or found in `~/.hetty/` when first running the CLI. To generate a default key and
|
||||||
|
certificate with hetty, simply run the command with no arguments.
|
||||||
|
|
||||||
|
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
|
||||||
|
`~/.hetty/hetty_cert.pem` respectively.
|
||||||
|
|
||||||
|
#### Generating CA certificates with OpenSSL
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
This following instructions are for Linux but should provide guidance for Windows
|
||||||
|
and macOS as well.
|
||||||
|
:::
|
||||||
|
|
||||||
|
You can start off by generating a new key and CA certificate which will both expire
|
||||||
|
after a month.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
$ mkdir ~/.hetty
|
||||||
|
$ openssl req -newkey rsa:2048 -new -nodes -x509 -days 31 -keyout ~/.hetty/hetty_key.pem -out ~/.hetty/hetty_cert.pem
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The default location which `hetty` will check for the key and CA certificate is under
|
||||||
|
`~/.hetty/`, at `hetty_key.pem` and `hetty_cert.pem` respectively. You can move them
|
||||||
|
here and `hetty` will detect them automatically. Otherwise, you can specify the
|
||||||
|
location of these as arguments to `hetty`.
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
\$ hetty -key /some/directory/key.pem -cert /some/directory/cert.pem
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Trusting the CA certificate
|
||||||
|
|
||||||
|
In order for your browser to allow traffic to the local Hetty proxy, you may need
|
||||||
|
to install these certificates to your local CA store.
|
||||||
|
|
||||||
|
On Ubuntu, you can update your local CA store with the certificate by running the
|
||||||
|
following commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
$ sudo cp ~/.hetty/hetty_cert.pem /usr/local/share/ca-certificates/hetty.crt
|
||||||
|
$ sudo update-ca-certificates
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, you would add your certificate by using the Certificate Manager,
|
||||||
|
which you can run via:
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
certmgr.msc
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS, you can add your certificate by using the Keychain Access program. This
|
||||||
|
can be found under `Application/Utilities/Keychain Access.app`. After opening this,
|
||||||
|
drag the certificate into the app. Next, open the certificate in the app, enter the
|
||||||
|
_Trust_ section, and under _When using this certificate_ select _Always Trust_.
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
Various Linux distributions may require other steps or commands for updating
|
||||||
|
their certificate authority. See the documentation relevant to your distribution for
|
||||||
|
more information on how to update the system to trust your self-signed certificate.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The scope module lets you define _rules_ that other modules can use to control
|
||||||
|
their behavior. For example, the [proxy logs module](#proxy-logs) can be configured to only
|
||||||
|
show logs for in-scope requests; meaning only requests are shown that match one
|
||||||
|
or more scope rules.
|
||||||
|
|
||||||
|
### Managing scope rules
|
||||||
|
|
||||||
|
You can manage scope rules via the “Scope” page, available via the crosshair icon
|
||||||
|
in the menu bar.
|
||||||
|
|
||||||
|
A rule consists of a _type_ and a regular expression ([RE2 syntax](https://github.com/google/re2/wiki/Syntax)).
|
||||||
|
The only supported type at the moment is “URL”.
|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
Just like all module configuration, scope rules are defined and stored per-project.
|
||||||
|
:::
|
||||||
|
|
||||||
|
#### Adding a rule
|
||||||
|
|
||||||
|
On the ”Scope” page, enter a regular expression and click “Add rule”:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
_Example: Rule that matches URLs with `example.com` (or any subdomain) on any path._
|
||||||
|
|
||||||
|
#### Deleting rules
|
||||||
|
|
||||||
|
Use the trash icon next to an existing scope rule to delete it.
|
||||||
|
|
||||||
|
## Proxy logs
|
||||||
|
|
||||||
|
You can few logs captured by the Proxy module on the Proxy logs page, available
|
||||||
|
via the proxy icon in the menu bar.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Showing a log entry
|
||||||
|
|
||||||
|
Click a row in the overview table to view log details in the bottom request and
|
||||||
|
response panes. When a request and/or response has a body, it's shown below the
|
||||||
|
HTTP headers. Header keys and values can be copied to clipboard by clicking them.
|
||||||
|
|
||||||
|
### Filtering logs
|
||||||
|
|
||||||
|
To only show log entries that match any of the [scope rules](#scope), click the
|
||||||
|
filter icon in the search bar and select “Only show in-scope requests”:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
::: tip INFO
|
||||||
|
At the moment of writing (`v0.2.0`), text based search is not implemented yet.
|
||||||
|
:::
|
BIN
docs/src/guide/proxy_logs.png
Normal file
After Width: | Height: | Size: 410 KiB |
6
docs/src/index.md
Executable file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
home: true
|
||||||
|
heroImage: https://hetty.xyz/assets/logo.png
|
||||||
|
actionText: Read the docs →
|
||||||
|
actionLink: /guide/
|
||||||
|
---
|
7869
docs/yarn.lock
Normal file
9
go.sum
@ -1,5 +1,3 @@
|
|||||||
github.com/99designs/gqlgen v0.11.3 h1:oFSxl1DFS9X///uHV3y6CEfpcXWrDUxVblR4Xib2bs4=
|
|
||||||
github.com/99designs/gqlgen v0.11.3/go.mod h1:RgX5GRRdDWNkh4pBrdzNpNPFVsdoUFY2+adM6nb1N+4=
|
|
||||||
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
|
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
|
||||||
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
|
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
@ -37,10 +35,6 @@ github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB
|
|||||||
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
|
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
|
||||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ=
|
|
||||||
github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
|
||||||
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
|
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
|
||||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
|
github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo=
|
||||||
@ -107,8 +101,6 @@ github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8W
|
|||||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||||
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
|
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e h1:+w0Zm/9gaWpEAyDlU1eKOuk5twTjAjuevXqcJJw8hrg=
|
||||||
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
||||||
github.com/vektah/gqlparser/v2 v2.0.1 h1:xgl5abVnsd4hkN9rk65OJID9bfcLSMuTaTcZj777q1o=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.0.1/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
|
github.com/vektah/gqlparser/v2 v2.1.0 h1:uiKJ+T5HMGGQM2kRKQ8Pxw8+Zq9qhhZhz/lieYvCMns=
|
||||||
github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
github.com/vektah/gqlparser/v2 v2.1.0/go.mod h1:SyUiHgLATUR8BiYURfTirrTcGpcE+4XkV2se04Px1Ms=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@ -128,6 +120,7 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtD
|
|||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
@env = CGO_CFLAGS=-DUSE_LIBSQLITE3 CGO_LDFLAGS=-Wl,-undefined,dynamic_lookup
|
|
||||||
**/*.go {
|
|
||||||
daemon +sigterm: @env go run -tags libsqlite3 ./cmd/hetty
|
|
||||||
}
|
|
@ -43,6 +43,10 @@ type DirectiveRoot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComplexityRoot struct {
|
type ComplexityRoot struct {
|
||||||
|
ClearHTTPRequestLogResult struct {
|
||||||
|
Success func(childComplexity int) int
|
||||||
|
}
|
||||||
|
|
||||||
CloseProjectResult struct {
|
CloseProjectResult struct {
|
||||||
Success func(childComplexity int) int
|
Success func(childComplexity int) int
|
||||||
}
|
}
|
||||||
@ -69,6 +73,7 @@ type ComplexityRoot struct {
|
|||||||
|
|
||||||
HTTPRequestLogFilter struct {
|
HTTPRequestLogFilter struct {
|
||||||
OnlyInScope func(childComplexity int) int
|
OnlyInScope func(childComplexity int) int
|
||||||
|
SearchExpression func(childComplexity int) int
|
||||||
}
|
}
|
||||||
|
|
||||||
HTTPResponseLog struct {
|
HTTPResponseLog struct {
|
||||||
@ -81,6 +86,7 @@ type ComplexityRoot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Mutation struct {
|
Mutation struct {
|
||||||
|
ClearHTTPRequestLog func(childComplexity int) int
|
||||||
CloseProject func(childComplexity int) int
|
CloseProject func(childComplexity int) int
|
||||||
DeleteProject func(childComplexity int, name string) int
|
DeleteProject func(childComplexity int, name string) int
|
||||||
OpenProject func(childComplexity int, name string) int
|
OpenProject func(childComplexity int, name string) int
|
||||||
@ -118,6 +124,7 @@ type MutationResolver interface {
|
|||||||
OpenProject(ctx context.Context, name string) (*Project, error)
|
OpenProject(ctx context.Context, name string) (*Project, error)
|
||||||
CloseProject(ctx context.Context) (*CloseProjectResult, error)
|
CloseProject(ctx context.Context) (*CloseProjectResult, error)
|
||||||
DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error)
|
DeleteProject(ctx context.Context, name string) (*DeleteProjectResult, error)
|
||||||
|
ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error)
|
||||||
SetScope(ctx context.Context, scope []ScopeRuleInput) ([]ScopeRule, error)
|
SetScope(ctx context.Context, scope []ScopeRuleInput) ([]ScopeRule, error)
|
||||||
SetHTTPRequestLogFilter(ctx context.Context, filter *HTTPRequestLogFilterInput) (*HTTPRequestLogFilter, error)
|
SetHTTPRequestLogFilter(ctx context.Context, filter *HTTPRequestLogFilterInput) (*HTTPRequestLogFilter, error)
|
||||||
}
|
}
|
||||||
@ -145,6 +152,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
|||||||
_ = ec
|
_ = ec
|
||||||
switch typeName + "." + field {
|
switch typeName + "." + field {
|
||||||
|
|
||||||
|
case "ClearHTTPRequestLogResult.success":
|
||||||
|
if e.complexity.ClearHTTPRequestLogResult.Success == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.complexity.ClearHTTPRequestLogResult.Success(childComplexity), true
|
||||||
|
|
||||||
case "CloseProjectResult.success":
|
case "CloseProjectResult.success":
|
||||||
if e.complexity.CloseProjectResult.Success == nil {
|
if e.complexity.CloseProjectResult.Success == nil {
|
||||||
break
|
break
|
||||||
@ -236,6 +250,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
|||||||
|
|
||||||
return e.complexity.HTTPRequestLogFilter.OnlyInScope(childComplexity), true
|
return e.complexity.HTTPRequestLogFilter.OnlyInScope(childComplexity), true
|
||||||
|
|
||||||
|
case "HttpRequestLogFilter.searchExpression":
|
||||||
|
if e.complexity.HTTPRequestLogFilter.SearchExpression == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.complexity.HTTPRequestLogFilter.SearchExpression(childComplexity), true
|
||||||
|
|
||||||
case "HttpResponseLog.body":
|
case "HttpResponseLog.body":
|
||||||
if e.complexity.HTTPResponseLog.Body == nil {
|
if e.complexity.HTTPResponseLog.Body == nil {
|
||||||
break
|
break
|
||||||
@ -278,6 +299,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
|
|||||||
|
|
||||||
return e.complexity.HTTPResponseLog.StatusReason(childComplexity), true
|
return e.complexity.HTTPResponseLog.StatusReason(childComplexity), true
|
||||||
|
|
||||||
|
case "Mutation.clearHTTPRequestLog":
|
||||||
|
if e.complexity.Mutation.ClearHTTPRequestLog == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.complexity.Mutation.ClearHTTPRequestLog(childComplexity), true
|
||||||
|
|
||||||
case "Mutation.closeProject":
|
case "Mutation.closeProject":
|
||||||
if e.complexity.Mutation.CloseProject == nil {
|
if e.complexity.Mutation.CloseProject == nil {
|
||||||
break
|
break
|
||||||
@ -553,12 +581,18 @@ type DeleteProjectResult {
|
|||||||
success: Boolean!
|
success: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClearHTTPRequestLogResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
input HttpRequestLogFilterInput {
|
input HttpRequestLogFilterInput {
|
||||||
onlyInScope: Boolean
|
onlyInScope: Boolean
|
||||||
|
searchExpression: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type HttpRequestLogFilter {
|
type HttpRequestLogFilter {
|
||||||
onlyInScope: Boolean!
|
onlyInScope: Boolean!
|
||||||
|
searchExpression: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
@ -574,6 +608,7 @@ type Mutation {
|
|||||||
openProject(name: String!): Project
|
openProject(name: String!): Project
|
||||||
closeProject: CloseProjectResult!
|
closeProject: CloseProjectResult!
|
||||||
deleteProject(name: String!): DeleteProjectResult!
|
deleteProject(name: String!): DeleteProjectResult!
|
||||||
|
clearHTTPRequestLog: ClearHTTPRequestLogResult!
|
||||||
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
|
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
|
||||||
setHttpRequestLogFilter(
|
setHttpRequestLogFilter(
|
||||||
filter: HttpRequestLogFilterInput
|
filter: HttpRequestLogFilterInput
|
||||||
@ -730,6 +765,41 @@ func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArg
|
|||||||
|
|
||||||
// region **************************** field.gotpl *****************************
|
// region **************************** field.gotpl *****************************
|
||||||
|
|
||||||
|
func (ec *executionContext) _ClearHTTPRequestLogResult_success(ctx context.Context, field graphql.CollectedField, obj *ClearHTTPRequestLogResult) (ret graphql.Marshaler) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
|
ret = graphql.Null
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fc := &graphql.FieldContext{
|
||||||
|
Object: "ClearHTTPRequestLogResult",
|
||||||
|
Field: field,
|
||||||
|
Args: nil,
|
||||||
|
IsMethod: false,
|
||||||
|
IsResolver: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||||
|
ctx = rctx // use context from middleware stack in children
|
||||||
|
return obj.Success, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.(bool)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _CloseProjectResult_success(ctx context.Context, field graphql.CollectedField, obj *CloseProjectResult) (ret graphql.Marshaler) {
|
func (ec *executionContext) _CloseProjectResult_success(ctx context.Context, field graphql.CollectedField, obj *CloseProjectResult) (ret graphql.Marshaler) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@ -1179,6 +1249,38 @@ func (ec *executionContext) _HttpRequestLogFilter_onlyInScope(ctx context.Contex
|
|||||||
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
|
return ec.marshalNBoolean2bool(ctx, field.Selections, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) _HttpRequestLogFilter_searchExpression(ctx context.Context, field graphql.CollectedField, obj *HTTPRequestLogFilter) (ret graphql.Marshaler) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
|
ret = graphql.Null
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fc := &graphql.FieldContext{
|
||||||
|
Object: "HttpRequestLogFilter",
|
||||||
|
Field: field,
|
||||||
|
Args: nil,
|
||||||
|
IsMethod: false,
|
||||||
|
IsResolver: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||||
|
ctx = rctx // use context from middleware stack in children
|
||||||
|
return obj.SearchExpression, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.(*string)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalOString2ᚖstring(ctx, field.Selections, res)
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _HttpResponseLog_requestId(ctx context.Context, field graphql.CollectedField, obj *HTTPResponseLog) (ret graphql.Marshaler) {
|
func (ec *executionContext) _HttpResponseLog_requestId(ctx context.Context, field graphql.CollectedField, obj *HTTPResponseLog) (ret graphql.Marshaler) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@ -1502,6 +1604,41 @@ func (ec *executionContext) _Mutation_deleteProject(ctx context.Context, field g
|
|||||||
return ec.marshalNDeleteProjectResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐDeleteProjectResult(ctx, field.Selections, res)
|
return ec.marshalNDeleteProjectResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐDeleteProjectResult(ctx, field.Selections, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) _Mutation_clearHTTPRequestLog(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
ec.Error(ctx, ec.Recover(ctx, r))
|
||||||
|
ret = graphql.Null
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fc := &graphql.FieldContext{
|
||||||
|
Object: "Mutation",
|
||||||
|
Field: field,
|
||||||
|
Args: nil,
|
||||||
|
IsMethod: true,
|
||||||
|
IsResolver: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = graphql.WithFieldContext(ctx, fc)
|
||||||
|
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
|
||||||
|
ctx = rctx // use context from middleware stack in children
|
||||||
|
return ec.resolvers.Mutation().ClearHTTPRequestLog(rctx)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ec.Error(ctx, err)
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
if resTmp == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, fc) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
res := resTmp.(*ClearHTTPRequestLogResult)
|
||||||
|
fc.Result = res
|
||||||
|
return ec.marshalNClearHTTPRequestLogResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐClearHTTPRequestLogResult(ctx, field.Selections, res)
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) _Mutation_setScope(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
func (ec *executionContext) _Mutation_setScope(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@ -3193,6 +3330,14 @@ func (ec *executionContext) unmarshalInputHttpRequestLogFilterInput(ctx context.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return it, err
|
return it, err
|
||||||
}
|
}
|
||||||
|
case "searchExpression":
|
||||||
|
var err error
|
||||||
|
|
||||||
|
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("searchExpression"))
|
||||||
|
it.SearchExpression, err = ec.unmarshalOString2ᚖstring(ctx, v)
|
||||||
|
if err != nil {
|
||||||
|
return it, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3271,6 +3416,33 @@ func (ec *executionContext) unmarshalInputScopeRuleInput(ctx context.Context, ob
|
|||||||
|
|
||||||
// region **************************** object.gotpl ****************************
|
// region **************************** object.gotpl ****************************
|
||||||
|
|
||||||
|
var clearHTTPRequestLogResultImplementors = []string{"ClearHTTPRequestLogResult"}
|
||||||
|
|
||||||
|
func (ec *executionContext) _ClearHTTPRequestLogResult(ctx context.Context, sel ast.SelectionSet, obj *ClearHTTPRequestLogResult) graphql.Marshaler {
|
||||||
|
fields := graphql.CollectFields(ec.OperationContext, sel, clearHTTPRequestLogResultImplementors)
|
||||||
|
|
||||||
|
out := graphql.NewFieldSet(fields)
|
||||||
|
var invalids uint32
|
||||||
|
for i, field := range fields {
|
||||||
|
switch field.Name {
|
||||||
|
case "__typename":
|
||||||
|
out.Values[i] = graphql.MarshalString("ClearHTTPRequestLogResult")
|
||||||
|
case "success":
|
||||||
|
out.Values[i] = ec._ClearHTTPRequestLogResult_success(ctx, field, obj)
|
||||||
|
if out.Values[i] == graphql.Null {
|
||||||
|
invalids++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
panic("unknown field " + strconv.Quote(field.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Dispatch()
|
||||||
|
if invalids > 0 {
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
var closeProjectResultImplementors = []string{"CloseProjectResult"}
|
var closeProjectResultImplementors = []string{"CloseProjectResult"}
|
||||||
|
|
||||||
func (ec *executionContext) _CloseProjectResult(ctx context.Context, sel ast.SelectionSet, obj *CloseProjectResult) graphql.Marshaler {
|
func (ec *executionContext) _CloseProjectResult(ctx context.Context, sel ast.SelectionSet, obj *CloseProjectResult) graphql.Marshaler {
|
||||||
@ -3429,6 +3601,8 @@ func (ec *executionContext) _HttpRequestLogFilter(ctx context.Context, sel ast.S
|
|||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
invalids++
|
invalids++
|
||||||
}
|
}
|
||||||
|
case "searchExpression":
|
||||||
|
out.Values[i] = ec._HttpRequestLogFilter_searchExpression(ctx, field, obj)
|
||||||
default:
|
default:
|
||||||
panic("unknown field " + strconv.Quote(field.Name))
|
panic("unknown field " + strconv.Quote(field.Name))
|
||||||
}
|
}
|
||||||
@ -3516,6 +3690,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
|
|||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
invalids++
|
invalids++
|
||||||
}
|
}
|
||||||
|
case "clearHTTPRequestLog":
|
||||||
|
out.Values[i] = ec._Mutation_clearHTTPRequestLog(ctx, field)
|
||||||
|
if out.Values[i] == graphql.Null {
|
||||||
|
invalids++
|
||||||
|
}
|
||||||
case "setScope":
|
case "setScope":
|
||||||
out.Values[i] = ec._Mutation_setScope(ctx, field)
|
out.Values[i] = ec._Mutation_setScope(ctx, field)
|
||||||
if out.Values[i] == graphql.Null {
|
if out.Values[i] == graphql.Null {
|
||||||
@ -3985,6 +4164,20 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) marshalNClearHTTPRequestLogResult2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐClearHTTPRequestLogResult(ctx context.Context, sel ast.SelectionSet, v ClearHTTPRequestLogResult) graphql.Marshaler {
|
||||||
|
return ec._ClearHTTPRequestLogResult(ctx, sel, &v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *executionContext) marshalNClearHTTPRequestLogResult2ᚖgithubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐClearHTTPRequestLogResult(ctx context.Context, sel ast.SelectionSet, v *ClearHTTPRequestLogResult) graphql.Marshaler {
|
||||||
|
if v == nil {
|
||||||
|
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
|
||||||
|
ec.Errorf(ctx, "must not be null")
|
||||||
|
}
|
||||||
|
return graphql.Null
|
||||||
|
}
|
||||||
|
return ec._ClearHTTPRequestLogResult(ctx, sel, v)
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *executionContext) marshalNCloseProjectResult2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐCloseProjectResult(ctx context.Context, sel ast.SelectionSet, v CloseProjectResult) graphql.Marshaler {
|
func (ec *executionContext) marshalNCloseProjectResult2githubᚗcomᚋdstotijnᚋhettyᚋpkgᚋapiᚐCloseProjectResult(ctx context.Context, sel ast.SelectionSet, v CloseProjectResult) graphql.Marshaler {
|
||||||
return ec._CloseProjectResult(ctx, sel, &v)
|
return ec._CloseProjectResult(ctx, sel, &v)
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ClearHTTPRequestLogResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
type CloseProjectResult struct {
|
type CloseProjectResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
@ -35,10 +39,12 @@ type HTTPRequestLog struct {
|
|||||||
|
|
||||||
type HTTPRequestLogFilter struct {
|
type HTTPRequestLogFilter struct {
|
||||||
OnlyInScope bool `json:"onlyInScope"`
|
OnlyInScope bool `json:"onlyInScope"`
|
||||||
|
SearchExpression *string `json:"searchExpression"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HTTPRequestLogFilterInput struct {
|
type HTTPRequestLogFilterInput struct {
|
||||||
OnlyInScope *bool `json:"onlyInScope"`
|
OnlyInScope *bool `json:"onlyInScope"`
|
||||||
|
SearchExpression *string `json:"searchExpression"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HTTPResponseLog struct {
|
type HTTPResponseLog struct {
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/dstotijn/hetty/pkg/proj"
|
"github.com/dstotijn/hetty/pkg/proj"
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
"github.com/dstotijn/hetty/pkg/scope"
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -30,13 +31,7 @@ func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
|||||||
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
|
func (r *queryResolver) HTTPRequestLogs(ctx context.Context) ([]HTTPRequestLog, error) {
|
||||||
reqs, err := r.RequestLogService.FindRequests(ctx)
|
reqs, err := r.RequestLogService.FindRequests(ctx)
|
||||||
if err == proj.ErrNoProject {
|
if err == proj.ErrNoProject {
|
||||||
return nil, &gqlerror.Error{
|
return nil, noActiveProjectErr(ctx)
|
||||||
Path: graphql.GetPath(ctx),
|
|
||||||
Message: "No active project.",
|
|
||||||
Extensions: map[string]interface{}{
|
|
||||||
"code": "no_active_project",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not query repository for requests: %v", err)
|
return nil, fmt.Errorf("could not query repository for requests: %v", err)
|
||||||
@ -209,6 +204,13 @@ func (r *mutationResolver) DeleteProject(ctx context.Context, name string) (*Del
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) ClearHTTPRequestLog(ctx context.Context) (*ClearHTTPRequestLogResult, error) {
|
||||||
|
if err := r.RequestLogService.ClearRequests(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not clear request log: %v", err)
|
||||||
|
}
|
||||||
|
return &ClearHTTPRequestLogResult{true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *mutationResolver) SetScope(ctx context.Context, input []ScopeRuleInput) ([]ScopeRule, error) {
|
func (r *mutationResolver) SetScope(ctx context.Context, input []ScopeRuleInput) ([]ScopeRule, error) {
|
||||||
rules := make([]scope.Rule, len(input))
|
rules := make([]scope.Rule, len(input))
|
||||||
for i, rule := range input {
|
for i, rule := range input {
|
||||||
@ -256,15 +258,18 @@ func (r *mutationResolver) SetHTTPRequestLogFilter(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
input *HTTPRequestLogFilterInput,
|
input *HTTPRequestLogFilterInput,
|
||||||
) (*HTTPRequestLogFilter, error) {
|
) (*HTTPRequestLogFilter, error) {
|
||||||
filter := findRequestsFilterFromInput(input)
|
filter, err := findRequestsFilterFromInput(input)
|
||||||
if err := r.RequestLogService.SetRequestLogFilter(ctx, filter); err != nil {
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse request log filter: %v", err)
|
||||||
|
}
|
||||||
|
err = r.RequestLogService.SetRequestLogFilter(ctx, filter)
|
||||||
|
if err == proj.ErrNoProject {
|
||||||
|
return nil, noActiveProjectErr(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not set request log filter: %v", err)
|
return nil, fmt.Errorf("could not set request log filter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
empty := reqlog.FindRequestsFilter{}
|
|
||||||
if filter == empty {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return findReqFilterToHTTPReqLogFilter(filter), nil
|
return findReqFilterToHTTPReqLogFilter(filter), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,13 +295,21 @@ func scopeToScopeRules(rules []scope.Rule) []ScopeRule {
|
|||||||
return scopeRules
|
return scopeRules
|
||||||
}
|
}
|
||||||
|
|
||||||
func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter) {
|
func findRequestsFilterFromInput(input *HTTPRequestLogFilterInput) (filter reqlog.FindRequestsFilter, err error) {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if input.OnlyInScope != nil {
|
if input.OnlyInScope != nil {
|
||||||
filter.OnlyInScope = *input.OnlyInScope
|
filter.OnlyInScope = *input.OnlyInScope
|
||||||
}
|
}
|
||||||
|
if input.SearchExpression != nil && *input.SearchExpression != "" {
|
||||||
|
expr, err := search.ParseQuery(*input.SearchExpression)
|
||||||
|
if err != nil {
|
||||||
|
return reqlog.FindRequestsFilter{}, fmt.Errorf("could not parse search query: %v", err)
|
||||||
|
}
|
||||||
|
filter.RawSearchExpr = *input.SearchExpression
|
||||||
|
filter.SearchExpr = expr
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -310,5 +323,19 @@ func findReqFilterToHTTPReqLogFilter(findReqFilter reqlog.FindRequestsFilter) *H
|
|||||||
OnlyInScope: findReqFilter.OnlyInScope,
|
OnlyInScope: findReqFilter.OnlyInScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if findReqFilter.RawSearchExpr != "" {
|
||||||
|
httpReqLogFilter.SearchExpression = &findReqFilter.RawSearchExpr
|
||||||
|
}
|
||||||
|
|
||||||
return httpReqLogFilter
|
return httpReqLogFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func noActiveProjectErr(ctx context.Context) error {
|
||||||
|
return &gqlerror.Error{
|
||||||
|
Path: graphql.GetPath(ctx),
|
||||||
|
Message: "No active project.",
|
||||||
|
Extensions: map[string]interface{}{
|
||||||
|
"code": "no_active_project",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -58,12 +58,18 @@ type DeleteProjectResult {
|
|||||||
success: Boolean!
|
success: Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClearHTTPRequestLogResult {
|
||||||
|
success: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
input HttpRequestLogFilterInput {
|
input HttpRequestLogFilterInput {
|
||||||
onlyInScope: Boolean
|
onlyInScope: Boolean
|
||||||
|
searchExpression: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type HttpRequestLogFilter {
|
type HttpRequestLogFilter {
|
||||||
onlyInScope: Boolean!
|
onlyInScope: Boolean!
|
||||||
|
searchExpression: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
@ -79,6 +85,7 @@ type Mutation {
|
|||||||
openProject(name: String!): Project
|
openProject(name: String!): Project
|
||||||
closeProject: CloseProjectResult!
|
closeProject: CloseProjectResult!
|
||||||
deleteProject(name: String!): DeleteProjectResult!
|
deleteProject(name: String!): DeleteProjectResult!
|
||||||
|
clearHTTPRequestLog: ClearHTTPRequestLogResult!
|
||||||
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
|
setScope(scope: [ScopeRuleInput!]!): [ScopeRule!]!
|
||||||
setHttpRequestLogFilter(
|
setHttpRequestLogFilter(
|
||||||
filter: HttpRequestLogFilterInput
|
filter: HttpRequestLogFilterInput
|
||||||
|
@ -1,759 +0,0 @@
|
|||||||
/*
|
|
||||||
** 2012-11-13
|
|
||||||
**
|
|
||||||
** The author disclaims copyright to this source code. In place of
|
|
||||||
** a legal notice, here is a blessing:
|
|
||||||
**
|
|
||||||
** May you do good and not evil.
|
|
||||||
** May you find forgiveness for yourself and forgive others.
|
|
||||||
** May you share freely, never taking more than you give.
|
|
||||||
**
|
|
||||||
******************************************************************************
|
|
||||||
**
|
|
||||||
** The code in this file implements a compact but reasonably
|
|
||||||
** efficient regular-expression matcher for posix extended regular
|
|
||||||
** expressions against UTF8 text.
|
|
||||||
**
|
|
||||||
** This file is an SQLite extension. It registers a single function
|
|
||||||
** named "regexp(A,B)" where A is the regular expression and B is the
|
|
||||||
** string to be matched. By registering this function, SQLite will also
|
|
||||||
** then implement the "B regexp A" operator. Note that with the function
|
|
||||||
** the regular expression comes first, but with the operator it comes
|
|
||||||
** second.
|
|
||||||
**
|
|
||||||
** The following regular expression syntax is supported:
|
|
||||||
**
|
|
||||||
** X* zero or more occurrences of X
|
|
||||||
** X+ one or more occurrences of X
|
|
||||||
** X? zero or one occurrences of X
|
|
||||||
** X{p,q} between p and q occurrences of X
|
|
||||||
** (X) match X
|
|
||||||
** X|Y X or Y
|
|
||||||
** ^X X occurring at the beginning of the string
|
|
||||||
** X$ X occurring at the end of the string
|
|
||||||
** . Match any single character
|
|
||||||
** \c Character c where c is one of \{}()[]|*+?.
|
|
||||||
** \c C-language escapes for c in afnrtv. ex: \t or \n
|
|
||||||
** \uXXXX Where XXXX is exactly 4 hex digits, unicode value XXXX
|
|
||||||
** \xXX Where XX is exactly 2 hex digits, unicode value XX
|
|
||||||
** [abc] Any single character from the set abc
|
|
||||||
** [^abc] Any single character not in the set abc
|
|
||||||
** [a-z] Any single character in the range a-z
|
|
||||||
** [^a-z] Any single character not in the range a-z
|
|
||||||
** \b Word boundary
|
|
||||||
** \w Word character. [A-Za-z0-9_]
|
|
||||||
** \W Non-word character
|
|
||||||
** \d Digit
|
|
||||||
** \D Non-digit
|
|
||||||
** \s Whitespace character
|
|
||||||
** \S Non-whitespace character
|
|
||||||
**
|
|
||||||
** A nondeterministic finite automaton (NFA) is used for matching, so the
|
|
||||||
** performance is bounded by O(N*M) where N is the size of the regular
|
|
||||||
** expression and M is the size of the input string. The matcher never
|
|
||||||
** exhibits exponential behavior. Note that the X{p,q} operator expands
|
|
||||||
** to p copies of X following by q-p copies of X? and that the size of the
|
|
||||||
** regular expression in the O(N*M) performance bound is computed after
|
|
||||||
** this expansion.
|
|
||||||
*/
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include "sqlite3ext.h"
|
|
||||||
SQLITE_EXTENSION_INIT1
|
|
||||||
|
|
||||||
/*
|
|
||||||
** The following #defines change the names of some functions implemented in
|
|
||||||
** this file to prevent name collisions with C-library functions of the
|
|
||||||
** same name.
|
|
||||||
*/
|
|
||||||
#define re_match sqlite3re_match
|
|
||||||
#define re_compile sqlite3re_compile
|
|
||||||
#define re_free sqlite3re_free
|
|
||||||
|
|
||||||
/* The end-of-input character */
|
|
||||||
#define RE_EOF 0 /* End of input */
|
|
||||||
|
|
||||||
/* The NFA is implemented as sequence of opcodes taken from the following
|
|
||||||
** set. Each opcode has a single integer argument.
|
|
||||||
*/
|
|
||||||
#define RE_OP_MATCH 1 /* Match the one character in the argument */
|
|
||||||
#define RE_OP_ANY 2 /* Match any one character. (Implements ".") */
|
|
||||||
#define RE_OP_ANYSTAR 3 /* Special optimized version of .* */
|
|
||||||
#define RE_OP_FORK 4 /* Continue to both next and opcode at iArg */
|
|
||||||
#define RE_OP_GOTO 5 /* Jump to opcode at iArg */
|
|
||||||
#define RE_OP_ACCEPT 6 /* Halt and indicate a successful match */
|
|
||||||
#define RE_OP_CC_INC 7 /* Beginning of a [...] character class */
|
|
||||||
#define RE_OP_CC_EXC 8 /* Beginning of a [^...] character class */
|
|
||||||
#define RE_OP_CC_VALUE 9 /* Single value in a character class */
|
|
||||||
#define RE_OP_CC_RANGE 10 /* Range of values in a character class */
|
|
||||||
#define RE_OP_WORD 11 /* Perl word character [A-Za-z0-9_] */
|
|
||||||
#define RE_OP_NOTWORD 12 /* Not a perl word character */
|
|
||||||
#define RE_OP_DIGIT 13 /* digit: [0-9] */
|
|
||||||
#define RE_OP_NOTDIGIT 14 /* Not a digit */
|
|
||||||
#define RE_OP_SPACE 15 /* space: [ \t\n\r\v\f] */
|
|
||||||
#define RE_OP_NOTSPACE 16 /* Not a digit */
|
|
||||||
#define RE_OP_BOUNDARY 17 /* Boundary between word and non-word */
|
|
||||||
|
|
||||||
/* Each opcode is a "state" in the NFA */
|
|
||||||
typedef unsigned short ReStateNumber;
|
|
||||||
|
|
||||||
/* Because this is an NFA and not a DFA, multiple states can be active at
|
|
||||||
** once. An instance of the following object records all active states in
|
|
||||||
** the NFA. The implementation is optimized for the common case where the
|
|
||||||
** number of actives states is small.
|
|
||||||
*/
|
|
||||||
typedef struct ReStateSet {
|
|
||||||
unsigned nState; /* Number of current states */
|
|
||||||
ReStateNumber *aState; /* Current states */
|
|
||||||
} ReStateSet;
|
|
||||||
|
|
||||||
/* An input string read one character at a time.
|
|
||||||
*/
|
|
||||||
typedef struct ReInput ReInput;
|
|
||||||
struct ReInput {
|
|
||||||
const unsigned char *z; /* All text */
|
|
||||||
int i; /* Next byte to read */
|
|
||||||
int mx; /* EOF when i>=mx */
|
|
||||||
};
|
|
||||||
|
|
||||||
/* A compiled NFA (or an NFA that is in the process of being compiled) is
|
|
||||||
** an instance of the following object.
|
|
||||||
*/
|
|
||||||
typedef struct ReCompiled ReCompiled;
|
|
||||||
struct ReCompiled {
|
|
||||||
ReInput sIn; /* Regular expression text */
|
|
||||||
const char *zErr; /* Error message to return */
|
|
||||||
char *aOp; /* Operators for the virtual machine */
|
|
||||||
int *aArg; /* Arguments to each operator */
|
|
||||||
unsigned (*xNextChar)(ReInput*); /* Next character function */
|
|
||||||
unsigned char zInit[12]; /* Initial text to match */
|
|
||||||
int nInit; /* Number of characters in zInit */
|
|
||||||
unsigned nState; /* Number of entries in aOp[] and aArg[] */
|
|
||||||
unsigned nAlloc; /* Slots allocated for aOp[] and aArg[] */
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Add a state to the given state set if it is not already there */
|
|
||||||
static void re_add_state(ReStateSet *pSet, int newState){
|
|
||||||
unsigned i;
|
|
||||||
for(i=0; i<pSet->nState; i++) if( pSet->aState[i]==newState ) return;
|
|
||||||
pSet->aState[pSet->nState++] = (ReStateNumber)newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extract the next unicode character from *pzIn and return it. Advance
|
|
||||||
** *pzIn to the first byte past the end of the character returned. To
|
|
||||||
** be clear: this routine converts utf8 to unicode. This routine is
|
|
||||||
** optimized for the common case where the next character is a single byte.
|
|
||||||
*/
|
|
||||||
static unsigned re_next_char(ReInput *p){
|
|
||||||
unsigned c;
|
|
||||||
if( p->i>=p->mx ) return 0;
|
|
||||||
c = p->z[p->i++];
|
|
||||||
if( c>=0x80 ){
|
|
||||||
if( (c&0xe0)==0xc0 && p->i<p->mx && (p->z[p->i]&0xc0)==0x80 ){
|
|
||||||
c = (c&0x1f)<<6 | (p->z[p->i++]&0x3f);
|
|
||||||
if( c<0x80 ) c = 0xfffd;
|
|
||||||
}else if( (c&0xf0)==0xe0 && p->i+1<p->mx && (p->z[p->i]&0xc0)==0x80
|
|
||||||
&& (p->z[p->i+1]&0xc0)==0x80 ){
|
|
||||||
c = (c&0x0f)<<12 | ((p->z[p->i]&0x3f)<<6) | (p->z[p->i+1]&0x3f);
|
|
||||||
p->i += 2;
|
|
||||||
if( c<=0x7ff || (c>=0xd800 && c<=0xdfff) ) c = 0xfffd;
|
|
||||||
}else if( (c&0xf8)==0xf0 && p->i+3<p->mx && (p->z[p->i]&0xc0)==0x80
|
|
||||||
&& (p->z[p->i+1]&0xc0)==0x80 && (p->z[p->i+2]&0xc0)==0x80 ){
|
|
||||||
c = (c&0x07)<<18 | ((p->z[p->i]&0x3f)<<12) | ((p->z[p->i+1]&0x3f)<<6)
|
|
||||||
| (p->z[p->i+2]&0x3f);
|
|
||||||
p->i += 3;
|
|
||||||
if( c<=0xffff || c>0x10ffff ) c = 0xfffd;
|
|
||||||
}else{
|
|
||||||
c = 0xfffd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
static unsigned re_next_char_nocase(ReInput *p){
|
|
||||||
unsigned c = re_next_char(p);
|
|
||||||
if( c>='A' && c<='Z' ) c += 'a' - 'A';
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Return true if c is a perl "word" character: [A-Za-z0-9_] */
|
|
||||||
static int re_word_char(int c){
|
|
||||||
return (c>='0' && c<='9') || (c>='a' && c<='z')
|
|
||||||
|| (c>='A' && c<='Z') || c=='_';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Return true if c is a "digit" character: [0-9] */
|
|
||||||
static int re_digit_char(int c){
|
|
||||||
return (c>='0' && c<='9');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Return true if c is a perl "space" character: [ \t\r\n\v\f] */
|
|
||||||
static int re_space_char(int c){
|
|
||||||
return c==' ' || c=='\t' || c=='\n' || c=='\r' || c=='\v' || c=='\f';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Run a compiled regular expression on the zero-terminated input
|
|
||||||
** string zIn[]. Return true on a match and false if there is no match.
|
|
||||||
*/
|
|
||||||
static int re_match(ReCompiled *pRe, const unsigned char *zIn, int nIn){
|
|
||||||
ReStateSet aStateSet[2], *pThis, *pNext;
|
|
||||||
ReStateNumber aSpace[100];
|
|
||||||
ReStateNumber *pToFree;
|
|
||||||
unsigned int i = 0;
|
|
||||||
unsigned int iSwap = 0;
|
|
||||||
int c = RE_EOF+1;
|
|
||||||
int cPrev = 0;
|
|
||||||
int rc = 0;
|
|
||||||
ReInput in;
|
|
||||||
|
|
||||||
in.z = zIn;
|
|
||||||
in.i = 0;
|
|
||||||
in.mx = nIn>=0 ? nIn : (int)strlen((char const*)zIn);
|
|
||||||
|
|
||||||
/* Look for the initial prefix match, if there is one. */
|
|
||||||
if( pRe->nInit ){
|
|
||||||
unsigned char x = pRe->zInit[0];
|
|
||||||
while( in.i+pRe->nInit<=in.mx
|
|
||||||
&& (zIn[in.i]!=x ||
|
|
||||||
strncmp((const char*)zIn+in.i, (const char*)pRe->zInit, pRe->nInit)!=0)
|
|
||||||
){
|
|
||||||
in.i++;
|
|
||||||
}
|
|
||||||
if( in.i+pRe->nInit>in.mx ) return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if( pRe->nState<=(sizeof(aSpace)/(sizeof(aSpace[0])*2)) ){
|
|
||||||
pToFree = 0;
|
|
||||||
aStateSet[0].aState = aSpace;
|
|
||||||
}else{
|
|
||||||
pToFree = sqlite3_malloc64( sizeof(ReStateNumber)*2*pRe->nState );
|
|
||||||
if( pToFree==0 ) return -1;
|
|
||||||
aStateSet[0].aState = pToFree;
|
|
||||||
}
|
|
||||||
aStateSet[1].aState = &aStateSet[0].aState[pRe->nState];
|
|
||||||
pNext = &aStateSet[1];
|
|
||||||
pNext->nState = 0;
|
|
||||||
re_add_state(pNext, 0);
|
|
||||||
while( c!=RE_EOF && pNext->nState>0 ){
|
|
||||||
cPrev = c;
|
|
||||||
c = pRe->xNextChar(&in);
|
|
||||||
pThis = pNext;
|
|
||||||
pNext = &aStateSet[iSwap];
|
|
||||||
iSwap = 1 - iSwap;
|
|
||||||
pNext->nState = 0;
|
|
||||||
for(i=0; i<pThis->nState; i++){
|
|
||||||
int x = pThis->aState[i];
|
|
||||||
switch( pRe->aOp[x] ){
|
|
||||||
case RE_OP_MATCH: {
|
|
||||||
if( pRe->aArg[x]==c ) re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_ANY: {
|
|
||||||
re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_WORD: {
|
|
||||||
if( re_word_char(c) ) re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_NOTWORD: {
|
|
||||||
if( !re_word_char(c) ) re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_DIGIT: {
|
|
||||||
if( re_digit_char(c) ) re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_NOTDIGIT: {
|
|
||||||
if( !re_digit_char(c) ) re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_SPACE: {
|
|
||||||
if( re_space_char(c) ) re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_NOTSPACE: {
|
|
||||||
if( !re_space_char(c) ) re_add_state(pNext, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_BOUNDARY: {
|
|
||||||
if( re_word_char(c)!=re_word_char(cPrev) ) re_add_state(pThis, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_ANYSTAR: {
|
|
||||||
re_add_state(pNext, x);
|
|
||||||
re_add_state(pThis, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_FORK: {
|
|
||||||
re_add_state(pThis, x+pRe->aArg[x]);
|
|
||||||
re_add_state(pThis, x+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_GOTO: {
|
|
||||||
re_add_state(pThis, x+pRe->aArg[x]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case RE_OP_ACCEPT: {
|
|
||||||
rc = 1;
|
|
||||||
goto re_match_end;
|
|
||||||
}
|
|
||||||
case RE_OP_CC_INC:
|
|
||||||
case RE_OP_CC_EXC: {
|
|
||||||
int j = 1;
|
|
||||||
int n = pRe->aArg[x];
|
|
||||||
int hit = 0;
|
|
||||||
for(j=1; j>0 && j<n; j++){
|
|
||||||
if( pRe->aOp[x+j]==RE_OP_CC_VALUE ){
|
|
||||||
if( pRe->aArg[x+j]==c ){
|
|
||||||
hit = 1;
|
|
||||||
j = -1;
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
if( pRe->aArg[x+j]<=c && pRe->aArg[x+j+1]>=c ){
|
|
||||||
hit = 1;
|
|
||||||
j = -1;
|
|
||||||
}else{
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if( pRe->aOp[x]==RE_OP_CC_EXC ) hit = !hit;
|
|
||||||
if( hit ) re_add_state(pNext, x+n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(i=0; i<pNext->nState; i++){
|
|
||||||
if( pRe->aOp[pNext->aState[i]]==RE_OP_ACCEPT ){ rc = 1; break; }
|
|
||||||
}
|
|
||||||
re_match_end:
|
|
||||||
sqlite3_free(pToFree);
|
|
||||||
return rc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Resize the opcode and argument arrays for an RE under construction.
|
|
||||||
*/
|
|
||||||
static int re_resize(ReCompiled *p, int N){
|
|
||||||
char *aOp;
|
|
||||||
int *aArg;
|
|
||||||
aOp = sqlite3_realloc64(p->aOp, N*sizeof(p->aOp[0]));
|
|
||||||
if( aOp==0 ) return 1;
|
|
||||||
p->aOp = aOp;
|
|
||||||
aArg = sqlite3_realloc64(p->aArg, N*sizeof(p->aArg[0]));
|
|
||||||
if( aArg==0 ) return 1;
|
|
||||||
p->aArg = aArg;
|
|
||||||
p->nAlloc = N;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Insert a new opcode and argument into an RE under construction. The
|
|
||||||
** insertion point is just prior to existing opcode iBefore.
|
|
||||||
*/
|
|
||||||
static int re_insert(ReCompiled *p, int iBefore, int op, int arg){
|
|
||||||
int i;
|
|
||||||
if( p->nAlloc<=p->nState && re_resize(p, p->nAlloc*2) ) return 0;
|
|
||||||
for(i=p->nState; i>iBefore; i--){
|
|
||||||
p->aOp[i] = p->aOp[i-1];
|
|
||||||
p->aArg[i] = p->aArg[i-1];
|
|
||||||
}
|
|
||||||
p->nState++;
|
|
||||||
p->aOp[iBefore] = (char)op;
|
|
||||||
p->aArg[iBefore] = arg;
|
|
||||||
return iBefore;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Append a new opcode and argument to the end of the RE under construction.
|
|
||||||
*/
|
|
||||||
static int re_append(ReCompiled *p, int op, int arg){
|
|
||||||
return re_insert(p, p->nState, op, arg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make a copy of N opcodes starting at iStart onto the end of the RE
|
|
||||||
** under construction.
|
|
||||||
*/
|
|
||||||
static void re_copy(ReCompiled *p, int iStart, int N){
|
|
||||||
if( p->nState+N>=p->nAlloc && re_resize(p, p->nAlloc*2+N) ) return;
|
|
||||||
memcpy(&p->aOp[p->nState], &p->aOp[iStart], N*sizeof(p->aOp[0]));
|
|
||||||
memcpy(&p->aArg[p->nState], &p->aArg[iStart], N*sizeof(p->aArg[0]));
|
|
||||||
p->nState += N;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Return true if c is a hexadecimal digit character: [0-9a-fA-F]
|
|
||||||
** If c is a hex digit, also set *pV = (*pV)*16 + valueof(c). If
|
|
||||||
** c is not a hex digit *pV is unchanged.
|
|
||||||
*/
|
|
||||||
static int re_hex(int c, int *pV){
|
|
||||||
if( c>='0' && c<='9' ){
|
|
||||||
c -= '0';
|
|
||||||
}else if( c>='a' && c<='f' ){
|
|
||||||
c -= 'a' - 10;
|
|
||||||
}else if( c>='A' && c<='F' ){
|
|
||||||
c -= 'A' - 10;
|
|
||||||
}else{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
*pV = (*pV)*16 + (c & 0xff);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A backslash character has been seen, read the next character and
|
|
||||||
** return its interpretation.
|
|
||||||
*/
|
|
||||||
static unsigned re_esc_char(ReCompiled *p){
|
|
||||||
static const char zEsc[] = "afnrtv\\()*.+?[$^{|}]";
|
|
||||||
static const char zTrans[] = "\a\f\n\r\t\v";
|
|
||||||
int i, v = 0;
|
|
||||||
char c;
|
|
||||||
if( p->sIn.i>=p->sIn.mx ) return 0;
|
|
||||||
c = p->sIn.z[p->sIn.i];
|
|
||||||
if( c=='u' && p->sIn.i+4<p->sIn.mx ){
|
|
||||||
const unsigned char *zIn = p->sIn.z + p->sIn.i;
|
|
||||||
if( re_hex(zIn[1],&v)
|
|
||||||
&& re_hex(zIn[2],&v)
|
|
||||||
&& re_hex(zIn[3],&v)
|
|
||||||
&& re_hex(zIn[4],&v)
|
|
||||||
){
|
|
||||||
p->sIn.i += 5;
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if( c=='x' && p->sIn.i+2<p->sIn.mx ){
|
|
||||||
const unsigned char *zIn = p->sIn.z + p->sIn.i;
|
|
||||||
if( re_hex(zIn[1],&v)
|
|
||||||
&& re_hex(zIn[2],&v)
|
|
||||||
){
|
|
||||||
p->sIn.i += 3;
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for(i=0; zEsc[i] && zEsc[i]!=c; i++){}
|
|
||||||
if( zEsc[i] ){
|
|
||||||
if( i<6 ) c = zTrans[i];
|
|
||||||
p->sIn.i++;
|
|
||||||
}else{
|
|
||||||
p->zErr = "unknown \\ escape";
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Forward declaration */
|
|
||||||
static const char *re_subcompile_string(ReCompiled*);
|
|
||||||
|
|
||||||
/* Peek at the next byte of input */
|
|
||||||
static unsigned char rePeek(ReCompiled *p){
|
|
||||||
return p->sIn.i<p->sIn.mx ? p->sIn.z[p->sIn.i] : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Compile RE text into a sequence of opcodes. Continue up to the
|
|
||||||
** first unmatched ")" character, then return. If an error is found,
|
|
||||||
** return a pointer to the error message string.
|
|
||||||
*/
|
|
||||||
static const char *re_subcompile_re(ReCompiled *p){
|
|
||||||
const char *zErr;
|
|
||||||
int iStart, iEnd, iGoto;
|
|
||||||
iStart = p->nState;
|
|
||||||
zErr = re_subcompile_string(p);
|
|
||||||
if( zErr ) return zErr;
|
|
||||||
while( rePeek(p)=='|' ){
|
|
||||||
iEnd = p->nState;
|
|
||||||
re_insert(p, iStart, RE_OP_FORK, iEnd + 2 - iStart);
|
|
||||||
iGoto = re_append(p, RE_OP_GOTO, 0);
|
|
||||||
p->sIn.i++;
|
|
||||||
zErr = re_subcompile_string(p);
|
|
||||||
if( zErr ) return zErr;
|
|
||||||
p->aArg[iGoto] = p->nState - iGoto;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Compile an element of regular expression text (anything that can be
|
|
||||||
** an operand to the "|" operator). Return NULL on success or a pointer
|
|
||||||
** to the error message if there is a problem.
|
|
||||||
*/
|
|
||||||
static const char *re_subcompile_string(ReCompiled *p){
|
|
||||||
int iPrev = -1;
|
|
||||||
int iStart;
|
|
||||||
unsigned c;
|
|
||||||
const char *zErr;
|
|
||||||
while( (c = p->xNextChar(&p->sIn))!=0 ){
|
|
||||||
iStart = p->nState;
|
|
||||||
switch( c ){
|
|
||||||
case '|':
|
|
||||||
case '$':
|
|
||||||
case ')': {
|
|
||||||
p->sIn.i--;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
case '(': {
|
|
||||||
zErr = re_subcompile_re(p);
|
|
||||||
if( zErr ) return zErr;
|
|
||||||
if( rePeek(p)!=')' ) return "unmatched '('";
|
|
||||||
p->sIn.i++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case '.': {
|
|
||||||
if( rePeek(p)=='*' ){
|
|
||||||
re_append(p, RE_OP_ANYSTAR, 0);
|
|
||||||
p->sIn.i++;
|
|
||||||
}else{
|
|
||||||
re_append(p, RE_OP_ANY, 0);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case '*': {
|
|
||||||
if( iPrev<0 ) return "'*' without operand";
|
|
||||||
re_insert(p, iPrev, RE_OP_GOTO, p->nState - iPrev + 1);
|
|
||||||
re_append(p, RE_OP_FORK, iPrev - p->nState + 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case '+': {
|
|
||||||
if( iPrev<0 ) return "'+' without operand";
|
|
||||||
re_append(p, RE_OP_FORK, iPrev - p->nState);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case '?': {
|
|
||||||
if( iPrev<0 ) return "'?' without operand";
|
|
||||||
re_insert(p, iPrev, RE_OP_FORK, p->nState - iPrev+1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case '{': {
|
|
||||||
int m = 0, n = 0;
|
|
||||||
int sz, j;
|
|
||||||
if( iPrev<0 ) return "'{m,n}' without operand";
|
|
||||||
while( (c=rePeek(p))>='0' && c<='9' ){ m = m*10 + c - '0'; p->sIn.i++; }
|
|
||||||
n = m;
|
|
||||||
if( c==',' ){
|
|
||||||
p->sIn.i++;
|
|
||||||
n = 0;
|
|
||||||
while( (c=rePeek(p))>='0' && c<='9' ){ n = n*10 + c-'0'; p->sIn.i++; }
|
|
||||||
}
|
|
||||||
if( c!='}' ) return "unmatched '{'";
|
|
||||||
if( n>0 && n<m ) return "n less than m in '{m,n}'";
|
|
||||||
p->sIn.i++;
|
|
||||||
sz = p->nState - iPrev;
|
|
||||||
if( m==0 ){
|
|
||||||
if( n==0 ) return "both m and n are zero in '{m,n}'";
|
|
||||||
re_insert(p, iPrev, RE_OP_FORK, sz+1);
|
|
||||||
n--;
|
|
||||||
}else{
|
|
||||||
for(j=1; j<m; j++) re_copy(p, iPrev, sz);
|
|
||||||
}
|
|
||||||
for(j=m; j<n; j++){
|
|
||||||
re_append(p, RE_OP_FORK, sz+1);
|
|
||||||
re_copy(p, iPrev, sz);
|
|
||||||
}
|
|
||||||
if( n==0 && m>0 ){
|
|
||||||
re_append(p, RE_OP_FORK, -sz);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case '[': {
|
|
||||||
int iFirst = p->nState;
|
|
||||||
if( rePeek(p)=='^' ){
|
|
||||||
re_append(p, RE_OP_CC_EXC, 0);
|
|
||||||
p->sIn.i++;
|
|
||||||
}else{
|
|
||||||
re_append(p, RE_OP_CC_INC, 0);
|
|
||||||
}
|
|
||||||
while( (c = p->xNextChar(&p->sIn))!=0 ){
|
|
||||||
if( c=='[' && rePeek(p)==':' ){
|
|
||||||
return "POSIX character classes not supported";
|
|
||||||
}
|
|
||||||
if( c=='\\' ) c = re_esc_char(p);
|
|
||||||
if( rePeek(p)=='-' ){
|
|
||||||
re_append(p, RE_OP_CC_RANGE, c);
|
|
||||||
p->sIn.i++;
|
|
||||||
c = p->xNextChar(&p->sIn);
|
|
||||||
if( c=='\\' ) c = re_esc_char(p);
|
|
||||||
re_append(p, RE_OP_CC_RANGE, c);
|
|
||||||
}else{
|
|
||||||
re_append(p, RE_OP_CC_VALUE, c);
|
|
||||||
}
|
|
||||||
if( rePeek(p)==']' ){ p->sIn.i++; break; }
|
|
||||||
}
|
|
||||||
if( c==0 ) return "unclosed '['";
|
|
||||||
p->aArg[iFirst] = p->nState - iFirst;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case '\\': {
|
|
||||||
int specialOp = 0;
|
|
||||||
switch( rePeek(p) ){
|
|
||||||
case 'b': specialOp = RE_OP_BOUNDARY; break;
|
|
||||||
case 'd': specialOp = RE_OP_DIGIT; break;
|
|
||||||
case 'D': specialOp = RE_OP_NOTDIGIT; break;
|
|
||||||
case 's': specialOp = RE_OP_SPACE; break;
|
|
||||||
case 'S': specialOp = RE_OP_NOTSPACE; break;
|
|
||||||
case 'w': specialOp = RE_OP_WORD; break;
|
|
||||||
case 'W': specialOp = RE_OP_NOTWORD; break;
|
|
||||||
}
|
|
||||||
if( specialOp ){
|
|
||||||
p->sIn.i++;
|
|
||||||
re_append(p, specialOp, 0);
|
|
||||||
}else{
|
|
||||||
c = re_esc_char(p);
|
|
||||||
re_append(p, RE_OP_MATCH, c);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
re_append(p, RE_OP_MATCH, c);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
iPrev = iStart;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Free and reclaim all the memory used by a previously compiled
|
|
||||||
** regular expression. Applications should invoke this routine once
|
|
||||||
** for every call to re_compile() to avoid memory leaks.
|
|
||||||
*/
|
|
||||||
static void re_free(ReCompiled *pRe){
|
|
||||||
if( pRe ){
|
|
||||||
sqlite3_free(pRe->aOp);
|
|
||||||
sqlite3_free(pRe->aArg);
|
|
||||||
sqlite3_free(pRe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
** Compile a textual regular expression in zIn[] into a compiled regular
|
|
||||||
** expression suitable for us by re_match() and return a pointer to the
|
|
||||||
** compiled regular expression in *ppRe. Return NULL on success or an
|
|
||||||
** error message if something goes wrong.
|
|
||||||
*/
|
|
||||||
static const char *re_compile(ReCompiled **ppRe, const char *zIn, int noCase){
|
|
||||||
ReCompiled *pRe;
|
|
||||||
const char *zErr;
|
|
||||||
int i, j;
|
|
||||||
|
|
||||||
*ppRe = 0;
|
|
||||||
pRe = sqlite3_malloc( sizeof(*pRe) );
|
|
||||||
if( pRe==0 ){
|
|
||||||
return "out of memory";
|
|
||||||
}
|
|
||||||
memset(pRe, 0, sizeof(*pRe));
|
|
||||||
pRe->xNextChar = noCase ? re_next_char_nocase : re_next_char;
|
|
||||||
if( re_resize(pRe, 30) ){
|
|
||||||
re_free(pRe);
|
|
||||||
return "out of memory";
|
|
||||||
}
|
|
||||||
if( zIn[0]=='^' ){
|
|
||||||
zIn++;
|
|
||||||
}else{
|
|
||||||
re_append(pRe, RE_OP_ANYSTAR, 0);
|
|
||||||
}
|
|
||||||
pRe->sIn.z = (unsigned char*)zIn;
|
|
||||||
pRe->sIn.i = 0;
|
|
||||||
pRe->sIn.mx = (int)strlen(zIn);
|
|
||||||
zErr = re_subcompile_re(pRe);
|
|
||||||
if( zErr ){
|
|
||||||
re_free(pRe);
|
|
||||||
return zErr;
|
|
||||||
}
|
|
||||||
if( rePeek(pRe)=='$' && pRe->sIn.i+1>=pRe->sIn.mx ){
|
|
||||||
re_append(pRe, RE_OP_MATCH, RE_EOF);
|
|
||||||
re_append(pRe, RE_OP_ACCEPT, 0);
|
|
||||||
*ppRe = pRe;
|
|
||||||
}else if( pRe->sIn.i>=pRe->sIn.mx ){
|
|
||||||
re_append(pRe, RE_OP_ACCEPT, 0);
|
|
||||||
*ppRe = pRe;
|
|
||||||
}else{
|
|
||||||
re_free(pRe);
|
|
||||||
return "unrecognized character";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The following is a performance optimization. If the regex begins with
|
|
||||||
** ".*" (if the input regex lacks an initial "^") and afterwards there are
|
|
||||||
** one or more matching characters, enter those matching characters into
|
|
||||||
** zInit[]. The re_match() routine can then search ahead in the input
|
|
||||||
** string looking for the initial match without having to run the whole
|
|
||||||
** regex engine over the string. Do not worry able trying to match
|
|
||||||
** unicode characters beyond plane 0 - those are very rare and this is
|
|
||||||
** just an optimization. */
|
|
||||||
if( pRe->aOp[0]==RE_OP_ANYSTAR ){
|
|
||||||
for(j=0, i=1; j<sizeof(pRe->zInit)-2 && pRe->aOp[i]==RE_OP_MATCH; i++){
|
|
||||||
unsigned x = pRe->aArg[i];
|
|
||||||
if( x<=127 ){
|
|
||||||
pRe->zInit[j++] = (unsigned char)x;
|
|
||||||
}else if( x<=0xfff ){
|
|
||||||
pRe->zInit[j++] = (unsigned char)(0xc0 | (x>>6));
|
|
||||||
pRe->zInit[j++] = 0x80 | (x&0x3f);
|
|
||||||
}else if( x<=0xffff ){
|
|
||||||
pRe->zInit[j++] = (unsigned char)(0xd0 | (x>>12));
|
|
||||||
pRe->zInit[j++] = 0x80 | ((x>>6)&0x3f);
|
|
||||||
pRe->zInit[j++] = 0x80 | (x&0x3f);
|
|
||||||
}else{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if( j>0 && pRe->zInit[j-1]==0 ) j--;
|
|
||||||
pRe->nInit = j;
|
|
||||||
}
|
|
||||||
return pRe->zErr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
** Implementation of the regexp() SQL function. This function implements
|
|
||||||
** the build-in REGEXP operator. The first argument to the function is the
|
|
||||||
** pattern and the second argument is the string. So, the SQL statements:
|
|
||||||
**
|
|
||||||
** A REGEXP B
|
|
||||||
**
|
|
||||||
** is implemented as regexp(B,A).
|
|
||||||
*/
|
|
||||||
static void re_sql_func(
|
|
||||||
sqlite3_context *context,
|
|
||||||
int argc,
|
|
||||||
sqlite3_value **argv
|
|
||||||
){
|
|
||||||
ReCompiled *pRe; /* Compiled regular expression */
|
|
||||||
const char *zPattern; /* The regular expression */
|
|
||||||
const unsigned char *zStr;/* String being searched */
|
|
||||||
const char *zErr; /* Compile error message */
|
|
||||||
int setAux = 0; /* True to invoke sqlite3_set_auxdata() */
|
|
||||||
|
|
||||||
pRe = sqlite3_get_auxdata(context, 0);
|
|
||||||
if( pRe==0 ){
|
|
||||||
zPattern = (const char*)sqlite3_value_text(argv[0]);
|
|
||||||
if( zPattern==0 ) return;
|
|
||||||
zErr = re_compile(&pRe, zPattern, 0);
|
|
||||||
if( zErr ){
|
|
||||||
re_free(pRe);
|
|
||||||
sqlite3_result_error(context, zErr, -1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if( pRe==0 ){
|
|
||||||
sqlite3_result_error_nomem(context);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAux = 1;
|
|
||||||
}
|
|
||||||
zStr = (const unsigned char*)sqlite3_value_text(argv[1]);
|
|
||||||
if( zStr!=0 ){
|
|
||||||
sqlite3_result_int(context, re_match(pRe, zStr, -1));
|
|
||||||
}
|
|
||||||
if( setAux ){
|
|
||||||
sqlite3_set_auxdata(context, 0, pRe, (void(*)(void*))re_free);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
** Invoke this routine to register the regexp() function with the
|
|
||||||
** SQLite database connection.
|
|
||||||
*/
|
|
||||||
#ifdef _WIN32
|
|
||||||
__declspec(dllexport)
|
|
||||||
#endif
|
|
||||||
int sqlite3_regexp_init(
|
|
||||||
sqlite3 *db,
|
|
||||||
char **pzErrMsg,
|
|
||||||
const sqlite3_api_routines *pApi
|
|
||||||
){
|
|
||||||
int rc = SQLITE_OK;
|
|
||||||
SQLITE_EXTENSION_INIT2(pApi);
|
|
||||||
rc = sqlite3_create_function(db, "regexp", 2, SQLITE_UTF8, 0, re_sql_func, 0, 0);
|
|
||||||
return rc;
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
package regexp
|
|
||||||
|
|
||||||
// #ifndef USE_LIBSQLITE3
|
|
||||||
// #include <sqlite3-binding.h>
|
|
||||||
// #else
|
|
||||||
// #include <sqlite3.h>
|
|
||||||
// #endif
|
|
||||||
//
|
|
||||||
// // Extension function defined in regexp.c.
|
|
||||||
// extern int sqlite3_regexp_init(sqlite3*, char**, const sqlite3_api_routines*);
|
|
||||||
//
|
|
||||||
// // Use constructor to register extension function with sqlite.
|
|
||||||
// void __attribute__((constructor)) init(void) {
|
|
||||||
// sqlite3_auto_extension((void*) sqlite3_regexp_init);
|
|
||||||
// }
|
|
||||||
import "C"
|
|
126
pkg/db/sqlite/search.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
var stringLiteralMap = map[string]string{
|
||||||
|
// http_requests
|
||||||
|
"req.id": "req.id",
|
||||||
|
"req.proto": "req.proto",
|
||||||
|
"req.url": "req.url",
|
||||||
|
"req.method": "req.method",
|
||||||
|
"req.body": "req.body",
|
||||||
|
"req.timestamp": "req.timestamp",
|
||||||
|
// http_responses
|
||||||
|
"res.id": "res.id",
|
||||||
|
"res.proto": "res.proto",
|
||||||
|
"res.statusCode": "res.status_code",
|
||||||
|
"res.statusReason": "res.status_reason",
|
||||||
|
"res.body": "res.body",
|
||||||
|
"res.timestamp": "res.timestamp",
|
||||||
|
// TODO: http_headers
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSearchExpr(expr search.Expression) (sq.Sqlizer, error) {
|
||||||
|
switch e := expr.(type) {
|
||||||
|
case *search.PrefixExpression:
|
||||||
|
return parsePrefixExpr(e)
|
||||||
|
case *search.InfixExpression:
|
||||||
|
return parseInfixExpr(e)
|
||||||
|
case *search.StringLiteral:
|
||||||
|
return parseStringLiteral(e)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("expression type (%v) not supported", expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrefixExpr(expr *search.PrefixExpression) (sq.Sqlizer, error) {
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpNot:
|
||||||
|
// TODO: Find a way to prefix an `sq.Sqlizer` with "NOT".
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
default:
|
||||||
|
return nil, errors.New("operator is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInfixExpr(expr *search.InfixExpression) (sq.Sqlizer, error) {
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpAnd:
|
||||||
|
left, err := parseSearchExpr(expr.Left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
right, err := parseSearchExpr(expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sq.And{left, right}, nil
|
||||||
|
case search.TokOpOr:
|
||||||
|
left, err := parseSearchExpr(expr.Left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
right, err := parseSearchExpr(expr.Right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sq.Or{left, right}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
left, ok := expr.Left.(*search.StringLiteral)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("left operand must be a string literal")
|
||||||
|
}
|
||||||
|
right, ok := expr.Right.(*search.StringLiteral)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("right operand must be a string literal")
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedLeft, ok := stringLiteralMap[left.Value]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid string literal: %v", left)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch expr.Operator {
|
||||||
|
case search.TokOpEq:
|
||||||
|
return sq.Eq{mappedLeft: right.Value}, nil
|
||||||
|
case search.TokOpNotEq:
|
||||||
|
return sq.NotEq{mappedLeft: right.Value}, nil
|
||||||
|
case search.TokOpGt:
|
||||||
|
return sq.Gt{mappedLeft: right.Value}, nil
|
||||||
|
case search.TokOpLt:
|
||||||
|
return sq.Lt{mappedLeft: right.Value}, nil
|
||||||
|
case search.TokOpGtEq:
|
||||||
|
return sq.GtOrEq{mappedLeft: right.Value}, nil
|
||||||
|
case search.TokOpLtEq:
|
||||||
|
return sq.LtOrEq{mappedLeft: right.Value}, nil
|
||||||
|
case search.TokOpRe:
|
||||||
|
return sq.Expr(fmt.Sprintf("regexp(?, %v)", mappedLeft), right.Value), nil
|
||||||
|
case search.TokOpNotRe:
|
||||||
|
return sq.Expr(fmt.Sprintf("NOT regexp(?, %v)", mappedLeft), right.Value), nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("unsupported operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStringLiteral(strLiteral *search.StringLiteral) (sq.Sqlizer, error) {
|
||||||
|
// Sorting is not necessary, but makes it easier to do assertions in tests.
|
||||||
|
sortedKeys := make([]string, 0, len(stringLiteralMap))
|
||||||
|
for _, v := range stringLiteralMap {
|
||||||
|
sortedKeys = append(sortedKeys, v)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedKeys)
|
||||||
|
|
||||||
|
or := make(sq.Or, len(stringLiteralMap))
|
||||||
|
for i, value := range sortedKeys {
|
||||||
|
or[i] = sq.Like{value: "%" + strLiteral.Value + "%"}
|
||||||
|
}
|
||||||
|
return or, nil
|
||||||
|
}
|
217
pkg/db/sqlite/search_test.go
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sq "github.com/Masterminds/squirrel"
|
||||||
|
|
||||||
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSearchExpr(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
searchExpr search.Expression
|
||||||
|
expectedSqlizer sq.Sqlizer
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "req.body = bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.Eq{"req.body": "bar"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body != bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpNotEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.NotEq{"req.body": "bar"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body > bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpGt,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.Gt{"req.body": "bar"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body < bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpLt,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.Lt{"req.body": "bar"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body >= bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpGtEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.GtOrEq{"req.body": "bar"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body <= bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpLtEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.LtOrEq{"req.body": "bar"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body =~ bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpRe,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.Expr("regexp(?, req.body)", "bar"),
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body !~ bar",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpNotRe,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.Expr("NOT regexp(?, req.body)", "bar"),
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body = bar AND res.body = yolo",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpAnd,
|
||||||
|
Left: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
Right: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "res.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "yolo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.And{
|
||||||
|
sq.Eq{"req.body": "bar"},
|
||||||
|
sq.Eq{"res.body": "yolo"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body = bar AND res.body = yolo AND req.method = POST",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpAnd,
|
||||||
|
Left: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
Right: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpAnd,
|
||||||
|
Left: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "res.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "yolo"},
|
||||||
|
},
|
||||||
|
Right: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.method"},
|
||||||
|
Right: &search.StringLiteral{Value: "POST"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.And{
|
||||||
|
sq.Eq{"req.body": "bar"},
|
||||||
|
sq.And{
|
||||||
|
sq.Eq{"res.body": "yolo"},
|
||||||
|
sq.Eq{"req.method": "POST"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "req.body = bar OR res.body = yolo",
|
||||||
|
searchExpr: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpOr,
|
||||||
|
Left: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "req.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
Right: &search.InfixExpression{
|
||||||
|
Operator: search.TokOpEq,
|
||||||
|
Left: &search.StringLiteral{Value: "res.body"},
|
||||||
|
Right: &search.StringLiteral{Value: "yolo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.Or{
|
||||||
|
sq.Eq{"req.body": "bar"},
|
||||||
|
sq.Eq{"res.body": "yolo"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "foo",
|
||||||
|
searchExpr: &search.StringLiteral{
|
||||||
|
Value: "foo",
|
||||||
|
},
|
||||||
|
expectedSqlizer: sq.Or{
|
||||||
|
sq.Like{"req.body": "%foo%"},
|
||||||
|
sq.Like{"req.id": "%foo%"},
|
||||||
|
sq.Like{"req.method": "%foo%"},
|
||||||
|
sq.Like{"req.proto": "%foo%"},
|
||||||
|
sq.Like{"req.timestamp": "%foo%"},
|
||||||
|
sq.Like{"req.url": "%foo%"},
|
||||||
|
sq.Like{"res.body": "%foo%"},
|
||||||
|
sq.Like{"res.id": "%foo%"},
|
||||||
|
sq.Like{"res.proto": "%foo%"},
|
||||||
|
sq.Like{"res.status_code": "%foo%"},
|
||||||
|
sq.Like{"res.status_reason": "%foo%"},
|
||||||
|
sq.Like{"res.timestamp": "%foo%"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, err := parseSearchExpr(tt.searchExpr)
|
||||||
|
assertError(t, tt.expectedError, err)
|
||||||
|
if !reflect.DeepEqual(tt.expectedSqlizer, got) {
|
||||||
|
t.Errorf("expected: %#v, got: %#v", tt.expectedSqlizer, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertError(t *testing.T, exp, got error) {
|
||||||
|
switch {
|
||||||
|
case exp == nil && got != nil:
|
||||||
|
t.Fatalf("expected: nil, got: %v", got)
|
||||||
|
case exp != nil && got == nil:
|
||||||
|
t.Fatalf("expected: %v, got: nil", exp.Error())
|
||||||
|
case exp != nil && got != nil && exp.Error() != got.Error():
|
||||||
|
t.Fatalf("expected: %v, got: %v", exp.Error(), got.Error())
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -21,14 +22,22 @@ import (
|
|||||||
"github.com/99designs/gqlgen/graphql"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/mattn/go-sqlite3"
|
||||||
// Register `sqlite3` driver.
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
|
|
||||||
// Register `regexp()` function.
|
|
||||||
_ "github.com/dstotijn/hetty/pkg/db/sqlite/regexp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var regexpFn = func(pattern string, value interface{}) (bool, error) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return regexp.MatchString(pattern, v)
|
||||||
|
case int64:
|
||||||
|
return regexp.MatchString(pattern, fmt.Sprintf("%v", v))
|
||||||
|
case []byte:
|
||||||
|
return regexp.Match(pattern, v)
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unsupported type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Client implements reqlog.Repository.
|
// Client implements reqlog.Repository.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
@ -43,6 +52,17 @@ type httpRequestLogsQuery struct {
|
|||||||
joinResponse bool
|
joinResponse bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
sql.Register("sqlite3_with_regexp", &sqlite3.SQLiteDriver{
|
||||||
|
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||||
|
if err := conn.RegisterFunc("regexp", regexpFn, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func New(dbPath string) (*Client, error) {
|
func New(dbPath string) (*Client, error) {
|
||||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||||
if err := os.MkdirAll(dbPath, 0755); err != nil {
|
if err := os.MkdirAll(dbPath, 0755); err != nil {
|
||||||
@ -65,7 +85,7 @@ func (c *Client) OpenProject(name string) error {
|
|||||||
|
|
||||||
dbPath := filepath.Join(c.dbPath, name+".db")
|
dbPath := filepath.Join(c.dbPath, name+".db")
|
||||||
dsn := fmt.Sprintf("file:%v?%v", dbPath, opts.Encode())
|
dsn := fmt.Sprintf("file:%v?%v", dbPath, opts.Encode())
|
||||||
db, err := sqlx.Open("sqlite3", dsn)
|
db, err := sqlx.Open("sqlite3_with_regexp", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("sqlite: could not open database: %v", err)
|
return fmt.Errorf("sqlite: could not open database: %v", err)
|
||||||
}
|
}
|
||||||
@ -195,6 +215,17 @@ var headerFieldToColumnMap = map[string]string{
|
|||||||
"value": "value",
|
"value": "value",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) ClearRequestLogs(ctx context.Context) error {
|
||||||
|
if c.db == nil {
|
||||||
|
return proj.ErrNoProject
|
||||||
|
}
|
||||||
|
_, err := c.db.Exec("DELETE FROM http_requests")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sqlite: could not delete requests: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) FindRequestLogs(
|
func (c *Client) FindRequestLogs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
filter reqlog.FindRequestsFilter,
|
filter reqlog.FindRequestsFilter,
|
||||||
@ -218,7 +249,7 @@ func (c *Client) FindRequestLogs(
|
|||||||
var ruleExpr []sq.Sqlizer
|
var ruleExpr []sq.Sqlizer
|
||||||
for _, rule := range scope.Rules() {
|
for _, rule := range scope.Rules() {
|
||||||
if rule.URL != nil {
|
if rule.URL != nil {
|
||||||
ruleExpr = append(ruleExpr, sq.Expr("req.url regexp ?", rule.URL.String()))
|
ruleExpr = append(ruleExpr, sq.Expr("regexp(?, req.url)", rule.URL.String()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(ruleExpr) > 0 {
|
if len(ruleExpr) > 0 {
|
||||||
@ -226,6 +257,14 @@ func (c *Client) FindRequestLogs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if filter.SearchExpr != nil {
|
||||||
|
sqlizer, err := parseSearchExpr(filter.SearchExpr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sqlite: could not parse search expression: %v", err)
|
||||||
|
}
|
||||||
|
reqQuery = reqQuery.Where(sqlizer)
|
||||||
|
}
|
||||||
|
|
||||||
sql, args, err := reqQuery.ToSql()
|
sql, args, err := reqQuery.ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("sqlite: could not parse query: %v", err)
|
return nil, fmt.Errorf("sqlite: could not parse query: %v", err)
|
||||||
|
@ -17,6 +17,7 @@ type Repository interface {
|
|||||||
FindRequestLogByID(ctx context.Context, id int64) (Request, error)
|
FindRequestLogByID(ctx context.Context, id int64) (Request, error)
|
||||||
AddRequestLog(ctx context.Context, req http.Request, body []byte, timestamp time.Time) (*Request, error)
|
AddRequestLog(ctx context.Context, req http.Request, body []byte, timestamp time.Time) (*Request, error)
|
||||||
AddResponseLog(ctx context.Context, reqID int64, res http.Response, body []byte, timestamp time.Time) (*Response, error)
|
AddResponseLog(ctx context.Context, reqID int64, res http.Response, body []byte, timestamp time.Time) (*Response, error)
|
||||||
|
ClearRequestLogs(ctx context.Context) error
|
||||||
UpsertSettings(ctx context.Context, module string, settings interface{}) error
|
UpsertSettings(ctx context.Context, module string, settings interface{}) error
|
||||||
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error
|
FindSettingsByModule(ctx context.Context, module string, settings interface{}) error
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/dstotijn/hetty/pkg/proj"
|
"github.com/dstotijn/hetty/pkg/proj"
|
||||||
"github.com/dstotijn/hetty/pkg/proxy"
|
"github.com/dstotijn/hetty/pkg/proxy"
|
||||||
"github.com/dstotijn/hetty/pkg/scope"
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
|
"github.com/dstotijn/hetty/pkg/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey int
|
type contextKey int
|
||||||
@ -52,6 +54,8 @@ type Service struct {
|
|||||||
|
|
||||||
type FindRequestsFilter struct {
|
type FindRequestsFilter struct {
|
||||||
OnlyInScope bool
|
OnlyInScope bool
|
||||||
|
SearchExpr search.Expression `json:"-"`
|
||||||
|
RawSearchExpr string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -99,6 +103,10 @@ func (svc *Service) SetRequestLogFilter(ctx context.Context, filter FindRequests
|
|||||||
return svc.repo.UpsertSettings(ctx, "reqlog", svc)
|
return svc.repo.UpsertSettings(ctx, "reqlog", svc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *Service) ClearRequests(ctx context.Context) error {
|
||||||
|
return svc.repo.ClearRequestLogs(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *Service) addRequest(
|
func (svc *Service) addRequest(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req http.Request,
|
req http.Request,
|
||||||
@ -206,6 +214,34 @@ func (svc *Service) ResponseModifier(next proxy.ResponseModifyFunc) proxy.Respon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler.
|
||||||
|
func (f *FindRequestsFilter) UnmarshalJSON(b []byte) error {
|
||||||
|
var dto struct {
|
||||||
|
OnlyInScope bool
|
||||||
|
RawSearchExpr string
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &dto); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := FindRequestsFilter{
|
||||||
|
OnlyInScope: dto.OnlyInScope,
|
||||||
|
RawSearchExpr: dto.RawSearchExpr,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dto.RawSearchExpr != "" {
|
||||||
|
expr, err := search.ParseQuery(dto.RawSearchExpr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filter.SearchExpr = expr
|
||||||
|
}
|
||||||
|
|
||||||
|
*f = filter
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *Service) loadSettings() error {
|
func (svc *Service) loadSettings() error {
|
||||||
return svc.repo.FindSettingsByModule(context.Background(), moduleName, svc)
|
return svc.repo.FindSettingsByModule(context.Background(), moduleName, svc)
|
||||||
}
|
}
|
||||||
|
53
pkg/search/ast.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type Expression interface {
|
||||||
|
String() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrefixExpression struct {
|
||||||
|
Operator TokenType
|
||||||
|
Right Expression
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pe *PrefixExpression) expressionNode() {}
|
||||||
|
func (pe *PrefixExpression) String() string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.WriteString("(")
|
||||||
|
b.WriteString(pe.Operator.String())
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(pe.Right.String())
|
||||||
|
b.WriteString(")")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type InfixExpression struct {
|
||||||
|
Operator TokenType
|
||||||
|
Left Expression
|
||||||
|
Right Expression
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ie *InfixExpression) expressionNode() {}
|
||||||
|
func (ie *InfixExpression) String() string {
|
||||||
|
b := strings.Builder{}
|
||||||
|
b.WriteString("(")
|
||||||
|
b.WriteString(ie.Left.String())
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(ie.Operator.String())
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(ie.Right.String())
|
||||||
|
b.WriteString(")")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type StringLiteral struct {
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *StringLiteral) expressionNode() {}
|
||||||
|
func (sl *StringLiteral) String() string {
|
||||||
|
return sl.Value
|
||||||
|
}
|
264
pkg/search/lexer.go
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenType int
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
Type TokenType
|
||||||
|
Literal string
|
||||||
|
}
|
||||||
|
|
||||||
|
const eof = 0
|
||||||
|
|
||||||
|
// Token types.
|
||||||
|
const (
|
||||||
|
// Flow
|
||||||
|
TokInvalid TokenType = iota
|
||||||
|
TokEOF
|
||||||
|
TokParenOpen
|
||||||
|
TokParenClose
|
||||||
|
|
||||||
|
// Literals
|
||||||
|
TokString
|
||||||
|
|
||||||
|
// Boolean operators
|
||||||
|
TokOpNot
|
||||||
|
TokOpAnd
|
||||||
|
TokOpOr
|
||||||
|
|
||||||
|
// Comparison operators
|
||||||
|
TokOpEq
|
||||||
|
TokOpNotEq
|
||||||
|
TokOpGt
|
||||||
|
TokOpLt
|
||||||
|
TokOpGtEq
|
||||||
|
TokOpLtEq
|
||||||
|
TokOpRe
|
||||||
|
TokOpNotRe
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
keywords = map[string]TokenType{
|
||||||
|
"NOT": TokOpNot,
|
||||||
|
"AND": TokOpAnd,
|
||||||
|
"OR": TokOpOr,
|
||||||
|
}
|
||||||
|
reservedRunes = []rune{'=', '!', '<', '>', '(', ')'}
|
||||||
|
tokenTypeStrings = map[TokenType]string{
|
||||||
|
TokInvalid: "INVALID",
|
||||||
|
TokEOF: "EOF",
|
||||||
|
TokParenOpen: "(",
|
||||||
|
TokParenClose: ")",
|
||||||
|
TokString: "STRING",
|
||||||
|
TokOpNot: "NOT",
|
||||||
|
TokOpAnd: "AND",
|
||||||
|
TokOpOr: "OR",
|
||||||
|
TokOpEq: "=",
|
||||||
|
TokOpNotEq: "!=",
|
||||||
|
TokOpGt: ">",
|
||||||
|
TokOpLt: "<",
|
||||||
|
TokOpGtEq: ">=",
|
||||||
|
TokOpLtEq: "<=",
|
||||||
|
TokOpRe: "=~",
|
||||||
|
TokOpNotRe: "!~",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type stateFn func(*Lexer) stateFn
|
||||||
|
|
||||||
|
type Lexer struct {
|
||||||
|
input string
|
||||||
|
pos int
|
||||||
|
start int
|
||||||
|
width int
|
||||||
|
tokens chan Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLexer(input string) *Lexer {
|
||||||
|
l := &Lexer{
|
||||||
|
input: input,
|
||||||
|
tokens: make(chan Token),
|
||||||
|
}
|
||||||
|
|
||||||
|
go l.run(begin)
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) Next() Token {
|
||||||
|
return <-l.tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tt TokenType) String() string {
|
||||||
|
if typeString, ok := tokenTypeStrings[tt]; ok {
|
||||||
|
return typeString
|
||||||
|
}
|
||||||
|
return "<unknown>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) run(init stateFn) {
|
||||||
|
for nextState := init; nextState != nil; {
|
||||||
|
nextState = nextState(l)
|
||||||
|
}
|
||||||
|
close(l.tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) read() (r rune) {
|
||||||
|
if l.pos >= len(l.input) {
|
||||||
|
l.width = 0
|
||||||
|
return eof
|
||||||
|
}
|
||||||
|
r, l.width = utf8.DecodeRuneInString(l.input[l.pos:])
|
||||||
|
l.pos += l.width
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) emit(tokenType TokenType) {
|
||||||
|
l.tokens <- Token{
|
||||||
|
Type: tokenType,
|
||||||
|
Literal: l.input[l.start:l.pos],
|
||||||
|
}
|
||||||
|
l.start = l.pos
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) ignore() {
|
||||||
|
l.start = l.pos
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) skip() {
|
||||||
|
l.pos += l.width
|
||||||
|
l.start = l.pos
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) backup() {
|
||||||
|
l.pos -= l.width
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) errorf(format string, args ...interface{}) stateFn {
|
||||||
|
l.tokens <- Token{
|
||||||
|
Type: TokInvalid,
|
||||||
|
Literal: fmt.Sprintf(format, args...),
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func begin(l *Lexer) stateFn {
|
||||||
|
r := l.read()
|
||||||
|
switch r {
|
||||||
|
case '=':
|
||||||
|
if next := l.read(); next == '~' {
|
||||||
|
l.emit(TokOpRe)
|
||||||
|
} else {
|
||||||
|
l.backup()
|
||||||
|
l.emit(TokOpEq)
|
||||||
|
}
|
||||||
|
return begin
|
||||||
|
case '!':
|
||||||
|
switch next := l.read(); next {
|
||||||
|
case '=':
|
||||||
|
l.emit(TokOpNotEq)
|
||||||
|
case '~':
|
||||||
|
l.emit(TokOpNotRe)
|
||||||
|
default:
|
||||||
|
return l.errorf("invalid rune %v", r)
|
||||||
|
}
|
||||||
|
return begin
|
||||||
|
case '<':
|
||||||
|
if next := l.read(); next == '=' {
|
||||||
|
l.emit(TokOpLtEq)
|
||||||
|
} else {
|
||||||
|
l.backup()
|
||||||
|
l.emit(TokOpLt)
|
||||||
|
}
|
||||||
|
return begin
|
||||||
|
case '>':
|
||||||
|
if next := l.read(); next == '=' {
|
||||||
|
l.emit(TokOpGtEq)
|
||||||
|
} else {
|
||||||
|
l.backup()
|
||||||
|
l.emit(TokOpGt)
|
||||||
|
}
|
||||||
|
return begin
|
||||||
|
case '(':
|
||||||
|
l.emit(TokParenOpen)
|
||||||
|
return begin
|
||||||
|
case ')':
|
||||||
|
l.emit(TokParenClose)
|
||||||
|
return begin
|
||||||
|
case '"':
|
||||||
|
return l.delimString(r)
|
||||||
|
case eof:
|
||||||
|
l.emit(TokEOF)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
l.ignore()
|
||||||
|
return begin
|
||||||
|
}
|
||||||
|
|
||||||
|
return unquotedString
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) delimString(delim rune) stateFn {
|
||||||
|
// Ignore the start delimiter rune.
|
||||||
|
l.ignore()
|
||||||
|
|
||||||
|
for r := l.read(); r != delim; r = l.read() {
|
||||||
|
if r == eof {
|
||||||
|
return l.errorf("unexpected EOF, unclosed delimiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Don't include the end delimiter in emitted token.
|
||||||
|
l.backup()
|
||||||
|
l.emit(TokString)
|
||||||
|
// Skip end delimiter.
|
||||||
|
l.skip()
|
||||||
|
|
||||||
|
return begin
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquotedString(l *Lexer) stateFn {
|
||||||
|
for r := l.read(); ; r = l.read() {
|
||||||
|
switch {
|
||||||
|
case r == eof:
|
||||||
|
l.backup()
|
||||||
|
l.emitUnquotedString()
|
||||||
|
return begin
|
||||||
|
case unicode.IsSpace(r):
|
||||||
|
l.backup()
|
||||||
|
l.emitUnquotedString()
|
||||||
|
l.skip()
|
||||||
|
return begin
|
||||||
|
case isReserved(r):
|
||||||
|
l.backup()
|
||||||
|
l.emitUnquotedString()
|
||||||
|
return begin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lexer) emitUnquotedString() {
|
||||||
|
str := l.input[l.start:l.pos]
|
||||||
|
if tokType, ok := keywords[str]; ok {
|
||||||
|
l.emit(tokType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.emit(TokString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReserved(r rune) bool {
|
||||||
|
for _, v := range reservedRunes {
|
||||||
|
if r == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
89
pkg/search/lexer_test.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNextToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected []Token
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unquoted string",
|
||||||
|
input: "foo bar",
|
||||||
|
expected: []Token{
|
||||||
|
{TokString, "foo"},
|
||||||
|
{TokString, "bar"},
|
||||||
|
{TokEOF, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted string",
|
||||||
|
input: `"foo bar" "baz"`,
|
||||||
|
expected: []Token{
|
||||||
|
{TokString, "foo bar"},
|
||||||
|
{TokString, "baz"},
|
||||||
|
{TokEOF, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean operator token types",
|
||||||
|
input: "NOT AND OR",
|
||||||
|
expected: []Token{
|
||||||
|
{TokOpNot, "NOT"},
|
||||||
|
{TokOpAnd, "AND"},
|
||||||
|
{TokOpOr, "OR"},
|
||||||
|
{TokEOF, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comparison operator token types",
|
||||||
|
input: `= != < > <= >= =~ !~`,
|
||||||
|
expected: []Token{
|
||||||
|
{TokOpEq, "="},
|
||||||
|
{TokOpNotEq, "!="},
|
||||||
|
{TokOpLt, "<"},
|
||||||
|
{TokOpGt, ">"},
|
||||||
|
{TokOpLtEq, "<="},
|
||||||
|
{TokOpGtEq, ">="},
|
||||||
|
{TokOpRe, "=~"},
|
||||||
|
{TokOpNotRe, "!~"},
|
||||||
|
{TokEOF, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with parentheses",
|
||||||
|
input: "(foo AND bar) OR baz",
|
||||||
|
expected: []Token{
|
||||||
|
{TokParenOpen, "("},
|
||||||
|
{TokString, "foo"},
|
||||||
|
{TokOpAnd, "AND"},
|
||||||
|
{TokString, "bar"},
|
||||||
|
{TokParenClose, ")"},
|
||||||
|
{TokOpOr, "OR"},
|
||||||
|
{TokString, "baz"},
|
||||||
|
{TokEOF, ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
l := NewLexer(tt.input)
|
||||||
|
|
||||||
|
for _, exp := range tt.expected {
|
||||||
|
got := l.Next()
|
||||||
|
if got.Type != exp.Type {
|
||||||
|
t.Errorf("invalid type (idx: %v, expected: %v, got: %v)",
|
||||||
|
i, exp.Type, got.Type)
|
||||||
|
}
|
||||||
|
if got.Literal != exp.Literal {
|
||||||
|
t.Errorf("invalid literal (idx: %v, expected: %v, got: %v)",
|
||||||
|
i, exp.Literal, got.Literal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
237
pkg/search/parser.go
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type precedence int
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ precedence = iota
|
||||||
|
precLowest
|
||||||
|
precAnd
|
||||||
|
precOr
|
||||||
|
precNot
|
||||||
|
precEq
|
||||||
|
precLessGreater
|
||||||
|
precPrefix
|
||||||
|
precGroup
|
||||||
|
)
|
||||||
|
|
||||||
|
type prefixParser func(*Parser) (Expression, error)
|
||||||
|
type infixParser func(*Parser, Expression) (Expression, error)
|
||||||
|
|
||||||
|
var (
|
||||||
|
prefixParsers = map[TokenType]prefixParser{}
|
||||||
|
infixParsers = map[TokenType]infixParser{}
|
||||||
|
)
|
||||||
|
|
||||||
|
var tokenPrecedences = map[TokenType]precedence{
|
||||||
|
TokParenOpen: precGroup,
|
||||||
|
TokOpNot: precNot,
|
||||||
|
TokOpAnd: precAnd,
|
||||||
|
TokOpOr: precOr,
|
||||||
|
TokOpEq: precEq,
|
||||||
|
TokOpNotEq: precEq,
|
||||||
|
TokOpGt: precLessGreater,
|
||||||
|
TokOpLt: precLessGreater,
|
||||||
|
TokOpGtEq: precLessGreater,
|
||||||
|
TokOpLtEq: precLessGreater,
|
||||||
|
TokOpRe: precEq,
|
||||||
|
TokOpNotRe: precEq,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Populate maps in `init`, because package global variables would cause an
|
||||||
|
// initialization cycle.
|
||||||
|
infixOperators := []TokenType{
|
||||||
|
TokOpAnd,
|
||||||
|
TokOpOr,
|
||||||
|
TokOpEq,
|
||||||
|
TokOpNotEq,
|
||||||
|
TokOpGt,
|
||||||
|
TokOpLt,
|
||||||
|
TokOpGtEq,
|
||||||
|
TokOpLtEq,
|
||||||
|
TokOpRe,
|
||||||
|
TokOpNotRe,
|
||||||
|
}
|
||||||
|
for _, op := range infixOperators {
|
||||||
|
infixParsers[op] = parseInfixExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixParsers[TokOpNot] = parsePrefixExpression
|
||||||
|
prefixParsers[TokString] = parseStringLiteral
|
||||||
|
prefixParsers[TokParenOpen] = parseGroupedExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
type Parser struct {
|
||||||
|
l *Lexer
|
||||||
|
cur Token
|
||||||
|
peek Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewParser(l *Lexer) *Parser {
|
||||||
|
p := &Parser{l: l}
|
||||||
|
p.nextToken()
|
||||||
|
p.nextToken()
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseQuery(input string) (expr Expression, err error) {
|
||||||
|
p := &Parser{l: NewLexer(input)}
|
||||||
|
p.nextToken()
|
||||||
|
p.nextToken()
|
||||||
|
|
||||||
|
if p.curTokenIs(TokEOF) {
|
||||||
|
return nil, fmt.Errorf("search: unexpected EOF")
|
||||||
|
}
|
||||||
|
|
||||||
|
for !p.curTokenIs(TokEOF) {
|
||||||
|
right, err := p.parseExpression(precLowest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search: could not parse expression: %v", err)
|
||||||
|
}
|
||||||
|
if expr == nil {
|
||||||
|
expr = right
|
||||||
|
} else {
|
||||||
|
expr = &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: expr,
|
||||||
|
Right: right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.nextToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) nextToken() {
|
||||||
|
p.cur = p.peek
|
||||||
|
p.peek = p.l.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) curTokenIs(t TokenType) bool {
|
||||||
|
return p.cur.Type == t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) peekTokenIs(t TokenType) bool {
|
||||||
|
return p.peek.Type == t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) expectPeek(t TokenType) error {
|
||||||
|
if !p.peekTokenIs(t) {
|
||||||
|
return fmt.Errorf("expected next token to be %v, got %v", t, p.peek.Type)
|
||||||
|
}
|
||||||
|
p.nextToken()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) curPrecedence() precedence {
|
||||||
|
if p, ok := tokenPrecedences[p.cur.Type]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return precLowest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) peekPrecedence() precedence {
|
||||||
|
if p, ok := tokenPrecedences[p.peek.Type]; ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return precLowest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Parser) parseExpression(prec precedence) (Expression, error) {
|
||||||
|
prefixParser, ok := prefixParsers[p.cur.Type]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no prefix parse function for %v found", p.cur.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
expr, err := prefixParser(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse expression prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for !p.peekTokenIs(eof) && prec < p.peekPrecedence() {
|
||||||
|
infixParser, ok := infixParsers[p.peek.Type]
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
p.nextToken()
|
||||||
|
|
||||||
|
expr, err = infixParser(p, expr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse infix expression: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrefixExpression(p *Parser) (Expression, error) {
|
||||||
|
expr := &PrefixExpression{
|
||||||
|
Operator: p.cur.Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.nextToken()
|
||||||
|
|
||||||
|
right, err := p.parseExpression(precPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse expression for right operand: %v", err)
|
||||||
|
}
|
||||||
|
expr.Right = right
|
||||||
|
|
||||||
|
return expr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInfixExpression(p *Parser, left Expression) (Expression, error) {
|
||||||
|
expr := &InfixExpression{
|
||||||
|
Operator: p.cur.Type,
|
||||||
|
Left: left,
|
||||||
|
}
|
||||||
|
|
||||||
|
prec := p.curPrecedence()
|
||||||
|
p.nextToken()
|
||||||
|
|
||||||
|
right, err := p.parseExpression(prec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse expression for right operand: %v", err)
|
||||||
|
}
|
||||||
|
expr.Right = right
|
||||||
|
|
||||||
|
return expr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStringLiteral(p *Parser) (Expression, error) {
|
||||||
|
return &StringLiteral{Value: p.cur.Literal}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGroupedExpression(p *Parser) (Expression, error) {
|
||||||
|
p.nextToken()
|
||||||
|
|
||||||
|
expr, err := p.parseExpression(precLowest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse grouped expression: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for p.nextToken(); !p.curTokenIs(TokParenClose); p.nextToken() {
|
||||||
|
if p.curTokenIs(TokEOF) {
|
||||||
|
return nil, fmt.Errorf("unexpected EOF: unmatched parentheses")
|
||||||
|
}
|
||||||
|
right, err := p.parseExpression(precLowest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse expression: %v", err)
|
||||||
|
}
|
||||||
|
expr = &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: expr,
|
||||||
|
Right: right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expr, nil
|
||||||
|
}
|
244
pkg/search/parser_test.go
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseQuery(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectedExpression Expression
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty query",
|
||||||
|
input: "",
|
||||||
|
expectedExpression: nil,
|
||||||
|
expectedError: errors.New("search: unexpected EOF"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string literal expression",
|
||||||
|
input: "foobar",
|
||||||
|
expectedExpression: &StringLiteral{Value: "foobar"},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with equal operator",
|
||||||
|
input: "foo = bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpEq,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with not equal operator",
|
||||||
|
input: "foo != bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpNotEq,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with greater than operator",
|
||||||
|
input: "foo > bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpGt,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with less than operator",
|
||||||
|
input: "foo < bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpLt,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with greater than or equal operator",
|
||||||
|
input: "foo >= bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpGtEq,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with less than or equal operator",
|
||||||
|
input: "foo <= bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpLtEq,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with regular expression operator",
|
||||||
|
input: "foo =~ bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpRe,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with not regular expression operator",
|
||||||
|
input: "foo !~ bar",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpNotRe,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with AND, OR and NOT operators",
|
||||||
|
input: "foo AND bar OR NOT baz",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &InfixExpression{
|
||||||
|
Operator: TokOpOr,
|
||||||
|
Left: &StringLiteral{Value: "bar"},
|
||||||
|
Right: &PrefixExpression{
|
||||||
|
Operator: TokOpNot,
|
||||||
|
Right: &StringLiteral{Value: "baz"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "boolean expression with nested group",
|
||||||
|
input: "(foo AND bar) OR NOT baz",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpOr,
|
||||||
|
Left: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
Right: &PrefixExpression{
|
||||||
|
Operator: TokOpNot,
|
||||||
|
Right: &StringLiteral{Value: "baz"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "implicit boolean expression with string literal operands",
|
||||||
|
input: "foo bar baz",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
Right: &StringLiteral{Value: "baz"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "implicit boolean expression nested in group",
|
||||||
|
input: "(foo bar)",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "implicit and explicit boolean expression with string literal operands",
|
||||||
|
input: "foo bar OR baz yolo",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &InfixExpression{
|
||||||
|
Operator: TokOpOr,
|
||||||
|
Left: &StringLiteral{Value: "bar"},
|
||||||
|
Right: &StringLiteral{Value: "baz"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Right: &StringLiteral{Value: "yolo"},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "implicit boolean expression with comparison operands",
|
||||||
|
input: "foo=bar baz=~yolo",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpAnd,
|
||||||
|
Left: &InfixExpression{
|
||||||
|
Operator: TokOpEq,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
Right: &InfixExpression{
|
||||||
|
Operator: TokOpRe,
|
||||||
|
Left: &StringLiteral{Value: "baz"},
|
||||||
|
Right: &StringLiteral{Value: "yolo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eq operator takes precedence over boolean ops",
|
||||||
|
input: "foo=bar OR baz=yolo",
|
||||||
|
expectedExpression: &InfixExpression{
|
||||||
|
Operator: TokOpOr,
|
||||||
|
Left: &InfixExpression{
|
||||||
|
Operator: TokOpEq,
|
||||||
|
Left: &StringLiteral{Value: "foo"},
|
||||||
|
Right: &StringLiteral{Value: "bar"},
|
||||||
|
},
|
||||||
|
Right: &InfixExpression{
|
||||||
|
Operator: TokOpEq,
|
||||||
|
Left: &StringLiteral{Value: "baz"},
|
||||||
|
Right: &StringLiteral{Value: "yolo"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedError: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, err := ParseQuery(tt.input)
|
||||||
|
assertError(t, tt.expectedError, err)
|
||||||
|
if !reflect.DeepEqual(tt.expectedExpression, got) {
|
||||||
|
t.Errorf("expected: %v, got: %v", tt.expectedExpression, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertError(t *testing.T, exp, got error) {
|
||||||
|
switch {
|
||||||
|
case exp == nil && got != nil:
|
||||||
|
t.Fatalf("expected: nil, got: %v", got)
|
||||||
|
case exp != nil && got == nil:
|
||||||
|
t.Fatalf("expected: %v, got: nil", exp.Error())
|
||||||
|
case exp != nil && got != nil && exp.Error() != got.Error():
|
||||||
|
t.Fatalf("expected: %v, got: %v", exp.Error(), got.Error())
|
||||||
|
}
|
||||||
|
}
|