mirror of
https://github.com/tarampampam/error-pages.git
synced 2024-08-30 18:22:40 +00:00
Compare commits
46 Commits
master
...
v3.0.0-alp
Author | SHA1 | Date | |
---|---|---|---|
e3499a940c | |||
f3c2646f4e | |||
e91a6f99fd | |||
2664c467c0 | |||
52063654ab | |||
616e4ad7e2 | |||
1563a8c226 | |||
8b29a95bb1 | |||
b7ed431c8f | |||
6766f3f787 | |||
75476a22c0 | |||
4e653f37d3 | |||
4e7abaafbf | |||
9e719c7feb | |||
cbbe2ea631 | |||
4bb567b1d6 | |||
cea34f0475 | |||
86aa4aab93 | |||
7ed6ec414f | |||
14815b4c73 | |||
b7228c3933 | |||
1fa6e1de4f | |||
5976f2903d | |||
63b125e080 | |||
a759504971 | |||
2776c41e0d | |||
42c4e7bf84 | |||
c80229ea05 | |||
f1bcd0d8c4 | |||
3505288e7a | |||
ea7dfe4870 | |||
6326e78cf4 | |||
3f22916cbb | |||
015e686635 | |||
2a1fa5c108 | |||
1682a3513f | |||
65fc5ecc7f | |||
c1eaee0287 | |||
ceeb7f9384 | |||
a52dbde00c | |||
1b94bc367c | |||
669aaf6a1e | |||
b71475fcf7 | |||
71f8cfc162 | |||
e2193cd82e | |||
15d1bcf9c7 |
@ -1,18 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json",
|
||||
"name": "default",
|
||||
"image": "golang:1.22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/guiyomh/features/golangci-lint:0": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {},
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "go mod download"
|
||||
}
|
33
.github/workflows/release.yml
vendored
33
.github/workflows/release.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
run: mkdir ./out && ./${{ steps.values.outputs.binary-name }} build --index --disable-minification --target-dir ./out
|
||||
run: mkdir ./out && ./${{ steps.values.outputs.binary-name }} build --index --target-dir ./out
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@ -44,16 +44,6 @@ jobs:
|
||||
path: out/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
working-directory: ./out
|
||||
run: zip -r ./../templates.zip .
|
||||
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: templates.zip
|
||||
asset_name: error-pages-static.zip
|
||||
tag: ${{ github.ref }}
|
||||
|
||||
demo:
|
||||
name: Update the demo (GitHub Pages)
|
||||
@ -86,19 +76,20 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
|
||||
build-args: "APP_VERSION=${{ steps.slug.outputs.version }}"
|
||||
tags: |
|
||||
tarampampam/error-pages:latest
|
||||
tarampampam/error-pages:${{ steps.slug.outputs.version }}
|
||||
tarampampam/error-pages:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
|
||||
tarampampam/error-pages:${{ steps.slug.outputs.version-major }}
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
|
||||
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}
|
||||
tags: ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
|
||||
# tags: | # TODO: uncomment after the stable release
|
||||
# tarampampam/error-pages:latest
|
||||
# tarampampam/error-pages:${{ steps.slug.outputs.version }}
|
||||
# tarampampam/error-pages:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
|
||||
# tarampampam/error-pages:${{ steps.slug.outputs.version-major }}
|
||||
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest
|
||||
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
|
||||
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
|
||||
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}
|
||||
|
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@ -83,7 +83,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- {uses: gacts/github-slug@v1, id: slug}
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
|
@ -61,7 +61,7 @@ linters: # All available linters list: <https://golangci-lint.run/usage/linters/
|
||||
- errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases
|
||||
- errorlint # find code that will cause problems with the error wrapping scheme introduced in Go 1.13
|
||||
- exhaustive # check exhaustiveness of enum switch statements
|
||||
- copyloopvar # detects places where loop variables are copied
|
||||
- exportloopref # checks for pointers to enclosing loop variables
|
||||
- funlen # Tool for detection of long functions
|
||||
- gci # Gci control golang package import order and make it always deterministic
|
||||
- godot # Check if comments end in a period
|
||||
|
27
Dockerfile
27
Dockerfile
@ -3,12 +3,12 @@
|
||||
# -✂- this stage is used to develop and build the application locally -------------------------------------------------
|
||||
FROM docker.io/library/golang:1.22-bookworm AS develop
|
||||
|
||||
# use the /var/tmp/go as the GOPATH to reuse the modules cache
|
||||
# use the /var/tmp as the GOPATH to reuse the modules cache
|
||||
ENV GOPATH="/var/tmp/go"
|
||||
|
||||
RUN set -x \
|
||||
# renovate: source=github-releases name=golangci/golangci-lint
|
||||
&& GOLANGCI_LINT_VERSION="1.60.3" \
|
||||
&& GOLANGCI_LINT_VERSION="1.59.1" \
|
||||
&& wget -O- -nv "https://cdn.jsdelivr.net/gh/golangci/golangci-lint@v${GOLANGCI_LINT_VERSION}/install.sh" \
|
||||
| sh -s -- -b /bin "v${GOLANGCI_LINT_VERSION}"
|
||||
|
||||
@ -32,10 +32,7 @@ FROM develop AS compile
|
||||
# can be passed with any prefix (like `v1.2.3@GITHASH`), e.g.: `docker build --build-arg "APP_VERSION=v1.2.3" .`
|
||||
ARG APP_VERSION="undefined@docker"
|
||||
|
||||
# copy the source code
|
||||
COPY . /src
|
||||
|
||||
RUN set -x \
|
||||
RUN --mount=type=bind,source=.,target=/src set -x \
|
||||
&& go generate ./... \
|
||||
&& CGO_ENABLED=0 LDFLAGS="-s -w -X gh.tarampamp.am/error-pages/internal/appmeta.version=${APP_VERSION}" \
|
||||
go build -trimpath -ldflags "${LDFLAGS}" -o /tmp/error-pages ./cmd/error-pages/ \
|
||||
@ -48,11 +45,10 @@ FROM docker.io/library/alpine:3.20 AS rootfs
|
||||
WORKDIR /tmp/rootfs
|
||||
|
||||
# prepare rootfs for runtime
|
||||
RUN set -x \
|
||||
&& mkdir -p ./etc/ssl/certs ./bin \
|
||||
RUN --mount=type=bind,source=.,target=/src set -x \
|
||||
&& mkdir -p ./etc ./bin \
|
||||
&& echo 'appuser:x:10001:10001::/nonexistent:/sbin/nologin' > ./etc/passwd \
|
||||
&& echo 'appuser:x:10001:' > ./etc/group \
|
||||
&& cp /etc/ssl/certs/ca-certificates.crt ./etc/ssl/certs/
|
||||
&& echo 'appuser:x:10001:' > ./etc/group
|
||||
|
||||
# take the binary from the compile stage
|
||||
COPY --from=compile /tmp/error-pages ./bin/error-pages
|
||||
@ -73,7 +69,7 @@ ARG APP_VERSION="undefined@docker"
|
||||
LABEL \
|
||||
# docs: https://github.com/opencontainers/image-spec/blob/master/annotations.md
|
||||
org.opencontainers.image.title="error-pages" \
|
||||
org.opencontainers.image.description="Pretty server's error pages" \
|
||||
org.opencontainers.image.description="Static server error pages in the docker image" \
|
||||
org.opencontainers.image.url="https://github.com/tarampampam/error-pages" \
|
||||
org.opencontainers.image.source="https://github.com/tarampampam/error-pages" \
|
||||
org.opencontainers.image.vendor="tarampampam" \
|
||||
@ -91,12 +87,11 @@ WORKDIR /opt
|
||||
# to find out which environment variables and CLI arguments are supported by the application, run the app
|
||||
# with the `--help` flag or refer to the documentation at https://github.com/tarampampam/error-pages#readme
|
||||
|
||||
ENV LOG_LEVEL="warn" \
|
||||
LOG_FORMAT="json"
|
||||
|
||||
# docs: https://docs.docker.com/reference/dockerfile/#healthcheck
|
||||
HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=2s CMD ["/bin/error-pages", "healthcheck"]
|
||||
HEALTHCHECK --interval=10s --start-interval=1s --start-period=5s --timeout=2s CMD [\
|
||||
"/bin/error-pages", "--log-format", "json", "healthcheck" \
|
||||
]
|
||||
|
||||
ENTRYPOINT ["/bin/error-pages"]
|
||||
|
||||
CMD ["serve"]
|
||||
CMD ["--log-format", "json", "serve"]
|
||||
|
923
README.md
923
README.md
@ -1,637 +1,3 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/tarampampam/error-pages#readme">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Dark">
|
||||
<img align="center" src="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Light">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/tarampampam/error-pages?longCache=true&label=&logo=go&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/tests.yml?branch=master&maxAge=30&label=tests&logo=github&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/release.yml?maxAge=30&label=release&logo=github&style=flat-square" alt="" /></a>
|
||||
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/pulls/tarampampam/error-pages.svg?maxAge=30&label=pulls&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30&label=size&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/blob/master/LICENSE"><img src="https://img.shields.io/github/license/tarampampam/error-pages.svg?maxAge=30&style=flat-square" alt="" /></a>
|
||||
</p>
|
||||
|
||||
One day, you might want to replace the standard error pages of your HTTP server or K8S cluster with something more
|
||||
original and attractive. That's why this repository was created :) It contains:
|
||||
|
||||
- A simple error page generator written in Go
|
||||
- Single-page error templates (themes) with various designs (located in the [templates][templates-dir] directory) that
|
||||
you can customize as you wish
|
||||
- A fast and lightweight HTTP server is available as a single binary file and Docker image. It includes built-in error
|
||||
page templates from this repository. You don't need anything except the compiled binary file or Docker image
|
||||
- Pre-generated error pages (sources can be [found here][preview-sources], and the [**demo** is always
|
||||
accessible here][preview-demo])
|
||||
|
||||
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
|
||||
[preview-demo]:https://tarampampam.github.io/error-pages/
|
||||
[templates-dir]:https://github.com/tarampampam/error-pages/tree/master/templates
|
||||
|
||||
## 🔥 Features List
|
||||
|
||||
- HTTP server written in Go, utilizing the extremely fast [FastHTTP][fasthttp] and in-memory caching
|
||||
- Respects the `Content-Type` HTTP header (and `X-Format`) value, responding with the corresponding format
|
||||
(supported formats: `json`, `xml`, and `plaintext`)
|
||||
- Error pages are configured to be excluded from search engine indexing (using meta tags and HTTP headers) to
|
||||
prevent SEO issues on your website
|
||||
- HTML content (including CSS, SVG, and JS) is minified on the fly
|
||||
- Logs written in `json` format
|
||||
- Contains a health check endpoint (`/healthz`)
|
||||
- Consumes very few resources and is suitable for use in resource-constrained environments
|
||||
- Lightweight Docker image, distroless, and uses an unprivileged user by default
|
||||
- [Go-template](https://pkg.go.dev/text/template) tags are allowed in the templates
|
||||
- Ready for integration with [Traefik][traefik], [Ingress-nginx][ingress-nginx], and more
|
||||
- Error pages can be embedded into your own Docker image with `nginx` in a few simple steps
|
||||
- Fully configurable
|
||||
- Distributed as a Docker image and compiled binary files
|
||||
- Localized HTML error pages (🇺🇸, 🇫🇷, 🇺🇦, 🇷🇺, 🇵🇹, 🇳🇱, 🇩🇪, 🇪🇸, 🇨🇳, 🇮🇩, 🇵🇱, 🇰🇷) - translation process
|
||||
[described here][l10n-dir] - other translations are welcome!
|
||||
|
||||
[fasthttp]:https://github.com/valyala/fasthttp
|
||||
[traefik]:https://github.com/traefik/traefik
|
||||
[l10n-dir]:https://github.com/tarampampam/error-pages/tree/master/l10n
|
||||
|
||||
## 🧩 Install
|
||||
|
||||
Download the latest binary file for your OS/architecture from the [releases page][latest-release] or use our Docker image:
|
||||
|
||||
| Registry | Image |
|
||||
|-----------------------------------|-----------------------------------|
|
||||
| [GitHub Container Registry][ghcr] | `ghcr.io/tarampampam/error-pages` |
|
||||
| [Docker Hub][docker-hub] (mirror) | `tarampampam/error-pages` |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Using the `latest` tag for the Docker image is highly discouraged due to potential backward-incompatible changes
|
||||
> during **major** upgrades. Please use tags in the `X.Y.Z` format.
|
||||
|
||||
💣 **Or** you can also download the **already rendered** error pages pack as a [zip][pages-pack-zip] or
|
||||
[tar.gz][pages-pack-tar-gz] archive.
|
||||
|
||||
[latest-release]:https://github.com/tarampampam/error-pages/releases/latest
|
||||
[docker-hub]:https://hub.docker.com/r/tarampampam/error-pages
|
||||
[ghcr]:https://github.com/tarampampam/error-pages/pkgs/container/error-pages
|
||||
[pages-pack-zip]:https://github.com/tarampampam/error-pages/zipball/gh-pages/
|
||||
[pages-pack-tar-gz]:https://github.com/tarampampam/error-pages/tarball/gh-pages/
|
||||
|
||||
## 🪂 Templates (themes)
|
||||
|
||||
The following templates are built-in and available for use without any additional setup:
|
||||
|
||||
> [!NOTE]
|
||||
> The `cats` template is the only one of those that fetches resources (the actual cat pictures) from external
|
||||
> servers - all other templates are self-contained.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>Preview</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>app-down</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fapp-down.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/app-down/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/4e668a56-a4c4-47cd-ac4d-b6b45db54ab8">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/ad4b4fd7-7c7b-4bdc-a6b6-44f9ba7f77ca">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>cats</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fcats.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/cats/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/5689880b-f770-406c-81dd-2d28629e6f2e">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/056cd00e-bc9a-4120-8325-310d7b0ebd1b">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>connection</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fconnection.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/connection/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/3f03dc1b-c1ee-4a91-b3d7-e3b93c79020e">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/099ecc2d-e724-4d9c-b5ed-66ddabd71139">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>ghost</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fghost.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/ghost/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/714482ab-f8c1-4455-8ae8-b2ae78f7a2c6">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/f253dfe7-96a0-4e96-915b-d4c544d4a237">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>hacker-terminal</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fhacker-terminal.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/hacker-terminal/404.html">
|
||||
<picture>
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/c197fc35-0844-43d0-9830-82440cee4559">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>l7</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fl7.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/l7/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/18e43ea3-6389-4459-be41-0fc6566a073f">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/05f26669-94ec-40ce-8d67-a199cde54202">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>lost-in-space</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Flost-in-space.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/lost-in-space/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/debf87c0-6f27-41a8-b141-ee3464cbd6cc">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/c347e63d-13a7-46d4-81b9-b25266819a1d">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>noise</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fnoise.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/noise/404.html">
|
||||
<picture>
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/4cc5c3bd-6ebb-4e96-bee8-02d4ad4e7266">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>orient</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Forient.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/orient/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/bc2b0dad-c32c-4628-98f6-e3eab61dd1f2">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/8fc0a7ea-694d-49ce-bb50-3ea032d52d1e">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<code>shuffle</code><br/><br/>
|
||||
<picture>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fshuffle.json&query=%24.count&label=used%20times" alt="used times">
|
||||
</picture>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://tarampampam.github.io/error-pages/shuffle/404.html">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/7504b7c3-b0cb-4991-9ac2-759cd6c50fc0">
|
||||
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/d2a73fc8-cf5f-4f42-bff8-cce33d8ae47e">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
> [!NOTE]
|
||||
> The "used times" counter increments when someone start the server with the specified template. Stats service does
|
||||
> not collect any information about location, IP addresses, and so on. Moreover, the stats are open and available for
|
||||
> everyone at [error-pages.goatcounter.com](https://error-pages.goatcounter.com/). This is simply a counter to display
|
||||
> how often a particular template is used, nothing more.
|
||||
|
||||
## 🛠 Usage scenarios
|
||||
|
||||
### HTTP server starting, utilizing either a binary file or Docker image
|
||||
|
||||
First, ensure you have a precompiled binary file on your machine or have Docker/Podman installed. Next, start the
|
||||
server with the following command:
|
||||
|
||||
```bash
|
||||
$ ./error-pages serve
|
||||
# --- or ---
|
||||
$ docker run --rm -p '8080:8080/tcp' tarampampam/error-pages serve
|
||||
```
|
||||
|
||||
That's it! The server will begin running and listen on address `0.0.0.0` and port `8080`. Access error pages using
|
||||
URLs like `http://127.0.0.1:8080/{page_code}.html`.
|
||||
|
||||
To retrieve different error page codes using a static URL, use the `X-Code` HTTP header:
|
||||
|
||||
```bash
|
||||
$ curl -H 'X-Code: 500' http://127.0.0.1:8080/
|
||||
```
|
||||
|
||||
The server respects the `Content-Type` HTTP header (and `X-Format`), delivering responses in requested formats
|
||||
such as HTML, XML, JSON, and PlainText. Customization of these formats is possible via CLI flags or environment
|
||||
variables.
|
||||
|
||||
For integration with [ingress-nginx][ingress-nginx] or debugging purposes, start the server with `--show-details`
|
||||
(or set the environment variable `SHOW_DETAILS=true`) to enrich error pages (including JSON and XML responses)
|
||||
with upstream proxy information.
|
||||
|
||||
Switch themes using the `TEMPLATE_NAME` environment variable or the `--template-name` flag; available templates
|
||||
are detailed in the readme file below.
|
||||
|
||||
> [!TIP]
|
||||
> Use the `--rotation-mode` flag or the `TEMPLATES_ROTATION_MODE` environment variable to automate theme
|
||||
> rotation. Available modes include `random-on-startup`, `random-on-each-request`, `random-hourly`,
|
||||
> and `random-daily`.
|
||||
|
||||
To proxy HTTP headers from requests to responses, utilize the `--proxy-headers` flag or environment variable
|
||||
(comma-separated list of headers).
|
||||
|
||||
<details>
|
||||
<summary><strong>🚀 Start the HTTP server with my custom template (theme)</strong></summary>
|
||||
|
||||
First, create your own template file, for example `my-super-theme.html`:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ code }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>YEAH! {{ message }}: {{ description }}</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
And simply start the server with the following command:
|
||||
|
||||
```bash
|
||||
$ docker run --rm \
|
||||
-v "$(pwd)/my-super-theme.html:/opt/my-template.html:ro" \
|
||||
-p '8080:8080/tcp' ghcr.io/tarampampam/error-pages:3 serve \
|
||||
--add-template /opt/my-template.html \
|
||||
--template-name my-template
|
||||
# --- or ---
|
||||
$ ./error-pages serve \
|
||||
--add-template /opt/my-template.html \
|
||||
--template-name my-template
|
||||
```
|
||||
|
||||
And test it:
|
||||
|
||||
```bash
|
||||
$ curl -H "Accept: text/html" http://127.0.0.1:8080/503
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>503</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>YEAH! Service Unavailable: The server is temporarily overloading or down</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🚀 Generate a set of error pages using built-in or my own template</strong></summary>
|
||||
|
||||
Generating a set of error pages is straightforward. If you prefer to use your own template, start by crafting it.
|
||||
Create a file like this:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ code }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ message }}: {{ description }}</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Save it as `my-template.html` and use it as your custom template. Then, generate your error pages using the command:
|
||||
|
||||
```bash
|
||||
$ mkdir -p /path/to/output
|
||||
$ ./error-pages build --add-template /path/to/your/my-template.html --target-dir /path/to/output
|
||||
```
|
||||
|
||||
This will create error pages based on your template in the specified output directory:
|
||||
|
||||
```bash
|
||||
$ cd /path/to/output && tree .
|
||||
├── my-template
|
||||
│ ├── 400.html
|
||||
│ ├── 401.html
|
||||
│ ├── 403.html
|
||||
│ ├── 404.html
|
||||
│ ├── 405.html
|
||||
│ ├── 407.html
|
||||
│ ├── 408.html
|
||||
│ ├── 409.html
|
||||
│ ├── 410.html
|
||||
│ ├── 411.html
|
||||
│ ├── 412.html
|
||||
│ ├── 413.html
|
||||
│ ├── 416.html
|
||||
│ ├── 418.html
|
||||
│ ├── 429.html
|
||||
│ ├── 500.html
|
||||
│ ├── 502.html
|
||||
│ ├── 503.html
|
||||
│ ├── 504.html
|
||||
│ └── 505.html
|
||||
…
|
||||
|
||||
$ cat my-template/403.html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>403</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Forbidden: Access is forbidden to the requested page</h1>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🚀 Customize error pages within your own Nginx Docker image</strong></summary>
|
||||
|
||||
To create this cocktail, we need two components:
|
||||
|
||||
- Nginx configuration file
|
||||
- A Dockerfile to build the image
|
||||
|
||||
Let's start with the Nginx configuration file:
|
||||
|
||||
```nginx
|
||||
# File: nginx.conf
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
error_page 401 /_error-pages/401.html;
|
||||
error_page 403 /_error-pages/403.html;
|
||||
error_page 404 /_error-pages/404.html;
|
||||
error_page 500 /_error-pages/500.html;
|
||||
error_page 502 /_error-pages/502.html;
|
||||
error_page 503 /_error-pages/503.html;
|
||||
|
||||
location ^~ /_error-pages/ {
|
||||
internal;
|
||||
root /usr/share/nginx/errorpages;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
And the Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
FROM docker.io/library/nginx:1.27-alpine
|
||||
|
||||
# override default Nginx configuration
|
||||
COPY --chown=nginx ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# copy statically built error pages from the error-pages image
|
||||
# (instead of `ghost` you may use any other template)
|
||||
COPY --chown=nginx \
|
||||
--from=ghcr.io/tarampampam/error-pages:3 \
|
||||
/opt/html/ghost /usr/share/nginx/errorpages/_error-pages
|
||||
```
|
||||
|
||||
Now, we can build the image:
|
||||
|
||||
```bash
|
||||
$ docker build --tag your-nginx:local -f ./Dockerfile .
|
||||
```
|
||||
|
||||
And voilà! Let's start the image and test if everything is working as expected:
|
||||
|
||||
```bash
|
||||
$ docker run --rm -p '8081:80/tcp' your-nginx:local
|
||||
|
||||
$ curl http://127.0.0.1:8081/foobar | head -n 15 # in another terminal
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🚀 Usage with Traefik and local Docker Compose</strong></summary>
|
||||
|
||||
Instead of thousands of words, let's take a look at one compose file:
|
||||
|
||||
```yaml
|
||||
# file: compose.yml (or docker-compose.yml)
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: docker.io/library/traefik:v3.1
|
||||
command:
|
||||
#- --log.level=DEBUG
|
||||
- --api.dashboard=true # activate dashboard
|
||||
- --api.insecure=true # enable the API in insecure mode
|
||||
- --providers.docker=true # enable Docker backend with default settings
|
||||
- --providers.docker.exposedbydefault=false # do not expose containers by default
|
||||
- --entrypoints.web.address=:80 # --entrypoints.<name>.address for ports, 80 (i.e., name = web)
|
||||
ports:
|
||||
- "80:80/tcp" # HTTP (web)
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# dashboard
|
||||
traefik.http.routers.traefik.rule: Host(`traefik.localtest.me`)
|
||||
traefik.http.routers.traefik.service: api@internal
|
||||
traefik.http.routers.traefik.entrypoints: web
|
||||
traefik.http.routers.traefik.middlewares: error-pages-middleware
|
||||
depends_on:
|
||||
error-pages: {condition: service_healthy}
|
||||
|
||||
error-pages:
|
||||
image: ghcr.io/tarampampam/error-pages:3 # using the latest tag is highly discouraged
|
||||
environment:
|
||||
TEMPLATE_NAME: l7 # set the error pages template
|
||||
labels:
|
||||
traefik.enable: true
|
||||
# use as "fallback" for any NON-registered services (with priority below normal)
|
||||
traefik.http.routers.error-pages-router.rule: HostRegexp(`.+`)
|
||||
traefik.http.routers.error-pages-router.priority: 10
|
||||
# should say that all of your services work on https
|
||||
traefik.http.routers.error-pages-router.entrypoints: web
|
||||
traefik.http.routers.error-pages-router.middlewares: error-pages-middleware
|
||||
# "errors" middleware settings
|
||||
traefik.http.middlewares.error-pages-middleware.errors.status: 400-599
|
||||
traefik.http.middlewares.error-pages-middleware.errors.service: error-pages-service
|
||||
traefik.http.middlewares.error-pages-middleware.errors.query: /{status}.html
|
||||
# define service properties
|
||||
traefik.http.services.error-pages-service.loadbalancer.server.port: 8080
|
||||
|
||||
nginx-or-any-another-service:
|
||||
image: docker.io/library/nginx:1.27-alpine
|
||||
labels:
|
||||
traefik.enable: true
|
||||
traefik.http.routers.test-service.rule: Host(`test.localtest.me`)
|
||||
traefik.http.routers.test-service.entrypoints: web
|
||||
traefik.http.routers.test-service.middlewares: error-pages-middleware
|
||||
```
|
||||
|
||||
After executing `docker compose up` in the same directory as the `compose.yml` file, you can:
|
||||
|
||||
- Open the Traefik dashboard [at `traefik.localtest.me`](http://traefik.localtest.me/dashboard/#/)
|
||||
- [View customized error pages on the Traefik dashboard](http://traefik.localtest.me/foobar404)
|
||||
- Open the nginx index page [at `test.localtest.me`](http://test.localtest.me/)
|
||||
- View customized error pages for non-existent [pages](http://test.localtest.me/404) and [domains](http://404.localtest.me/)
|
||||
|
||||
Isn't this kind of magic? 😀
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🚀 Kubernetes (K8s) & Ingress Nginx</strong></summary>
|
||||
|
||||
Error-pages can be configured to work with the [ingress-nginx][ingress-nginx] helm chart in Kubernetes.
|
||||
|
||||
- Set the `custom-http-errors` config value
|
||||
- Enable default backend
|
||||
- Set the default backend image
|
||||
|
||||
```yaml
|
||||
controller:
|
||||
config:
|
||||
custom-http-errors: >-
|
||||
401,403,404,500,501,502,503
|
||||
|
||||
defaultBackend:
|
||||
enabled: true
|
||||
image:
|
||||
repository: ghcr.io/tarampampam/error-pages
|
||||
tag: '3' # using the latest tag is highly discouraged
|
||||
extraEnvs:
|
||||
- name: TEMPLATE_NAME # Optional: change the default theme
|
||||
value: l7
|
||||
- name: SHOW_DETAILS # Optional: enables the output of additional information on error pages
|
||||
value: 'true'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🦾 Performance
|
||||
|
||||
Hardware used:
|
||||
|
||||
- 12th Gen Intel® Core™ i7-1260P (16 cores)
|
||||
- 32 GiB RAM
|
||||
|
||||
RPS: **~180k** 🔥 requests served without any errors, with peak memory usage ~60 MiB under the default configuration
|
||||
|
||||
<details>
|
||||
<summary>Performance test details (click to expand)</summary>
|
||||
|
||||
```shell
|
||||
$ ulimit -aH | grep file
|
||||
core file size (blocks, -c) unlimited
|
||||
file size (blocks, -f) unlimited
|
||||
open files (-n) 1048576
|
||||
file locks (-x) unlimited
|
||||
|
||||
$ go build ./cmd/error-pages/ && ./error-pages --log-level warn serve
|
||||
|
||||
$ ./error-pages perftest # in separate terminal
|
||||
Starting the test to bomb ONE PAGE (code). Please, be patient...
|
||||
Test completed successfully. Here is the output:
|
||||
|
||||
Running 15s test @ http://127.0.0.1:8080/
|
||||
12 threads and 400 connections
|
||||
Thread Stats Avg Stdev Max +/- Stdev
|
||||
Latency 4.52ms 6.43ms 94.34ms 85.44%
|
||||
Req/Sec 15.76k 2.83k 29.64k 69.20%
|
||||
2839632 requests in 15.09s, 32.90GB read
|
||||
Requests/sec: 188185.61
|
||||
Transfer/sec: 2.18GB
|
||||
|
||||
Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...
|
||||
Test completed successfully. Here is the output:
|
||||
|
||||
Running 15s test @ http://127.0.0.1:8080/
|
||||
12 threads and 400 connections
|
||||
Thread Stats Avg Stdev Max +/- Stdev
|
||||
Latency 6.75ms 13.71ms 252.66ms 91.94%
|
||||
Req/Sec 14.06k 3.25k 26.39k 71.98%
|
||||
2534473 requests in 15.10s, 29.22GB read
|
||||
Requests/sec: 167899.78
|
||||
Transfer/sec: 1.94GB
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<!--GENERATED:CLI_DOCS-->
|
||||
<!-- Documentation inside this block generated by github.com/urfave/cli; DO NOT EDIT -->
|
||||
## CLI interface
|
||||
@ -646,12 +12,12 @@ Global flags:
|
||||
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|--------------------|---------------------------------------|:-------------:|:---------------------:|
|
||||
| `--log-level="…"` | Logging level (debug/info/warn/error) | `info` | `LOG_LEVEL` |
|
||||
| `--log-format="…"` | Logging format (console/json) | `console` | `LOG_FORMAT` |
|
||||
| `--log-level="…"` | logging level (debug/info/warn/error) | `info` | `LOG_LEVEL` |
|
||||
| `--log-format="…"` | logging format (console/json) | `console` | `LOG_FORMAT` |
|
||||
|
||||
### `serve` command (aliases: `s`, `server`, `http`)
|
||||
|
||||
Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D.
|
||||
Start HTTP server.
|
||||
|
||||
Usage:
|
||||
|
||||
@ -661,25 +27,23 @@ $ error-pages [GLOBAL FLAGS] serve [COMMAND FLAGS] [ARGUMENTS...]
|
||||
|
||||
The following flags are supported:
|
||||
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------:|:---------------------------:|
|
||||
| `--listen="…"` (`-l`) | The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, 0.0.0.0 to listen on all interfaces, or specify a custom IP) | `0.0.0.0` | `LISTEN_ADDR` |
|
||||
| `--port="…"` (`-p`) | The TCP port number for the HTTP server to listen on (0-65535) | `8080` | `LISTEN_PORT` |
|
||||
| `--add-template="…"` | To add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | `ADD_TEMPLATE` |
|
||||
| `--disable-template="…"` | Disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
|
||||
| `--add-code="…"` | To add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
|
||||
| `--json-format="…"` | Override the default error page response in JSON format (Go templates are supported; the error page will use this template if the client requests JSON content type) | | `RESPONSE_JSON_FORMAT` |
|
||||
| `--xml-format="…"` | Override the default error page response in XML format (Go templates are supported; the error page will use this template if the client requests XML content type) | | `RESPONSE_XML_FORMAT` |
|
||||
| `--plaintext-format="…"` | Override the default error page response in plain text format (Go templates are supported; the error page will use this template if the client requests plain text content type or does not specify any) | | `RESPONSE_PLAINTEXT_FORMAT` |
|
||||
| `--template-name="…"` (`-t`) | Name of the template to use for rendering error pages (built-in templates: app-down, cats, connection, ghost, hacker-terminal, l7, lost-in-space, noise, orient, shuffle) | `app-down` | `TEMPLATE_NAME` |
|
||||
| `--disable-l10n` | Disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
|
||||
| `--default-error-page="…"` | The code of the default (index page, when a code is not specified) error page to render | `404` | `DEFAULT_ERROR_PAGE` |
|
||||
| `--send-same-http-code` | The HTTP response should have the same status code as the requested error page (by default, every response with an error page will have a status code of 200) | `false` | `SEND_SAME_HTTP_CODE` |
|
||||
| `--show-details` | Show request details in the error page response (if supported by the template) | `false` | `SHOW_DETAILS` |
|
||||
| `--proxy-headers="…"` | HTTP headers listed here will be proxied from the original request to the error page response (comma-separated list) | `X-Request-Id,X-Trace-Id,X-Amzn-Trace-Id` | `PROXY_HTTP_HEADERS` |
|
||||
| `--rotation-mode="…"` | Templates automatic rotation mode (disabled/random-on-startup/random-on-each-request/random-hourly/random-daily) | `disabled` | `TEMPLATES_ROTATION_MODE` |
|
||||
| `--read-buffer-size="…"` | Per-connection buffer size in bytes for reading requests, this also limits the maximum header size (increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., large cookies), note that increasing this value will increase memory consumption) | `5120` | `READ_BUFFER_SIZE` |
|
||||
| `--disable-minification` | Disable the minification of HTML pages, including CSS, SVG, and JS (may be useful for debugging) | `false` | `DISABLE_MINIFICATION` |
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|--------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------:|:---------------------------:|
|
||||
| `--listen="…"` (`-l`) | the HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1 for localhost, 0.0.0.0 to listen on all interfaces, or specify a custom IP) | `0.0.0.0` | `LISTEN_ADDR` |
|
||||
| `--port="…"` (`-p`) | the TCP port number for the HTTP server to listen on (0-65535) | `8080` | `LISTEN_PORT` |
|
||||
| `--add-template="…"` | to add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* |
|
||||
| `--disable-template="…"` | disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
|
||||
| `--add-http-code="…"` (`--add-code`) | to add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
|
||||
| `--json-format="…"` | override the default error page response in JSON format (Go templates are supported; the error page will use this template if the client requests JSON content type) | | `RESPONSE_JSON_FORMAT` |
|
||||
| `--xml-format="…"` | override the default error page response in XML format (Go templates are supported; the error page will use this template if the client requests XML content type) | | `RESPONSE_XML_FORMAT` |
|
||||
| `--plaintext-format="…"` | override the default error page response in plain text format (Go templates are supported; the error page will use this template if the client requests plain text content type or does not specify any) | | `RESPONSE_PLAINTEXT_FORMAT` |
|
||||
| `--template-name="…"` (`-t`) | name of the template to use for rendering error pages (built-in templates: app-down, cats, connection, ghost, hacker-terminal, l7, lost-in-space, noise, orient, shuffle) | `app-down` | `TEMPLATE_NAME` |
|
||||
| `--disable-l10n` | disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
|
||||
| `--default-error-page="…"` | the code of the default (index page, when a code is not specified) error page to render | `404` | `DEFAULT_ERROR_PAGE` |
|
||||
| `--send-same-http-code` | the HTTP response should have the same status code as the requested error page (by default, every response with an error page will have a status code of 200) | `false` | `SEND_SAME_HTTP_CODE` |
|
||||
| `--show-details` | show request details in the error page response (if supported by the template) | `false` | `SHOW_DETAILS` |
|
||||
| `--proxy-headers="…"` | HTTP headers listed here will be proxied from the original request to the error page response (comma-separated list) | `X-Request-Id,X-Trace-Id,X-Amzn-Trace-Id` | `PROXY_HTTP_HEADERS` |
|
||||
| `--rotation-mode="…"` | templates automatic rotation mode (disabled/random-on-startup/random-on-each-request/random-hourly/random-daily) | `disabled` | `TEMPLATES_ROTATION_MODE` |
|
||||
|
||||
### `build` command (aliases: `b`)
|
||||
|
||||
@ -693,15 +57,14 @@ $ error-pages [GLOBAL FLAGS] build [COMMAND FLAGS] [ARGUMENTS...]
|
||||
|
||||
The following flags are supported:
|
||||
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------:|:----------------------:|
|
||||
| `--add-template="…"` | To add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | `ADD_TEMPLATE` |
|
||||
| `--disable-template="…"` | Disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
|
||||
| `--add-code="…"` | To add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
|
||||
| `--disable-l10n` | Disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
|
||||
| `--index` (`-i`) | Generate index.html file with links to all error pages | `false` | *none* |
|
||||
| `--target-dir="…"` (`--out`, `--dir`, `-o`) | Directory to put the built error pages into | `.` | *none* |
|
||||
| `--disable-minification` | Disable the minification of HTML pages, including CSS, SVG, and JS (may be useful for debugging) | `false` | `DISABLE_MINIFICATION` |
|
||||
| Name | Description | Default value | Environment variables |
|
||||
|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------:|:---------------------:|
|
||||
| `--add-template="…"` | to add a new template, provide the path to the file using this flag (the filename without the extension will be used as the template name) | `[]` | *none* |
|
||||
| `--disable-template="…"` | disable the specified template by its name (useful to disable the built-in templates and use only custom ones) | `[]` | *none* |
|
||||
| `--add-http-code="…"` (`--add-code`) | to add a new HTTP status code, provide the code and its message/description using this flag (the format should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously) | `map[]` | *none* |
|
||||
| `--disable-l10n` | disable localization of error pages (if the template supports localization) | `false` | `DISABLE_L10N` |
|
||||
| `--index` (`-i`) | generate index.html file with links to all error pages | `false` | *none* |
|
||||
| `--target-dir="…"` (`--out`, `--dir`, `-o`) | directory to put the built error pages into | `.` | *none* |
|
||||
|
||||
### `healthcheck` command (aliases: `chk`, `health`, `check`)
|
||||
|
||||
@ -721,6 +84,198 @@ The following flags are supported:
|
||||
|
||||
<!--/GENERATED:CLI_DOCS-->
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!--
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/tarampampam/error-pages#readme"><img src="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Dark" alt="banner" width="100%" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/tarampampam/error-pages?longCache=true&label=&logo=go&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://codecov.io/gh/tarampampam/error-pages"><img src="https://img.shields.io/codecov/c/github/tarampampam/error-pages/master.svg?maxAge=30&label=&logo=codecov&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/tests.yml?branch=master&maxAge=30&label=tests&logo=github&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/release.yml?maxAge=30&label=release&logo=github&style=flat-square" alt="" /></a>
|
||||
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/pulls/tarampampam/error-pages.svg?maxAge=30&label=pulls&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30&label=size&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
|
||||
<a href="https://github.com/tarampampam/error-pages/blob/master/LICENSE"><img src="https://img.shields.io/github/license/tarampampam/error-pages.svg?maxAge=30&style=flat-square" alt="" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><sup>
|
||||
22 feb. 2022 - ⚡ Our Docker image was downloaded <strong>one MILLION times</strong> from the docker hub! ⚡<br/>
|
||||
10 apr. 2023 - ⚡ <strong>Two million times</strong> from the docker hub and <strong>one million</strong> from the ghcr! ⚡
|
||||
</sup></p>
|
||||
|
||||
One day you may want to replace the standard error pages of your HTTP server with something more original and pretty. That's what this repository was created for :) It contains:
|
||||
|
||||
- Simple error pages generator, written in Go
|
||||
- Single-page error page templates with different designs (located in the [templates](https://github.com/tarampampam/error-pages/tree/master/templates) directory)
|
||||
- Fast and lightweight HTTP server
|
||||
- Already generated error pages (sources can be [found here][preview-sources], the **demonstration** is always accessible [here][preview-demo])
|
||||
|
||||
## 🔥 Features list
|
||||
|
||||
- HTTP server written in Go, with the extremely fast [FastHTTP][fasthttp] under the hood
|
||||
- Respects the `Content-Type` HTTP header (and `X-Format`) value and responds with the corresponding format (supported formats are `json` and `xml`)
|
||||
- Writes logs in `json` format
|
||||
- Contains healthcheck endpoint (`/healthz`)
|
||||
- Contains metrics endpoint (`/metrics`) in Prometheus format
|
||||
- Lightweight docker image _(~4.6Mb compressed size)_, distroless and uses the unleveled user by default
|
||||
- [Go-template](https://pkg.go.dev/text/template) tags are allowed in the templates
|
||||
- Ready for integration with [Traefik][traefik] ([error pages customization](https://doc.traefik.io/traefik/middlewares/http/errorpages/)) and [Ingress-nginx][ingress-nginx]
|
||||
- Error pages can be [embedded into your own `nginx`][wiki-usage-with-nginx] docker image
|
||||
- Fully configurable (take a look at the [configuration file](https://github.com/tarampampam/error-pages/blob/master/error-pages.yml) and [project Wiki][wiki])
|
||||
- Distributed using docker image and compiled binary files
|
||||
- Localized (🇺🇸, 🇫🇷, 🇺🇦, 🇷🇺, 🇵🇹, 🇳🇱, 🇩🇪, 🇪🇸, 🇨🇳, 🇮🇩, 🇵🇱) HTML error pages (translation process [described here](https://github.com/tarampampam/error-pages/tree/master/l10n) - other translations are welcome!)
|
||||
|
||||
## 🧩 Install
|
||||
|
||||
Download the latest binary file for your os/arch from the [releases page][releases] or use our docker image:
|
||||
|
||||
| Registry | Image |
|
||||
|-----------------------------------|-----------------------------------|
|
||||
| [Docker Hub][docker-hub] | `tarampampam/error-pages` |
|
||||
| [GitHub Container Registry][ghcr] | `ghcr.io/tarampampam/error-pages` |
|
||||
|
||||
> Using the `latest` tag for the docker image is highly discouraged because of possible backward-incompatible changes during **major** upgrades. Please, use tags in `X.Y.Z` format
|
||||
|
||||
💣 **Or** you can download **already rendered** error pages pack as a [zip][pages-pack-zip] or [tar.gz][pages-pack-tar-gz] archive.
|
||||
|
||||
[pages-pack-zip]:https://github.com/tarampampam/error-pages/zipball/gh-pages/
|
||||
[pages-pack-tar-gz]:https://github.com/tarampampam/error-pages/tarball/gh-pages/
|
||||
|
||||
## 🛠 Usage
|
||||
|
||||
Please, take a look at [our Wiki][wiki] for the common usage stories:
|
||||
|
||||
- [HTTP server][wiki-http-server] (routes, formats, flags and environment variables)
|
||||
- [Pages generator][wiki-generator] (build your own error page set)
|
||||
- [Static error pages][wiki-static-error-pages] (extract generated static error pages from the docker image)
|
||||
- [Usage with nginx][wiki-usage-with-nginx] (include our error pages into an image with nginx)
|
||||
- [Usage with Traefik and local Docker Compose][wiki-traefik-docker-compose] (it's a good starting point for the tests)
|
||||
- [Usage with Traefik and Docker Swarm][wiki-traefik-swarm]
|
||||
- [Kubernetes & ingress nginx][wiki-k8s-ingress-nginx]
|
||||
|
||||
[wiki]:https://github.com/tarampampam/error-pages/wiki
|
||||
[wiki-http-server]:https://github.com/tarampampam/error-pages/wiki/HTTP-server
|
||||
[wiki-generator]:https://github.com/tarampampam/error-pages/wiki/Generator
|
||||
[wiki-static-error-pages]:https://github.com/tarampampam/error-pages/wiki/Static-error-pages
|
||||
[wiki-usage-with-nginx]:https://github.com/tarampampam/error-pages/wiki/Usage-with-nginx
|
||||
[wiki-traefik-swarm]:https://github.com/tarampampam/error-pages/wiki/Traefik-(docker-swarm)
|
||||
[wiki-traefik-docker-compose]:https://github.com/tarampampam/error-pages/wiki/Traefik-(docker-compose)
|
||||
[wiki-k8s-ingress-nginx]:https://github.com/tarampampam/error-pages/wiki/Kubernetes-&-ingress-nginx
|
||||
|
||||
## 🦾 Performance
|
||||
|
||||
Used hardware:
|
||||
|
||||
- Intel® Core™ i7-10510U CPU @ 1.80GHz × 8
|
||||
- 16 GiB RAM
|
||||
|
||||
```shell
|
||||
$ ulimit -aH | grep file
|
||||
-f: file size (blocks) unlimited
|
||||
-c: core file size (blocks) unlimited
|
||||
-n: file descriptors 1048576
|
||||
-x: file locks unlimited
|
||||
|
||||
$ docker run --rm -p "8080:8080/tcp" -e "SHOW_DETAILS=true" error-pages:local # in separate terminal
|
||||
|
||||
$ wrk --timeout 1s -t12 -c400 -d30s -s ./test/wrk/request.lua http://127.0.0.1:8080/
|
||||
Running 30s test @ http://127.0.0.1:8080/
|
||||
12 threads and 400 connections
|
||||
Thread Stats Avg Stdev Max +/- Stdev
|
||||
Latency 10.84ms 7.89ms 135.91ms 79.36%
|
||||
Req/Sec 3.23k 785.11 6.30k 70.04%
|
||||
1160567 requests in 30.10s, 4.12GB read
|
||||
Requests/sec: 38552.04
|
||||
Transfer/sec: 140.23MB
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>FS & memory usage stats during the test</summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://hsto.org/webt/ts/w-/lz/tsw-lznvru0ngjneiimkwq7ysyc.png" alt="" />
|
||||
</p>
|
||||
</details>
|
||||
|
||||
## 🪂 Templates
|
||||
|
||||
| Name | Preview |
|
||||
|:-----------------:|:------------------------------------------------------------------:|
|
||||
| `ghost` | [![ghost][ghost-screen]][ghost-link] |
|
||||
| `l7-light` | [![l7-light][l7-light-screen]][l7-light-link] |
|
||||
| `l7-dark` | [![l7-dark][l7-dark-screen]][l7-dark-link] |
|
||||
| `shuffle` | [![shuffle][shuffle-screen]][shuffle-link] |
|
||||
| `noise` | [![noise][noise-screen]][noise-link] |
|
||||
| `hacker-terminal` | [![hacker-terminal][hacker-terminal-screen]][hacker-terminal-link] |
|
||||
| `cats` | [![cats][cats-screen]][cats-link] |
|
||||
| `lost-in-space` | [![lost-in-space][lost-in-space-screen]][lost-in-space-link] |
|
||||
| `app-down` | [![app-down][app-down-screen]][app-down-link] |
|
||||
| `connection` | [![connection][connection-screen]][connection-link] |
|
||||
| `matrix` | [![matrix][matrix-screen]][matrix-link] |
|
||||
| `orient` | [![orient][orient-screen]][orient-link] |
|
||||
|
||||
> Note: `noise` template highly uses the CPU, be careful
|
||||
|
||||
[ghost-screen]:https://hsto.org/webt/oj/cl/4k/ojcl4ko_cvusy5xuki6efffzsyo.gif
|
||||
[ghost-link]:https://tarampampam.github.io/error-pages/ghost/404.html
|
||||
[l7-light-screen]:https://hsto.org/webt/hx/ca/mm/hxcammfm7qjmogtvsjxcidgf7c8.png
|
||||
[l7-light-link]:https://tarampampam.github.io/error-pages/l7-light/404.html
|
||||
[l7-dark-screen]:https://hsto.org/webt/s1/ih/yr/s1ihyrqs_y-sgraoimfhk6ypney.png
|
||||
[l7-dark-link]:https://tarampampam.github.io/error-pages/l7-dark/404.html
|
||||
[shuffle-screen]:https://hsto.org/webt/7w/rk/3m/7wrk3mrzz3y8qfqwovmuvacu-bs.gif
|
||||
[shuffle-link]:https://tarampampam.github.io/error-pages/shuffle/404.html
|
||||
[noise-screen]:https://hsto.org/webt/42/oq/8y/42oq8yok_i-arrafjt6hds_7ahy.gif
|
||||
[noise-link]:https://tarampampam.github.io/error-pages/noise/404.html
|
||||
[hacker-terminal-screen]:https://hsto.org/webt/5s/l0/p1/5sl0p1_ud_nalzjzsj5slz6dfda.gif
|
||||
[hacker-terminal-link]:https://tarampampam.github.io/error-pages/hacker-terminal/404.html
|
||||
[cats-screen]:https://hsto.org/webt/_g/y-/ke/_gy-keqinz-3867jbw36v37-iwe.jpeg
|
||||
[cats-link]:https://tarampampam.github.io/error-pages/cats/404.html
|
||||
[lost-in-space-screen]:https://hsto.org/webt/lf/ln/x8/lflnx8fuy4rofxju34ttskijdsu.gif
|
||||
[lost-in-space-link]:https://tarampampam.github.io/error-pages/lost-in-space/404.html
|
||||
[app-down-screen]:https://habrastorage.org/webt/j2/la/fj/j2lafjvu_xjflzrvhiixobxy_ca.png
|
||||
[app-down-link]:https://tarampampam.github.io/error-pages/app-down/404.html
|
||||
[connection-screen]:https://hsto.org/webt/x4/ah/jb/x4ahjboo4-arm3bxpaash_sflmw.png
|
||||
[connection-link]:https://tarampampam.github.io/error-pages/connection/404.html
|
||||
[matrix-screen]:https://hsto.org/webt/ng/tf/oi/ngtfoiolvmq6hf15kimcxmhprhk.gif
|
||||
[matrix-link]:https://tarampampam.github.io/error-pages/matrix/404.html
|
||||
[orient-screen]:https://hsto.org/webt/pz/eu/v_/pzeuv_lyeqr0xpusa4zfrtgk7sa.png
|
||||
[orient-link]:https://tarampampam.github.io/error-pages/orient/404.html
|
||||
|
||||
## 🦾 Contributors
|
||||
|
||||
I want to say a big thank you to everyone who contributed to this project:
|
||||
@ -729,23 +284,45 @@ I want to say a big thank you to everyone who contributed to this project:
|
||||
|
||||
[contributors]:https://github.com/tarampampam/error-pages/graphs/contributors
|
||||
|
||||
## 📰 Changes log
|
||||
|
||||
[![Release date][badge-release-date]][releases]
|
||||
[![Commits since latest release][badge-commits]][commits]
|
||||
|
||||
Changes log can be [found here][changelog].
|
||||
|
||||
## 👾 Support
|
||||
|
||||
[![Issues][badge-issues]][issues]
|
||||
[![Issues][badge-prs]][prs]
|
||||
|
||||
If you encounter any bugs in the project, please [create an issue][new-issue] in this repository.
|
||||
|
||||
[badge-issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?maxAge=45
|
||||
[badge-prs]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?maxAge=45
|
||||
[issues]:https://github.com/tarampampam/error-pages/issues
|
||||
[prs]:https://github.com/tarampampam/error-pages/pulls
|
||||
[new-issue]:https://github.com/tarampampam/error-pages/issues/new/choose
|
||||
If you find any bugs in the project, please [create an issue][new-issue] in the current repository.
|
||||
|
||||
## 📖 License
|
||||
|
||||
This is open-sourced software licensed under the [MIT License][license].
|
||||
|
||||
[license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
|
||||
[badge-release]:https://img.shields.io/github/release/tarampampam/error-pages.svg?maxAge=30
|
||||
[badge-release-date]:https://img.shields.io/github/release-date/tarampampam/error-pages.svg?maxAge=180
|
||||
[badge-commits]:https://img.shields.io/github/commits-since/tarampampam/error-pages/latest.svg?maxAge=45
|
||||
[badge-issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?maxAge=45
|
||||
[badge-prs]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?maxAge=45
|
||||
|
||||
[docker-hub]:https://hub.docker.com/r/tarampampam/error-pages
|
||||
[docker-hub-tags]:https://hub.docker.com/r/tarampampam/error-pages/tags
|
||||
[license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
|
||||
[releases]:https://github.com/tarampampam/error-pages/releases
|
||||
[commits]:https://github.com/tarampampam/error-pages/commits
|
||||
[changelog]:https://github.com/tarampampam/error-pages/blob/master/CHANGELOG.md
|
||||
[issues]:https://github.com/tarampampam/error-pages/issues
|
||||
[new-issue]:https://github.com/tarampampam/error-pages/issues/new/choose
|
||||
[prs]:https://github.com/tarampampam/error-pages/pulls
|
||||
[ghcr]:https://github.com/users/tarampampam/packages/container/package/error-pages
|
||||
|
||||
[fasthttp]:https://github.com/valyala/fasthttp
|
||||
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
|
||||
[preview-demo]:https://tarampampam.github.io/error-pages/
|
||||
[traefik]:https://github.com/traefik/traefik
|
||||
[ingress-nginx]:https://github.com/kubernetes/ingress-nginx/tree/main/charts/ingress-nginx
|
||||
|
||||
-->
|
||||
|
6
go.mod
6
go.mod
@ -4,24 +4,18 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/tdewolff/minify/v2 v2.20.35
|
||||
github.com/urfave/cli-docs/v3 v3.0.0-alpha5
|
||||
github.com/urfave/cli/v3 v3.0.0-alpha9
|
||||
github.com/valyala/fasthttp v1.55.0
|
||||
go.uber.org/automaxprocs v1.5.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.15 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
15
go.sum
15
go.sum
@ -1,12 +1,8 @@
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@ -26,21 +22,10 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tdewolff/minify/v2 v2.20.35 h1:/Vq/oivpkFyi2PViD25XHZZbJz+eO4OmPSgePex1kBU=
|
||||
github.com/tdewolff/minify/v2 v2.20.35/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
|
||||
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
|
||||
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
|
||||
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
|
||||
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||
github.com/urfave/cli-docs/v3 v3.0.0-alpha5 h1:H1oWnR2/GN0dNm2PVylws+GxSOD6YOwW/jI5l78YfPk=
|
||||
github.com/urfave/cli-docs/v3 v3.0.0-alpha5/go.mod h1:AIqom6Q60U4tiqHp41i7+/AB2XHgi1WvQ7jOFlccmZ4=
|
||||
github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo=
|
||||
github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
_ "github.com/urfave/cli-docs/v3" // required for `go generate` to work
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/appmeta"
|
||||
@ -16,7 +17,7 @@ import (
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
//go:generate go run app_generate.go
|
||||
//go:generate go run update_readme.go
|
||||
|
||||
// NewApp creates a new console application.
|
||||
func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
@ -24,7 +25,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
logLevelFlag = cli.StringFlag{
|
||||
Name: "log-level",
|
||||
Value: logger.InfoLevel.String(),
|
||||
Usage: "Logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
|
||||
Usage: "logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
|
||||
Sources: cli.EnvVars("LOG_LEVEL"),
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
@ -40,7 +41,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
logFormatFlag = cli.StringFlag{
|
||||
Name: "log-format",
|
||||
Value: logger.ConsoleFormat.String(),
|
||||
Usage: "Logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
|
||||
Usage: "logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
|
||||
Sources: cli.EnvVars("LOG_FORMAT"),
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
@ -79,7 +80,7 @@ func NewApp(appName string) *cli.Command { //nolint:funlen
|
||||
serve.NewCommand(log),
|
||||
build.NewCommand(log),
|
||||
healthcheck.NewCommand(log, healthcheck.NewHTTPHealthChecker()),
|
||||
perftest.NewCommand(),
|
||||
perftest.NewCommand(log),
|
||||
},
|
||||
Version: fmt.Sprintf("%s (%s)", appmeta.Version(), runtime.Version()),
|
||||
Flags: []cli.Flag{ // global flags
|
||||
|
@ -39,24 +39,21 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
|
||||
cmd command
|
||||
cfg = config.New()
|
||||
|
||||
addTplFlag = shared.AddTemplatesFlag
|
||||
disableTplFlag = shared.DisableTemplateNamesFlag
|
||||
addCodeFlag = shared.AddHTTPCodesFlag
|
||||
disableL10nFlag = shared.DisableL10nFlag
|
||||
disableMinificationFlag = shared.DisableMinificationFlag
|
||||
createIndexFlag = cli.BoolFlag{
|
||||
Name: "index",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Generate index.html file with links to all error pages",
|
||||
Category: shared.CategoryBuild,
|
||||
addTplFlag = shared.AddTemplatesFlag
|
||||
disableTplFlag = shared.DisableTemplateNamesFlag
|
||||
addCodeFlag = shared.AddHTTPCodesFlag
|
||||
disableL10nFlag = shared.DisableL10nFlag
|
||||
createIndexFlag = cli.BoolFlag{
|
||||
Name: "index",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "generate index.html file with links to all error pages",
|
||||
}
|
||||
targetDirFlag = cli.StringFlag{
|
||||
Name: "target-dir",
|
||||
Aliases: []string{"out", "dir", "o"},
|
||||
Usage: "Directory to put the built error pages into",
|
||||
Usage: "directory to put the built error pages into",
|
||||
Value: ".", // current directory by default
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Category: shared.CategoryBuild,
|
||||
OnlyOnce: true,
|
||||
Validator: func(dir string) error {
|
||||
if dir == "" {
|
||||
@ -82,7 +79,6 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
|
||||
Usage: "Build the static error pages and put them into a specified directory",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
|
||||
cfg.DisableMinification = c.Bool(disableMinificationFlag.Name)
|
||||
cmd.opt.createIndex = c.Bool(createIndexFlag.Name)
|
||||
cmd.opt.targetDirAbsPath, _ = filepath.Abs(c.String(targetDirFlag.Name)) // an error checked by [os.Stat] validator
|
||||
|
||||
@ -142,15 +138,14 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
|
||||
&disableL10nFlag,
|
||||
&createIndexFlag,
|
||||
&targetDirFlag,
|
||||
&disableMinificationFlag,
|
||||
},
|
||||
}
|
||||
|
||||
return cmd.c
|
||||
}
|
||||
|
||||
func (cmd *command) Run( //nolint:funlen,gocognit
|
||||
_ context.Context,
|
||||
func (cmd *command) Run( //nolint:funlen
|
||||
ctx context.Context,
|
||||
log *logger.Logger,
|
||||
cfg *config.Config,
|
||||
) error {
|
||||
@ -175,21 +170,13 @@ func (cmd *command) Run( //nolint:funlen,gocognit
|
||||
|
||||
var outFilePath = path.Join(cmd.opt.targetDirAbsPath, templateName, code+".html")
|
||||
|
||||
if content, renderErr := appTemplate.Render(templateContent, appTemplate.Props{ //nolint:nestif
|
||||
Code: uint16(codeAsUint), //nolint:gosec
|
||||
if content, renderErr := appTemplate.Render(templateContent, appTemplate.Props{
|
||||
Code: uint16(codeAsUint),
|
||||
Message: codeDescription.Message,
|
||||
Description: codeDescription.Description,
|
||||
L10nDisabled: cfg.L10n.Disable,
|
||||
ShowRequestDetails: false,
|
||||
}); renderErr == nil {
|
||||
if !cfg.DisableMinification {
|
||||
if mini, minErr := appTemplate.MiniHTML(content); minErr != nil {
|
||||
log.Warn("Cannot minify the content", logger.Error(minErr))
|
||||
} else {
|
||||
content = mini
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outFilePath, []byte(content), os.FileMode(0664)); err != nil { //nolint:mnd
|
||||
return err
|
||||
}
|
||||
|
@ -1,59 +1,32 @@
|
||||
package perftest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/cli/shared"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
const wrkOneCodeTestLua = `
|
||||
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
|
||||
|
||||
request = function()
|
||||
wrk.headers["User-Agent"] = "wrk"
|
||||
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
|
||||
|
||||
return wrk.format("GET", "/500.html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
|
||||
end
|
||||
`
|
||||
|
||||
//nolint:lll
|
||||
const bombDifferentCodes = `
|
||||
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
|
||||
|
||||
request = function()
|
||||
wrk.headers["User-Agent"] = "wrk"
|
||||
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
|
||||
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
|
||||
|
||||
return wrk.format("GET", "/" .. tostring(math.random(400, 599)) .. ".html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
|
||||
end
|
||||
`
|
||||
|
||||
// NewCommand creates `perftest` command.
|
||||
func NewCommand() *cli.Command { //nolint:funlen
|
||||
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
|
||||
var (
|
||||
portFlag = shared.ListenPortFlag
|
||||
durationFlag = cli.DurationFlag{
|
||||
Name: "duration",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Duration of test",
|
||||
Value: 15 * time.Second, //nolint:mnd
|
||||
Usage: "duration of the test",
|
||||
Value: 10 * time.Second, //nolint:mnd
|
||||
Validator: func(d time.Duration) error {
|
||||
if d <= time.Second {
|
||||
return errors.New("duration can't be less than 1 second")
|
||||
@ -65,28 +38,11 @@ func NewCommand() *cli.Command { //nolint:funlen
|
||||
threadsFlag = cli.UintFlag{
|
||||
Name: "threads",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Number of threads to use",
|
||||
Value: max(2, uint64(math.Round(float64(runtime.NumCPU())/1.3))), //nolint:mnd
|
||||
Usage: "number of threads",
|
||||
Value: max(2, uint64(runtime.NumCPU()/2)), //nolint:mnd
|
||||
Validator: func(u uint64) error {
|
||||
if u == 0 {
|
||||
return errors.New("threads number can't be zero")
|
||||
} else if u > math.MaxUint16 {
|
||||
return errors.New("threads number can't be greater than 65535")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
connectionsFlag = cli.UintFlag{
|
||||
Name: "connections",
|
||||
Aliases: []string{"c"},
|
||||
Usage: "Number of connections to keep open",
|
||||
Value: max(16, uint64(runtime.NumCPU()*25)), //nolint:mnd
|
||||
Validator: func(u uint64) error {
|
||||
if u == 0 {
|
||||
return errors.New("threads number can't be zero")
|
||||
} else if u > math.MaxUint16 {
|
||||
return errors.New("threads number can't be greater than 65535")
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -96,47 +52,97 @@ func NewCommand() *cli.Command { //nolint:funlen
|
||||
|
||||
return &cli.Command{
|
||||
Name: "perftest",
|
||||
Aliases: []string{"perf", "benchmark", "bench"},
|
||||
Aliases: []string{"perf", "test"},
|
||||
Hidden: true,
|
||||
Usage: "Performance (load) test for the HTTP server (locally installed wrk is required)",
|
||||
Usage: "Simple performance (load) test for the HTTP server",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
var wrkBinPath, lErr = exec.LookPath("wrk")
|
||||
if lErr != nil {
|
||||
return fmt.Errorf("seems like wrk (https://github.com/wg/wrk) is not installed: %w", lErr)
|
||||
var (
|
||||
perfCtx, cancel = context.WithTimeout(ctx, c.Duration(durationFlag.Name))
|
||||
startedAt = time.Now()
|
||||
|
||||
wg sync.WaitGroup
|
||||
success atomic.Uint64
|
||||
failed atomic.Uint64
|
||||
)
|
||||
|
||||
defer func() {
|
||||
cancel()
|
||||
|
||||
log.Info("Summary",
|
||||
logger.Uint64("success", success.Load()),
|
||||
logger.Uint64("failed", failed.Load()),
|
||||
logger.Duration("duration", time.Since(startedAt)),
|
||||
logger.Float64("RPS", float64(success.Load()+failed.Load())/time.Since(startedAt).Seconds()),
|
||||
logger.Float64("errors rate", float64(failed.Load())/float64(success.Load()+failed.Load())*100), //nolint:mnd
|
||||
)
|
||||
}()
|
||||
|
||||
log.Info("Running test",
|
||||
logger.Uint64("threads", c.Uint(threadsFlag.Name)),
|
||||
logger.Duration("duration", c.Duration(durationFlag.Name)),
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{
|
||||
Transport: &http.Transport{MaxConnsPerHost: max(2, int(c.Uint(threadsFlag.Name))-1)}, //nolint:mnd
|
||||
Timeout: c.Duration(durationFlag.Name) + time.Second,
|
||||
}
|
||||
|
||||
var runTest = func(scriptContent string) error {
|
||||
if stdOut, stdErr, err := wrkRunTest(ctx,
|
||||
wrkBinPath,
|
||||
uint16(c.Uint(threadsFlag.Name)), //nolint:gosec
|
||||
uint16(c.Uint(connectionsFlag.Name)), //nolint:gosec
|
||||
c.Duration(durationFlag.Name),
|
||||
uint16(c.Uint(portFlag.Name)), //nolint:gosec
|
||||
scriptContent,
|
||||
); err != nil {
|
||||
var errData, _ = io.ReadAll(stdErr)
|
||||
for i := uint64(0); i < c.Uint(threadsFlag.Name); i++ {
|
||||
wg.Add(1)
|
||||
|
||||
return fmt.Errorf("failed to execute the test: %w (%s)", err, string(errData))
|
||||
} else {
|
||||
var outData, _ = io.ReadAll(stdOut)
|
||||
go func(log *logger.Logger) {
|
||||
defer wg.Done()
|
||||
|
||||
printf("Test completed successfully. Here is the output:\n\n%s\n", string(outData))
|
||||
}
|
||||
if perfCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
var req, rErr = makeRequest(perfCtx, uint16(c.Uint(portFlag.Name)))
|
||||
if rErr != nil {
|
||||
log.Error("Failed to create a new request", logger.Error(rErr))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
var sentAt = time.Now()
|
||||
|
||||
var resp, respErr = httpClient.Do(req)
|
||||
if resp != nil {
|
||||
if _, err := io.Copy(io.Discard, resp.Body); err != nil && !errIsDone(err) {
|
||||
log.Error("Failed to read response body", logger.Error(err))
|
||||
}
|
||||
|
||||
if err := resp.Body.Close(); err != nil && !errIsDone(err) {
|
||||
log.Error("Failed to close response body", logger.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if respErr != nil {
|
||||
if errIsDone(respErr) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Request failed", logger.Error(respErr))
|
||||
failed.Add(1)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("Response received",
|
||||
logger.String("status", resp.Status),
|
||||
logger.Duration("duration", time.Since(sentAt)),
|
||||
logger.Int64("size", resp.ContentLength),
|
||||
logger.Uint64("success", success.Load()),
|
||||
logger.Uint64("failed", failed.Load()),
|
||||
)
|
||||
|
||||
success.Add(1)
|
||||
}
|
||||
}(log.Named(fmt.Sprintf("thread-%d", i)))
|
||||
}
|
||||
|
||||
printf("Starting the test to bomb ONE PAGE (code). Please, be patient...\n")
|
||||
|
||||
if err := runTest(wrkOneCodeTestLua); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printf("Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...\n")
|
||||
|
||||
if err := runTest(bombDifferentCodes); err != nil {
|
||||
return err
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
},
|
||||
@ -144,51 +150,54 @@ func NewCommand() *cli.Command { //nolint:funlen
|
||||
&portFlag,
|
||||
&durationFlag,
|
||||
&threadsFlag,
|
||||
&connectionsFlag,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func printf(format string, args ...any) { fmt.Printf(format, args...) } //nolint:forbidigo
|
||||
// randomIntBetween returns a random integer between min and max.
|
||||
func randomIntBetween(min, max int) int { return min + rand.Intn(max-min) } //nolint:gosec
|
||||
|
||||
func wrkRunTest(
|
||||
ctx context.Context,
|
||||
wrkBinPath string,
|
||||
threadsCount, connectionsCount uint16,
|
||||
duration time.Duration,
|
||||
port uint16,
|
||||
scriptContent string,
|
||||
) (io.Reader, io.Reader, error) {
|
||||
var tmpFile, tErr = os.CreateTemp("", "ep-perf-one-page")
|
||||
if tErr != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create a temporary file: %w", tErr)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.WriteString(scriptContent); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to write to a temporary file: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
var cmd = exec.CommandContext(ctx, wrkBinPath, //nolint:gosec
|
||||
"--timeout", "1s",
|
||||
"--threads", strconv.FormatUint(uint64(threadsCount), 10),
|
||||
"--connections", strconv.FormatUint(uint64(connectionsCount), 10),
|
||||
"--duration", duration.String(),
|
||||
"--script", tmpFile.Name(),
|
||||
fmt.Sprintf("http://127.0.0.1:%d/", port),
|
||||
// makeRequest creates a new HTTP request for the performance test.
|
||||
func makeRequest(ctx context.Context, port uint16) (*http.Request, error) {
|
||||
var req, rErr = http.NewRequestWithContext(ctx,
|
||||
http.MethodGet,
|
||||
fmt.Sprintf(
|
||||
"http://127.0.0.1:%d/%d.html?rnd=%d", // for load testing purposes only
|
||||
port,
|
||||
randomIntBetween(400, 418), //nolint:mnd
|
||||
randomIntBetween(1, 999_999_999), //nolint:mnd
|
||||
),
|
||||
http.NoBody,
|
||||
)
|
||||
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
if rErr != nil {
|
||||
return nil, rErr
|
||||
}
|
||||
|
||||
return &stdout, &stderr, cmd.Run() // execute
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
req.Header.Set("User-Agent", "perftest")
|
||||
req.Header.Set("X-Namespace", fmt.Sprintf("namespace-%d", randomIntBetween(1, 999_999_999))) //nolint:mnd
|
||||
req.Header.Set("X-Request-ID", fmt.Sprintf("req-id-%d", randomIntBetween(1, 999_999_999))) //nolint:mnd
|
||||
|
||||
var contentType string
|
||||
|
||||
switch randomIntBetween(1, 4) { //nolint:mnd
|
||||
case 1:
|
||||
contentType = "application/json"
|
||||
case 2: //nolint:mnd
|
||||
contentType = "application/xml"
|
||||
case 3: //nolint:mnd
|
||||
contentType = "text/html"
|
||||
default:
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// errIsDone checks if the error is a context.DeadlineExceeded or context.Canceled.
|
||||
func errIsDone(err error) bool {
|
||||
return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
@ -22,9 +22,9 @@ type command struct {
|
||||
|
||||
opt struct {
|
||||
http struct { // our HTTP server
|
||||
addr string
|
||||
port uint16
|
||||
readBufferSize uint
|
||||
addr string
|
||||
port uint16
|
||||
// readBufferSize uint
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,37 +38,33 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
|
||||
)
|
||||
|
||||
var (
|
||||
addrFlag = shared.ListenAddrFlag
|
||||
portFlag = shared.ListenPortFlag
|
||||
addTplFlag = shared.AddTemplatesFlag
|
||||
disableTplFlag = shared.DisableTemplateNamesFlag
|
||||
addCodeFlag = shared.AddHTTPCodesFlag
|
||||
disableL10nFlag = shared.DisableL10nFlag
|
||||
disableMinificationFlag = shared.DisableMinificationFlag
|
||||
jsonFormatFlag = cli.StringFlag{
|
||||
addrFlag = shared.ListenAddrFlag
|
||||
portFlag = shared.ListenPortFlag
|
||||
addTplFlag = shared.AddTemplatesFlag
|
||||
disableTplFlag = shared.DisableTemplateNamesFlag
|
||||
addCodeFlag = shared.AddHTTPCodesFlag
|
||||
disableL10nFlag = shared.DisableL10nFlag
|
||||
jsonFormatFlag = cli.StringFlag{
|
||||
Name: "json-format",
|
||||
Usage: "Override the default error page response in JSON format (Go templates are supported; the error " +
|
||||
Usage: "override the default error page response in JSON format (Go templates are supported; the error " +
|
||||
"page will use this template if the client requests JSON content type)",
|
||||
Sources: env("RESPONSE_JSON_FORMAT"),
|
||||
Category: shared.CategoryFormats,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
xmlFormatFlag = cli.StringFlag{
|
||||
Name: "xml-format",
|
||||
Usage: "Override the default error page response in XML format (Go templates are supported; the error " +
|
||||
Usage: "override the default error page response in XML format (Go templates are supported; the error " +
|
||||
"page will use this template if the client requests XML content type)",
|
||||
Sources: env("RESPONSE_XML_FORMAT"),
|
||||
Category: shared.CategoryFormats,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
plainTextFormatFlag = cli.StringFlag{
|
||||
Name: "plaintext-format",
|
||||
Usage: "Override the default error page response in plain text format (Go templates are supported; the " +
|
||||
Usage: "override the default error page response in plain text format (Go templates are supported; the " +
|
||||
"error page will use this template if the client requests plain text content type or does not specify any)",
|
||||
Sources: env("RESPONSE_PLAINTEXT_FORMAT"),
|
||||
Category: shared.CategoryFormats,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
@ -76,19 +72,17 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
|
||||
Name: "template-name",
|
||||
Aliases: []string{"t"},
|
||||
Value: cfg.TemplateName,
|
||||
Usage: "Name of the template to use for rendering error pages (built-in templates: " +
|
||||
Usage: "name of the template to use for rendering error pages (built-in templates: " +
|
||||
strings.Join(cfg.Templates.Names(), ", ") + ")",
|
||||
Sources: env("TEMPLATE_NAME"),
|
||||
Category: shared.CategoryTemplates,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
defaultCodeToRenderFlag = cli.UintFlag{
|
||||
Name: "default-error-page",
|
||||
Usage: "The code of the default (index page, when a code is not specified) error page to render",
|
||||
Value: uint64(cfg.DefaultCodeToRender),
|
||||
Sources: env("DEFAULT_ERROR_PAGE"),
|
||||
Category: shared.CategoryCodes,
|
||||
Name: "default-error-page",
|
||||
Usage: "the code of the default (index page, when a code is not specified) error page to render",
|
||||
Value: uint64(cfg.DefaultCodeToRender),
|
||||
Sources: env("DEFAULT_ERROR_PAGE"),
|
||||
Validator: func(code uint64) error {
|
||||
if code > 999 { //nolint:mnd
|
||||
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
|
||||
@ -100,19 +94,17 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
|
||||
}
|
||||
sendSameHTTPCodeFlag = cli.BoolFlag{
|
||||
Name: "send-same-http-code",
|
||||
Usage: "The HTTP response should have the same status code as the requested error page (by default, " +
|
||||
Usage: "the HTTP response should have the same status code as the requested error page (by default, " +
|
||||
"every response with an error page will have a status code of 200)",
|
||||
Value: cfg.RespondWithSameHTTPCode,
|
||||
Sources: env("SEND_SAME_HTTP_CODE"),
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
showDetailsFlag = cli.BoolFlag{
|
||||
Name: "show-details",
|
||||
Usage: "Show request details in the error page response (if supported by the template)",
|
||||
Usage: "show request details in the error page response (if supported by the template)",
|
||||
Value: cfg.ShowDetails,
|
||||
Sources: env("SHOW_DETAILS"),
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
proxyHeadersListFlag = cli.StringFlag{
|
||||
@ -130,16 +122,14 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
|
||||
|
||||
return nil
|
||||
},
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
}
|
||||
rotationModeFlag = cli.StringFlag{
|
||||
Name: "rotation-mode",
|
||||
Value: config.RotationModeDisabled.String(),
|
||||
Usage: "Templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
|
||||
Usage: "templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
|
||||
Sources: env("TEMPLATES_ROTATION_MODE"),
|
||||
Category: shared.CategoryTemplates,
|
||||
OnlyOnce: true,
|
||||
Config: trim,
|
||||
Validator: func(s string) error {
|
||||
@ -150,40 +140,28 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
|
||||
return nil
|
||||
},
|
||||
}
|
||||
readBufferSizeFlag = cli.UintFlag{
|
||||
Name: "read-buffer-size",
|
||||
Usage: "Per-connection buffer size in bytes for reading requests, this also limits the maximum header size " +
|
||||
"(increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., " +
|
||||
"large cookies), note that increasing this value will increase memory consumption)",
|
||||
Value: 1024 * 5, //nolint:mnd // 5 KB
|
||||
Sources: env("READ_BUFFER_SIZE"),
|
||||
Category: shared.CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
)
|
||||
|
||||
// override some flag usage messages
|
||||
addrFlag.Usage = "The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, " +
|
||||
addrFlag.Usage = "the HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1 for localhost, " +
|
||||
"0.0.0.0 to listen on all interfaces, or specify a custom IP)"
|
||||
portFlag.Usage = "The TCP port number for the HTTP server to listen on (0-65535)"
|
||||
portFlag.Usage = "the TCP port number for the HTTP server to listen on (0-65535)"
|
||||
|
||||
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
|
||||
|
||||
cmd.c = &cli.Command{
|
||||
Name: "serve",
|
||||
Aliases: []string{"s", "server", "http"},
|
||||
Usage: "Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D",
|
||||
Usage: "Start HTTP server",
|
||||
Suggest: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
cmd.opt.http.addr = c.String(addrFlag.Name)
|
||||
cmd.opt.http.port = uint16(c.Uint(portFlag.Name)) //nolint:gosec
|
||||
cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name))
|
||||
cmd.opt.http.port = uint16(c.Uint(portFlag.Name))
|
||||
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
|
||||
cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name)) //nolint:gosec
|
||||
cfg.DefaultCodeToRender = uint16(c.Uint(defaultCodeToRenderFlag.Name))
|
||||
cfg.RespondWithSameHTTPCode = c.Bool(sendSameHTTPCodeFlag.Name)
|
||||
cfg.RotationMode, _ = config.ParseRotationMode(c.String(rotationModeFlag.Name))
|
||||
cfg.ShowDetails = c.Bool(showDetailsFlag.Name)
|
||||
cfg.DisableMinification = c.Bool(disableMinificationFlag.Name)
|
||||
|
||||
{ // override default JSON, XML, and PlainText formats
|
||||
if c.IsSet(jsonFormatFlag.Name) {
|
||||
@ -304,8 +282,6 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
|
||||
&showDetailsFlag,
|
||||
&proxyHeadersListFlag,
|
||||
&rotationModeFlag,
|
||||
&readBufferSizeFlag,
|
||||
&disableMinificationFlag,
|
||||
},
|
||||
}
|
||||
|
||||
@ -314,7 +290,7 @@ func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocy
|
||||
|
||||
// Run current command.
|
||||
func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error { //nolint:funlen
|
||||
var srv = appHttp.NewServer(log, cmd.opt.http.readBufferSize)
|
||||
var srv = appHttp.NewServer(ctx, log)
|
||||
|
||||
if err := srv.Register(cfg); err != nil {
|
||||
return err
|
||||
|
@ -36,7 +36,7 @@ func TestCommand_Run(t *testing.T) {
|
||||
"--add-template", "./testdata/foo-template.html",
|
||||
"--disable-template", "ghost",
|
||||
"--disable-template", "<unknown>",
|
||||
"--add-code", "200=Code/Description",
|
||||
"--add-http-code", "200=Code/Description",
|
||||
"--json-format", "json format",
|
||||
"--xml-format", "xml format",
|
||||
"--plaintext-format", "plaintext format",
|
||||
@ -97,5 +97,5 @@ func getFreeTcpPort(t *testing.T) uint16 {
|
||||
<-time.After(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
return uint16(port) //nolint:gosec
|
||||
return uint16(port)
|
||||
}
|
||||
|
@ -11,15 +11,6 @@ import (
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
CategoryHTTP = "HTTP:"
|
||||
CategoryTemplates = "TEMPLATES:"
|
||||
CategoryCodes = "HTTP CODES:"
|
||||
CategoryFormats = "FORMATS:"
|
||||
CategoryBuild = "BUILD:"
|
||||
CategoryOther = "OTHER:"
|
||||
)
|
||||
|
||||
// Note: Don't use pointers for flags, because they have own state which is not thread-safe.
|
||||
// https://github.com/urfave/cli/issues/1926
|
||||
|
||||
@ -29,7 +20,6 @@ var ListenAddrFlag = cli.StringFlag{
|
||||
Usage: "IP (v4 or v6) address to listen on",
|
||||
Value: "0.0.0.0", // bind to all interfaces by default
|
||||
Sources: cli.EnvVars("LISTEN_ADDR"),
|
||||
Category: CategoryHTTP,
|
||||
OnlyOnce: true,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(ip string) error {
|
||||
@ -51,7 +41,6 @@ var ListenPortFlag = cli.UintFlag{
|
||||
Usage: "TCP port number",
|
||||
Value: 8080, // default port number
|
||||
Sources: cli.EnvVars("LISTEN_PORT"),
|
||||
Category: CategoryHTTP,
|
||||
OnlyOnce: true,
|
||||
Validator: func(port uint64) error {
|
||||
if port == 0 || port > 65535 {
|
||||
@ -64,11 +53,9 @@ var ListenPortFlag = cli.UintFlag{
|
||||
|
||||
var AddTemplatesFlag = cli.StringSliceFlag{
|
||||
Name: "add-template",
|
||||
Usage: "To add a new template, provide the path to the file using this flag (the filename without the extension " +
|
||||
Usage: "to add a new template, provide the path to the file using this flag (the filename without the extension " +
|
||||
"will be used as the template name)",
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Sources: cli.EnvVars("ADD_TEMPLATE"),
|
||||
Category: CategoryTemplates,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(paths []string) error {
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
@ -85,19 +72,18 @@ var AddTemplatesFlag = cli.StringSliceFlag{
|
||||
}
|
||||
|
||||
var DisableTemplateNamesFlag = cli.StringSliceFlag{
|
||||
Name: "disable-template",
|
||||
Usage: "Disable the specified template by its name (useful to disable the built-in templates and use only custom ones)",
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Category: CategoryTemplates,
|
||||
Name: "disable-template",
|
||||
Usage: "disable the specified template by its name (useful to disable the built-in templates and use only custom ones)",
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
}
|
||||
|
||||
var AddHTTPCodesFlag = cli.StringMapFlag{
|
||||
Name: "add-code",
|
||||
Usage: "To add a new HTTP status code, provide the code and its message/description using this flag (the format " +
|
||||
Name: "add-http-code",
|
||||
Aliases: []string{"add-code"},
|
||||
Usage: "to add a new HTTP status code, provide the code and its message/description using this flag (the format " +
|
||||
"should be '%code%=%message%/%description%'; the code may contain a wildcard '*' to cover multiple codes at " +
|
||||
"once, for example, '4**' will cover all 4xx codes unless a more specific code is described previously)",
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Category: CategoryCodes,
|
||||
Config: cli.StringConfig{TrimSpace: true},
|
||||
Validator: func(codes map[string]string) error {
|
||||
for code, msgAndDesc := range codes {
|
||||
if code == "" {
|
||||
@ -142,16 +128,7 @@ func ParseHTTPCodes(codes map[string]string) map[string]config.CodeDescription {
|
||||
|
||||
var DisableL10nFlag = cli.BoolFlag{
|
||||
Name: "disable-l10n",
|
||||
Usage: "Disable localization of error pages (if the template supports localization)",
|
||||
Usage: "disable localization of error pages (if the template supports localization)",
|
||||
Sources: cli.EnvVars("DISABLE_L10N"),
|
||||
Category: CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
|
||||
var DisableMinificationFlag = cli.BoolFlag{
|
||||
Name: "disable-minification",
|
||||
Usage: "Disable the minification of HTML pages, including CSS, SVG, and JS (may be useful for debugging)",
|
||||
Sources: cli.EnvVars("DISABLE_MINIFICATION"),
|
||||
Category: CategoryOther,
|
||||
OnlyOnce: true,
|
||||
}
|
||||
|
@ -91,7 +91,6 @@ func TestAddTemplatesFlag(t *testing.T) {
|
||||
var flag = shared.AddTemplatesFlag
|
||||
|
||||
assert.Equal(t, "add-template", flag.Name)
|
||||
assert.Contains(t, flag.Sources.String(), "ADD_TEMPLATE")
|
||||
|
||||
for wantErrMsg, giveValue := range map[string][]string{
|
||||
"missing template path": {""},
|
||||
@ -123,7 +122,7 @@ func TestAddHTTPCodesFlag(t *testing.T) {
|
||||
|
||||
var flag = shared.AddHTTPCodesFlag
|
||||
|
||||
assert.Equal(t, "add-code", flag.Name)
|
||||
assert.Equal(t, "add-http-code", flag.Name)
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
giveValue map[string]string
|
||||
@ -217,12 +216,3 @@ func TestDisableL10nFlag(t *testing.T) {
|
||||
assert.Equal(t, "disable-l10n", flag.Name)
|
||||
assert.Contains(t, flag.Sources.String(), "DISABLE_L10N")
|
||||
}
|
||||
|
||||
func TestDisableMinificationFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag = shared.DisableMinificationFlag
|
||||
|
||||
assert.Equal(t, "disable-minification", flag.Name)
|
||||
assert.Contains(t, flag.Sources.String(), "DISABLE_MINIFICATION")
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
//go:build generate
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
@ -16,10 +17,8 @@ func main() {
|
||||
if stat, err := os.Stat(readmePath); err == nil && stat.Mode().IsRegular() {
|
||||
if err = cliDocs.ToTabularToFileBetweenTags(cli.NewApp(""), "error-pages", readmePath); err != nil {
|
||||
panic(err)
|
||||
} else {
|
||||
println("✔ cli docs updated successfully")
|
||||
}
|
||||
} else if err != nil {
|
||||
println("⚠ readme file not found, cli docs not updated:", err.Error())
|
||||
println("readme file not found, cli docs not updated:", err.Error())
|
||||
}
|
||||
}
|
@ -56,9 +56,6 @@ type Config struct {
|
||||
// ShowDetails determines whether to show additional details in the error response, extracted from the
|
||||
// incoming request (if supported by the template).
|
||||
ShowDetails bool
|
||||
|
||||
// DisableMinification determines whether to disable minification of the rendered content (e.g., HTML, CSS) or not.
|
||||
DisableMinification bool
|
||||
}
|
||||
|
||||
const defaultJSONFormat string = `{
|
||||
@ -75,7 +72,7 @@ const defaultJSONFormat string = `{
|
||||
"service_name": {{ service_name | json }},
|
||||
"service_port": {{ service_port | json }},
|
||||
"request_id": {{ request_id | json }},
|
||||
"timestamp": {{ nowUnix }}
|
||||
"timestamp": {{ now.Unix }}
|
||||
}{{ end }}
|
||||
}
|
||||
` // an empty line at the end is important for better UX
|
||||
@ -94,7 +91,7 @@ const defaultXMLFormat string = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<serviceName>{{ service_name }}</serviceName>
|
||||
<servicePort>{{ service_port }}</servicePort>
|
||||
<requestID>{{ request_id }}</requestID>
|
||||
<timestamp>{{ nowUnix }}</timestamp>
|
||||
<timestamp>{{ now.Unix }}</timestamp>
|
||||
</details>{{ end }}
|
||||
</error>
|
||||
` // an empty line at the end is important for better UX
|
||||
@ -110,7 +107,7 @@ Ingress Name: {{ ingress_name }}
|
||||
Service Name: {{ service_name }}
|
||||
Service Port: {{ service_port }}
|
||||
Request ID: {{ request_id }}
|
||||
Timestamp: {{ nowUnix }}{{ end }}
|
||||
Timestamp: {{ now.Unix }}{{ end }}
|
||||
` // an empty line at the end is important for better UX
|
||||
|
||||
//nolint:lll
|
||||
|
@ -24,7 +24,6 @@ func TestNew(t *testing.T) {
|
||||
assert.NotEmpty(t, cfg.TemplateName)
|
||||
assert.True(t, cfg.Templates.Has(cfg.TemplateName))
|
||||
assert.Equal(t, uint16(http.StatusNotFound), cfg.DefaultCodeToRender)
|
||||
assert.False(t, cfg.DisableMinification)
|
||||
})
|
||||
|
||||
t.Run("changing cfg1 should not affect cfg2", func(t *testing.T) {
|
||||
|
@ -1,111 +0,0 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5" //nolint:gosec
|
||||
"encoding/gob"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
type (
|
||||
// RenderedCache is a cache for rendered error pages. It's safe for concurrent use.
|
||||
// It uses a hash of the template and props as a key.
|
||||
//
|
||||
// To remove expired items, call ClearExpired method periodically (a bit more often than the ttl).
|
||||
RenderedCache struct {
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
items map[[32]byte]cacheItem // map[template_hash[0:15];props_hash[16:32]]cache_item
|
||||
}
|
||||
|
||||
cacheItem struct {
|
||||
content []byte
|
||||
addedAtNano int64
|
||||
}
|
||||
)
|
||||
|
||||
// NewRenderedCache creates a new RenderedCache with the specified ttl.
|
||||
func NewRenderedCache(ttl time.Duration) *RenderedCache {
|
||||
return &RenderedCache{ttl: ttl, items: make(map[[32]byte]cacheItem)}
|
||||
}
|
||||
|
||||
// genKey generates a key for the cache item by hashing the template and props.
|
||||
func (rc *RenderedCache) genKey(template string, props template.Props) [32]byte {
|
||||
var (
|
||||
key [32]byte
|
||||
th, ph = hash(template), hash(props) // template hash, props hash
|
||||
)
|
||||
|
||||
copy(key[:16], th[:]) // first 16 bytes for the template hash
|
||||
copy(key[16:], ph[:]) // last 16 bytes for the props hash
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// Has checks if the cache has an item with the specified template and props.
|
||||
func (rc *RenderedCache) Has(template string, props template.Props) bool {
|
||||
var key = rc.genKey(template, props)
|
||||
|
||||
rc.mu.RLock()
|
||||
_, ok := rc.items[key]
|
||||
rc.mu.RUnlock()
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// Put adds a new item to the cache with the specified template, props, and content.
|
||||
func (rc *RenderedCache) Put(template string, props template.Props, content []byte) {
|
||||
var key = rc.genKey(template, props)
|
||||
|
||||
rc.mu.Lock()
|
||||
rc.items[key] = cacheItem{content: content, addedAtNano: time.Now().UnixNano()}
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get returns the content of the item with the specified template and props.
|
||||
func (rc *RenderedCache) Get(template string, props template.Props) ([]byte, bool) {
|
||||
var key = rc.genKey(template, props)
|
||||
|
||||
rc.mu.RLock()
|
||||
item, ok := rc.items[key]
|
||||
rc.mu.RUnlock()
|
||||
|
||||
return item.content, ok
|
||||
}
|
||||
|
||||
// ClearExpired removes all expired items from the cache.
|
||||
func (rc *RenderedCache) ClearExpired() {
|
||||
rc.mu.Lock()
|
||||
|
||||
var now = time.Now().UnixNano()
|
||||
|
||||
for key, item := range rc.items {
|
||||
if now-item.addedAtNano > rc.ttl.Nanoseconds() {
|
||||
delete(rc.items, key)
|
||||
}
|
||||
}
|
||||
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
|
||||
// Clear removes all items from the cache.
|
||||
func (rc *RenderedCache) Clear() {
|
||||
rc.mu.Lock()
|
||||
clear(rc.items)
|
||||
rc.mu.Unlock()
|
||||
}
|
||||
|
||||
// hash returns an MD5 hash of the provided value (it may be any built-in type).
|
||||
func hash(in any) [16]byte {
|
||||
var b bytes.Buffer
|
||||
|
||||
if err := gob.NewEncoder(&b).Encode(in); err != nil {
|
||||
return [16]byte{} // never happens because we encode only built-in types
|
||||
}
|
||||
|
||||
return md5.Sum(b.Bytes()) //nolint:gosec
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
package error_page_test
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
func TestRenderedCache_CRUD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var cache = error_page.NewRenderedCache(time.Millisecond)
|
||||
|
||||
t.Run("has", func(t *testing.T) {
|
||||
assert.False(t, cache.Has("template", template.Props{}))
|
||||
cache.Put("template", template.Props{}, []byte("content"))
|
||||
assert.True(t, cache.Has("template", template.Props{}))
|
||||
|
||||
assert.False(t, cache.Has("template", template.Props{Code: 1}))
|
||||
assert.False(t, cache.Has("foo", template.Props{Code: 1}))
|
||||
})
|
||||
|
||||
t.Run("exists", func(t *testing.T) {
|
||||
var got, ok = cache.Get("template", template.Props{})
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []byte("content"), got)
|
||||
|
||||
cache.Clear()
|
||||
|
||||
assert.False(t, cache.Has("template", template.Props{}))
|
||||
})
|
||||
|
||||
t.Run("not exists", func(t *testing.T) {
|
||||
var got, ok = cache.Get("template", template.Props{Code: 2})
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
|
||||
t.Run("race condition provocation", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
cache.Get("template", template.Props{})
|
||||
cache.Put("template"+strconv.Itoa(i), template.Props{}, []byte("content"))
|
||||
cache.Has("template", template.Props{})
|
||||
}(i)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
cache.ClearExpired()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func TestRenderedCache_Expiring(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var cache = error_page.NewRenderedCache(10 * time.Millisecond)
|
||||
|
||||
cache.Put("template", template.Props{}, []byte("content"))
|
||||
cache.ClearExpired()
|
||||
assert.True(t, cache.Has("template", template.Props{}))
|
||||
|
||||
<-time.After(10 * time.Millisecond)
|
||||
|
||||
assert.True(t, cache.Has("template", template.Props{})) // expired, but not cleared yet
|
||||
cache.ClearExpired()
|
||||
assert.False(t, cache.Has("template", template.Props{})) // cleared
|
||||
}
|
@ -1,11 +1,10 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// extractCodeFromURL extracts the error code from the given URL.
|
||||
@ -28,7 +27,7 @@ func extractCodeFromURL(url string) (uint16, bool) {
|
||||
}
|
||||
|
||||
if code, err := strconv.ParseUint(fileName, 10, 16); err == nil && code > 0 && code < 999 {
|
||||
return uint16(code), true //nolint:gosec
|
||||
return uint16(code), true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
@ -38,16 +37,12 @@ func extractCodeFromURL(url string) (uint16, bool) {
|
||||
func URLContainsCode(url string) (ok bool) { _, ok = extractCodeFromURL(url); return } //nolint:nlreturn
|
||||
|
||||
// extractCodeFromHeaders extracts the error code from the given headers.
|
||||
func extractCodeFromHeaders(headers *fasthttp.RequestHeader) (uint16, bool) {
|
||||
if headers == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func extractCodeFromHeaders(headers http.Header) (uint16, bool) {
|
||||
// https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
|
||||
// HTTP status code returned by the request
|
||||
if value := headers.Peek("X-Code"); len(value) > 0 && len(value) <= 3 {
|
||||
if code, err := strconv.ParseUint(string(value), 10, 16); err == nil && code > 0 && code < 999 {
|
||||
return uint16(code), true //nolint:gosec
|
||||
if value := headers.Get("X-Code"); len(value) > 0 && len(value) <= 3 {
|
||||
if code, err := strconv.ParseUint(value, 10, 16); err == nil && code > 0 && code < 999 {
|
||||
return uint16(code), true
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +50,7 @@ func extractCodeFromHeaders(headers *fasthttp.RequestHeader) (uint16, bool) {
|
||||
}
|
||||
|
||||
// HeadersContainCode checks if the given headers contain an error code.
|
||||
func HeadersContainCode(headers *fasthttp.RequestHeader) (ok bool) {
|
||||
func HeadersContainCode(headers http.Header) (ok bool) {
|
||||
_, ok = extractCodeFromHeaders(headers)
|
||||
|
||||
return
|
||||
|
@ -1,10 +1,10 @@
|
||||
package error_page_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
|
||||
)
|
||||
@ -36,26 +36,18 @@ func TestURLContainsCode(t *testing.T) {
|
||||
func TestHeadersContainCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var mkHeaders = func(key, value string) *fasthttp.RequestHeader {
|
||||
var out = new(fasthttp.RequestHeader)
|
||||
|
||||
out.Set(key, value)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
for name, _tt := range map[string]struct {
|
||||
giveHeaders *fasthttp.RequestHeader
|
||||
giveHeaders http.Header
|
||||
wantOk bool
|
||||
}{
|
||||
"with code": {giveHeaders: mkHeaders("X-Code", "404"), wantOk: true},
|
||||
"with code": {giveHeaders: http.Header{"X-Code": {"404"}}, wantOk: true},
|
||||
|
||||
"empty": {giveHeaders: nil},
|
||||
"no code": {giveHeaders: mkHeaders("X-Code", "")},
|
||||
"wrong": {giveHeaders: mkHeaders("X-Code", "foo")},
|
||||
"too big": {giveHeaders: mkHeaders("X-Code", "1000")},
|
||||
"too small": {giveHeaders: mkHeaders("X-Code", "0")},
|
||||
"negative": {giveHeaders: mkHeaders("X-Code", "-1")},
|
||||
"no code": {giveHeaders: http.Header{"X-Code": {""}}},
|
||||
"wrong": {giveHeaders: http.Header{"X-Code": {"foo"}}},
|
||||
"too big": {giveHeaders: http.Header{"X-Code": {"1000"}}},
|
||||
"too small": {giveHeaders: http.Header{"X-Code": {"0"}}},
|
||||
"negative": {giveHeaders: http.Header{"X-Code": {"-1"}}},
|
||||
} {
|
||||
tt := _tt
|
||||
|
||||
|
@ -2,11 +2,10 @@ package error_page
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
type preferredFormat = byte
|
||||
@ -22,10 +21,10 @@ const (
|
||||
// detectPreferredFormatForClient detects the preferred format for the client based on the headers.
|
||||
// It supports the following headers: Content-Type, Accept, X-Format.
|
||||
// If the headers are not set or the format is not recognized, it returns unknownFormat.
|
||||
func detectPreferredFormatForClient(headers *fasthttp.RequestHeader) preferredFormat { //nolint:funlen,gocognit
|
||||
func detectPreferredFormatForClient(headers http.Header) preferredFormat { //nolint:funlen,gocognit
|
||||
var contentType, accept string
|
||||
|
||||
if contentTypeHeader := strings.TrimSpace(string(headers.Peek("Content-Type"))); contentTypeHeader != "" { //nolint:nestif,lll
|
||||
if contentTypeHeader := strings.TrimSpace(headers.Get("Content-Type")); contentTypeHeader != "" { //nolint:nestif
|
||||
// https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Type
|
||||
// text/html; charset=utf-8
|
||||
// multipart/form-data; boundary=something
|
||||
@ -39,11 +38,11 @@ func detectPreferredFormatForClient(headers *fasthttp.RequestHeader) preferredFo
|
||||
// take the whole value
|
||||
contentType = contentTypeHeader
|
||||
}
|
||||
} else if xFormatHeader := strings.TrimSpace(string(headers.Peek("X-Format"))); xFormatHeader != "" {
|
||||
} else if xFormatHeader := strings.TrimSpace(headers.Get("X-Format")); xFormatHeader != "" {
|
||||
// https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
|
||||
// Value of the `Accept` header sent by the client
|
||||
accept = xFormatHeader
|
||||
} else if acceptHeader := strings.TrimSpace(string(headers.Peek("Accept"))); acceptHeader != "" {
|
||||
} else if acceptHeader := strings.TrimSpace(headers.Get("Accept")); acceptHeader != "" {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept
|
||||
// text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8
|
||||
// text/html
|
||||
|
@ -1,80 +1,80 @@
|
||||
package error_page
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
func Test_detectPreferredFormatForClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, _tt := range map[string]struct {
|
||||
giveHeaders map[string][]string
|
||||
giveHeaders http.Header
|
||||
wantFormat preferredFormat
|
||||
}{
|
||||
"content type json": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"application/jSoN"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"application/jSoN"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"content type xml": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"application/xml; charset=UTF-8"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"application/xml; charset=UTF-8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
"content type html": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"text/hTmL; charset=utf-8"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"text/hTmL; charset=utf-8"}},
|
||||
wantFormat: htmlFormat,
|
||||
},
|
||||
"content type plain": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"text/plaIN"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"text/plaIN"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
|
||||
"accept json": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/jsoN,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"Accept": {"application/jsoN,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"accept xml, depends on weight": {
|
||||
giveHeaders: map[string][]string{"Accept": {"text/html;q=0.5,application/xhtml+xml;q=0.9,application/xml;q=1,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"Accept": {"text/html;q=0.5,application/xhtml+xml;q=0.9,application/xml;q=1,*/*;q=0.8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
"accept json, depends on weight": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/jsoN,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"Accept": {"application/jsoN,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"accept xml": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"Accept": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
"accept html": {
|
||||
giveHeaders: map[string][]string{"Accept": {"text/html, application/xhtml+xml, application/xml;q=0.9, image/avif, image/webp, */*;q=0.8"}},
|
||||
giveHeaders: http.Header{"Accept": {"text/html, application/xhtml+xml, application/xml;q=0.9, image/avif, image/webp, */*;q=0.8"}},
|
||||
wantFormat: htmlFormat,
|
||||
},
|
||||
"accept plain": {
|
||||
giveHeaders: map[string][]string{"Accept": {"text/plaiN,text/html,application/xml;q=0.9,,,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"Accept": {"text/plaiN,text/html,application/xml;q=0.9,,,*/*;q=0.8"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
"accept json, weighted values only": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/jsoN;Q=0.1,text/html;q=1.1,application/xml;q=-1,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"Accept": {"application/jsoN;Q=0.1,text/html;q=1.1,application/xml;q=-1,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
|
||||
"x-format json, depends on weight": {
|
||||
giveHeaders: map[string][]string{"X-Format": {"application/jsoN,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"X-Format": {"application/jsoN,*/*;q=0.8"}},
|
||||
wantFormat: jsonFormat,
|
||||
},
|
||||
"x-format xml": {
|
||||
giveHeaders: map[string][]string{"X-Format": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
|
||||
giveHeaders: http.Header{"X-Format": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
|
||||
wantFormat: xmlFormat,
|
||||
},
|
||||
|
||||
"content type has priority over accept": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"text/plain"}, "Accept": {"application/xml"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"text/plain"}, "Accept": {"application/xml"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
"accept has priority over x-format": {
|
||||
giveHeaders: map[string][]string{"Accept": {"application/xml"}, "X-Format": {"text/plain"}},
|
||||
giveHeaders: http.Header{"Accept": {"application/xml"}, "X-Format": {"text/plain"}},
|
||||
wantFormat: plainTextFormat,
|
||||
},
|
||||
|
||||
@ -82,33 +82,25 @@ func Test_detectPreferredFormatForClient(t *testing.T) {
|
||||
giveHeaders: nil,
|
||||
},
|
||||
"empty content type": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {" "}},
|
||||
giveHeaders: http.Header{"Content-Type": {" "}},
|
||||
},
|
||||
"wrong content type": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"multipart/form-data; boundary=something"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"multipart/form-data; boundary=something"}},
|
||||
},
|
||||
"wrong accept": {
|
||||
giveHeaders: map[string][]string{"Accept": {";q=foobar,bar/baz;;;;;application/xml"}},
|
||||
giveHeaders: http.Header{"Accept": {";q=foobar,bar/baz;;;;;application/xml"}},
|
||||
},
|
||||
"none on invalid input": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"foo/bar; charset=utf-8"}, "Accept": {"foo/bar; charset=utf-8"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"foo/bar; charset=utf-8"}, "Accept": {"foo/bar; charset=utf-8"}},
|
||||
},
|
||||
"completely unknown": {
|
||||
giveHeaders: map[string][]string{"Content-Type": {"😀"}, "Accept": {"😄"}, "X-Format": {"😍"}},
|
||||
giveHeaders: http.Header{"Content-Type": {"😀"}, "Accept": {"😄"}, "X-Format": {"😍"}},
|
||||
},
|
||||
} {
|
||||
tt := _tt
|
||||
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var headers = new(fasthttp.RequestHeader)
|
||||
|
||||
for key, values := range tt.giveHeaders {
|
||||
for _, value := range values {
|
||||
headers.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantFormat, detectPreferredFormatForClient(headers))
|
||||
assert.Equal(t, tt.wantFormat, detectPreferredFormatForClient(tt.giveHeaders))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -4,53 +4,24 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
// New creates a new handler that returns an error page with the specified status code and format.
|
||||
func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, closeCache func()) { //nolint:funlen,gocognit,gocyclo,lll
|
||||
// if the ttl will be bigger than 1 second, the template functions like `nowUnix` will not work as expected
|
||||
const cacheTtl = 900 * time.Millisecond // the cache TTL
|
||||
func New(cfg *config.Config, log *logger.Logger) http.Handler { //nolint:funlen,gocognit,gocyclo
|
||||
const contentTypeHeader = "Content-Type"
|
||||
|
||||
var (
|
||||
cache, stopCh = NewRenderedCache(cacheTtl), make(chan struct{})
|
||||
stopOnce sync.Once
|
||||
)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var code uint16
|
||||
|
||||
// run a goroutine that will clear the cache from expired items. to stop the goroutine - close the stop channel
|
||||
// or call the closeCache
|
||||
go func() {
|
||||
var timer = time.NewTimer(cacheTtl)
|
||||
defer func() { timer.Stop(); cache.Clear() }()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
cache.ClearExpired()
|
||||
timer.Reset(cacheTtl)
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
var (
|
||||
reqHeaders = &ctx.Request.Header
|
||||
code uint16
|
||||
)
|
||||
|
||||
if fromUrl, okUrl := extractCodeFromURL(string(ctx.Path())); okUrl {
|
||||
if fromUrl, okUrl := extractCodeFromURL(r.URL.Path); okUrl {
|
||||
code = fromUrl
|
||||
} else if fromHeader, okHeaders := extractCodeFromHeaders(reqHeaders); okHeaders {
|
||||
} else if fromHeader, okHeaders := extractCodeFromHeaders(r.Header); okHeaders {
|
||||
code = fromHeader
|
||||
} else {
|
||||
code = cfg.DefaultCodeToRender
|
||||
@ -64,23 +35,23 @@ func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, clo
|
||||
httpCode = http.StatusOK
|
||||
}
|
||||
|
||||
var format = detectPreferredFormatForClient(reqHeaders)
|
||||
var format = detectPreferredFormatForClient(r.Header)
|
||||
|
||||
{ // deal with the headers
|
||||
switch format {
|
||||
case jsonFormat:
|
||||
ctx.SetContentType("application/json; charset=utf-8")
|
||||
w.Header().Set(contentTypeHeader, "application/json; charset=utf-8")
|
||||
case xmlFormat:
|
||||
ctx.SetContentType("application/xml; charset=utf-8")
|
||||
w.Header().Set(contentTypeHeader, "application/xml; charset=utf-8")
|
||||
case htmlFormat:
|
||||
ctx.SetContentType("text/html; charset=utf-8")
|
||||
w.Header().Set(contentTypeHeader, "text/html; charset=utf-8")
|
||||
default:
|
||||
ctx.SetContentType("text/plain; charset=utf-8") // plainTextFormat as default
|
||||
w.Header().Set(contentTypeHeader, "text/plain; charset=utf-8") // plainTextFormat as default
|
||||
}
|
||||
|
||||
// https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag
|
||||
// disallow indexing of the error pages
|
||||
ctx.Response.Header.Set("X-Robots-Tag", "noindex")
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
|
||||
switch code {
|
||||
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
|
||||
@ -88,18 +59,18 @@ func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, clo
|
||||
http.StatusGatewayTimeout:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
|
||||
// tell the client (search crawler) to retry the request after 120 seconds
|
||||
ctx.Response.Header.Set("Retry-After", "120")
|
||||
w.Header().Set("Retry-After", "120")
|
||||
}
|
||||
|
||||
// proxy the headers from the incoming request to the error page response if they are defined in the config
|
||||
for _, proxyHeader := range cfg.ProxyHeaders {
|
||||
if value := reqHeaders.Peek(proxyHeader); len(value) > 0 {
|
||||
ctx.Response.Header.SetBytesV(proxyHeader, value)
|
||||
if value := r.Header.Get(proxyHeader); value != "" {
|
||||
w.Header().Set(proxyHeader, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetStatusCode(httpCode)
|
||||
w.WriteHeader(httpCode)
|
||||
|
||||
// prepare the template properties for rendering
|
||||
var tplProps = template.Props{
|
||||
@ -110,14 +81,14 @@ func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, clo
|
||||
|
||||
//nolint:lll
|
||||
if cfg.ShowDetails { // https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
|
||||
tplProps.OriginalURI = string(reqHeaders.Peek("X-Original-URI")) // (ingress-nginx) URI that caused the error
|
||||
tplProps.Namespace = string(reqHeaders.Peek("X-Namespace")) // (ingress-nginx) namespace where the backend Service is located
|
||||
tplProps.IngressName = string(reqHeaders.Peek("X-Ingress-Name")) // (ingress-nginx) name of the Ingress where the backend is defined
|
||||
tplProps.ServiceName = string(reqHeaders.Peek("X-Service-Name")) // (ingress-nginx) name of the Service backing the backend
|
||||
tplProps.ServicePort = string(reqHeaders.Peek("X-Service-Port")) // (ingress-nginx) port number of the Service backing the backend
|
||||
tplProps.RequestID = string(reqHeaders.Peek("X-Request-Id")) // (ingress-nginx) unique ID that identifies the request - same as for backend service
|
||||
tplProps.ForwardedFor = string(reqHeaders.Peek("X-Forwarded-For")) // the value of the `X-Forwarded-For` header
|
||||
tplProps.Host = string(reqHeaders.Peek("Host")) // the value of the `Host` header
|
||||
tplProps.OriginalURI = r.Header.Get("X-Original-URI") // (ingress-nginx) URI that caused the error
|
||||
tplProps.Namespace = r.Header.Get("X-Namespace") // (ingress-nginx) namespace where the backend Service is located
|
||||
tplProps.IngressName = r.Header.Get("X-Ingress-Name") // (ingress-nginx) name of the Ingress where the backend is defined
|
||||
tplProps.ServiceName = r.Header.Get("X-Service-Name") // (ingress-nginx) name of the Service backing the backend
|
||||
tplProps.ServicePort = r.Header.Get("X-Service-Port") // (ingress-nginx) port number of the Service backing the backend
|
||||
tplProps.RequestID = r.Header.Get("X-Request-Id") // (ingress-nginx) unique ID that identifies the request - same as for backend service
|
||||
tplProps.ForwardedFor = r.Header.Get("X-Forwarded-For") // the value of the `X-Forwarded-For` header
|
||||
tplProps.Host = r.Host // the value of the `Host` header
|
||||
}
|
||||
|
||||
// try to find the code message and description in the config and if not - use the standard status text or fallback
|
||||
@ -132,90 +103,57 @@ func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, clo
|
||||
|
||||
switch {
|
||||
case format == jsonFormat && cfg.Formats.JSON != "":
|
||||
if cached, ok := cache.Get(cfg.Formats.JSON, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
|
||||
errAsJson, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
|
||||
write(ctx, log, errAsJson) // error during rendering
|
||||
} else {
|
||||
cache.Put(cfg.Formats.JSON, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content) // rendered successfully
|
||||
}
|
||||
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
|
||||
j, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
|
||||
write(w, log, j)
|
||||
} else {
|
||||
write(w, log, content)
|
||||
}
|
||||
|
||||
case format == xmlFormat && cfg.Formats.XML != "":
|
||||
if cached, ok := cache.Get(cfg.Formats.XML, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
|
||||
write(ctx, log, fmt.Sprintf(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>\n", err.Error(),
|
||||
))
|
||||
} else {
|
||||
cache.Put(cfg.Formats.XML, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content)
|
||||
}
|
||||
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
|
||||
write(w, log, fmt.Sprintf(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>", err.Error(),
|
||||
))
|
||||
} else {
|
||||
write(w, log, content)
|
||||
}
|
||||
|
||||
case format == htmlFormat:
|
||||
var templateName = templateToUse(cfg)
|
||||
|
||||
if tpl, found := cfg.Templates.Get(templateName); found { //nolint:nestif
|
||||
if cached, ok := cache.Get(tpl, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(tpl, tplProps); err != nil {
|
||||
// TODO: add GZIP compression for the HTML content support
|
||||
write(ctx, log, fmt.Sprintf(
|
||||
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>\n",
|
||||
templateName,
|
||||
err.Error(),
|
||||
))
|
||||
} else {
|
||||
if !cfg.DisableMinification {
|
||||
if mini, minErr := template.MiniHTML(content); minErr != nil {
|
||||
log.Warn("HTML minification failed", logger.Error(minErr))
|
||||
} else {
|
||||
content = mini
|
||||
}
|
||||
}
|
||||
|
||||
cache.Put(tpl, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content)
|
||||
}
|
||||
if tpl, found := cfg.Templates.Get(templateName); found {
|
||||
if content, err := template.Render(tpl, tplProps); err != nil {
|
||||
// TODO: add GZIP compression for the HTML content support
|
||||
write(w, log, fmt.Sprintf(
|
||||
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>",
|
||||
templateName,
|
||||
err.Error(),
|
||||
))
|
||||
} else {
|
||||
write(w, log, content)
|
||||
}
|
||||
} else {
|
||||
write(ctx, log, fmt.Sprintf(
|
||||
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>\n", templateName,
|
||||
write(w, log, fmt.Sprintf(
|
||||
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>", templateName,
|
||||
))
|
||||
}
|
||||
|
||||
default: // plainTextFormat as default
|
||||
if cfg.Formats.PlainText != "" { //nolint:nestif
|
||||
if cached, ok := cache.Get(cfg.Formats.PlainText, tplProps); ok { // cache hit
|
||||
write(ctx, log, cached)
|
||||
} else { // cache miss
|
||||
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
|
||||
write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
|
||||
} else {
|
||||
cache.Put(cfg.Formats.PlainText, tplProps, []byte(content))
|
||||
|
||||
write(ctx, log, content)
|
||||
}
|
||||
if cfg.Formats.PlainText != "" {
|
||||
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
|
||||
write(w, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
|
||||
} else {
|
||||
write(w, log, content)
|
||||
}
|
||||
} else {
|
||||
write(ctx, log, `The requested content format is not supported.
|
||||
write(w, log, `The requested content format is not supported.
|
||||
Please create an issue on the project's GitHub page to request support for this format.
|
||||
|
||||
Supported formats: JSON, XML, HTML, Plain Text
|
||||
`)
|
||||
Supported formats: JSON, XML, HTML, Plain Text`)
|
||||
}
|
||||
}
|
||||
}, func() { stopOnce.Do(func() { close(stopCh) }) }
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
@ -266,7 +204,7 @@ func templateToUse(cfg *config.Config) string {
|
||||
}
|
||||
|
||||
// write the content to the response writer and log the error if any.
|
||||
func write[T string | []byte](ctx *fasthttp.RequestCtx, log *logger.Logger, content T) {
|
||||
func write[T string | []byte](w http.ResponseWriter, log *logger.Logger, content T) {
|
||||
var data []byte
|
||||
|
||||
if s, ok := any(content).(string); ok {
|
||||
@ -275,7 +213,7 @@ func write[T string | []byte](ctx *fasthttp.RequestCtx, log *logger.Logger, cont
|
||||
data = any(content).([]byte)
|
||||
}
|
||||
|
||||
if _, err := ctx.Write(data); err != nil && log != nil {
|
||||
if _, err := w.Write(data); err != nil && log != nil {
|
||||
log.Error("failed to write the response body",
|
||||
logger.String("content", string(data)),
|
||||
logger.Error(err),
|
||||
|
@ -2,14 +2,13 @@ package error_page_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
@ -27,7 +26,7 @@ func TestHandler(t *testing.T) {
|
||||
}{
|
||||
"common, plain text": {
|
||||
giveConfig: func() *config.Config { cfg := config.New(); return &cfg },
|
||||
giveUrl: "http://testing/",
|
||||
giveUrl: "/",
|
||||
giveHeaders: map[string]string{"Content-Type": "text/plain"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
@ -42,13 +41,13 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/",
|
||||
giveUrl: "/",
|
||||
giveHeaders: map[string]string{"X-Format": "text/html", "X-Code": "407"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantHeaders: map[string]string{"Content-Type": "text/html; charset=utf-8"},
|
||||
wantBodyIncludes: []string{
|
||||
"<!doctype html>",
|
||||
"<!DOCTYPE html>",
|
||||
"<title>407: Proxy Authentication Required",
|
||||
"Proxy Authentication Required",
|
||||
},
|
||||
@ -61,7 +60,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/503.html?rnd=123",
|
||||
giveUrl: "/503.html",
|
||||
giveHeaders: map[string]string{"Accept": "application/json", "X-FooBar": "baz"},
|
||||
|
||||
wantStatusCode: http.StatusServiceUnavailable,
|
||||
@ -79,7 +78,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/500",
|
||||
giveUrl: "/500",
|
||||
giveHeaders: map[string]string{"Accept": "application/xml", "X-FooBar": "baz"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
@ -97,7 +96,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://example.com/503",
|
||||
giveUrl: "/503",
|
||||
giveHeaders: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"X-Original-URI": "/foo/bar",
|
||||
@ -107,6 +106,7 @@ func TestHandler(t *testing.T) {
|
||||
"X-Service-Port": "666",
|
||||
"X-Request-ID": "req-id-777",
|
||||
"X-Forwarded-For": "123.123.123.123:12312",
|
||||
"Host": "example.com",
|
||||
},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
@ -133,7 +133,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/100",
|
||||
giveUrl: "/100",
|
||||
giveHeaders: map[string]string{"Accept": "application/json"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
@ -148,7 +148,7 @@ func TestHandler(t *testing.T) {
|
||||
|
||||
return &cfg
|
||||
},
|
||||
giveUrl: "http://testing/1",
|
||||
giveUrl: "/1",
|
||||
giveHeaders: map[string]string{"Accept": "application/json"},
|
||||
|
||||
wantStatusCode: http.StatusOK,
|
||||
@ -159,31 +159,31 @@ func TestHandler(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var handler, closeCache = error_page.New(tt.giveConfig(), logger.NewNop())
|
||||
defer closeCache()
|
||||
|
||||
req, reqErr := http.NewRequest(http.MethodGet, tt.giveUrl, http.NoBody)
|
||||
require.NoError(t, reqErr)
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodGet, tt.giveUrl, http.NoBody)
|
||||
handler = error_page.New(tt.giveConfig(), logger.NewNop())
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
for k, v := range tt.giveHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
httptest.HandleFastRequest(t, handler, req, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, tt.wantStatusCode, status)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
for hName, hWant := range tt.wantHeaders {
|
||||
for hGot := range headers {
|
||||
if hGot == hName {
|
||||
assert.Contains(t, hWant, headers.Get(hGot))
|
||||
}
|
||||
assert.Equal(t, rr.Code, tt.wantStatusCode)
|
||||
|
||||
for hName, hWant := range tt.wantHeaders {
|
||||
for hGot := range rr.Header() {
|
||||
if hGot == hName {
|
||||
assert.Contains(t, hWant, rr.Header().Get(hGot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, wantBodyInclude := range tt.wantBodyIncludes {
|
||||
assert.Contains(t, body, wantBodyInclude)
|
||||
}
|
||||
})
|
||||
for _, wantBodyInclude := range tt.wantBodyIncludes {
|
||||
assert.Contains(t, rr.Body.String(), wantBodyInclude)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -203,23 +203,23 @@ func TestRotationModeOnEachRequest(t *testing.T) {
|
||||
lastResponseBody string
|
||||
changedTimes int
|
||||
|
||||
handler, closeCache = error_page.New(&cfg, logger.NewNop())
|
||||
handler = error_page.New(&cfg, logger.NewNop())
|
||||
)
|
||||
|
||||
defer func() { closeCache(); closeCache(); closeCache() }() // multiple calls should not panic
|
||||
|
||||
for range 300 {
|
||||
req, reqErr := http.NewRequest(http.MethodGet, "http://testing/", http.NoBody)
|
||||
require.NoError(t, reqErr)
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
httptest.HandleFastRequest(t, handler, req, func(status int, body string, headers http.Header) {
|
||||
if lastResponseBody != body {
|
||||
changedTimes++
|
||||
lastResponseBody = body
|
||||
}
|
||||
})
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if lastResponseBody != rr.Body.String() {
|
||||
changedTimes++
|
||||
lastResponseBody = rr.Body.String()
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, changedTimes > 30, "the template should be changed at least 30 times")
|
||||
|
@ -2,29 +2,24 @@ package live
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// New creates a new handler that returns "OK" for GET and HEAD requests.
|
||||
func New() fasthttp.RequestHandler {
|
||||
var (
|
||||
body = []byte("OK\n")
|
||||
notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
|
||||
)
|
||||
func New() http.Handler {
|
||||
var body = []byte("OK\n")
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
switch string(ctx.Method()) {
|
||||
case fasthttp.MethodGet:
|
||||
ctx.SetContentType("text/plain; charset=utf-8")
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
_, _ = ctx.Write(body)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
|
||||
case fasthttp.MethodHead:
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
case http.MethodHead:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -2,37 +2,43 @@ package live_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/live"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
handler = live.New()
|
||||
url = "http://testing"
|
||||
body = http.NoBody
|
||||
)
|
||||
var handler = live.New()
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "OK\n", body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Equal(t, "OK\n", rr.Body.String())
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodHead, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
assert.Empty(t, body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Empty(t, rr.Header().Get("Content-Type"))
|
||||
assert.Empty(t, rr.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
@ -42,11 +48,16 @@ func TestServeHTTP(t *testing.T) {
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
httptest.HandleFast(t, handler, method, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "Method Not Allowed\n", body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(method, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusMethodNotAllowed)
|
||||
assert.Equal(t, "Method Not Allowed\n", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -3,29 +3,25 @@ package static
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
//go:embed favicon.ico
|
||||
var Favicon []byte
|
||||
|
||||
// New creates a new handler that returns the provided content for GET and HEAD requests.
|
||||
func New(content []byte) fasthttp.RequestHandler {
|
||||
var notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
|
||||
func New(content []byte) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", http.DetectContentType(content))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(content)
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
switch string(ctx.Method()) {
|
||||
case fasthttp.MethodGet:
|
||||
ctx.SetContentType(http.DetectContentType(content))
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
_, _ = ctx.Write(content)
|
||||
|
||||
case fasthttp.MethodHead:
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
case http.MethodHead:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -2,37 +2,43 @@ package static_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/static"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
handler = static.New([]byte{1, 2, 3})
|
||||
url = "http://testing"
|
||||
body = http.NoBody
|
||||
)
|
||||
var handler = static.New([]byte{1, 2, 3})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "application/octet-stream", headers.Get("Content-Type"))
|
||||
assert.Equal(t, []byte{1, 2, 3}, []byte(body))
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "application/octet-stream")
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Equal(t, rr.Body.Bytes(), []byte{1, 2, 3})
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodHead, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
assert.Empty(t, body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Empty(t, rr.Header().Get("Content-Type"))
|
||||
assert.Empty(t, rr.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
@ -42,11 +48,16 @@ func TestServeHTTP(t *testing.T) {
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
httptest.HandleFast(t, handler, method, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "Method Not Allowed\n", body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(method, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusMethodNotAllowed)
|
||||
assert.Equal(t, "Method Not Allowed\n", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -54,15 +65,16 @@ func TestServeHTTP(t *testing.T) {
|
||||
func TestServeHTTP_Favicon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
httptest.HandleFast(t,
|
||||
static.New(static.Favicon),
|
||||
http.MethodGet,
|
||||
"http://testing",
|
||||
http.NoBody,
|
||||
func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "image/x-icon", headers.Get("Content-Type"))
|
||||
assert.Equal(t, static.Favicon, []byte(body))
|
||||
},
|
||||
var (
|
||||
handler = static.New(static.Favicon)
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "image/x-icon")
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Equal(t, rr.Body.Bytes(), static.Favicon)
|
||||
}
|
||||
|
@ -4,32 +4,28 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// New creates a handler that returns the version of the service in JSON format.
|
||||
func New(ver string) fasthttp.RequestHandler {
|
||||
func New(ver string) http.Handler {
|
||||
var body, _ = json.Marshal(struct { //nolint:errchkjson
|
||||
Version string `json:"version"`
|
||||
}{
|
||||
Version: strings.TrimSpace(ver),
|
||||
})
|
||||
|
||||
var notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
switch string(ctx.Method()) {
|
||||
case fasthttp.MethodGet:
|
||||
ctx.SetContentType("application/json; charset=utf-8")
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
_, _ = ctx.Write(body)
|
||||
|
||||
case fasthttp.MethodHead:
|
||||
ctx.SetStatusCode(http.StatusOK)
|
||||
case http.MethodHead:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -2,37 +2,43 @@ package version_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/handlers/version"
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
)
|
||||
|
||||
func TestServeHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
handler = version.New("\t\n foo@bar ")
|
||||
url = "http://testing"
|
||||
body = http.NoBody
|
||||
)
|
||||
var handler = version.New("\t\n foo@bar ")
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "application/json; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, `{"version":"foo@bar"}`, body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodGet, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "application/json; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Equal(t, rr.Body.String(), `{"version":"foo@bar"}`)
|
||||
})
|
||||
|
||||
t.Run("head", func(t *testing.T) {
|
||||
httptest.HandleFast(t, handler, http.MethodHead, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
assert.Empty(t, body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(http.MethodHead, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Code, http.StatusOK)
|
||||
assert.Empty(t, rr.Header().Get("Content-Type"))
|
||||
assert.Empty(t, rr.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("method not allowed", func(t *testing.T) {
|
||||
@ -42,11 +48,16 @@ func TestServeHTTP(t *testing.T) {
|
||||
http.MethodPost,
|
||||
http.MethodPut,
|
||||
} {
|
||||
httptest.HandleFast(t, handler, method, url, body, func(status int, body string, headers http.Header) {
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
|
||||
assert.Equal(t, "Method Not Allowed\n", body)
|
||||
})
|
||||
var (
|
||||
req = httptest.NewRequest(method, "http://testing", http.NoBody)
|
||||
rr = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, rr.Header().Get("Content-Type"), "text/plain; charset=utf-8")
|
||||
assert.Equal(t, rr.Code, http.StatusMethodNotAllowed)
|
||||
assert.Equal(t, "Method Not Allowed\n", rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -1,69 +0,0 @@
|
||||
// Package httptest provides utilities for (fast-)HTTP testing.
|
||||
package httptest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttputil"
|
||||
)
|
||||
|
||||
// HandleFastRequest serves http request using provided fasthttp handler and HTTP request.
|
||||
func HandleFastRequest(
|
||||
t *testing.T,
|
||||
handler fasthttp.RequestHandler,
|
||||
req *http.Request,
|
||||
check func(status int, body string, _ http.Header),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// create in-memory listener
|
||||
var ln = fasthttputil.NewInmemoryListener()
|
||||
defer func() { require.NoError(t, ln.Close()) }()
|
||||
|
||||
// start fasthttp server
|
||||
go func() { require.NoError(t, fasthttp.Serve(ln, handler)) }()
|
||||
|
||||
// send http request
|
||||
resp, respErr := (&http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return ln.Dial() },
|
||||
},
|
||||
}).Do(req)
|
||||
require.NoError(t, respErr)
|
||||
|
||||
// close response body after the test
|
||||
defer func() { assert.NoError(t, resp.Body.Close()) }()
|
||||
|
||||
// read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
// check the response
|
||||
check(resp.StatusCode, string(respBody), resp.Header)
|
||||
}
|
||||
|
||||
// HandleFast serves http request using provided fasthttp handler.
|
||||
func HandleFast(
|
||||
t *testing.T,
|
||||
handler fasthttp.RequestHandler,
|
||||
method string,
|
||||
url string,
|
||||
body io.Reader,
|
||||
check func(status int, body string, _ http.Header),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// create http request
|
||||
req, reqErr := http.NewRequest(method, url, body)
|
||||
require.NoError(t, reqErr)
|
||||
|
||||
// serve http request
|
||||
HandleFastRequest(t, handler, req, check)
|
||||
}
|
@ -1,24 +1,20 @@
|
||||
package logreq
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
|
||||
// New creates a middleware that logs every incoming request.
|
||||
// New creates a middleware for [http.ServeMux] that logs every incoming request.
|
||||
//
|
||||
// The skipper function should return true if the request should be skipped. It's ok to pass nil.
|
||||
func New(
|
||||
log *logger.Logger,
|
||||
skipper func(*fasthttp.RequestCtx) bool,
|
||||
) func(fasthttp.RequestHandler) fasthttp.RequestHandler {
|
||||
return func(next fasthttp.RequestHandler) fasthttp.RequestHandler {
|
||||
return func(ctx *fasthttp.RequestCtx) {
|
||||
if skipper != nil && skipper(ctx) {
|
||||
next(ctx)
|
||||
func New(log *logger.Logger, skipper func(*http.Request) bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if skipper != nil && skipper(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
@ -27,35 +23,27 @@ func New(
|
||||
|
||||
defer func() {
|
||||
var fields = []logger.Attr{
|
||||
logger.Int("status code", ctx.Response.StatusCode()),
|
||||
logger.String("useragent", string(ctx.UserAgent())),
|
||||
logger.String("method", string(ctx.Method())),
|
||||
logger.String("url", string(ctx.RequestURI())),
|
||||
logger.String("referer", string(ctx.Referer())),
|
||||
logger.String("content type", string(ctx.Response.Header.ContentType())),
|
||||
logger.String("remote addr", ctx.RemoteAddr().String()),
|
||||
logger.String("useragent", r.UserAgent()),
|
||||
logger.String("method", r.Method),
|
||||
logger.String("url", r.URL.String()),
|
||||
logger.String("referer", r.Referer()),
|
||||
logger.String("content type", w.Header().Get("Content-Type")),
|
||||
logger.String("remote addr", r.RemoteAddr),
|
||||
logger.String("method", r.Method),
|
||||
logger.Duration("duration", time.Since(now).Round(time.Microsecond)),
|
||||
}
|
||||
|
||||
if log.Level() <= logger.DebugLevel {
|
||||
var (
|
||||
reqHeaders = make(map[string]string)
|
||||
respHeaders = make(map[string]string)
|
||||
)
|
||||
|
||||
ctx.Request.Header.VisitAll(func(key, value []byte) { reqHeaders[string(key)] = string(value) })
|
||||
ctx.Response.Header.VisitAll(func(key, value []byte) { respHeaders[string(key)] = string(value) })
|
||||
|
||||
fields = append(fields,
|
||||
logger.Any("request headers", reqHeaders),
|
||||
logger.Any("response headers", respHeaders),
|
||||
logger.Any("request headers", r.Header.Clone()),
|
||||
logger.Any("response headers", w.Header().Clone()),
|
||||
)
|
||||
}
|
||||
|
||||
log.Info("HTTP request processed", fields...)
|
||||
}()
|
||||
|
||||
next(ctx)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -3,12 +3,11 @@ package logreq_test
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/http/httptest"
|
||||
"gh.tarampamp.am/error-pages/internal/http/middleware/logreq"
|
||||
"gh.tarampamp.am/error-pages/internal/logger"
|
||||
)
|
||||
@ -20,19 +19,18 @@ func TestNew(t *testing.T) {
|
||||
buf bytes.Buffer
|
||||
log, _ = logger.New(logger.DebugLevel, logger.JSONFormat, &buf)
|
||||
|
||||
mw = logreq.New(log, nil)
|
||||
req, _ = http.NewRequest(http.MethodPut, "http://testing/foo/bar", http.NoBody)
|
||||
mw = logreq.New(log, nil)
|
||||
rr = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/foo/bar", http.NoBody)
|
||||
)
|
||||
|
||||
req.Header.Set("User-Agent", "test")
|
||||
req.Header.Set("Referer", "https://example.com")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httptest.HandleFastRequest(t,
|
||||
mw(func(ctx *fasthttp.RequestCtx) { ctx.SetStatusCode(http.StatusOK) }),
|
||||
req,
|
||||
func(status int, body string, _ http.Header) { assert.Equal(t, http.StatusOK, status) },
|
||||
)
|
||||
mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})).ServeHTTP(rr, req)
|
||||
|
||||
var logRecord = buf.String()
|
||||
|
||||
|
@ -2,15 +2,12 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/appmeta"
|
||||
"gh.tarampamp.am/error-pages/internal/config"
|
||||
ep "gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
|
||||
@ -23,117 +20,92 @@ import (
|
||||
|
||||
// Server is an HTTP server for serving error pages.
|
||||
type Server struct {
|
||||
log *logger.Logger
|
||||
server *fasthttp.Server
|
||||
beforeStop func()
|
||||
log *logger.Logger
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a new HTTP server.
|
||||
func NewServer(log *logger.Logger, readBufferSize uint) Server {
|
||||
func NewServer(baseCtx context.Context, log *logger.Logger) Server {
|
||||
const (
|
||||
readTimeout = 30 * time.Second
|
||||
writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout
|
||||
readTimeout = 30 * time.Second
|
||||
writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout
|
||||
maxHeaderBytes = (1 << 20) * 5 //nolint:mnd // 5 MB
|
||||
)
|
||||
|
||||
return Server{
|
||||
log: log,
|
||||
server: &fasthttp.Server{
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
ReadBufferSize: int(readBufferSize), //nolint:gosec
|
||||
DisablePreParseMultipartForm: true,
|
||||
NoDefaultServerHeader: true,
|
||||
CloseOnShutdown: true,
|
||||
Logger: logger.NewStdLog(log),
|
||||
server: &http.Server{
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
ReadHeaderTimeout: readTimeout,
|
||||
MaxHeaderBytes: maxHeaderBytes,
|
||||
ErrorLog: logger.NewStdLog(log),
|
||||
BaseContext: func(net.Listener) context.Context { return baseCtx },
|
||||
},
|
||||
beforeStop: func() {}, // noop
|
||||
}
|
||||
}
|
||||
|
||||
// Register server handlers, middlewares, etc.
|
||||
func (s *Server) Register(cfg *config.Config) error { //nolint:funlen
|
||||
func (s *Server) Register(cfg *config.Config) error {
|
||||
var (
|
||||
liveHandler = live.New()
|
||||
versionHandler = version.New(appmeta.Version())
|
||||
faviconHandler = static.New(static.Favicon)
|
||||
|
||||
errorPagesHandler, closeCache = ep.New(cfg, s.log)
|
||||
|
||||
notFound = http.StatusText(http.StatusNotFound) + "\n"
|
||||
notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
|
||||
liveHandler = live.New()
|
||||
versionHandler = version.New(appmeta.Version())
|
||||
errorPagesHandler = ep.New(cfg, s.log)
|
||||
faviconHandler = static.New(static.Favicon)
|
||||
)
|
||||
|
||||
// wrap the before shutdown function to close the cache
|
||||
s.beforeStop = closeCache
|
||||
|
||||
s.server.Handler = func(ctx *fasthttp.RequestCtx) {
|
||||
var url, method = string(ctx.Path()), string(ctx.Method())
|
||||
s.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var url, method = r.URL.Path, r.Method
|
||||
|
||||
switch {
|
||||
// live endpoints
|
||||
case url == "/healthz" || url == "/health/live" || url == "/health" || url == "/live":
|
||||
liveHandler(ctx)
|
||||
case url == "/health/live" || url == "/health" || url == "/healthz" || url == "/live":
|
||||
liveHandler.ServeHTTP(w, r)
|
||||
|
||||
// version endpoint
|
||||
case url == "/version":
|
||||
versionHandler(ctx)
|
||||
versionHandler.ServeHTTP(w, r)
|
||||
|
||||
// favicon.ico endpoint
|
||||
case url == "/favicon.ico":
|
||||
faviconHandler(ctx)
|
||||
faviconHandler.ServeHTTP(w, r)
|
||||
|
||||
// error pages endpoints:
|
||||
// - /
|
||||
// - /{code}.html
|
||||
// - /{code}.htm
|
||||
// - /{code}
|
||||
//
|
||||
// the HTTP method is not limited to GET and HEAD - it can be any
|
||||
case url == "/" || ep.URLContainsCode(url) || ep.HeadersContainCode(&ctx.Request.Header):
|
||||
errorPagesHandler(ctx)
|
||||
case method == http.MethodGet && (url == "/" || ep.URLContainsCode(url) || ep.HeadersContainCode(r.Header)):
|
||||
errorPagesHandler.ServeHTTP(w, r)
|
||||
|
||||
// wrong requests handling
|
||||
default:
|
||||
switch {
|
||||
case method == fasthttp.MethodHead:
|
||||
ctx.Error(notAllowed, fasthttp.StatusNotFound)
|
||||
case method == fasthttp.MethodGet:
|
||||
ctx.Error(notFound, fasthttp.StatusNotFound)
|
||||
case method == http.MethodHead:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case method == http.MethodGet:
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
ctx.Error(notAllowed, fasthttp.StatusMethodNotAllowed)
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// apply middleware
|
||||
s.server.Handler = logreq.New(s.log, func(ctx *fasthttp.RequestCtx) bool {
|
||||
s.server.Handler = logreq.New(s.log, func(r *http.Request) bool {
|
||||
// skip logging healthcheck and .ico (favicon) requests
|
||||
return strings.Contains(strings.ToLower(string(ctx.UserAgent())), "healthcheck") ||
|
||||
strings.HasSuffix(string(ctx.Path()), ".ico")
|
||||
return strings.Contains(strings.ToLower(r.UserAgent()), "healthcheck") ||
|
||||
strings.HasSuffix(r.URL.Path, ".ico")
|
||||
})(s.server.Handler)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start server.
|
||||
func (s *Server) Start(ip string, port uint16) (err error) {
|
||||
if net.ParseIP(ip) == nil {
|
||||
return errors.New("invalid IP address")
|
||||
}
|
||||
func (s *Server) Start(ip string, port uint16) error {
|
||||
s.server.Addr = ip + ":" + strconv.Itoa(int(port))
|
||||
|
||||
var ln net.Listener
|
||||
|
||||
if strings.Count(ip, ":") >= 2 { //nolint:mnd // ipv6
|
||||
if ln, err = net.Listen("tcp6", fmt.Sprintf("[%s]:%d", ip, port)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else { // ipv4
|
||||
if ln, err = net.Listen("tcp4", fmt.Sprintf("%s:%d", ip, port)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.server.Serve(ln)
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
// Stop server gracefully.
|
||||
@ -141,7 +113,5 @@ func (s *Server) Stop(timeout time.Duration) error {
|
||||
var ctx, cancel = context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
s.beforeStop()
|
||||
|
||||
return s.server.ShutdownWithContext(ctx)
|
||||
return s.server.Shutdown(ctx)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -20,7 +21,7 @@ import (
|
||||
// TestRouting in fact is a test for the whole server, because it tests all the routes and their handlers.
|
||||
func TestRouting(t *testing.T) {
|
||||
var (
|
||||
srv = appHttp.NewServer(logger.NewNop(), 1025*5)
|
||||
srv = appHttp.NewServer(context.Background(), logger.NewNop())
|
||||
cfg = config.New()
|
||||
)
|
||||
|
||||
@ -38,7 +39,7 @@ func TestRouting(t *testing.T) {
|
||||
Service Name: {{ service_name }}
|
||||
Service Port: {{ service_port }}
|
||||
Request ID: {{ request_id }}
|
||||
Timestamp: {{ nowUnix }}
|
||||
Timestamp: {{ now.Unix }}
|
||||
</pre>{{ end }}
|
||||
</html>`))
|
||||
|
||||
@ -221,16 +222,6 @@ func TestRouting(t *testing.T) {
|
||||
assert.Contains(t, string(body), "404: Not Found")
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
})
|
||||
|
||||
t.Run("other HTTP methods", func(t *testing.T) {
|
||||
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
|
||||
var status, body, headers = sendRequest(t, method, baseUrl+"/404.html")
|
||||
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, string(body), "404: Not Found")
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("failure", func(t *testing.T) {
|
||||
@ -274,6 +265,15 @@ func TestRouting(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assertIsNotErrorPage(t, body)
|
||||
})
|
||||
|
||||
t.Run("invalid HTTP methods", func(t *testing.T) {
|
||||
for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} {
|
||||
var status, body, _ = sendRequest(t, method, baseUrl+"/404.html")
|
||||
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, status)
|
||||
assertIsNotErrorPage(t, body)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -296,7 +296,7 @@ func TestRouting(t *testing.T) {
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.Empty(t, body)
|
||||
assert.Contains(t, headers.Get("Content-Type"), "text/plain")
|
||||
assert.Empty(t, headers.Get("Content-Type"))
|
||||
}
|
||||
})
|
||||
|
||||
@ -368,7 +368,7 @@ func startServer(t *testing.T, srv *appHttp.Server) (_ string, stop func()) {
|
||||
<-time.After(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://%s", hostPort), func() { assert.NoError(t, srv.Stop(350*time.Millisecond)) }
|
||||
return fmt.Sprintf("http://%s", hostPort), func() { assert.NoError(t, srv.Stop(10*time.Millisecond)) }
|
||||
}
|
||||
|
||||
// getFreeTcpPort is a helper function to get a free TCP port number.
|
||||
@ -392,5 +392,5 @@ func getFreeTcpPort(t *testing.T) uint16 {
|
||||
<-time.After(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
return uint16(port) //nolint:gosec
|
||||
return uint16(port)
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/css"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
"github.com/tdewolff/minify/v2/js"
|
||||
"github.com/tdewolff/minify/v2/svg"
|
||||
)
|
||||
|
||||
var htmlMinify = func() *minify.M { //nolint:gochecknoglobals
|
||||
var m = minify.New()
|
||||
|
||||
m.AddFunc("text/css", css.Minify)
|
||||
m.Add("text/html", &html.Minifier{KeepDocumentTags: true, KeepEndTags: true, KeepQuotes: true})
|
||||
m.AddFunc("image/svg+xml", svg.Minify)
|
||||
m.AddFunc("application/javascript", js.Minify)
|
||||
|
||||
return m
|
||||
}()
|
||||
|
||||
// MiniHTML minifies HTML data, including inline CSS, SVG and JS.
|
||||
func MiniHTML(data string) (string, error) { return htmlMinify.String("text/html", data) }
|
@ -1,94 +0,0 @@
|
||||
package template_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"gh.tarampamp.am/error-pages/internal/template"
|
||||
)
|
||||
|
||||
func TestMiniHTML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range 100 { // race condition provocation
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for give, want := range map[string]string{
|
||||
"": "",
|
||||
`<!-- Simple HTML page -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1 align="center">Test</h1>
|
||||
</body>
|
||||
</html>`: `<!doctype html><html><head><title>Test</title></head><body><h1 align="center">Test</h1></body></html>`,
|
||||
`<!-- css styles -->
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.foo:hover {
|
||||
color: #f0a; /* comment */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p style="color: red" class="bar">Text</p>
|
||||
</body>
|
||||
</html>`: `<html><head><style>.foo:hover{color:#f0a}</style></head><body><p style="color:red" class="bar">Text</p></body></html>`,
|
||||
`<!-- svg -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<g>
|
||||
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
|
||||
</g>
|
||||
</svg>`: `<svg><g><circle cx="50" cy="50" r="40" stroke="#000" stroke-width="3" fill="red"/></g></svg>`,
|
||||
`<!-- js -->
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
// comment
|
||||
console.log('Hello, World!');
|
||||
|
||||
let foo = 1;
|
||||
foo++;
|
||||
</script>
|
||||
</body>
|
||||
</html>`: `<html><body><script>console.log("Hello, World!");let foo=1;foo++</script></body></html>`,
|
||||
`<!-- js module not changed -->
|
||||
<html>
|
||||
<body>
|
||||
<script type="module">
|
||||
// comment
|
||||
console.log('Hello, World!');
|
||||
|
||||
let foo = 1;
|
||||
foo++;
|
||||
</script>
|
||||
</body>
|
||||
</html>`: `<html><body><script type="module">
|
||||
// comment
|
||||
console.log('Hello, World!');
|
||||
|
||||
let foo = 1;
|
||||
foo++;
|
||||
</script></body></html>`,
|
||||
} {
|
||||
var got, err = template.MiniHTML(give)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
@ -16,9 +16,10 @@ import (
|
||||
)
|
||||
|
||||
var builtInFunctions = template.FuncMap{ //nolint:gochecknoglobals
|
||||
// the current time in unix format (seconds since 1970 UTC):
|
||||
// `{{ nowUnix }}` // `1631610000`
|
||||
"nowUnix": func() int64 { return time.Now().Unix() },
|
||||
// current time:
|
||||
// `{{ now.Unix }}` // `1631610000`
|
||||
// `{{ now.Hour }}:{{ now.Minute }}:{{ now.Second }}` // `15:4:5`
|
||||
"now": time.Now,
|
||||
|
||||
// current hostname:
|
||||
// `{{ hostname }}` // `localhost`
|
||||
|
@ -27,7 +27,7 @@ func TestRender_BuiltInFunction(t *testing.T) {
|
||||
wantErrMsg string
|
||||
}{
|
||||
"now (unix)": {
|
||||
giveTemplate: `{{ nowUnix }}`,
|
||||
giveTemplate: `{{ now.Unix }}`,
|
||||
wantResult: strconv.Itoa(int(time.Now().Unix())),
|
||||
},
|
||||
"hostname": {giveTemplate: `{{ hostname }}`, wantResult: hostname},
|
||||
|
74
l10n/l10n.js
74
l10n/l10n.js
@ -16,7 +16,7 @@ Object.defineProperty(window, 'l10n', {
|
||||
*
|
||||
* @link https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes language codes list (column `Set 1` or `ISO 639-1:2002`)
|
||||
*
|
||||
* @type {Map<string, Map<'fr'|'ru'|'uk'|'pt'|'nl'|'de'|'es'|'zh'|'id'|'pl'|'ko', string>>}
|
||||
* @type {Map<string, Map<'fr'|'ru'|'uk'|'pt'|'nl'|'de'|'es'|'zh'|'id'|'pl', string>>}
|
||||
*/
|
||||
const data = Object.freeze(new Map([
|
||||
[tkn('Error'), new Map([
|
||||
@ -30,7 +30,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '错误'],
|
||||
['id', 'Kesalahan'],
|
||||
['pl', 'Błąd'],
|
||||
['ko', '오류'],
|
||||
])],
|
||||
[tkn('Good luck'), new Map([
|
||||
['fr', 'Bonne chance'],
|
||||
@ -43,7 +42,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '祝好运'],
|
||||
['id', 'Semoga berhasil!'],
|
||||
['pl', 'Powodzenia'],
|
||||
['ko', '행운을 빌어요'],
|
||||
])],
|
||||
[tkn('UH OH'), new Map([
|
||||
['fr', 'Oups'],
|
||||
@ -56,7 +54,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '哎呀'],
|
||||
['id', 'Ups'],
|
||||
['pl', 'Ojej'],
|
||||
['ko', '헉'],
|
||||
])],
|
||||
[tkn('Request details'), new Map([
|
||||
['fr', 'Détails de la requête'],
|
||||
@ -69,7 +66,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求详情'],
|
||||
['id', 'Rincian permintaan'],
|
||||
['pl', 'Poproś o szczegóły'],
|
||||
['ko', '요청 세부사항'],
|
||||
])],
|
||||
[tkn('Double-check the URL'), new Map([
|
||||
['fr', 'Vérifiez l’URL'],
|
||||
@ -82,7 +78,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请再次检查地址'],
|
||||
['id', 'Periksa URL'],
|
||||
['pl', 'Sprawdź adres URL'],
|
||||
['ko', 'URL을 다시 확인하세요'],
|
||||
])],
|
||||
[tkn('Alternatively, go back'), new Map([
|
||||
['fr', 'Essayer de revenir en arrière'],
|
||||
@ -95,7 +90,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '或返回上一页'],
|
||||
['id', 'Atau, kembali'],
|
||||
['pl', 'Alternatywnie wróć'],
|
||||
['ko', '혹은, 돌아가기'],
|
||||
])],
|
||||
[tkn("Here's what might have happened"), new Map([
|
||||
['fr', 'Voici ce qui aurait pu se passer'],
|
||||
@ -108,7 +102,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '可能原因有'],
|
||||
['id', 'Inilah yang bisa saja terjadi'],
|
||||
['pl', 'Oto, co mogło się wydarzyć'],
|
||||
['ko', '다음이 발생했을 수 있어요'],
|
||||
])],
|
||||
[tkn('You may have mistyped the URL'), new Map([
|
||||
['fr', 'Vous avez peut-être mal tapé l’URL'],
|
||||
@ -121,7 +114,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '您可能输入了错误的地址'],
|
||||
['id', 'Anda mungkin tersalah memasukkan URL'],
|
||||
['pl', 'Być może błędnie wpisałeś adres URL'],
|
||||
['ko', 'URL을 잘못 입력하셨을 수 있어요'],
|
||||
])],
|
||||
[tkn('The site was moved'), new Map([
|
||||
['fr', 'Le site a été déplacé'],
|
||||
@ -134,7 +126,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '站点已被转移'],
|
||||
['id', 'Halaman dipindahkan'],
|
||||
['pl', 'Witryna została przeniesiona'],
|
||||
['ko', '사이트가 이동했어요'],
|
||||
])],
|
||||
[tkn('It was never here'), new Map([
|
||||
['fr', 'Il n’a jamais été ici'],
|
||||
@ -147,7 +138,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '站点从未存在'],
|
||||
['id', 'Itu Tidak pernah di sini'],
|
||||
['pl', 'Nigdy jej nie było'],
|
||||
['ko', '여기에 있던 적이 없어요'],
|
||||
])],
|
||||
[tkn('Bad Request'), new Map([
|
||||
['fr', 'Mauvaise requête'],
|
||||
@ -160,7 +150,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '错误请求'],
|
||||
['id', 'Permintaan yang salah'],
|
||||
['pl', 'Nieprawidłowe żądanie'],
|
||||
['ko', '잘못된 요청'],
|
||||
])],
|
||||
[tkn('The server did not understand the request'), new Map([
|
||||
['fr', 'Le serveur ne comprend pas la requête'],
|
||||
@ -173,7 +162,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务器不理解该请求'],
|
||||
['id', 'Server tidak memahami permintaan'],
|
||||
['pl', 'Serwer nie zrozumiał żądania'],
|
||||
['ko', '서버가 요청을 이해하지 못했어요'],
|
||||
])],
|
||||
[tkn('Unauthorized'), new Map([
|
||||
['fr', 'Non autorisé'],
|
||||
@ -186,7 +174,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '未经授权'],
|
||||
['id', 'Tidak diotorisasi'],
|
||||
['pl', 'Nieautoryzowany'],
|
||||
['ko', '권한 없음'],
|
||||
])],
|
||||
[tkn('The requested page needs a username and a password'), new Map([
|
||||
['fr', 'La page demandée nécessite un nom d’utilisateur et un mot de passe'],
|
||||
@ -199,7 +186,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求的页面需要用户名和密码'],
|
||||
['id', 'Halaman yang diminta membutuhkan nama pengguna dan kata sandi'],
|
||||
['pl', 'Żądana strona wymaga podania nazwy użytkownika i hasła'],
|
||||
['ko', '요청하신 페이지에는 사용자 이름과 비밀번호가 필요해요'],
|
||||
])],
|
||||
[tkn('Forbidden'), new Map([
|
||||
['fr', 'Interdit'],
|
||||
@ -212,7 +198,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '禁止访问'],
|
||||
['id', 'Dilarang'],
|
||||
['pl', 'Zabroniony'],
|
||||
['ko', '금지됨'],
|
||||
])],
|
||||
[tkn('Access is forbidden to the requested page'), new Map([
|
||||
['fr', 'Accès interdit à la page demandée'],
|
||||
@ -225,7 +210,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '禁止访问请求的页面'],
|
||||
['id', 'Akses dilarang ke halaman yang diminta'],
|
||||
['pl', 'Dostęp do żądanej strony jest zabroniony'],
|
||||
['ko', '요청하신 페이지에 대한 접근이 금지되어 있어요'],
|
||||
])],
|
||||
[tkn('Not Found'), new Map([
|
||||
['fr', 'Introuvable'],
|
||||
@ -238,7 +222,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '未找到'],
|
||||
['id', 'Tidak ditemukan'],
|
||||
['pl', 'Nie znaleziono'],
|
||||
['ko', '찾을 수 없음'],
|
||||
])],
|
||||
[tkn('The server can not find the requested page'), new Map([
|
||||
['fr', 'Le serveur ne peut trouver la page demandée'],
|
||||
@ -251,7 +234,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务器找不到请求的页面'],
|
||||
['id', 'Server tidak dapat menemukan halaman yang diminta'],
|
||||
['pl', 'Serwer nie może znaleźć żądanej strony'],
|
||||
['ko', '서버가 요청한 페이지를 찾을 수 없어요'],
|
||||
])],
|
||||
[tkn('Method Not Allowed'), new Map([
|
||||
['fr', 'Méthode Non Autorisée'],
|
||||
@ -264,7 +246,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '方法不被允许'],
|
||||
['id', 'Metode tidak diizinkan'],
|
||||
['pl', 'Niedozwolona metoda'],
|
||||
['ko', '허용되지 않은 메소드'],
|
||||
])],
|
||||
[tkn('The method specified in the request is not allowed'), new Map([
|
||||
['fr', 'La méthode spécifiée dans la requête n’est pas autorisée'],
|
||||
@ -277,7 +258,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求指定的方法不被允许'],
|
||||
['id', 'Metode dalam permintaan tidak diizinkan'],
|
||||
['pl', 'Metoda określona w żądaniu jest niedozwolona'],
|
||||
['ko', '요청에 사용한 메소드는 허용되지 않아요'],
|
||||
])],
|
||||
[tkn('Proxy Authentication Required'), new Map([
|
||||
['fr', 'Authentification proxy requise'],
|
||||
@ -290,7 +270,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '需要代理服务器身份验证'],
|
||||
['id', 'Diperlukan otentikasi proxy'],
|
||||
['pl', 'Wymagane uwierzytelnianie proxy'],
|
||||
['ko', '프록시 인증 필요'],
|
||||
])],
|
||||
[tkn('You must authenticate with a proxy server before this request can be served'), new Map([
|
||||
['fr', 'Vous devez vous authentifier avec un serveur proxy avant que cette requête puisse être servie'],
|
||||
@ -303,7 +282,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '您必须对代理服务器进行身份验证,然后才能让请求得到处理'],
|
||||
['id', 'Anda harus mengautentikasi dengan server proxy sebelum permintaan ini dapat dilayani'],
|
||||
['pl', 'Musisz uwierzytelnić się na serwerze proxy, zanim to żądanie będzie mogło zostać obsłużone'],
|
||||
['ko', '이 요청을 처리하려면 프록시 서버로 인증해야 해요'],
|
||||
])],
|
||||
[tkn('Request Timeout'), new Map([
|
||||
['fr', 'Requête expiré'],
|
||||
@ -316,7 +294,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求超时'],
|
||||
['id', 'Meminta batas waktu'],
|
||||
['pl', 'Przekroczenie limitu czasu żądania'],
|
||||
['ko', '요청 시간초과'],
|
||||
])],
|
||||
[tkn('The request took longer than the server was prepared to wait'), new Map([
|
||||
['fr', 'La requête prend plus de temps que prévu'],
|
||||
@ -329,7 +306,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求用时超过了服务器设置的最长等待时间'],
|
||||
['id', 'Permintaan memakan waktu lebih lama dari yang bisa ditunggu oleh server'],
|
||||
['pl', 'Żądanie trwało dłużej niż serwer był gotowy czekać'],
|
||||
['ko', '요청이 서버가 기다릴 수 있는 시간보다 오래 걸렸어요'],
|
||||
])],
|
||||
[tkn('Conflict'), new Map([
|
||||
['fr', 'Conflit'],
|
||||
@ -342,7 +318,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '冲突'],
|
||||
['id', 'Konflik'],
|
||||
['pl', 'Konflikt'],
|
||||
['ko', '상충'],
|
||||
])],
|
||||
[tkn('The request could not be completed because of a conflict'), new Map([
|
||||
['fr', 'La requête n’a pas pu être complétée à cause d’un conflit'],
|
||||
@ -355,7 +330,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '由于冲突,请求无法完成'],
|
||||
['id', 'Permintaan tidak dapat diselesaikan karena adanya konflik'],
|
||||
['pl', 'Żądanie nie mogło zostać wykonane z powodu konfliktu'],
|
||||
['ko', '상충으로 인해 요청을 완료할 수 없었어요'],
|
||||
])],
|
||||
[tkn('Gone'), new Map([
|
||||
['fr', 'Supprimé'],
|
||||
@ -368,7 +342,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '已移除'],
|
||||
['id', 'Menghilang'],
|
||||
['pl', 'Usunięto'],
|
||||
['ko', '사라짐'],
|
||||
])],
|
||||
[tkn('The requested page is no longer available'), new Map([
|
||||
['fr', 'La page demandée n’est plus disponible'],
|
||||
@ -381,7 +354,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求的页面不再可用'],
|
||||
['id', 'Halaman yang diminta tidak lagi tersedia'],
|
||||
['pl', 'Żądana strona nie jest już dostępna'],
|
||||
['ko', '요청하신 페이지는 더 이상 사용할 수 없어요'],
|
||||
])],
|
||||
[tkn('Length Required'), new Map([
|
||||
['fr', 'Longueur requise'],
|
||||
@ -394,7 +366,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '需要长度'],
|
||||
['id', 'Panjang yang diperlukan'],
|
||||
['pl', 'Wymagana długość'],
|
||||
['ko', '길이 필요'],
|
||||
])],
|
||||
[tkn('The "Content-Length" is not defined. The server will not accept the request without it'), new Map([
|
||||
['fr', 'Le "Content-Length" n’est pas défini. Le serveur ne prendra pas en compte la requête'],
|
||||
@ -407,7 +378,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '未指定Content-Length(内容长度)。服务器将不接受不包含此头信息的请求'],
|
||||
['id', '"Content-Length" tidak ditentukan. Server tidak akan menerima permintaan tanpa itu'],
|
||||
['pl', 'Wartość "Content-Length" nie jest zdefiniowana. Serwer nie zaakceptuje żądania bez tego parametru'],
|
||||
['ko', '"Content-Length"가 정의되지 않았습니다. 이 값이 없으면 서버는 요청을 수락하지 않아요'],
|
||||
])],
|
||||
[tkn('Precondition Failed'), new Map([
|
||||
['fr', 'Échec de la condition préalable'],
|
||||
@ -420,7 +390,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '前置条件判定失败'],
|
||||
['id', 'Prasyarat gagal'],
|
||||
['pl', 'Niespełnienie warunku wstępnego'],
|
||||
['ko', '선결 조건 실패'],
|
||||
])],
|
||||
[tkn('The pre condition given in the request evaluated to false by the server'), new Map([
|
||||
['fr', 'La précondition donnée dans la requête a été évaluée comme étant fausse par le serveur'],
|
||||
@ -433,7 +402,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务器评估请求中给出的前置条件的结果为false(假)'],
|
||||
['id', 'Prakondisi gagal'],
|
||||
['pl', 'Warunek wstępny podany w żądaniu został oceniony przez serwer jako nieprawidłowy'],
|
||||
['ko', '요청에 제공된 선결 조건을 서버는 거짓으로 평가했어요'],
|
||||
])],
|
||||
[tkn('Payload Too Large'), new Map([
|
||||
['fr', 'Charge trop volumineuse'],
|
||||
@ -446,7 +414,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求体过大'],
|
||||
['id', 'Muatan terlalu besar'],
|
||||
['pl', 'Żądanie jest zbyt duże'],
|
||||
['ko', '콘텐츠가 너무 큼'],
|
||||
])],
|
||||
[tkn('The server will not accept the request, because the request entity is too large'), new Map([
|
||||
['fr', 'Le serveur ne prendra pas en compte la requête, car l’entité de la requête est trop volumineuse'],
|
||||
@ -459,7 +426,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求体过大,服务器将不接受该请求'],
|
||||
['id', 'Server tidak akan menerima permintaan, karena entitas permintaan terlalu besar'],
|
||||
['pl', 'Serwer nie zaakceptuje żądania, ponieważ żądanie jest zbyt duże'],
|
||||
['ko', '요청 엔터티가 너무 크기 때문에 서버가 요청을 수락하지 않았어요'],
|
||||
])],
|
||||
[tkn('Requested Range Not Satisfiable'), new Map([
|
||||
['fr', 'Requête non satisfaisante'],
|
||||
@ -472,7 +438,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '不满足请求范围'],
|
||||
['id', 'Rentang yang diminta tidak dapat dipenuhi'],
|
||||
['pl', 'Żądany zakres nie jest satysfakcjonujący'],
|
||||
['ko', '처리할 수 없는 요청 범위'],
|
||||
])],
|
||||
[tkn('The requested byte range is not available and is out of bounds'), new Map([
|
||||
['fr', 'Le byte range demandé n’est pas disponible et est hors des limites'],
|
||||
@ -485,7 +450,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求的字节范围不可用,超出边界'],
|
||||
['id', 'Rentang byte yang diminta tidak tersedia dan di luar batas'],
|
||||
['pl', 'Żądany zakres bajtów nie jest dostępny i znajduje się poza zakresem'],
|
||||
['ko', '요청한 범위를 사용할 수 없고, 범위를 벗어났어요'],
|
||||
])],
|
||||
[tkn("I'm a teapot"), new Map([
|
||||
['fr', 'Je suis une théière'],
|
||||
@ -498,7 +462,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '我是一只茶壶'],
|
||||
['id', 'Saya adalah teko'],
|
||||
['pl', 'Jestem czajniczkiem'],
|
||||
['ko', '저는 찻주전자에요'],
|
||||
])],
|
||||
[tkn('Attempt to brew coffee with a teapot is not supported'), new Map([
|
||||
['fr', 'Tenter de préparer du café avec une théière n’est pas pris en charge'],
|
||||
@ -511,7 +474,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '用茶壶泡咖啡不受支持'],
|
||||
['id', 'Upaya menyeduh kopi dengan teko tidak didukung'],
|
||||
['pl', 'Próba zaparzenia kawy za pomocą czajniczka nie jest obsługiwana'],
|
||||
['ko', '찻주전자로 커피를 내리는 시도는 지원되지 않아요'],
|
||||
])],
|
||||
[tkn('Too Many Requests'), new Map([
|
||||
['fr', 'Trop de requêtes'],
|
||||
@ -524,7 +486,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求过多'],
|
||||
['id', 'Terlalu banyak permintaan'],
|
||||
['pl', 'Zbyt wiele żądań'],
|
||||
['ko', '요청이 너무 많음'],
|
||||
])],
|
||||
[tkn('Too many requests in a given amount of time'), new Map([
|
||||
['fr', 'Trop de requêtes dans un délai donné'],
|
||||
@ -537,7 +498,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '在给定的时间内发送了过多请求'],
|
||||
['id', 'Terlalu banyak permintaan dalam waktu tertentu'],
|
||||
['pl', 'Zbyt wiele żądań w określonym czasie'],
|
||||
['ko', '지정된 시간 내에 요청이 너무 많아요'],
|
||||
])],
|
||||
[tkn('Internal Server Error'), new Map([
|
||||
['fr', 'Erreur interne du serveur'],
|
||||
@ -550,7 +510,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '内部服务器错误'],
|
||||
['id', 'Kesalahan server internal'],
|
||||
['pl', 'Wewnętrzny błąd serwera'],
|
||||
['ko', '내부 서버 오류'],
|
||||
])],
|
||||
[tkn('The server met an unexpected condition'), new Map([
|
||||
['fr', 'Le serveur a rencontré une condition inattendue'],
|
||||
@ -563,7 +522,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务器遇到了意外情况'],
|
||||
['id', 'Server mengalami kondisi yang tidak terduga'],
|
||||
['pl', 'Serwer napotkał nieoczekiwany stan'],
|
||||
['ko', '서버가 예상치 못한 조건이에요'],
|
||||
])],
|
||||
[tkn('Bad Gateway'), new Map([
|
||||
['fr', 'Mauvaise passerelle'],
|
||||
@ -576,7 +534,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '无效网关'],
|
||||
['id', 'Gateway yang buruk'],
|
||||
['pl', 'Błąd bramki'],
|
||||
['ko', '게이트웨이 불량'],
|
||||
])],
|
||||
[tkn('The server received an invalid response from the upstream server'), new Map([
|
||||
['fr', 'Le serveur a reçu une réponse invalide du serveur distant'],
|
||||
@ -589,7 +546,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务器从上游服务器收到了无效的响应'],
|
||||
['id', 'Server menerima respons yang tidak valid dari server induk'],
|
||||
['pl', 'Serwer otrzymał nieprawidłową odpowiedź od serwera nadrzędnego'],
|
||||
['ko', '게이트웨이가 업스트림 서버로부터 잘못된 응답을 받았어요'],
|
||||
])],
|
||||
[tkn('Service Unavailable'), new Map([
|
||||
['fr', 'Service indisponible'],
|
||||
@ -602,7 +558,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务不可用'],
|
||||
['id', 'Layanan tidak tersedia'],
|
||||
['pl', 'Serwis niedostępny'],
|
||||
['ko', '서비스 불가능'],
|
||||
])],
|
||||
[tkn('The server is temporarily overloading or down'), new Map([
|
||||
['fr', 'Le serveur est temporairement en surcharge ou indisponible'],
|
||||
@ -615,7 +570,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务器暂时过载或不可用'],
|
||||
['id', 'Server untuk sementara kelebihan beban atau tidak tersedia'],
|
||||
['pl', 'Serwer jest tymczasowo przeciążony lub wyłączony'],
|
||||
['ko', '서버가 일시적으로 과부하 상태이거나 다운되었어요'],
|
||||
])],
|
||||
[tkn('Gateway Timeout'), new Map([
|
||||
['fr', 'Expiration Passerelle'],
|
||||
@ -628,7 +582,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '网关超时'],
|
||||
['id', 'Batas waktu gateway'],
|
||||
['pl', 'Przekroczenie limitu czasu bramki'],
|
||||
['ko', '게이트웨이 시간초과'],
|
||||
])],
|
||||
[tkn('The gateway has timed out'), new Map([
|
||||
['fr', 'Le temps d’attente de la passerelle est dépassé'],
|
||||
@ -641,7 +594,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '网关响应已经超时'],
|
||||
['id', 'Sambungan ke server induk telah kedaluwarsa'],
|
||||
['pl', 'Bramka przekroczyła limit czasu'],
|
||||
['ko', '게이트웨이 시간이 초과되었어요'],
|
||||
])],
|
||||
[tkn('HTTP Version Not Supported'), new Map([
|
||||
['fr', 'Version HTTP non prise en charge'],
|
||||
@ -654,7 +606,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', 'HTTP版本不受支持'],
|
||||
['id', 'Versi HTTP tidak didukung'],
|
||||
['pl', 'Wersja HTTP nie jest obsługiwana'],
|
||||
['ko', '지원하지 않는 HTTP 버전'],
|
||||
])],
|
||||
[tkn('The server does not support the "http protocol" version'), new Map([
|
||||
['fr', 'Le serveur ne supporte pas la version du protocole HTTP'],
|
||||
@ -667,7 +618,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务器不支持该HTTP协议版本'],
|
||||
['id', 'Server tidak mendukung versi HTTP ini'],
|
||||
['pl', 'Serwer nie obsługuje wersji "protokołu http"'],
|
||||
['ko', '서버가 해당 "HTTP 프로토콜"을 지원하지 않아요'],
|
||||
])],
|
||||
[tkn('Host'), new Map([
|
||||
['fr', 'Hôte'],
|
||||
@ -680,7 +630,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '主机'],
|
||||
['id', 'Host'],
|
||||
['pl', 'Host'],
|
||||
['ko', '호스트'],
|
||||
])],
|
||||
[tkn('Original URI'), new Map([
|
||||
['fr', 'URI d’origine'],
|
||||
@ -693,7 +642,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '原始URI'],
|
||||
['id', 'URL asli'],
|
||||
['pl', 'Oryginalny URI'],
|
||||
['ko', '원시 URI'],
|
||||
])],
|
||||
[tkn('Forwarded for'), new Map([
|
||||
['fr', 'Transmis pour'],
|
||||
@ -706,7 +654,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '转发自'],
|
||||
['id', 'Diteruskan untuk'],
|
||||
['pl', 'Przekazane do'],
|
||||
['ko', '전달받은 대상'],
|
||||
])],
|
||||
[tkn('Namespace'), new Map([
|
||||
['fr', 'Espace de noms'],
|
||||
@ -719,7 +666,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '命名空间'],
|
||||
['id', 'Ruang nama'],
|
||||
['pl', 'Przestrzeń nazw'],
|
||||
['ko', '네임스페이스'],
|
||||
])],
|
||||
[tkn('Ingress name'), new Map([
|
||||
['fr', 'Nom ingress'],
|
||||
@ -732,7 +678,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '入口名'],
|
||||
['id', 'Nama ingress'],
|
||||
['pl', 'Nazwa wejścia'],
|
||||
['ko', '인그레스 이름'],
|
||||
])],
|
||||
[tkn('Service name'), new Map([
|
||||
['fr', 'Nom du service'],
|
||||
@ -745,7 +690,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务名'],
|
||||
['id', 'Nama layanan'],
|
||||
['pl', 'Nazwa usługi'],
|
||||
['ko', '서비스 이름'],
|
||||
])],
|
||||
[tkn('Service port'), new Map([
|
||||
['fr', 'Port du service'],
|
||||
@ -758,7 +702,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务端口'],
|
||||
['id', 'Port layanan'],
|
||||
['pl', 'Port usługi'],
|
||||
['ko', '서비스 포트'],
|
||||
])],
|
||||
[tkn('Request ID'), new Map([
|
||||
['fr', 'Identifiant de la requête'],
|
||||
@ -771,7 +714,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请求ID'],
|
||||
['id', 'ID permintaan'],
|
||||
['pl', 'Identyfikator żądania'],
|
||||
['ko', '요청 ID'],
|
||||
])],
|
||||
[tkn('Timestamp'), new Map([
|
||||
['fr', 'Horodatage'],
|
||||
@ -784,7 +726,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '时间戳'],
|
||||
['id', 'Cap waktu'],
|
||||
['pl', 'Sygnatura czasowa'],
|
||||
['ko', '시간 기록'],
|
||||
])],
|
||||
[tkn('client-side error'), new Map([
|
||||
['fr', 'Erreur Client'],
|
||||
@ -797,7 +738,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '客户端错误'],
|
||||
['id', 'Kesalahan sisi klien'],
|
||||
['pl', 'błąd po stronie klienta'],
|
||||
['ko', '클라이언트 측 오류'],
|
||||
])],
|
||||
[tkn('server-side error'), new Map([
|
||||
['fr', 'Erreur Serveur'],
|
||||
@ -810,7 +750,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '服务端错误'],
|
||||
['id', 'Kesalahan sisi server'],
|
||||
['pl', 'błąd po stronie serwera'],
|
||||
['ko', '서버 측 오류'],
|
||||
])],
|
||||
[tkn('Your Client'), new Map([
|
||||
['fr', 'Votre Client'],
|
||||
@ -823,7 +762,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '您的客户端'],
|
||||
['id', 'Klien Anda'],
|
||||
['pl', 'Klient'],
|
||||
['ko', '내 클라이언트'],
|
||||
])],
|
||||
[tkn('Network'), new Map([
|
||||
['fr', 'Réseau'],
|
||||
@ -836,7 +774,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '网络'],
|
||||
['id', 'Jaringan'],
|
||||
['pl', 'Sieć'],
|
||||
['ko', '네트워크'],
|
||||
])],
|
||||
[tkn('Web Server'), new Map([
|
||||
['fr', 'Serveur Web'],
|
||||
@ -849,7 +786,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', 'Web服务器'],
|
||||
['id', 'Server web'],
|
||||
['pl', 'Serwer WWW'],
|
||||
['ko', '웹 서버'],
|
||||
])],
|
||||
[tkn('What happened?'), new Map([
|
||||
['fr', 'Que s’est-il passé ?'],
|
||||
@ -862,7 +798,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '发生了什么?'],
|
||||
['id', 'Apa yang terjadi?'],
|
||||
['pl', 'Co się stało?'],
|
||||
['ko', '어떤 일이 일어났나요?'],
|
||||
])],
|
||||
[tkn('What can I do?'), new Map([
|
||||
['fr', 'Que puis-je faire ?'],
|
||||
@ -875,7 +810,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '我能做什么?'],
|
||||
['id', 'Apa yang bisa saya lakukan?'],
|
||||
['pl', 'Co mogę zrobić?'],
|
||||
['ko', '어떤 것을 할 수 있나요?'],
|
||||
])],
|
||||
[tkn('Please try again in a few minutes'), new Map([
|
||||
['fr', 'Veuillez réessayer dans quelques minutes'],
|
||||
@ -888,7 +822,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请在几分钟后重试'],
|
||||
['id', 'Silakan coba lagi dalam beberapa menit'],
|
||||
['pl', 'Spróbuj ponownie za kilka minut'],
|
||||
['ko', '몇 분 후에 다시 시도해 주세요'],
|
||||
])],
|
||||
[tkn('Working'), new Map([
|
||||
['fr', 'Opérationnel'],
|
||||
@ -901,7 +834,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '正常运行'],
|
||||
['id', 'Fungsi'],
|
||||
['pl', 'Działa'],
|
||||
['ko', '작동 중'],
|
||||
])],
|
||||
[tkn('Unknown'), new Map([
|
||||
['fr', 'Inconnu'],
|
||||
@ -914,7 +846,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '未知'],
|
||||
['id', 'Tidak diketahui'],
|
||||
['pl', 'Nieznany'],
|
||||
['ko', '알 수 없음'],
|
||||
])],
|
||||
[tkn('Please try to change the request method, headers, payload, or URL'), new Map([
|
||||
['fr', 'Veuillez essayer de changer la méthode de requête, les en-têtes, le contenu ou l’URL'],
|
||||
@ -927,7 +858,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请尝试更改请求方法、标头、有效负载或URL'],
|
||||
['id', 'Coba lagi dengan metode, header, muatan, atau URL yang berbeda'],
|
||||
['pl', 'Spróbuj zmienić metodę żądania, nagłówki, żądanie lub adres URL'],
|
||||
['ko', '요청 방법, 헤더, 콘텐츠 또는 URL을 변경해 보세요'],
|
||||
])],
|
||||
[tkn('Please check your authorization data'), new Map([
|
||||
['fr', 'Veuillez vérifier vos données d’autorisation'],
|
||||
@ -940,7 +870,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请检查您的授权数据'],
|
||||
['id', 'Memeriksa detail autentikasi'],
|
||||
['pl', 'Sprawdź swoje dane autoryzacyjne'],
|
||||
['ko', '인증 데이터를 확인해주세요'],
|
||||
])],
|
||||
[tkn('Please double-check the URL and try again'), new Map([
|
||||
['fr', 'Veuillez vérifier l’URL et réessayer'],
|
||||
@ -953,7 +882,6 @@ Object.defineProperty(window, 'l10n', {
|
||||
['zh', '请再次检查URL并重试'],
|
||||
['id', 'Periksa URL dan coba lagi'],
|
||||
['pl', 'Sprawdź adres URL i spróbuj ponownie'],
|
||||
['ko', 'URL을 다시 한번 확인해 주세요'],
|
||||
])],
|
||||
]));
|
||||
|
||||
|
@ -118,8 +118,8 @@
|
||||
|
||||
const $langSwitch = document.getElementById('lang-switch');
|
||||
|
||||
['fr', 'ru', 'uk', 'pt', 'nl', 'de', 'es', 'zh', 'id', 'pl', 'ko' ].forEach((lang) => {
|
||||
// ^^^ add your newly added locale here
|
||||
['fr', 'ru', 'uk', 'pt', 'nl', 'de', 'es', 'zh', 'id', 'pl'].forEach((lang) => {
|
||||
// ^^^ add your newly added locale here
|
||||
const $li = document.createElement('li');
|
||||
const $btn = document.createElement('button');
|
||||
|
||||
|
@ -28,4 +28,3 @@ different locales, please follow these steps:
|
||||
- 🇨🇳 Chinese by [@CDN18](https://github.com/CDN18)
|
||||
- 🇮🇩 Indonesian by [@getwisp](https://github.com/getwisp)
|
||||
- 🇵🇱 Polish by [@wielorzeczownik](https://github.com/wielorzeczownik)
|
||||
- 🇰🇷 Korean by [@NavyStack](https://github.com/NavyStack)
|
||||
|
@ -50,7 +50,6 @@
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -342,7 +341,7 @@
|
||||
<!-- {{- end }}{{ if request_id -}} -->
|
||||
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
|
||||
<!-- {{- end -}} -->
|
||||
<li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
|
||||
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- {{- end -}} -->
|
||||
|
@ -37,7 +37,6 @@
|
||||
color: var(--color-inverted);
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -151,7 +150,7 @@
|
||||
<!-- {{- end -}} -->
|
||||
<tr>
|
||||
<td class="name" data-l10n>Timestamp</td>
|
||||
<td class="value">{{ nowUnix }}</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -46,11 +46,11 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-primary);
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -328,7 +328,7 @@
|
||||
<!-- {{- end }}{{ if request_id -}} -->
|
||||
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
|
||||
<!-- {{- end -}} -->
|
||||
<li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
|
||||
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- {{- end -}} -->
|
||||
|
@ -39,7 +39,6 @@
|
||||
color: var(--color-inverted);
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -236,7 +235,7 @@
|
||||
<!-- {{- end -}} -->
|
||||
<tr>
|
||||
<td class="name" data-l10n>Timestamp</td>
|
||||
<td class="value">{{ nowUnix }}</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -25,7 +25,6 @@
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
body {
|
||||
@ -175,7 +174,7 @@
|
||||
<!-- {{- end }}{{ if request_id -}} -->
|
||||
<p class="output small"><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></p>
|
||||
<!-- {{- end -}} -->
|
||||
<p class="output small"><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></p>
|
||||
<p class="output small"><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></p>
|
||||
</div>
|
||||
<!-- {{- end -}} -->
|
||||
</main>
|
||||
|
@ -37,7 +37,6 @@
|
||||
color: var(--color-inverted);
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -158,7 +157,7 @@
|
||||
<!-- {{- end }}{{ if request_id -}} -->
|
||||
<li class="value">{{ request_id }}</li>
|
||||
<!-- {{- end -}} -->
|
||||
<li class="value">{{ nowUnix }}</li>
|
||||
<li class="value">{{ now.Unix }}</li>
|
||||
</ul>
|
||||
<!-- {{- end -}} -->
|
||||
</div>
|
||||
|
@ -43,7 +43,6 @@
|
||||
color: var(--color-text-primary);
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -443,7 +442,7 @@
|
||||
<!-- {{- end }}{{ if request_id -}} -->
|
||||
<li><span data-l10n>Request ID</span>: <code>{{ request_id }}</code></li>
|
||||
<!-- {{- end -}} -->
|
||||
<li><span data-l10n>Timestamp</span>: <code>{{ nowUnix }}</code></li>
|
||||
<li><span data-l10n>Timestamp</span>: <code>{{ now.Unix }}</code></li>
|
||||
</ul>
|
||||
<!-- {{- end -}} -->
|
||||
</div>
|
||||
|
@ -9,7 +9,7 @@
|
||||
{{ if service_name }}Service name: {{ service_name }}{{ end }}
|
||||
{{ if service_port }}Service port: {{ service_port }}{{ end }}
|
||||
{{ if request_id }}Request ID: {{ request_id }}{{ end }}
|
||||
Timestamp: {{ nowUnix }}
|
||||
Timestamp: {{ now.Unix }}
|
||||
{{ end }}
|
||||
-->
|
||||
<html lang="en">
|
||||
@ -39,7 +39,6 @@
|
||||
overflow: hidden;
|
||||
font-family: sans-serif;
|
||||
font-size: 20px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
canvas {
|
||||
|
@ -41,7 +41,6 @@
|
||||
padding: 0;
|
||||
background-color: var(--color-bg-primary);
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -254,7 +253,7 @@
|
||||
<!-- {{- end -}} -->
|
||||
<tr>
|
||||
<td class="name" data-l10n>Timestamp</td>
|
||||
<td class="value">{{ nowUnix }}</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -37,7 +37,6 @@
|
||||
color: var(--color-inverted);
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
@ -168,7 +167,7 @@
|
||||
<!-- {{- end -}} -->
|
||||
<tr>
|
||||
<td class="name"><span data-l10n>Timestamp</span>:</td>
|
||||
<td class="value">{{ nowUnix }}</td>
|
||||
<td class="value">{{ now.Unix }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- {{- end -}} -->
|
||||
|
Reference in New Issue
Block a user