This commit is contained in:
Pаramtamtām 2024-07-03 07:12:13 -07:00 committed by GitHub
parent d4b2b5ef96
commit 6b3be0d550
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
177 changed files with 10140 additions and 8093 deletions

View File

@ -1,26 +0,0 @@
# Docs: <https://docs.codecov.io/docs/commit-status>
coverage:
# coverage lower than 50 is red, higher than 90 green
range: 30..80
status:
project:
default:
# Choose a minimum coverage ratio that the commit must meet to be considered a success.
#
# `auto` will use the coverage from the base commit (pull request base or parent commit) coverage to compare
# against.
target: auto
# Allow the coverage to drop by X%, and posting a success status.
threshold: 5%
# Resulting status will pass no matter what the coverage is or what other settings are specified.
informational: true
patch:
default:
target: auto
threshold: 5%
informational: true

View File

@ -0,0 +1,18 @@
{
"$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"
}

View File

@ -4,6 +4,6 @@
## Except the following files and directories
!/cmd
!/internal
!/l10n
!/templates
!/error-pages.yml
!/go.*

View File

@ -10,5 +10,9 @@ indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[{Makefile, go.mod, *.go}]
[{*.yml,*.yaml}]
ij_any_spaces_within_braces = false
ij_any_spaces_within_brackets = false
[{Makefile,go.mod,*.go}]
indent_style = tab

9
.gitattributes vendored
View File

@ -1,9 +0,0 @@
# Text files have auto line endings
* text=auto
# Go source files always have LF line endings
*.go text eol=lf
# Disable next extensions in project "used languages" list
*.lua linguist-detectable=false
*.html linguist-detectable=false

View File

@ -1,4 +1,5 @@
# Docs: <https://git.io/JR5E4>
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json
# docs: https://git.io/JR5E4
name: 🐞 Bug report
description: File a bug/issue
@ -35,7 +36,9 @@ body:
id: configs
attributes:
label: Configuration files
description: Please copy and paste any relevant configuration files. This will be automatically formatted into code (yaml), so no need for backticks.
description: |
Please copy and paste any relevant configuration files. This will be automatically formatted
into code (yaml), so no need for backticks.
render: yaml
placeholder: Traefik, docker-compose, helm, etc.
@ -43,11 +46,12 @@ body:
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code (shell), so no need for backticks.
description: |
Please copy and paste any relevant log output. This will be automatically formatted into code
(shell), so no need for backticks.
render: shell
- type: textarea
attributes:
label: Anything else?
description: Links? References? Anything that will give us more context about the issue you are encountering!
placeholder: You can attach images or log files by clicking this area to highlight it and then dragging files in

View File

@ -1,4 +1,5 @@
# Docs: <https://git.io/JP3tm>
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
# docs: https://git.io/JP3tm
blank_issues_enabled: false

View File

@ -1,4 +1,5 @@
# Docs: <https://git.io/JR5E4>
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json
# docs: https://git.io/JR5E4
name: 💡 Feature request
description: Suggest an idea for this project

View File

@ -1,4 +1,5 @@
# Docs: <https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/customizing-dependency-updates>
# yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json
# docs: https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/customizing-dependency-updates
version: 2

13
.github/release.yml vendored Normal file
View File

@ -0,0 +1,13 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-release-config.json
# docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
changelog:
categories:
- title: 🛠 Fixes
labels: [type:fix, type:bug]
- title: 🚀 Features
labels: [type:feature, type:feature_request]
- title: 📦 Dependency updates
labels: [dependencies]
- title: Other Changes
labels: ['*']

View File

@ -1,4 +1,7 @@
name: dependabot
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
name: 🤖 Dependabot
on:
pull_request: {}
@ -9,6 +12,7 @@ permissions:
jobs:
dependabot: # https://tinyurl.com/e69djmen
name: Enable auto-merge for Dependabot PRs
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
@ -16,10 +20,8 @@ jobs:
id: metadata
with: {github-token: "${{ secrets.GITHUB_TOKEN }}"}
- name: Enable auto-merge for Dependabot PRs
if: ${{ contains(fromJSON('["version-update:semver-minor", "version-update:semver-patch"]'), steps.metadata.outputs.update-type) }}
- if: ${{ contains(fromJSON('["version-update:semver-minor", "version-update:semver-patch"]'), steps.metadata.outputs.update-type) }}
run: gh pr merge --auto --merge "$PR_URL"
continue-on-error: true
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,4 +1,7 @@
name: documentation
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
name: 📚 Documentation
on:
push:
@ -12,7 +15,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: peter-evans/dockerhub-description@v4 # Action page: <https://github.com/peter-evans/dockerhub-description>
- uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_USER_PASSWORD }}

View File

@ -1,113 +1,105 @@
name: release
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
name: 🚀 Release
on:
release: # Docs: <https://git.io/JeBz1#release-event-release>
types: [published]
jobs:
purge-cdn-cache:
name: Purge jsDelivr CDN cache
runs-on: ubuntu-latest
steps:
- uses: gacts/purge-jsdelivr-cache@v1 # Action page: <https://github.com/gacts/purge-jsdelivr-cache>
with:
url: |
https://cdn.jsdelivr.net/gh/tarampampam/error-pages@2/l10n/l10n.js
https://cdn.jsdelivr.net/gh/tarampampam/error-pages@2/l10n/l10n.min.js
https://cdn.jsdelivr.net/gh/tarampampam/error-pages@latest/l10n/l10n.js
https://cdn.jsdelivr.net/gh/tarampampam/error-pages@latest/l10n/l10n.min.js
build:
name: Build for ${{ matrix.os }} (${{ matrix.arch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [linux, darwin] # linux, freebsd, darwin, windows
arch: [amd64] # amd64, 386
os: [linux, darwin, windows] # freebsd
arch: [amd64, arm64] # 386
steps:
- uses: actions/checkout@v4
- uses: gacts/setup-go-with-cache@v1
with: {go-version-file: go.mod}
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- {uses: gacts/github-slug@v1, id: slug}
- name: Generate builder values
id: values
run: echo "binary-name=error-pages-${{ matrix.os }}-${{ matrix.arch }}" >> $GITHUB_OUTPUT
- name: Build application
env:
- id: values
run: echo "binary-name=error-pages-${{ matrix.os }}-${{ matrix.arch }}`[ ${{ matrix.os }} = 'windows' ] && echo '.exe'`" >> $GITHUB_OUTPUT
- env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: 0
LDFLAGS: -s -w -X gh.tarampamp.am/error-pages/internal/version.version=${{ steps.slug.outputs.version }}
run: go build -trimpath -ldflags "$LDFLAGS" -o "./${{ steps.values.outputs.binary-name }}" ./cmd/error-pages/
- name: Upload binary file to release
uses: svenstaro/upload-release-action@v2
- uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.values.outputs.binary-name }}
asset_name: ${{ steps.values.outputs.binary-name }}
tag: ${{ github.ref }}
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
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:
name: error-pages-static
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)
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/download-artifact@v4
with:
name: error-pages-static
path: .artifact
- uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./.artifact
docker-image:
name: Build docker image
name: Build the docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- {uses: gacts/github-slug@v1, id: slug}
- uses: docker/setup-qemu-action@v3 # Action page: <https://github.com/docker/setup-qemu-action>
- uses: docker/setup-buildx-action@v3 # Action page: <https://github.com/docker/setup-buildx-action>
- uses: docker/login-action@v3 # Action page: <https://github.com/docker/login-action>
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: docker/login-action@v3 # Action page: <https://github.com/docker/login-action>
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5 # Action page: <https://github.com/docker/build-push-action>
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm64/v8
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
build-args: "APP_VERSION=${{ steps.slug.outputs.version }}"
tags: |
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 }}
tarampampam/error-pages: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 }}
ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest
demo:
name: Update the demonstration
runs-on: ubuntu-latest
needs: [docker-image]
steps:
- {uses: gacts/github-slug@v1, id: slug}
- name: Take rendered templates from the built docker image
run: |
docker create --name img ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
docker cp img:/opt/html ./out
docker rm -f img
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./out
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 }}

View File

@ -1,4 +1,7 @@
name: tests
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
name: 🧪 Tests
on:
push:
@ -12,76 +15,29 @@ concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs: # Docs: <https://git.io/JvxXE>
jobs:
gitleaks:
name: Gitleaks
name: Check for GitLeaks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- name: Check for GitLeaks
uses: gacts/gitleaks@v1 # Action page: <https://github.com/gacts/gitleaks>
- {uses: actions/checkout@v4, with: {fetch-depth: 0}}
- uses: gacts/gitleaks@v1
golangci-lint:
name: Golang-CI (lint)
name: Run golangci-lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: gacts/setup-go-with-cache@v1
with: {go-version-file: go.mod}
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- uses: golangci/golangci-lint-action@v6
with: {skip-pkg-cache: true, skip-build-cache: true}
validate-config-file:
name: Validate config file
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- {uses: actions/setup-node@v4, with: {node-version: 16}}
- name: Install linter
run: npm install -g ajv-cli # Package page: <https://www.npmjs.com/package/ajv-cli>
- name: Run linter
run: ajv validate --all-errors --verbose -s ./schemas/config/1.0.schema.json -d ./error-pages.y*ml
lint-l10n:
name: Lint l10n file(s)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- {uses: actions/setup-node@v4, with: {node-version: 16}}
- name: Install eslint
run: npm install -g eslint@v8 # Package page: <https://www.npmjs.com/package/eslint>
- name: Run linter
working-directory: l10n
run: eslint ./*.js
go-test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 2} # Fixes codecov error 'Issue detecting commit SHA'
- uses: gacts/setup-go-with-cache@v1
with: {go-version-file: go.mod}
- name: Run Unit tests
run: go test -race -covermode=atomic -coverprofile /tmp/coverage.txt ./...
- uses: codecov/codecov-action@v4 # https://github.com/codecov/codecov-action
continue-on-error: true
with:
file: /tmp/coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- run: go test -race ./...
build:
name: Build for ${{ matrix.os }} (${{ matrix.arch }})
@ -89,61 +45,28 @@ jobs: # Docs: <https://git.io/JvxXE>
strategy:
fail-fast: false
matrix:
os: [linux, darwin] # linux, freebsd, darwin, windows
arch: [amd64] # amd64, 386
needs: [golangci-lint, go-test, validate-config-file]
os: [linux, darwin, windows] # freebsd
arch: [amd64, arm64] # 386
needs: [golangci-lint, go-test]
steps:
- uses: actions/checkout@v4
- uses: gacts/setup-go-with-cache@v1
with: {go-version-file: go.mod}
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- {uses: gacts/github-slug@v1, id: slug}
- name: Build application
env:
- env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: 0
LDFLAGS: -s -w -X gh.tarampamp.am/error-pages/internal/version.version=${{ steps.slug.outputs.branch-name-slug }}@${{ steps.slug.outputs.commit-hash-short }}
LDFLAGS: -s -w -X gh.tarampamp.am/error-pages/internal/appmeta.version=${{ steps.slug.outputs.commit-hash-short }}
run: go build -trimpath -ldflags "$LDFLAGS" -o ./error-pages ./cmd/error-pages/
- name: Try to execute
if: matrix.os == 'linux'
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
run: ./error-pages --version && ./error-pages -h
- uses: actions/upload-artifact@v4
with:
name: error-pages-${{ matrix.os }}-${{ matrix.arch }}
path: error-pages
if-no-files-found: error
retention-days: 1
generate:
name: Run templates generator
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: error-pages-linux-amd64
path: .artifact
- name: Prepare binary file to run
working-directory: .artifact
run: mv ./error-pages ./../error-pages && chmod +x ./../error-pages
- name: Run generator
run: ./error-pages --verbose build --index ./out
- name: Test files creation
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
run: mkdir ./out && ./error-pages --log-level=debug build --index --target-dir ./out
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
run: |
test -f ./out/index.html
test -f ./out/ghost/404.html
test -f ./out/l7-dark/404.html
test -f ./out/l7-light/404.html
test -f ./out/l7/404.html
test -f ./out/shuffle/404.html
test -f ./out/noise/404.html
test -f ./out/hacker-terminal/404.html
@ -151,85 +74,19 @@ jobs: # Docs: <https://git.io/JvxXE>
test -f ./out/lost-in-space/404.html
test -f ./out/app-down/404.html
test -f ./out/connection/404.html
test -f ./out/matrix/404.html
test -f ./out/orient/404.html
docker-image:
name: Build docker image
name: Build the docker image
runs-on: ubuntu-latest
needs: [golangci-lint, go-test, validate-config-file]
needs: [golangci-lint, go-test]
steps:
- uses: actions/checkout@v4
- {uses: gacts/github-slug@v1, id: slug}
- uses: docker/build-push-action@v5 # Action page: <https://github.com/docker/build-push-action>
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: "APP_VERSION=${{ steps.slug.outputs.branch-name-slug }}@${{ steps.slug.outputs.commit-hash-short }}"
build-args: "APP_VERSION=${{ steps.slug.outputs.commit-hash-short }}"
tags: app:ci
- run: docker save app:ci > ./docker-image.tar
- uses: actions/upload-artifact@v4
with:
name: docker-image
path: ./docker-image.tar
retention-days: 1
scan-docker-image:
name: Scan the docker image
runs-on: ubuntu-latest
needs: [docker-image]
steps:
- uses: actions/checkout@v4 # is needed for `upload-sarif` action
- uses: actions/download-artifact@v4
with:
name: docker-image
path: .artifact
- uses: aquasecurity/trivy-action@0.21.0 # action page: <https://github.com/aquasecurity/trivy-action>
with:
input: .artifact/docker-image.tar
format: sarif
severity: MEDIUM,HIGH,CRITICAL
exit-code: 1
output: trivy-results.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
continue-on-error: true
with: {sarif_file: trivy-results.sarif}
poke-docker-image:
name: Run the docker image
runs-on: ubuntu-latest
needs: [docker-image]
timeout-minutes: 2
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: docker-image
path: .artifact
- working-directory: .artifact
run: docker load < docker-image.tar
- uses: gacts/install-hurl@v1
- name: Run container with the app
run: docker run --rm -d -p "8080:8080/tcp" -e "SHOW_DETAILS=true" -e "PROXY_HTTP_HEADERS=X-Foo,Bar,Baz_blah" --name app app:ci
- name: Wait for container "healthy" state
run: until [[ "`docker inspect -f {{.State.Health.Status}} app`" == "healthy" ]]; do echo "wait 1 sec.."; sleep 1; done
- run: hurl --color --test --fail-at-end --variable host=127.0.0.1 --variable port=8080 ./test/hurl/*.hurl
- name: Stop the container
if: always()
run: docker kill app

7
.gitignore vendored
View File

@ -8,9 +8,14 @@
## Temp dirs & trash
/temp
/tmp
*.env
/*-old
/cmd/test*
.DS_Store
/go.work*
*.cache
*.out
*.env
/out
/gen
/cover*.*
/report.xml

View File

@ -1,26 +1,32 @@
# Documentation: <https://github.com/golangci/golangci-lint#config-file>
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
# docs: https://github.com/golangci/golangci-lint#config-file
run:
timeout: 1m
skip-dirs:
- .github
- .git
- tmp
- temp
timeout: 2m
modules-download-mode: readonly
allow-parallel-runners: true
output:
format: colored-line-number # colored-line-number|line-number|json|tab|checkstyle|code-climate
formats: [{format: colored-line-number}] # colored-line-number|line-number|json|tab|checkstyle|code-climate
linters-settings:
gci:
sections:
- standard
- default
- prefix(gh.tarampamp.am/error-pages)
gofmt:
simplify: false
rewrite-rules:
- { pattern: 'interface{}', replacement: 'any' }
govet:
check-shadowing: true
enable:
- shadow
gocyclo:
min-complexity: 15
godot:
scope: declarations
capital: true
capital: false
dupl:
threshold: 100
goconst:
@ -28,15 +34,22 @@ linters-settings:
min-occurrences: 3
misspell:
locale: US
ignore-words: [cancelled]
lll:
line-length: 120
forbidigo:
forbid:
- '^(fmt\.Print(|f|ln)|print(|ln))(# it looks like a forgotten debugging printing call)?$'
prealloc:
simple: true
range-loops: true
for-loops: true
nolintlint:
allow-leading-space: false
require-specific: true
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.
# Default: 30
max-func-lines: 100
linters: # All available linters list: <https://golangci-lint.run/usage/linters/>
disable-all: true
@ -50,40 +63,65 @@ linters: # All available linters list: <https://golangci-lint.run/usage/linters/
- exhaustive # check exhaustiveness of enum switch statements
- 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
- gochecknoglobals # Checks that no globals are present in Go code
- gochecknoinits # Checks that no init functions are present in Go code
- gocognit # Computes and checks the cognitive complexity of functions
- goconst # Finds repeated strings that could be replaced by a constant
- gocritic # The most opinionated Go source code linter
- gocyclo # Computes and checks the cyclomatic complexity of functions
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports
- gomnd # An analyzer to detect magic numbers
- gofmt # Gofmt checks whether code was gofmt-ed. By default, this tool runs with -s option to check for code simplification
- goimports # Goimports does everything that gofmt does. Additionally, it checks unused imports
- mnd # An analyzer to detect magic numbers
- goprintffuncname # Checks that printf-like functions are named with `f` at the end
- gosec # Inspects source code for security problems
- gosimple # Linter for Go source code that specializes in simplifying a code
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
- ineffassign # Detects when assignments to existing variables are not used
- lll # Reports long lines
- forbidigo # Forbids identifiers
- misspell # Finds commonly misspelled English words in comments
- nakedret # Finds naked returns in functions greater than a specified function length
- nestif # Reports deeply nested if statements
- nlreturn # checks for a new line before return and branch statements to increase code clarity
- nolintlint # Reports ill-formed or insufficient nolint directives
- prealloc # Finds slice declarations that could potentially be preallocated
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks
- stylecheck # Stylecheck is a replacement for golint
- promlinter # Check Prometheus metrics naming via promlint.
- typecheck # Like the front-end of a Go compiler, parses and type-checks Go code
- unconvert # Remove unnecessary type conversions
- unused # Checks Go code for unused constants, variables, functions and types
- whitespace # Tool for detection of leading and trailing whitespace
- wsl # Whitespace Linter - Forces you to use empty lines!
- unused # Checks Go code for unused constants, variables, functions and types
- gosimple # Linter for Go source code that specializes in simplifying code
- staticcheck # It's a set of rules from staticcheck
- asasalint # Check for pass []any as any in variadic func(...any)
- bodyclose # Checks whether HTTP response body is closed successfully
- contextcheck # Check whether the function uses a non-inherited context
- decorder # Check declaration order and count of types, constants, variables and functions
- dupword # Checks for duplicate words in the source code
- durationcheck # Check for two durations multiplied together
- errchkjson # Checks types passed to the json encoding functions
- errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
issues:
exclude-dirs:
- .github
- .git
- tmp
- temp
- testdata
exclude-rules:
- {path: flags\.go, linters: [gochecknoglobals, lll, mnd, dupl]}
- {path: env\.go, linters: [lll, gosec]}
- path: _test\.go
linters:
- dupl
- dupword
- lll
- nolintlint
- funlen
- scopelint
- gocognit
- noctx
- goconst
- nlreturn
- gochecknoglobals

View File

@ -1,460 +0,0 @@
# Changelog
All notable changes to this package will be documented in this file.
The format is based on [Keep a Changelog][keepachangelog] and this project adheres to [Semantic Versioning][semver].
## v2.27.0
### Changed
- Go updated from `1.21` up to `1.22`
- Go dependencies updated
## v2.26.0
### Added
- Error pages now translated into 🇵🇱 [#226]
- Possibility to set custom read buffer size (using `--read-buffer-size` flag or environment variable `READ_BUFFER_SIZE`) [#238], [#244]
[#226]:https://github.com/tarampampam/error-pages/pull/226
[#238]:https://github.com/tarampampam/error-pages/issues/238
[#244]:https://github.com/tarampampam/error-pages/pull/244
## v2.25.0
### Added
- Go updated from `1.20` up to `1.21`
- Error pages now translated into 🇮🇩 [#218]
- Possibility catch all paths with error page 404 (using `--catch-all` flag for the `serve` or environment variable `CATCH_ALL=true`) [#217]
[#218]:https://github.com/tarampampam/error-pages/pull/218
[#217]:https://github.com/tarampampam/error-pages/issues/217
## v2.24.0
### Added
- Support for IPv6 addresses in the `--listen` flag [#191]
[#191]:https://github.com/tarampampam/error-pages/issues/191
## v2.23.0
### Added
- Template `orient` [#190]
[#190]:https://github.com/tarampampam/error-pages/pull/190
## v2.22.0
### Changed
- Non-existing pages now return styled `404` status page (with `404` status code) [#188]
[#188]:https://github.com/tarampampam/error-pages/issues/188
## v2.21.0
### Changed
- Go updated from `1.19` up to `1.20`
- Go dependencies updated
- Module name changed from `github.com/tarampampam/error-pages` to `gh.tarampamp.am/error-pages`
## v2.20.0
### Changed
- `version` subcommand replaced by `--version` flag [#163]
- `--config-file` flag is not global anymore (use `error-pages (serve|build) --config-file ...` instead of `error-pages --config-file ... (serve|build) ...`) [#163]
- Flags `--verbose`, `--debug` and `--log-json` are deprecated, use `--log-level` and `--log-format` instead [#163]
### Added
- Possibility to use custom env variables in templates [#164], [#165]
[#164]:https://github.com/tarampampam/error-pages/issues/164
[#165]:https://github.com/tarampampam/error-pages/pull/165
[#163]:https://github.com/tarampampam/error-pages/pull/163
## v2.19.0
### Changed
- Go updated from `1.18` up to `1.19`
### Added
- Error pages now translated into Chinese 🇨🇳 [#147]
[#147]:https://github.com/tarampampam/error-pages/pull/147
## v2.18.0
### Changed
- Replaced `fonts.googleapis.com` by `fonts.bunny.net` regarding GDPR compliance [#131]
[#131]:https://github.com/tarampampam/error-pages/pull/131
## v2.17.0
### Added
- Error pages now translated into Spanish 🇪🇸 [#124]
[#124]:https://github.com/tarampampam/error-pages/pull/124
## v2.16.0
### Added
- Error pages are now translated into German 🇩🇪 [#115]
[#115]:https://github.com/tarampampam/error-pages/pull/115
## v2.15.0
### Added
- Error pages now translated into Dutch 🇳🇱 [#104]
[#104]:https://github.com/tarampampam/error-pages/pull/104
## v2.14.0
### Added
- Error pages now translated into Portuguese 🇵🇹 [#103]
### Changed
- Go updated from `1.18.0` up to `1.18.1`
[#103]:https://github.com/tarampampam/error-pages/pull/103
## v2.13.0
### Added
- Possibility to disable error pages auto-localization (using `--disable-l10n` flag for the `serve` & `build` commands or environment variable `DISABLE_L10N`) [#91]
### Fixed
- User UID/GID changed to the numeric values in the dockerfile [#92]
[#92]:https://github.com/tarampampam/error-pages/issues/92
[#91]:https://github.com/tarampampam/error-pages/issues/91
## v2.12.1
### Fixed
- Fix translation 🇫🇷 [#86]
[#85]:https://github.com/tarampampam/error-pages/pull/86
## v2.12.0
### Changed
- Error pages now translated into 🇫🇷 [#82]
[#82]:https://github.com/tarampampam/error-pages/pull/82
## v2.11.0
### Added
- Template `matrix` [#81]
### Fixed
- Localization mistakes [#81]
[#81]:https://github.com/tarampampam/error-pages/pull/81
## v2.10.1
### Fixed
- Template `shuffle`
- Localization mistakes
## v2.10.0
### Changed
- Error pages now translated into 🇺🇦 and 🇷🇺 languages [#80]
[#80]:https://github.com/tarampampam/error-pages/pull/80
## v2.9.0
### Added
- Template `connection` [#79]
[#79]:https://github.com/tarampampam/error-pages/pull/79
## v2.8.1
### Fixed
- Dark mode for `app-down` template
### Changed
- The index page for built error pages now supports a dark theme
## v2.8.0
### Added
- Template `app-down` [#74]
### Changed
- Go updated from `1.17.6` up to `1.18.0`
[#74]:https://github.com/tarampampam/error-pages/pull/74
## v2.7.0
### Changed
- Logs includes request/response headers now [#67]
### Added
- Possibility to proxy HTTP headers from the requests to the responses (can be enabled using `--proxy-headers` flag for the `serve` command or environment variable `PROXY_HTTP_HEADERS`, headers list should be comma-separated) [#67]
- Template `lost-in-space` [#68]
### Fixed
- Template `l7-light` uses the dark colors in the browsers with the preferred dark theme
[#67]:https://github.com/tarampampam/error-pages/pull/67
[#68]:https://github.com/tarampampam/error-pages/pull/68
## v2.6.0
### Added
- Possibility to change the template to the random once a day using "special" template name `random-daily` (or hourly, using `random-hourly`) [#48]
[#48]:https://github.com/tarampampam/error-pages/issues/48
## v2.5.0
### Changed
- Go updated from `1.17.5` up to `1.17.6`
### Added
- `Host` and `X-Forwarded-For` Header to error pages [#61]
### Fixed
- Performance issue, that affects template rendering. Now templates are cached in memory (for 2 seconds), and it has improved performance by more than 200% [#60]
[#60]:https://github.com/tarampampam/error-pages/pull/60
[#61]:https://github.com/tarampampam/error-pages/pull/61
## v2.4.0
### Changed
- It is now possible to use [golang-tags of templates](https://pkg.go.dev/text/template) in error page templates and formatted (`json`, `xml`) responses [#49]
- Health-check route become `/healthz` (instead `/health/live`, previous route marked as deprecated) [#49]
### Added
- The templates contain details block now (can be enabled using `--show-details` flag for the `serve` command or environment variable `SHOW_DETAILS=true`) [#49]
- Formatted response templates (`json`, `xml`) - the server responds with a formatted response depending on the `Content-Type` (and `X-Format`) request header value [#49]
- HTTP header `X-Robots-Tag: noindex` for the error pages [#49]
- Possibility to pass the needed error page code using `X-Code` HTTP header [#49]
- Possibility to integrate with [ingress-nginx](https://kubernetes.github.io/ingress-nginx/) [#49]
- Metrics HTTP endpoint `/metrics` in prometheus format [#54]
### Fixed
- Potential race condition (in the `pick.StringsSlice` struct) [#49]
[#54]:https://github.com/tarampampam/error-pages/pull/54
[#49]:https://github.com/tarampampam/error-pages/pull/49
## v2.3.0
### Added
- Flag `--default-http-code` for the `serve` subcommand (`404` is used by default instead of `200`, environment name `DEFAULT_HTTP_CODE`) [#41]
### Changed
- Go updated from `1.17.1` up to `1.17.5`
[#41]:https://github.com/tarampampam/error-pages/issues/41
## v2.2.0
### Added
- Template `cats` [#31]
[#31]:https://github.com/tarampampam/error-pages/pull/31
## v2.1.0
### Added
- `referer` field in access log records
- Flag `--default-error-page` for the `serve` subcommand (`404` is used by default, environment name `DEFAULT_ERROR_PAGE`)
### Changed
- The source code has been refactored
- The index page (`/`) now returns the error page with a code, declared using `--default-error-page` flag (HTTP code 200, when a page code exists)
## v2.0.0
### Changed
- Application rewritten in Go
## v1.8.0
### Added
- Nginx health-check endpoint (`/health/live`) and dockerfile `HEALTHCHECK` to utilise (thx [@modem7](https://github.com/modem7)) [#22], [#23]
[#22]:https://github.com/tarampampam/error-pages/pull/22
[#23]:https://github.com/tarampampam/error-pages/pull/23
## v1.7.2
### Changed
- Nginx updated up to `1.21` (from `1.19`)
## v1.7.1
### Fixed
- Random template selecting (thx [@xpliz](https://github.com/xpliz)) [#12]
[#12]:https://github.com/tarampampam/error-pages/pull/12
## v1.7.0
### Added
- Template `hacker-terminal` [#13]
- HTML comments with error code and description into each template (header and footer, it seems more readable for curl usage)
[#10]:https://github.com/tarampampam/error-pages/pull/13
## v1.6.0
### Added
- Template `noise` [#10]
### Fixed
- File permissions in docker image
[#10]:https://github.com/tarampampam/error-pages/issues/10
## v1.5.0
### Changed
- Repository files structure
- Nginx updated from `1.18` up to `1.19` in docker image
- Docker image now uses default `nginx` entrypoint scripts and command
### Added
- Support for `linux/arm64/v8`, `linux/arm/v6` and `linux/arm/v7` platforms for docker image
- Random template selecting (use `random` as a template name) for docker image
## v1.4.0
### Added
- Template `shuffle` [#4]
[#4]:https://github.com/tarampampam/error-pages/issues/4
## v1.3.1
### Fixed
- `can't create directory '/opt/html/nginx-error-pages'` error [#3]
[#3]:https://github.com/tarampampam/error-pages/issues/3
## v1.3.0
### Added
- `418` status code error page
- Set `server_tokens off;` in `nginx` server configuration
## v1.2.0
### Fixed
- By default `nginx` in docker container returns 404 http code instead 200 when `/` requested
### Changed
- Default value for `TEMPLATE_NAME` is `ghost` now
### Removed
- Environment variable `DEFAULT_ERROR_CODE` support in docker image
### Added
- Templates `l7-light` and `l7-dark`
## v1.1.0
### Added
- Environment variable `DEFAULT_ERROR_CODE` support in docker image
## v1.0.1
### Changed
- Repository (not docker image) renamed from `error-pages-docker` to `error-pages`
- `configuration.json` renamed to `config.json`
- Makefile contains new targets (`install`, `gen`, `preview`)
- Generator logging messages
### Added
- `docker-compose` for development
### Fixed
- Readme file content [#1]
[#1]:https://github.com/tarampampam/error-pages/issues/1
## v1.0.0
### Changed
- First project release
[keepachangelog]:https://keepachangelog.com/en/1.0.0/
[semver]:https://semver.org/spec/v2.0.0.html

View File

@ -1,63 +1,73 @@
# syntax=docker/dockerfile:1
# this stage is used to build the application
FROM docker.io/library/golang:1.22-bookworm AS builder
# -✂- this stage is used to develop and build the application locally -------------------------------------------------
FROM docker.io/library/golang:1.22-bookworm AS develop
COPY ./go.* /src/
# 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.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}"
RUN set -x \
# customize the shell prompt (for the bash)
&& echo "PS1='\[\033[1;36m\][go] \[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'" >> /etc/bash.bashrc
WORKDIR /src
# burn the modules cache
RUN go mod download
RUN \
--mount=type=bind,source=go.mod,target=/src/go.mod \
--mount=type=bind,source=go.sum,target=/src/go.sum \
go mod download -x \
&& find "${GOPATH}" -type d -exec chmod 0777 {} \; \
&& find "${GOPATH}" -type f -exec chmod 0666 {} \;
# this stage is used to compile the application
FROM builder AS compiler
# -✂- this stage is used to compile the application -------------------------------------------------------------------
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@GITHASH" .`
# 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"
WORKDIR /src
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/ \
&& /tmp/error-pages --version \
&& /tmp/error-pages -h
COPY . .
# arguments to pass on each go tool link invocation
ENV LDFLAGS="-s -w -X gh.tarampamp.am/error-pages/internal/version.version=$APP_VERSION"
# build the application
RUN set -x \
&& CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o ./error-pages ./cmd/error-pages/ \
&& ./error-pages --version \
&& ./error-pages -h
# -✂- this stage is used to prepare the runtime fs --------------------------------------------------------------------
FROM docker.io/library/alpine:3.20 AS rootfs
WORKDIR /tmp/rootfs
# prepare rootfs for runtime
RUN set -x \
&& mkdir -p \
./etc \
./bin \
./opt/html \
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 \
&& mv /src/error-pages ./bin/error-pages \
&& mv /src/templates ./opt/templates \
&& rm ./opt/templates/*.md \
&& mv /src/error-pages.yml ./opt/error-pages.yml
&& echo 'appuser:x:10001:' > ./etc/group
# take the binary from the compile stage
COPY --from=compile /tmp/error-pages ./bin/error-pages
WORKDIR /tmp/rootfs/opt
# generate static error pages (for usage inside another docker images, for example)
# generate static error pages (for use inside other Docker images, for example)
RUN set -x \
&& ./../bin/error-pages --verbose build --config-file ./error-pages.yml --index ./html \
&& mkdir ./html \
&& ./../bin/error-pages build --index --target-dir ./html \
&& ls -l ./html
# use empty filesystem
# -✂- and this is the final stage (an empty filesystem is used) -------------------------------------------------------
FROM scratch AS runtime
ARG APP_VERSION="undefined@docker"
LABEL \
# Docs: <https://github.com/opencontainers/image-spec/blob/master/annotations.md>
# docs: https://github.com/opencontainers/image-spec/blob/master/annotations.md
org.opencontainers.image.title="error-pages" \
org.opencontainers.image.description="Static server error pages in the docker image" \
org.opencontainers.image.url="https://github.com/tarampampam/error-pages" \
@ -66,25 +76,24 @@ LABEL \
org.opencontainers.version="$APP_VERSION" \
org.opencontainers.image.licenses="MIT"
# Import from builder
COPY --from=compiler /tmp/rootfs /
# import from builder
COPY --from=rootfs /tmp/rootfs /
# Use an unprivileged user
# use an unprivileged user
USER 10001:10001
WORKDIR /opt
ENV LISTEN_PORT="8080" \
TEMPLATE_NAME="ghost" \
DEFAULT_ERROR_PAGE="404" \
DEFAULT_HTTP_CODE="404" \
SHOW_DETAILS="false" \
DISABLE_L10N="false" \
READ_BUFFER_SIZE="2048"
# 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
# Docs: <https://docs.docker.com/engine/reference/builder/#healthcheck>
HEALTHCHECK --interval=7s --timeout=2s CMD ["/bin/error-pages", "--log-json", "healthcheck"]
ENV LOG_LEVEL="warn"
# docs: https://docs.docker.com/reference/dockerfile/#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 ["--log-json", "serve"]
CMD ["--log-format", "json", "serve"]

View File

@ -1,64 +1,38 @@
#!/usr/bin/make
# Makefile readme (ru): <http://linux.yaroslavl.ru/docs/prog/gnu_make_3-79_russian_manual.html>
# Makefile readme (en): <https://www.gnu.org/software/make/manual/html_node/index.html#SEC_Contents>
SHELL = /bin/sh
LDFLAGS = "-s -w -X gh.tarampamp.am/error-pages/internal/version.version=$(shell git rev-parse HEAD)"
DC_RUN_ARGS = --rm --user "$(shell id -u):$(shell id -g)"
APP_NAME = $(notdir $(CURDIR))
.PHONY : help \
image dive build fmt lint gotest int-test test shell \
up down restart \
clean
.DEFAULT_GOAL : help
.SILENT : lint gotest
# This will output the help for each task. thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
help: ## Show this help
@printf "\033[33m%s:\033[0m\n" 'Available commands'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[32m%-11s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
image: ## Build docker image with app
docker build -f ./Dockerfile -t $(APP_NAME):local .
docker run --rm $(APP_NAME):local version
@printf "\n \e[30;42m %s \033[0m\n\n" 'Now you can use image like `docker run --rm -p "8080:8080/tcp" $(APP_NAME):local ...`';
.PHONY: up
up: ## Start the application in watch mode
docker compose kill web --remove-orphans 2>/dev/null || true
docker compose up --detach --wait web
$$SHELL -c "\
trap 'docker compose down --remove-orphans --timeout 30' EXIT; \
docker compose watch --no-up web \
"
dive: image ## Explore the docker image
docker run --rm -it -v "/var/run/docker.sock:/var/run/docker.sock:ro" wagoodman/dive:latest $(APP_NAME):local
.PHONY: down
down: ## Stop the application
docker compose down --remove-orphans
build: ## Build app binary file
docker-compose run $(DC_RUN_ARGS) -e "CGO_ENABLED=0" --no-deps app go build -trimpath -ldflags $(LDFLAGS) -o ./error-pages ./cmd/error-pages/
.PHONY: shell
shell: ## Start shell into development environment
docker compose run -ti $(DC_RUN_ARGS) develop bash
fmt: ## Run source code formatter tools
docker-compose run $(DC_RUN_ARGS) -e "GO111MODULE=off" --no-deps app sh -c 'go get golang.org/x/tools/cmd/goimports && $$GOPATH/bin/goimports -d -w .'
docker-compose run $(DC_RUN_ARGS) --no-deps app gofmt -s -w -d .
docker-compose run $(DC_RUN_ARGS) --no-deps app go mod tidy
.PHONY: test
test: ## Run tests
docker compose run $(DC_RUN_ARGS) develop gotestsum --format pkgname -- -race -timeout 2m ./...
lint: ## Run app linters
docker-compose run --rm --no-deps golint golangci-lint run
.PHONY: lint
lint: ## Run linters
docker compose run $(DC_RUN_ARGS) develop golangci-lint run
gotest: ## Run app tests
docker-compose run $(DC_RUN_ARGS) --no-deps app go test -v -race -timeout 10s ./...
int-test: ## Run integration tests (docs: https://hurl.dev/docs/man-page.html#options)
docker-compose run --rm hurl --color --test --fail-at-end --variable host=web --variable port=8080 ./test/hurl/*.hurl
test: lint gotest int-test ## Run app tests and linters
shell: ## Start shell into container with golang
docker-compose run $(DC_RUN_ARGS) app bash
up: ## Create and start containers
docker-compose up --detach web
@printf "\n \e[30;42m %s \033[0m\n\n" 'Navigate your browser to ⇒ http://127.0.0.1:8080';
down: ## Stop all services
docker-compose down -t 5
restart: down up ## Restart all containers
clean: ## Make clean
docker-compose down -v -t 1
-docker rmi $(APP_NAME):local -f
.PHONY: gen
gen: ## Generate code
docker compose run $(DC_RUN_ARGS) develop go generate ./...

705
README.md
View File

@ -4,7 +4,6 @@
<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>
@ -12,148 +11,612 @@
<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 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:
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:
- A simple error page generator written in Go
- Single-page error templates (themes) with various designs (located in the [templates](templates) 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])
- 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])
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
[preview-demo]:https://tarampampam.github.io/error-pages/
## 🔥 Features list
## 🔥 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
- 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`)
- 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] ([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!)
- 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) - other translations are welcome!
[fasthttp]:https://github.com/valyala/fasthttp
[traefik]:https://github.com/traefik/traefik
## 🧩 Install
Download the latest binary file for your os/arch from the [releases page][releases] or use our docker image:
Download the latest binary file for your OS/architecture from the [releases page][latest-release] or use our Docker image:
| Registry | Image |
|-----------------------------------|-----------------------------------|
| [Docker Hub][docker-hub] | `tarampampam/error-pages` |
| [GitHub Container Registry][ghcr] | `ghcr.io/tarampampam/error-pages` |
| [Docker Hub][docker-hub] (mirror) | `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
> [!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 download **already rendered** error pages pack as a [zip][pages-pack-zip] or [tar.gz][pages-pack-tar-gz] archive.
💣 **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/
## 🛠 Usage
## 🛠 Usage scenarios
Please, take a look at [our Wiki][wiki] for the common usage stories:
### HTTP server starting, utilizing either a binary file or Docker image
- [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]
First, ensure you have a precompiled binary file on your machine or have Docker/Podman installed. Next, start the
server with the following command:
[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
```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>🚀 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
Used hardware:
Hardware used:
- Intel® Core™ i7-10510U CPU @ 1.80GHz × 8
- 16 GiB RAM
- 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
-f: file size (blocks) unlimited
-c: core file size (blocks) unlimited
-n: file descriptors 1048576
-x: file locks unlimited
core file size (blocks, -c) unlimited
file size (blocks, -f) unlimited
open files (-n) 1048576
file locks (-x) unlimited
$ docker run --rm -p "8080:8080/tcp" -e "SHOW_DETAILS=true" error-pages:local # in separate terminal
$ go build ./cmd/error-pages/ && ./error-pages --log-level warn serve
$ 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/
$ ./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 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
Latency 3.54ms 4.90ms 74.57ms 86.55%
Req/Sec 16.47k 2.89k 38.11k 69.46%
2967567 requests in 15.09s, 44.70GB read
Requests/sec: 196596.49
Transfer/sec: 2.96GB
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 4.25ms 6.03ms 74.23ms 86.97%
Req/Sec 14.29k 2.75k 32.16k 69.63%
2563245 requests in 15.07s, 38.47GB read
Requests/sec: 170062.69
Transfer/sec: 2.55GB
```
<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
<!--GENERATED:CLI_DOCS-->
<!-- Documentation inside this block generated by github.com/urfave/cli; DO NOT EDIT -->
## CLI interface
| 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] |
Usage:
> Note: `noise` template highly uses the CPU, be careful
```bash
$ error-pages [GLOBAL FLAGS] [COMMAND] [COMMAND FLAGS] [ARGUMENTS...]
```
[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
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` |
### `serve` command (aliases: `s`, `server`, `http`)
Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D.
Usage:
```bash
$ 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) | `[]` | *none* |
| `--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` |
### `build` command (aliases: `b`)
Build the static error pages and put them into a specified directory.
Usage:
```bash
$ 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) | `[]` | *none* |
| `--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* |
### `healthcheck` command (aliases: `chk`, `health`, `check`)
Health checker for the HTTP server. The use case - docker health check.
Usage:
```bash
$ error-pages [GLOBAL FLAGS] healthcheck [COMMAND FLAGS] [ARGUMENTS...]
```
The following flags are supported:
| Name | Description | Default value | Environment variables |
|---------------------|-----------------------------------------------|:-------------:|:---------------------:|
| `--port="…"` (`-p`) | TCP port number with the HTTP server to check | `8080` | `LISTEN_PORT` |
<!--/GENERATED:CLI_DOCS-->
## 🪂 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/>
<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">
</td>
<td>
<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>
</td>
</tr>
<tr>
<td align="center">
<code>cats</code><br/><br/>
<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">
</td>
<td>
<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>
</td>
</tr>
<tr>
<td align="center">
<code>connection</code><br/><br/>
<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">
</td>
<td>
<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>
</td>
</tr>
<tr>
<td align="center">
<code>ghost</code><br/><br/>
<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">
</td>
<td>
<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>
</td>
</tr>
<tr>
<td align="center">
<code>hacker-terminal</code><br/><br/>
<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">
</td>
<td>
<picture>
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/c197fc35-0844-43d0-9830-82440cee4559">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>l7</code><br/><br/>
<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">
</td>
<td>
<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>
</td>
</tr>
<tr>
<td align="center">
<code>lost-in-space</code><br/><br/>
<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">
</td>
<td>
<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>
</td>
</tr>
<tr>
<td align="center">
<code>noise</code><br/><br/>
<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">
</td>
<td>
<picture>
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/4cc5c3bd-6ebb-4e96-bee8-02d4ad4e7266">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>orient</code><br/><br/>
<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">
</td>
<td>
<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>
</td>
</tr>
<tr>
<td align="center">
<code>shuffle</code><br/><br/>
<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">
</td>
<td>
<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>
</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.
## 🦾 Contributors
@ -163,43 +626,23 @@ 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 find any bugs in the project, please [create an issue][new-issue] in the current repository.
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
## 📖 License
This is open-sourced software licensed under the [MIT License][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

View File

@ -1,32 +1,35 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/fatih/color"
"go.uber.org/automaxprocs/maxprocs"
"gh.tarampamp.am/error-pages/internal/cli"
)
// set GOMAXPROCS to match Linux container CPU quota.
var _, _ = maxprocs.Set(maxprocs.Min(1), maxprocs.Logger(func(_ string, _ ...any) {}))
// exitFn is a function for application exiting.
var exitFn = os.Exit //nolint:gochecknoglobals
// main CLI application entrypoint.
func main() { exitFn(run()) }
func main() {
// automatically set GOMAXPROCS to match Linux container CPU quota
_, _ = maxprocs.Set(maxprocs.Min(1), maxprocs.Logger(func(_ string, _ ...any) {}))
if err := run(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
// run this CLI application.
// Exit codes documentation: <https://tldp.org/LDP/abs/html/exitcodes.html>
func run() int {
if err := (cli.NewApp(filepath.Base(os.Args[0]))).Run(os.Args); err != nil {
_, _ = color.New(color.FgHiRed, color.Bold).Fprintln(os.Stderr, err.Error())
func run() error {
// create a context that is canceled when the user interrupts the program
var ctx, cancel = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
return 1
}
return 0
return (cli.NewApp(filepath.Base(os.Args[0]))).Run(ctx, os.Args)
}

View File

@ -1,21 +0,0 @@
package main
import (
"os"
"testing"
"github.com/kami-zh/go-capturer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_MainHelp(t *testing.T) {
os.Args = []string{"", "--help"}
exitFn = func(code int) { require.Equal(t, 0, code) }
output := capturer.CaptureStdout(main)
assert.Contains(t, output, "USAGE:")
assert.Contains(t, output, "COMMANDS:")
assert.Contains(t, output, "GLOBAL OPTIONS:")
}

19
compose.yml Normal file
View File

@ -0,0 +1,19 @@
# yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/compose-spec/compose-spec@master/schema/compose-spec.json
services:
develop:
build: {target: develop}
environment: {HOME: /tmp}
volumes: [.:/src:rw, tmp-data:/tmp:rw]
security_opt: [no-new-privileges:true]
web:
build: {target: runtime}
ports: ['8080:8080/tcp'] # open http://127.0.0.1:8080
command: --log-level debug serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah
develop: # available since docker compose v2.22, https://docs.docker.com/compose/file-watch/
watch: [{action: rebuild, path: .}]
security_opt: [no-new-privileges:true]
volumes:
tmp-data: {}

View File

@ -1,52 +0,0 @@
# Docker-compose file is used only for local development. This is not production-ready example.
version: '3.8'
volumes:
tmp-data: {}
golint-go: {}
golint-cache: {}
services:
app: &go
build: {target: builder}
environment:
HOME: /tmp
GOPATH: /tmp
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
- .:/src:rw
- tmp-data:/tmp:rw
security_opt: [no-new-privileges:true]
web:
<<: *go
ports:
- "8080:8080/tcp" # Open <http://127.0.0.1:8080>
command: sh -c "go build -buildvcs=false -o /tmp/app ./cmd/error-pages && /tmp/app serve --show-details --proxy-headers=X-Foo,Bar,Baz_blah --catch-all"
healthcheck:
test: ['CMD', '/tmp/app', '--log-json', 'healthcheck']
interval: 4s
start_period: 5s
retries: 5
golint:
image: golangci/golangci-lint:v1.59-alpine # Image page: <https://hub.docker.com/r/golangci/golangci-lint>
environment:
GOLANGCI_LINT_CACHE: /tmp/golint # <https://github.com/golangci/golangci-lint/blob/v1.42.0/internal/cache/default.go#L68>
volumes:
- golint-go:/go:rw # go dependencies will be downloaded on each run without this
- golint-cache:/tmp/golint:rw
- .:/src:ro
working_dir: /src
security_opt: [no-new-privileges:true]
hurl:
image: ghcr.io/orange-opensource/hurl:4.3.0
volumes:
- .:/src:ro
working_dir: /src
depends_on:
web: {condition: service_healthy}
security_opt: [no-new-privileges:true]

View File

@ -1,140 +0,0 @@
templates:
# - name: {string} Template name (optional, if path is defined)
# path: {string} Path to the template file
# content: {string} Template content, if path is not defined
- path: ./templates/ghost.html
name: ghost # name is optional, if path is defined
content: ${GHOST_TEMPLATE_CONTENT}
- path: ./templates/l7-light.html
- path: ./templates/l7-dark.html
- path: ./templates/shuffle.html
- path: ./templates/noise.html
- path: ./templates/hacker-terminal.html
- path: ./templates/cats.html
- path: ./templates/lost-in-space.html
- path: ./templates/app-down.html
- path: ./templates/connection.html
- path: ./templates/matrix.html
- path: ./templates/orient.html
formats:
json:
content: |
{
"error": true,
"code": {{ code | json }},
"message": {{ message | json }},
"description": {{ description | json }}{{ if show_details }},
"details": {
"host": {{ host | json }},
"original_uri": {{ original_uri | json }},
"forwarded_for": {{ forwarded_for | json }},
"namespace": {{ namespace | json }},
"ingress_name": {{ ingress_name | json }},
"service_name": {{ service_name | json }},
"service_port": {{ service_port | json }},
"request_id": {{ request_id | json }},
"timestamp": {{ now.Unix }}
}{{ end }}
}
xml:
content: |
<?xml version="1.0" encoding="utf-8"?>
<error>
<code>{{ code }}</code>
<message>{{ message }}</message>
<description>{{ description }}</description>{{ if show_details }}
<details>
<host>{{ host }}</host>
<originalURI>{{ original_uri }}</originalURI>
<forwardedFor>{{ forwarded_for }}</forwardedFor>
<namespace>{{ namespace }}</namespace>
<ingressName>{{ ingress_name }}</ingressName>
<serviceName>{{ service_name }}</serviceName>
<servicePort>{{ service_port }}</servicePort>
<requestID>{{ request_id }}</requestID>
<timestamp>{{ now.Unix }}</timestamp>
</details>{{ end }}
</error>
pages:
400:
message: Bad Request
description: The server did not understand the request
401:
message: Unauthorized
description: The requested page needs a username and a password
403:
message: Forbidden
description: Access is forbidden to the requested page
404:
message: Not Found
description: The server can not find the requested page
405:
message: Method Not Allowed
description: The method specified in the request is not allowed
407:
message: Proxy Authentication Required
description: You must authenticate with a proxy server before this request can be served
408:
message: Request Timeout
description: The request took longer than the server was prepared to wait
409:
message: Conflict
description: The request could not be completed because of a conflict
410:
message: Gone
description: The requested page is no longer available
411:
message: Length Required
description: The "Content-Length" is not defined. The server will not accept the request without it
412:
message: Precondition Failed
description: The pre condition given in the request evaluated to false by the server
413:
message: Payload Too Large
description: The server will not accept the request, because the request entity is too large
416:
message: Requested Range Not Satisfiable
description: The requested byte range is not available and is out of bounds
418:
message: I'm a teapot
description: Attempt to brew coffee with a teapot is not supported
429:
message: Too Many Requests
description: Too many requests in a given amount of time
500:
message: Internal Server Error
description: The server met an unexpected condition
502:
message: Bad Gateway
description: The server received an invalid response from the upstream server
503:
message: Service Unavailable
description: The server is temporarily overloading or down
504:
message: Gateway Timeout
description: The gateway has timed out
505:
message: HTTP Version Not Supported
description: The server does not support the "http protocol" version

31
go.mod
View File

@ -1,41 +1,26 @@
module gh.tarampamp.am/error-pages
go 1.21
go 1.22
require (
github.com/a8m/envsubst v1.4.2
github.com/fasthttp/router v1.5.1
github.com/fatih/color v1.17.0
github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.19.1
github.com/prometheus/client_model v0.6.1
github.com/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.2
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
go.uber.org/goleak v1.3.0
go.uber.org/zap v1.27.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.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/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/common v0.50.0 // indirect
github.com/prometheus/procfs v0.13.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.21.0 // indirect
google.golang.org/protobuf v1.33.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
)

63
go.sum
View File

@ -1,78 +1,43 @@
github.com/a8m/envsubst v1.4.2 h1:4yWIHXOLEJHQEFd4UjrWDrYeYlV7ncFWJOCBRLOZHQg=
github.com/a8m/envsubst v1.4.2/go.mod h1:MVUTQNGQ3tsjOOtKCNd+fl8RzhsXcDvvAEzkhGtlsbY=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/fasthttp/router v1.5.1 h1:uViy8UYYhm5npJSKEZ4b/ozM//NGzVCfJbh6VJ0VKr8=
github.com/fasthttp/router v1.5.1/go.mod h1:WrmsLo3mrerZP2VEXRV1E8nL8ymJFYCDTr4HmnB8+Zs=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d h1:cVtBfNW5XTHiKQe7jDaDBSh/EVM4XLPutLAGboIXuM0=
github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d/go.mod h1:P2viExyCEfeWGU259JnaQ34Inuec4R38JCyBx2edgD0=
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=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ=
github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
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/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI=
github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM=
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-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
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=
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

2
internal/appmeta/doc.go Normal file
View File

@ -0,0 +1,2 @@
// Package appmeta provides the application metadata, such as version.
package appmeta

View File

@ -1,5 +1,4 @@
// Package version is used as a place, where application version defined.
package version
package appmeta
import "strings"
@ -8,7 +7,7 @@ var version = "v0.0.0@undefined"
// Version returns version value (without `v` prefix).
func Version() string {
v := strings.TrimSpace(version)
var v = strings.TrimSpace(version)
if len(v) > 1 && ((v[0] == 'v' || v[0] == 'V') && (v[1] >= '0' && v[1] <= '9')) {
return v[1:]

View File

@ -1,10 +1,10 @@
package version
package appmeta
import (
"testing"
)
import "testing"
func TestVersion(t *testing.T) {
t.Parallel()
for give, want := range map[string]string{
// without changes
"vvv": "vvv",

View File

@ -1,54 +0,0 @@
// Package breaker provides OSSignals struct for OS signals handling (with context).
package breaker
import (
"context"
"os"
"os/signal"
"syscall"
)
// OSSignals allows subscribing for system signals.
type OSSignals struct {
ctx context.Context
ch chan os.Signal
}
// NewOSSignals creates new subscriber for system signals.
func NewOSSignals(ctx context.Context) OSSignals {
return OSSignals{
ctx: ctx,
ch: make(chan os.Signal, 1),
}
}
// Subscribe for some system signals (call Stop for stopping).
func (oss *OSSignals) Subscribe(onSignal func(os.Signal), signals ...os.Signal) {
if len(signals) == 0 {
signals = []os.Signal{os.Interrupt, syscall.SIGINT, syscall.SIGTERM} // default signals
}
signal.Notify(oss.ch, signals...)
go func(ch <-chan os.Signal) {
select {
case <-oss.ctx.Done():
break
case sig, opened := <-ch:
if oss.ctx.Err() != nil {
break
}
if opened && sig != nil {
onSignal(sig)
}
}
}(oss.ch)
}
// Stop system signals listening.
func (oss *OSSignals) Stop() {
signal.Stop(oss.ch)
close(oss.ch)
}

View File

@ -1,57 +0,0 @@
package breaker_test
import (
"context"
"os"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/breaker"
)
func TestNewOSSignals(t *testing.T) {
oss := breaker.NewOSSignals(context.Background())
gotSignal := make(chan os.Signal, 1)
oss.Subscribe(func(signal os.Signal) {
gotSignal <- signal
}, syscall.SIGUSR2)
defer oss.Stop()
proc, err := os.FindProcess(os.Getpid())
assert.NoError(t, err)
assert.NoError(t, proc.Signal(syscall.SIGUSR2)) // send the signal
time.Sleep(time.Millisecond * 5)
assert.Equal(t, syscall.SIGUSR2, <-gotSignal)
}
func TestNewOSSignalCtxCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
oss := breaker.NewOSSignals(ctx)
gotSignal := make(chan os.Signal, 1)
oss.Subscribe(func(signal os.Signal) {
gotSignal <- signal
}, syscall.SIGUSR2)
defer oss.Stop()
proc, err := os.FindProcess(os.Getpid())
assert.NoError(t, err)
cancel()
assert.NoError(t, proc.Signal(syscall.SIGUSR2)) // send the signal
assert.Empty(t, gotSignal)
}

View File

@ -1,56 +0,0 @@
package checkers
import (
"context"
"fmt"
"net/http"
"time"
)
type httpClient interface {
Do(*http.Request) (*http.Response, error)
}
// HealthChecker is a heals checker.
type HealthChecker struct {
ctx context.Context
httpClient httpClient
}
const defaultHTTPClientTimeout = time.Second * 3
// NewHealthChecker creates heals checker.
func NewHealthChecker(ctx context.Context, client ...httpClient) *HealthChecker {
var c httpClient
if len(client) == 1 {
c = client[0]
} else {
c = &http.Client{Timeout: defaultHTTPClientTimeout} // default
}
return &HealthChecker{ctx: ctx, httpClient: c}
}
// Check application using liveness probe.
func (c *HealthChecker) Check(port uint16) error {
req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/healthz", port), nil) //nolint:lll
if err != nil {
return err
}
req.Header.Set("User-Agent", "HealthChecker/internal")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
if code := resp.StatusCode; code != http.StatusOK {
return fmt.Errorf("wrong status code [%d] from live endpoint", code)
}
return nil
}

View File

@ -1,49 +0,0 @@
package checkers_test
import (
"bytes"
"context"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/checkers"
)
type httpClientFunc func(*http.Request) (*http.Response, error)
func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { return f(req) }
func TestHealthChecker_CheckSuccess(t *testing.T) {
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, "http://127.0.0.1:123/healthz", req.URL.String())
assert.Equal(t, "HealthChecker/internal", req.Header.Get("User-Agent"))
return &http.Response{
Body: io.NopCloser(bytes.NewReader([]byte{})),
StatusCode: http.StatusOK,
}, nil
}
checker := checkers.NewHealthChecker(context.Background(), httpMock)
assert.NoError(t, checker.Check(123))
}
func TestHealthChecker_CheckFail(t *testing.T) {
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
return &http.Response{
Body: io.NopCloser(bytes.NewReader([]byte{})),
StatusCode: http.StatusBadGateway,
}, nil
}
checker := checkers.NewHealthChecker(context.Background(), httpMock)
err := checker.Check(123)
assert.Error(t, err)
assert.Contains(t, err.Error(), "wrong status code")
}

View File

@ -1,10 +0,0 @@
package checkers
// LiveChecker is a liveness checker.
type LiveChecker struct{}
// NewLiveChecker creates liveness checker.
func NewLiveChecker() *LiveChecker { return &LiveChecker{} }
// Check application is alive?
func (*LiveChecker) Check() error { return nil }

View File

@ -1,13 +0,0 @@
package checkers_test
import (
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/checkers"
)
func TestLiveChecker_Check(t *testing.T) {
assert.NoError(t, checkers.NewLiveChecker().Check())
}

View File

@ -6,98 +6,86 @@ import (
"runtime"
"strings"
"github.com/urfave/cli/v2"
_ "github.com/urfave/cli-docs/v3" // required for `go generate` to work
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/checkers"
"gh.tarampamp.am/error-pages/internal/appmeta"
"gh.tarampamp.am/error-pages/internal/cli/build"
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
"gh.tarampamp.am/error-pages/internal/cli/perftest"
"gh.tarampamp.am/error-pages/internal/cli/serve"
"gh.tarampamp.am/error-pages/internal/env"
"gh.tarampamp.am/error-pages/internal/logger"
"gh.tarampamp.am/error-pages/internal/version"
)
// NewApp creates new console application.
func NewApp(appName string) *cli.App { //nolint:funlen
const (
logLevelFlagName = "log-level"
logFormatFlagName = "log-format"
verboseFlagName = "verbose"
debugFlagName = "debug"
logJSONFlagName = "log-json"
//go:generate go run update_readme.go
defaultLogLevel = logger.InfoLevel
defaultLogFormat = logger.ConsoleFormat
// NewApp creates a new console application.
func NewApp(appName string) *cli.Command { //nolint:funlen
var (
logLevelFlag = cli.StringFlag{
Name: "log-level",
Value: logger.InfoLevel.String(),
Usage: "Logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
Sources: cli.EnvVars("LOG_LEVEL"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error {
if _, err := logger.ParseLevel(s); err != nil {
return err
}
return nil
},
}
logFormatFlag = cli.StringFlag{
Name: "log-format",
Value: logger.ConsoleFormat.String(),
Usage: "Logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
Sources: cli.EnvVars("LOG_FORMAT"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error {
if _, err := logger.ParseFormat(s); err != nil {
return err
}
return nil
},
}
)
// create "default" logger (will be overwritten later with customized)
var log, _ = logger.New(defaultLogLevel, defaultLogFormat) // error will never occurs
// create a "default" logger (will be swapped later with customized)
var log, _ = logger.New(logger.InfoLevel, logger.ConsoleFormat) // error will never occur
return &cli.App{
Usage: appName,
Before: func(c *cli.Context) (err error) {
_ = log.Sync() // sync previous logger instance
return &cli.Command{
Usage: appName,
Suggest: true,
Before: func(ctx context.Context, c *cli.Command) error {
var (
logLevel, _ = logger.ParseLevel(c.String(logLevelFlag.Name)) // error ignored because the flag validates itself
logFormat, _ = logger.ParseFormat(c.String(logFormatFlag.Name)) // --//--
)
var logLevel, logFormat = defaultLogLevel, defaultLogFormat //nolint:ineffassign
if c.Bool(verboseFlagName) || c.Bool(debugFlagName) {
logLevel = logger.DebugLevel
} else {
// parse logging level
if logLevel, err = logger.ParseLevel(c.String(logLevelFlagName)); err != nil {
return err
}
}
if c.Bool(logJSONFlagName) {
logFormat = logger.JSONFormat
} else {
// parse logging format
if logFormat, err = logger.ParseFormat(c.String(logFormatFlagName)); err != nil {
return err
}
}
configured, err := logger.New(logLevel, logFormat) // create new logger instance
configured, err := logger.New(logLevel, logFormat) // create a new logger instance
if err != nil {
return err
}
*log = *configured // replace "default" logger with customized
*log = *configured // swap the "default" logger with customized
return nil
},
Commands: []*cli.Command{
healthcheck.NewCommand(checkers.NewHealthChecker(context.TODO())),
build.NewCommand(log),
serve.NewCommand(log),
build.NewCommand(log),
healthcheck.NewCommand(log, healthcheck.NewHTTPHealthChecker()),
perftest.NewCommand(),
},
Version: fmt.Sprintf("%s (%s)", version.Version(), runtime.Version()),
Version: fmt.Sprintf("%s (%s)", appmeta.Version(), runtime.Version()),
Flags: []cli.Flag{ // global flags
&cli.BoolFlag{ // kept for backward compatibility
Name: verboseFlagName,
Usage: "verbose output (DEPRECATED FLAG)",
},
&cli.BoolFlag{ // kept for backward compatibility
Name: debugFlagName,
Usage: "debug output (DEPRECATED FLAG)",
},
&cli.BoolFlag{ // kept for backward compatibility
Name: logJSONFlagName,
Usage: "logs in JSON format (DEPRECATED FLAG)",
},
&cli.StringFlag{
Name: logLevelFlagName,
Value: defaultLogLevel.String(),
Usage: "logging level (`" + strings.Join(logger.LevelStrings(), "/") + "`)",
EnvVars: []string{env.LogLevel.String()},
},
&cli.StringFlag{
Name: logFormatFlagName,
Value: defaultLogFormat.String(),
Usage: "logging format (`" + strings.Join(logger.FormatStrings(), "/") + "`)",
EnvVars: []string{env.LogFormat.String()},
},
&logLevelFlag,
&logFormatFlag,
},
}
}

View File

@ -1,6 +1,7 @@
package cli_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
@ -8,12 +9,10 @@ import (
"gh.tarampamp.am/error-pages/internal/cli"
)
func TestNewCommand(t *testing.T) {
func TestNewApp(t *testing.T) {
t.Parallel()
app := cli.NewApp("app")
app := cli.NewApp("appName")
assert.NotEmpty(t, app.Flags)
assert.NoError(t, app.Run([]string{"", "--log-level", "debug", "--log-format", "json"}))
assert.NoError(t, app.Run(context.Background(), []string{""}))
}

View File

@ -1,147 +1,234 @@
package build
import (
"context"
_ "embed"
"errors"
"fmt"
"html/template"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/tpl"
"gh.tarampamp.am/error-pages/internal/logger"
appTemplate "gh.tarampamp.am/error-pages/internal/template"
)
//go:embed index.html
var indexHtml string
type command struct {
c *cli.Command
opt struct {
createIndex bool
targetDirAbsPath string
}
}
// NewCommand creates `build` command.
func NewCommand(log *zap.Logger) *cli.Command {
var cmd = command{}
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit
var (
cmd command
cfg = config.New()
const (
generateIndexFlagName = "index"
disableL10nFlagName = "disable-l10n"
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",
Category: shared.CategoryBuild,
}
targetDirFlag = cli.StringFlag{
Name: "target-dir",
Aliases: []string{"out", "dir", "o"},
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 == "" {
return errors.New("missing target directory")
}
if stat, err := os.Stat(dir); err != nil {
return fmt.Errorf("cannot access the target directory '%s': %w", dir, err)
} else if !stat.IsDir() {
return fmt.Errorf("'%s' is not a directory", dir)
}
return nil
},
}
)
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
cmd.c = &cli.Command{
Name: "build",
Aliases: []string{"b"},
Usage: "build <output-directory>",
Description: "Build the error pages",
Action: func(c *cli.Context) error {
cfg, cfgErr := config.FromYamlFile(c.String(shared.ConfigFileFlag.Name))
if cfgErr != nil {
return cfgErr
Name: "build",
Aliases: []string{"b"},
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)
cmd.opt.createIndex = c.Bool(createIndexFlag.Name)
cmd.opt.targetDirAbsPath, _ = filepath.Abs(c.String(targetDirFlag.Name)) // an error checked by [os.Stat] validator
// add templates from files to the configuration
if add := c.StringSlice(addTplFlag.Name); len(add) > 0 {
for _, templatePath := range add {
if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil {
return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
} else {
log.Info("Template added",
logger.String("name", addedName),
logger.String("path", templatePath),
)
}
}
}
if c.Args().Len() != 1 {
return errors.New("wrong arguments count")
// disable templates specified by the user
if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
for _, templateName := range disable {
if ok := cfg.Templates.Remove(templateName); ok {
log.Info("Template disabled", logger.String("name", templateName))
}
}
}
return cmd.Run(log, cfg, c.Args().First(), c.Bool(generateIndexFlagName), c.Bool(disableL10nFlagName))
// add custom HTTP codes to the configuration
if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
for code, desc := range shared.ParseHTTPCodes(add) {
cfg.Codes[code] = desc
log.Info("HTTP code added",
logger.String("code", code),
logger.String("message", desc.Message),
logger.String("description", desc.Description),
)
}
}
if len(cfg.Templates) == 0 {
return errors.New("no templates specified")
}
log.Info("Building error pages",
logger.String("targetDir", cmd.opt.targetDirAbsPath),
logger.Strings("templates", cfg.Templates.Names()...),
logger.Bool("index", cmd.opt.createIndex),
logger.Bool("l10n", !cfg.L10n.Disable),
)
return cmd.Run(ctx, log, &cfg)
},
Flags: []cli.Flag{ // global flags
&cli.BoolFlag{
Name: generateIndexFlagName,
Aliases: []string{"i"},
Usage: "generate index page",
},
&cli.BoolFlag{
Name: disableL10nFlagName,
Usage: "disable error pages localization",
},
shared.ConfigFileFlag,
Flags: []cli.Flag{
&addTplFlag,
&disableTplFlag,
&addCodeFlag,
&disableL10nFlag,
&createIndexFlag,
&targetDirFlag,
},
}
return cmd.c
}
const (
outHTMLFileExt = ".html"
outIndexFileName = "index"
outFilePerm = os.FileMode(0664)
outDirPerm = os.FileMode(0775)
)
func (cmd *command) Run( //nolint:funlen
ctx context.Context,
log *logger.Logger,
cfg *config.Config,
) error {
type historyItem struct{ Code, Message, RelativePath string }
func (cmd *command) Run(log *zap.Logger, cfg *config.Config, outDirectoryPath string, generateIndex, disableL10n bool) error { //nolint:funlen,lll
if len(cfg.Templates) == 0 {
return errors.New("no loaded templates")
}
var history = make(map[string][]historyItem, len(cfg.Codes)*len(cfg.Templates)) // map[template_name]codes
log.Info("output directory preparing", zap.String("path", outDirectoryPath))
for templateName, templateContent := range cfg.Templates {
log.Debug("Processing template", logger.String("name", templateName))
if err := cmd.createDirectory(outDirectoryPath, outDirPerm); err != nil {
return errors.Wrap(err, "cannot prepare output directory")
}
history, renderer := newBuildingHistory(), tpl.NewTemplateRenderer()
defer func() { _ = renderer.Close() }()
for _, template := range cfg.Templates {
log.Debug("template processing", zap.String("name", template.Name()))
for _, page := range cfg.Pages {
if err := cmd.createDirectory(path.Join(outDirectoryPath, template.Name()), outDirPerm); err != nil {
return err
for code, codeDescription := range cfg.Codes {
if err := createDirectory(filepath.Join(cmd.opt.targetDirAbsPath, templateName)); err != nil {
return fmt.Errorf("cannot create directory for template '%s': %w", templateName, err)
}
var (
fileName = page.Code() + outHTMLFileExt
filePath = path.Join(outDirectoryPath, template.Name(), fileName)
)
var codeAsUint, codeParsingErr = strconv.ParseUint(code, 10, 32)
if codeParsingErr != nil {
log.Warn("Cannot parse code", logger.String("code", code))
content, renderingErr := renderer.Render(template.Content(), tpl.Properties{
Code: page.Code(),
Message: page.Message(),
Description: page.Description(),
continue
}
var outFilePath = path.Join(cmd.opt.targetDirAbsPath, templateName, code+".html")
if content, renderErr := appTemplate.Render(templateContent, appTemplate.Props{
Code: uint16(codeAsUint),
Message: codeDescription.Message,
Description: codeDescription.Description,
L10nDisabled: cfg.L10n.Disable,
ShowRequestDetails: false,
L10nDisabled: disableL10n,
}); renderErr == nil {
if err := os.WriteFile(outFilePath, []byte(content), os.FileMode(0664)); err != nil { //nolint:mnd
return err
}
} else {
return fmt.Errorf("cannot render template '%s': %w", templateName, renderErr)
}
log.Debug("Page built", logger.String("template", templateName), logger.String("code", code))
history[templateName] = append(history[templateName], historyItem{
Code: code,
Message: codeDescription.Message,
RelativePath: "." + strings.TrimPrefix(outFilePath, cmd.opt.targetDirAbsPath), // to make it relative
})
if renderingErr != nil {
return renderingErr
}
if err := os.WriteFile(filePath, content, outFilePerm); err != nil {
return err
}
log.Debug("page rendered", zap.String("path", filePath))
if generateIndex {
history.Append(
template.Name(),
page.Code(),
page.Message(),
path.Join(template.Name(), fileName),
)
}
}
}
if generateIndex {
var filepath = path.Join(outDirectoryPath, outIndexFileName+outHTMLFileExt)
if cmd.opt.createIndex {
log.Debug("Creating the index file")
log.Info("index file generation", zap.String("path", filepath))
for name := range history {
slices.SortFunc(history[name], func(a, b historyItem) int { return strings.Compare(a.Code, b.Code) })
}
if err := history.WriteIndexFile(filepath, outFilePerm); err != nil {
indexTpl, tplErr := template.New("index").Parse(indexHtml)
if tplErr != nil {
return tplErr
}
var buf strings.Builder
if err := indexTpl.Execute(&buf, history); err != nil {
return err
}
}
log.Info("job is done")
return os.WriteFile(
filepath.Join(cmd.opt.targetDirAbsPath, "index.html"),
[]byte(buf.String()),
os.FileMode(0664), //nolint:mnd
)
}
return nil
}
func (cmd *command) createDirectory(path string, perm os.FileMode) error {
stat, err := os.Stat(path)
func createDirectory(path string) error {
var stat, err = os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return os.MkdirAll(path, perm)
return os.MkdirAll(path, os.FileMode(0775)) //nolint:mnd
}
return err

View File

@ -1,26 +0,0 @@
package build_test
import (
"flag"
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
"go.uber.org/goleak"
"go.uber.org/zap"
"gh.tarampamp.am/error-pages/internal/cli/build"
)
func TestNewCommand(t *testing.T) {
defer goleak.VerifyNone(t)
cmd := build.NewCommand(zap.NewNop())
assert.NotEmpty(t, cmd.Flags)
assert.Error(t, cmd.Run(
cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil),
"",
), "should fail because of missing external services")
}

View File

@ -1,59 +0,0 @@
package build
import (
"bytes"
_ "embed"
"os"
"sort"
"text/template"
)
type (
buildingHistory struct {
items map[string][]historyItem
}
historyItem struct {
Code, Message, Path string
}
)
func newBuildingHistory() buildingHistory {
return buildingHistory{items: make(map[string][]historyItem)}
}
func (bh *buildingHistory) Append(templateName, pageCode, message, path string) {
if _, ok := bh.items[templateName]; !ok {
bh.items[templateName] = make([]historyItem, 0)
}
bh.items[templateName] = append(bh.items[templateName], historyItem{
Code: pageCode,
Message: message,
Path: path,
})
sort.Slice(bh.items[templateName], func(i, j int) bool { // keep history items sorted
return bh.items[templateName][i].Code < bh.items[templateName][j].Code
})
}
//go:embed index.tpl.html
var indexPageTemplate string
func (bh *buildingHistory) WriteIndexFile(path string, perm os.FileMode) error {
t, err := template.New("index").Parse(indexPageTemplate)
if err != nil {
return err
}
var buf bytes.Buffer
if err = t.Execute(&buf, bh.items); err != nil {
return err
}
defer buf.Reset() // optimization (is needed here?)
return os.WriteFile(path, buf.Bytes(), perm)
}

View File

@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="follow,index">
<title>Error pages list</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {
--color-primary: #fff;
--color-inverted: #202020;
--color-link: #395364;
}
@media (prefers-color-scheme: dark) {
:root {
--color-primary: #1a1a1a;
--color-inverted: #fff;
--color-link: #5cb0d3;
}
}
html, body {
margin: 0;
padding: 0;
min-height: 100%;
height: 100%;
width: 100%;
background-color: var(--color-primary);
color: var(--color-inverted);
font-family: sans-serif;
font-size: 16px;
}
@media screen and (min-width: 2000px) {
html, body {
font-size: 20px;
}
}
body {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
a {
color: var(--color-link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
main {
width: 1200px;
height: 100%;
text-align: left;
}
header {
padding: 3em 0;
}
header h1 {
font-size: 3em;
text-align: center;
margin: 0;
}
article h2 {
font-size: 1.7em;
}
article h2 code {
text-decoration: underline;
}
article ul {
list-style: none;
margin: 1em 0;
padding: 0 0 0 1em;
}
article ul li {
margin: 0;
padding: 0;
}
footer {
padding: 3em 0;
text-align: center;
font-size: 0.8em;
}
</style>
</head>
<body>
<main>
<header>
<h1>Error pages index</h1>
</header>
<article>
<!-- {{- range $templateName, $details := . -}} -->
<h2>Template name: <Code>{{ $templateName }}</Code></h2>
<ul class="mb-5">
<!-- {{ range $details -}}-->
<li><a href="{{ .RelativePath }}"><strong>{{ .Code }}</strong>: {{ .Message }}</a></li>
<!-- {{ end -}} -->
</ul>
<!-- {{ end }} -->
</article>
<footer>
For online documentation and support please refer to the
<a href="https://gh.tarampamp.am/error-pages">project repository</a>.
</footer>
</main>
</body>
</html>

View File

@ -1,41 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title>Error pages list</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css"
integrity="sha512-GQGU0fMMi238uA+a/bdWJfpUGKUkBdgfFdgBm72SUQ6BeyWjoY/ton0tEjH+OSH9iP4Dfh+7HM0I9f5eR0L/4w=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
@media (prefers-color-scheme:dark){
:root {--bs-light:#212529;--bs-light-rgb:33,37,41;--bs-body-color:#eee}a{color:#91b4e8}a:hover{color:#a2bfec}
}
</style>
</head>
<body class="bg-light">
<div class="container">
<main>
<div class="py-5 text-center">
<img class="d-block mx-auto mb-4" src="https://hsto.org/webt/rm/9y/ww/rm9ywwx3gjv9agwkcmllhsuyo7k.png"
alt="" width="94">
<h2>Error pages index</h2>
</div>
{{- range $template, $item := . -}}
<h2 class="mb-3">Template name: <Code>{{ $template }}</Code></h2>
<ul class="mb-5">
{{ range $item -}}
<li><a href="{{ .Path }}"><strong>{{ .Code }}</strong>: {{ .Message }}</a></li>
{{ end -}}
</ul>
{{ end }}
</main>
</div>
<footer class="footer">
<div class="container text-center text-muted mt-3 mb-3">
For online documentation and support please refer to the
<a href="https://gh.tarampamp.am/error-pages">project repository</a>.
</div>
</footer>
</body>
</html>

View File

@ -0,0 +1,89 @@
package healthcheck
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"strings"
"time"
"gh.tarampamp.am/error-pages/internal/appmeta"
)
type (
httpClient interface {
Do(*http.Request) (*http.Response, error)
}
// HealthCheckerOption allows you to change some settings of the checker.
HealthCheckerOption func(*HTTPHealthChecker)
)
// WithHttpClient allows to set http client.
func WithHttpClient(c httpClient) HealthCheckerOption {
return func(hc *HTTPHealthChecker) { hc.httpClient = c }
}
// WithLiveEndpoint set the endpoint to check.
func WithLiveEndpoint(endpoint string) HealthCheckerOption {
if len(endpoint) > 0 && endpoint[0] != '/' {
endpoint = "/" + endpoint
}
return func(hc *HTTPHealthChecker) { hc.liveEndpoint = endpoint }
}
// HTTPHealthChecker is HTTP probe checker.
type HTTPHealthChecker struct {
httpClient httpClient
liveEndpoint string
}
var _ checker = (*HTTPHealthChecker)(nil) // ensure that HTTPHealthChecker implements checker interface
func NewHTTPHealthChecker(opts ...HealthCheckerOption) *HTTPHealthChecker {
const (
httpClientTimeout = 3 * time.Second
liveRoute = "/healthz"
)
var c = HTTPHealthChecker{
httpClient: &http.Client{
Timeout: httpClientTimeout,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec
},
liveEndpoint: liveRoute,
}
for _, opt := range opts {
opt(&c)
}
return &c
}
// Check performs HTTP get request.
func (c *HTTPHealthChecker) Check(ctx context.Context, baseURL string) error {
var endpoint = strings.TrimRight(strings.TrimSpace(baseURL), "/") + c.liveEndpoint
var req, err = http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
if err != nil {
return err
}
req.Header.Set("User-Agent", fmt.Sprintf("ErrorPages/%s (HealthCheck)", appmeta.Version()))
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
if code := resp.StatusCode; code != http.StatusOK && code != http.StatusNoContent {
return fmt.Errorf("wrong status code [%d] from the live endpoint (%s)", code, endpoint)
}
return nil
}

View File

@ -0,0 +1,130 @@
package healthcheck_test
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/appmeta"
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
)
type httpClientFunc func(*http.Request) (*http.Response, error)
func (f httpClientFunc) Do(req *http.Request) (*http.Response, error) { return f(req) }
func TestHealthChecker_CheckSuccess(t *testing.T) {
t.Parallel()
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, "foobar:123/healthz", req.URL.String())
assert.Equal(t, fmt.Sprintf("ErrorPages/%s (HealthCheck)", appmeta.Version()), req.Header.Get("User-Agent"))
return &http.Response{
Body: io.NopCloser(bytes.NewReader([]byte("ok"))),
StatusCode: http.StatusOK,
}, nil
}
assert.NoError(t, healthcheck.NewHTTPHealthChecker(
healthcheck.WithHttpClient(httpMock),
).Check(context.Background(), "foobar:123"))
}
func TestHealthChecker_CheckFail(t *testing.T) {
t.Parallel()
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
assert.Equal(t, "foobar:123/foo", req.URL.String())
return &http.Response{
Body: http.NoBody,
StatusCode: http.StatusBadGateway,
}, nil
}
var err = healthcheck.NewHTTPHealthChecker(
healthcheck.WithHttpClient(httpMock),
healthcheck.WithLiveEndpoint("foo"),
).Check(context.Background(), "foobar:123")
assert.Error(t, err)
assert.Contains(t, err.Error(), "wrong status code [502]")
}
func TestHealthChecker_ClientDoError(t *testing.T) {
t.Parallel()
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
return nil, assert.AnError
}
var err = healthcheck.NewHTTPHealthChecker(
healthcheck.WithHttpClient(httpMock),
healthcheck.WithLiveEndpoint("foo"),
).Check(context.Background(), "foobar:123")
assert.ErrorIs(t, err, assert.AnError)
}
func TestHTTPHealthChecker_CheckNormalize(t *testing.T) {
t.Parallel()
for name, _tc := range map[string]struct {
giveBaseURL string
giveLive string
wantURL string
}{
"no-live": {
giveBaseURL: "foobar:123",
wantURL: "foobar:123",
},
"live with slash": {
giveBaseURL: "foobar:123",
giveLive: "/foo",
wantURL: "foobar:123/foo",
},
"live without slash": {
giveBaseURL: "foobar:123",
giveLive: "foo",
wantURL: "foobar:123/foo",
},
"base with slash": {
giveBaseURL: "foobar:123/",
giveLive: "foo",
wantURL: "foobar:123/foo",
},
"all of slashes": {
giveBaseURL: "foobar:123/",
giveLive: "/foo",
wantURL: "foobar:123/foo",
},
} {
tc := _tc
t.Run(name, func(t *testing.T) {
t.Parallel()
var httpMock httpClientFunc = func(req *http.Request) (*http.Response, error) {
assert.Equal(t, tc.wantURL, req.URL.String())
return &http.Response{
Body: http.NoBody,
StatusCode: http.StatusOK,
}, nil
}
require.NoError(t, healthcheck.NewHTTPHealthChecker(
healthcheck.WithHttpClient(httpMock),
healthcheck.WithLiveEndpoint(tc.giveLive),
).Check(context.Background(), tc.giveBaseURL))
})
}
}

View File

@ -1,36 +1,34 @@
// Package healthcheck contains CLI `healthcheck` command implementation.
package healthcheck
import (
"errors"
"math"
"context"
"fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/logger"
)
type checker interface {
Check(port uint16) error
Check(ctx context.Context, baseURL string) error
}
// NewCommand creates `healthcheck` command.
func NewCommand(checker checker) *cli.Command {
func NewCommand(_ *logger.Logger, checker checker) *cli.Command {
var portFlag = shared.ListenPortFlag
portFlag.Usage = "TCP port number with the HTTP server to check"
return &cli.Command{
Name: "healthcheck",
Aliases: []string{"chk", "health", "check"},
Usage: "Health checker for the HTTP server. Use case - docker healthcheck",
Action: func(c *cli.Context) error {
var port = c.Uint(shared.ListenPortFlag.Name)
if port <= 0 || port > math.MaxUint16 {
return errors.New("port value out of range")
}
return checker.Check(uint16(port))
Usage: "Health checker for the HTTP server. The use case - docker health check",
Action: func(ctx context.Context, c *cli.Command) error {
return checker.Check(ctx, fmt.Sprintf("http://127.0.0.1:%d", c.Uint(portFlag.Name)))
},
Flags: []cli.Flag{
shared.ListenPortFlag,
&portFlag,
},
}
}

View File

@ -1,47 +1,55 @@
package healthcheck_test
import (
"errors"
"flag"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
"gh.tarampamp.am/error-pages/internal/logger"
)
type fakeChecker struct{ err error }
func TestNewCommand(t *testing.T) {
t.Parallel()
func (c *fakeChecker) Check(port uint16) error { return c.err }
func TestProperties(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
var cmd = healthcheck.NewCommand(logger.NewNop(), nil)
assert.Equal(t, "healthcheck", cmd.Name)
assert.ElementsMatch(t, []string{"chk", "health", "check"}, cmd.Aliases)
assert.NotNil(t, cmd.Action)
assert.Equal(t, []string{"chk", "health", "check"}, cmd.Aliases)
}
func TestCommandRun(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
assert.NoError(t, cmd.Run(cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil)))
type fakeHealthChecker struct {
t *testing.T
wantAddress string
giveErr error
}
func TestCommandRunFailed(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: errors.New("foo err")})
func (m *fakeHealthChecker) Check(_ context.Context, addr string) error {
assert.Equal(m.t, m.wantAddress, addr)
assert.ErrorContains(t, cmd.Run(cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil)), "foo err")
return m.giveErr
}
func TestPortFlagWrongArgument(t *testing.T) {
cmd := healthcheck.NewCommand(&fakeChecker{err: nil})
func TestCommand_RunSuccess(t *testing.T) {
var cmd = healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
t: t,
wantAddress: "http://127.0.0.1:1234",
})
err := cmd.Run(
cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil),
"", "-p", "65536",
require.NoError(t, cmd.Run(context.Background(), []string{"", "--port", "1234"}))
}
func TestCommand_RunFail(t *testing.T) {
cmd := healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
t: t,
wantAddress: "http://127.0.0.1:4321",
giveErr: assert.AnError,
})
assert.ErrorIs(t,
cmd.Run(context.Background(), []string{"", "--port", "4321"}),
assert.AnError,
)
assert.ErrorContains(t, err, "port value out of range")
}

View File

@ -0,0 +1,194 @@
package perftest
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
"os"
"os/exec"
"runtime"
"strconv"
"time"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
)
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
var (
portFlag = shared.ListenPortFlag
durationFlag = cli.DurationFlag{
Name: "duration",
Aliases: []string{"d"},
Usage: "Duration of test",
Value: 15 * 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")
}
return nil
},
}
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
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
},
}
)
return &cli.Command{
Name: "perftest",
Aliases: []string{"perf", "benchmark", "bench"},
Hidden: true,
Usage: "Performance (load) test for the HTTP server (locally installed wrk is required)",
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 runTest = func(scriptContent string) error {
if stdOut, stdErr, err := wrkRunTest(ctx,
wrkBinPath,
uint16(c.Uint(threadsFlag.Name)),
uint16(c.Uint(connectionsFlag.Name)),
c.Duration(durationFlag.Name),
uint16(c.Uint(portFlag.Name)),
scriptContent,
); err != nil {
var errData, _ = io.ReadAll(stdErr)
return fmt.Errorf("failed to execute the test: %w (%s)", err, string(errData))
} else {
var outData, _ = io.ReadAll(stdOut)
printf("Test completed successfully. Here is the output:\n\n%s\n", string(outData))
}
return nil
}
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
}
return nil
},
Flags: []cli.Flag{
&portFlag,
&durationFlag,
&threadsFlag,
&connectionsFlag,
},
}
}
func printf(format string, args ...any) { fmt.Printf(format, args...) } //nolint:forbidigo
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),
)
cmd.Stdout, cmd.Stderr = &stdout, &stderr
return &stdout, &stderr, cmd.Run() // execute
}

View File

@ -4,166 +4,305 @@ import (
"context"
"errors"
"fmt"
"net"
"os"
"net/http"
"net/url"
"strings"
"time"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/breaker"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/env"
appHttp "gh.tarampamp.am/error-pages/internal/http"
"gh.tarampamp.am/error-pages/internal/options"
"gh.tarampamp.am/error-pages/internal/pick"
"gh.tarampamp.am/error-pages/internal/logger"
)
type command struct {
c *cli.Command
opt struct {
http struct { // our HTTP server
addr string
port uint16
readBufferSize uint
}
}
}
const (
templateNameFlagName = "template-name"
defaultErrorPageFlagName = "default-error-page"
defaultHTTPCodeFlagName = "default-http-code"
showDetailsFlagName = "show-details"
proxyHTTPHeadersFlagName = "proxy-headers"
disableL10nFlagName = "disable-l10n"
catchAllFlagName = "catch-all"
readBufferSizeFlagName = "read-buffer-size"
)
const (
useRandomTemplate = "random"
useRandomTemplateOnEachRequest = "i-said-random"
useRandomTemplateDaily = "random-daily"
useRandomTemplateHourly = "random-hourly"
)
// NewCommand creates `serve` command.
func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen
var cmd = command{}
func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit,gocyclo
var (
cmd command
cfg = config.New()
env, trim = cli.EnvVars, cli.StringConfig{TrimSpace: true}
)
var (
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 " +
"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 " +
"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 " +
"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,
}
templateNameFlag = cli.StringFlag{
Name: "template-name",
Aliases: []string{"t"},
Value: cfg.TemplateName,
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,
Validator: func(code uint64) error {
if code > 999 { //nolint:mnd
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
}
return nil
},
OnlyOnce: true,
}
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, " +
"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)",
Value: cfg.ShowDetails,
Sources: env("SHOW_DETAILS"),
Category: shared.CategoryOther,
OnlyOnce: true,
}
proxyHeadersListFlag = cli.StringFlag{
Name: "proxy-headers",
Usage: "HTTP headers listed here will be proxied from the original request to the error page response " +
"(comma-separated list)",
Value: strings.Join(cfg.ProxyHeaders, ","),
Sources: env("PROXY_HTTP_HEADERS"),
Validator: func(s string) error {
for _, raw := range strings.Split(s, ",") {
if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') {
return fmt.Errorf("whitespaces in the HTTP headers are not allowed: %s", clean)
}
}
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(), "/") + ")",
Sources: env("TEMPLATES_ROTATION_MODE"),
Category: shared.CategoryTemplates,
OnlyOnce: true,
Config: trim,
Validator: func(s string) error {
if _, err := config.ParseRotationMode(s); err != nil {
return err
}
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, " +
"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)"
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
cmd.c = &cli.Command{
Name: "serve",
Aliases: []string{"s", "server"},
Usage: "Start HTTP server",
Action: func(c *cli.Context) error {
var cfg *config.Config
Aliases: []string{"s", "server", "http"},
Usage: "Please start the HTTP server to serve the error pages. You can configure various options - please RTFM :D",
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))
cmd.opt.http.readBufferSize = uint(c.Uint(readBufferSizeFlag.Name))
cfg.L10n.Disable = c.Bool(disableL10nFlag.Name)
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)
if configPath := c.String(shared.ConfigFileFlag.Name); configPath == "" { // load config from file
return errors.New("path to the config file is required for this command")
} else if loadedCfg, err := config.FromYamlFile(c.String(shared.ConfigFileFlag.Name)); err != nil {
return err
} else {
cfg = loadedCfg
{ // override default JSON, XML, and PlainText formats
if c.IsSet(jsonFormatFlag.Name) {
cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name))
}
if c.IsSet(xmlFormatFlag.Name) {
cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name))
}
if c.IsSet(plainTextFormatFlag.Name) {
cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name))
}
}
var (
ip = c.String(shared.ListenAddrFlag.Name)
port = uint16(c.Uint(shared.ListenPortFlag.Name))
o options.ErrorPage
)
if net.ParseIP(ip) == nil {
return fmt.Errorf("wrong IP address [%s] for listening", ip)
}
{ // fill options
o.Template.Name = c.String(templateNameFlagName)
o.L10n.Disabled = c.Bool(disableL10nFlagName)
o.Default.PageCode = c.String(defaultErrorPageFlagName)
o.Default.HTTPCode = uint16(c.Uint(defaultHTTPCodeFlagName))
o.ShowDetails = c.Bool(showDetailsFlagName)
o.CatchAll = c.Bool(catchAllFlagName)
if headers := c.String(proxyHTTPHeadersFlagName); headers != "" { //nolint:nestif
var m = make(map[string]struct{})
// make unique and ignore empty strings
for _, header := range strings.Split(headers, ",") {
if h := strings.TrimSpace(header); h != "" {
if strings.ContainsRune(h, ' ') {
return fmt.Errorf("whitespaces in the HTTP headers for proxying [%s] are not allowed", header)
}
if _, ok := m[h]; !ok {
m[h] = struct{}{}
}
}
}
// convert map into slice
o.ProxyHTTPHeaders = make([]string, 0, len(m))
for h := range m {
o.ProxyHTTPHeaders = append(o.ProxyHTTPHeaders, h)
// add templates from files to the configuration
if add := c.StringSlice(addTplFlag.Name); len(add) > 0 {
for _, templatePath := range add {
if addedName, err := cfg.Templates.AddFromFile(templatePath); err != nil {
return fmt.Errorf("cannot add template from file %s: %w", templatePath, err)
} else {
log.Info("Template added",
logger.String("name", addedName),
logger.String("path", templatePath),
)
}
}
}
if o.Default.HTTPCode > 599 { //nolint:gomnd
return fmt.Errorf("wrong default HTTP response code [%d]", o.Default.HTTPCode)
// set the list of HTTP headers we need to proxy from the incoming request to the error page response
if c.IsSet(proxyHeadersListFlag.Name) {
var m = make(map[string]struct{}) // map is used to avoid duplicates
for _, header := range strings.Split(c.String(proxyHeadersListFlag.Name), ",") {
m[http.CanonicalHeaderKey(strings.TrimSpace(header))] = struct{}{}
}
clear(cfg.ProxyHeaders) // clear the list before adding new headers
for header := range m {
cfg.ProxyHeaders = append(cfg.ProxyHeaders, header)
}
}
return cmd.Run(c.Context, log, cfg, ip, port, c.Uint(readBufferSizeFlagName), o)
// add custom HTTP codes to the configuration
if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
for code, desc := range shared.ParseHTTPCodes(add) {
cfg.Codes[code] = desc
log.Info("HTTP code added",
logger.String("code", code),
logger.String("message", desc.Message),
logger.String("description", desc.Description),
)
}
}
// disable templates specified by the user
if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
for _, templateName := range disable {
if ok := cfg.Templates.Remove(templateName); ok {
log.Info("Template disabled", logger.String("name", templateName))
}
}
}
// check if there are any templates available to render error pages
if len(cfg.Templates.Names()) == 0 {
return errors.New("no templates available to render error pages")
}
// if the rotation mode is set to random-on-startup, pick a random template (ignore the user-provided
// template name)
if cfg.RotationMode == config.RotationModeRandomOnStartup {
cfg.TemplateName = cfg.Templates.RandomName()
} else { // otherwise, use the user-provided template name
cfg.TemplateName = c.String(templateNameFlag.Name)
if !cfg.Templates.Has(cfg.TemplateName) {
return fmt.Errorf(
"template '%s' not found and cannot be used (available templates: %s)",
cfg.TemplateName,
cfg.Templates.Names(),
)
}
}
log.Debug("Configuration",
logger.Strings("loaded templates", cfg.Templates.Names()...),
logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
logger.String("JSON format", cfg.Formats.JSON),
logger.String("XML format", cfg.Formats.XML),
logger.String("plain text format", cfg.Formats.PlainText),
logger.String("template name", cfg.TemplateName),
logger.Bool("disable localization", cfg.L10n.Disable),
logger.Uint16("default code to render", cfg.DefaultCodeToRender),
logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
logger.String("rotation mode", cfg.RotationMode.String()),
logger.Bool("show details", cfg.ShowDetails),
logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
)
return cmd.Run(ctx, log, &cfg)
},
Flags: []cli.Flag{
shared.ConfigFileFlag,
shared.ListenPortFlag,
shared.ListenAddrFlag,
&cli.StringFlag{
Name: templateNameFlagName,
Aliases: []string{"t"},
Usage: fmt.Sprintf(
"template name (set \"%s\" to use a randomized or \"%s\" to use a randomized template on "+
"each request or \"%s/%s\" daily/hourly randomized)",
useRandomTemplate,
useRandomTemplateOnEachRequest,
useRandomTemplateDaily,
useRandomTemplateHourly,
),
EnvVars: []string{env.TemplateName.String()},
},
&cli.StringFlag{
Name: defaultErrorPageFlagName,
Value: "404",
Usage: "default error page",
EnvVars: []string{env.DefaultErrorPage.String()},
},
&cli.UintFlag{
Name: defaultHTTPCodeFlagName,
Value: 404, //nolint:gomnd
Usage: "default HTTP response code",
EnvVars: []string{env.DefaultHTTPCode.String()},
},
&cli.BoolFlag{
Name: showDetailsFlagName,
Usage: "show request details in response",
EnvVars: []string{env.ShowDetails.String()},
},
&cli.StringFlag{
Name: proxyHTTPHeadersFlagName,
Usage: "proxy HTTP request headers list (comma-separated)",
EnvVars: []string{env.ProxyHTTPHeaders.String()},
},
&cli.BoolFlag{
Name: disableL10nFlagName,
Usage: "disable error pages localization",
EnvVars: []string{env.DisableL10n.String()},
},
&cli.BoolFlag{
Name: catchAllFlagName,
Usage: "catch all pages",
EnvVars: []string{env.CatchAll.String()},
},
&cli.UintFlag{
Name: readBufferSizeFlagName,
Usage: "read buffer size (0 = use default value)",
EnvVars: []string{env.ReadBufferSize.String()},
},
&addrFlag,
&portFlag,
&addTplFlag,
&disableTplFlag,
&addCodeFlag,
&jsonFormatFlag,
&xmlFormatFlag,
&plainTextFormatFlag,
&templateNameFlag,
&disableL10nFlag,
&defaultCodeToRenderFlag,
&sendSameHTTPCodeFlag,
&showDetailsFlag,
&proxyHeadersListFlag,
&rotationModeFlag,
&readBufferSizeFlag,
},
}
@ -171,104 +310,64 @@ func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen
}
// Run current command.
func (cmd *command) Run( //nolint:funlen
parentCtx context.Context,
log *zap.Logger,
cfg *config.Config,
ip string,
port uint16,
readBufferSize uint,
opt options.ErrorPage,
) error {
var (
ctx, cancel = context.WithCancel(parentCtx) // serve context creation
oss = breaker.NewOSSignals(ctx) // OS signals listener
)
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)
// subscribe for system signals
oss.Subscribe(func(sig os.Signal) {
log.Warn("Stopping by OS signal..", zap.String("signal", sig.String()))
cancel()
})
defer func() {
cancel() // call the cancellation function after all
oss.Stop() // stop system signals listening
}()
var (
templateNames = cfg.TemplateNames()
picker interface{ Pick() string }
)
switch opt.Template.Name {
case useRandomTemplate:
log.Info("A random template will be used")
picker = pick.NewStringsSlice(templateNames, pick.RandomOnce)
case useRandomTemplateOnEachRequest:
log.Info("A random template on EACH request will be used")
picker = pick.NewStringsSlice(templateNames, pick.RandomEveryTime)
case useRandomTemplateDaily:
log.Info("A random template will be used and changed once a day")
picker = pick.NewStringsSliceWithInterval(templateNames, pick.RandomEveryTime, time.Hour*24) //nolint:gomnd
case useRandomTemplateHourly:
log.Info("A random template will be used and changed hourly")
picker = pick.NewStringsSliceWithInterval(templateNames, pick.RandomEveryTime, time.Hour)
case "":
log.Info("The first template (ordered by name) will be used")
picker = pick.NewStringsSlice(templateNames, pick.First)
default:
if t, found := cfg.Template(opt.Template.Name); found {
log.Info("We will use the requested template", zap.String("name", t.Name()))
picker = pick.NewStringsSlice([]string{t.Name()}, pick.First)
} else {
return errors.New("requested nonexistent template: " + opt.Template.Name)
}
}
// create HTTP server
server := appHttp.NewServer(log, readBufferSize)
// register server routes, middlewares, etc.
if err := server.Register(cfg, picker, opt); err != nil {
if err := srv.Register(cfg); err != nil {
return err
}
startedAt, startingErrCh := time.Now(), make(chan error, 1) // channel for server starting error
var startingErrCh = make(chan error, 1) // channel for server starting error
defer close(startingErrCh)
// to track the frequency of each template's use, we send a simple GET request to the GoatCounter API
// (https://goatcounter.com, https://github.com/arp242/goatcounter) to increment the counter. this service is
// free and does not require an API key. no private data is sent, as shown in the URL below. this is necessary
// to render a badge displaying the number of template usages on the error-pages repository README file :D
//
// badge code example:
// ![Used times](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)
//
// if you wish, you may view the collected statistics at any time here - https://error-pages.goatcounter.com/
go func() {
var tpl = url.QueryEscape(cfg.TemplateName)
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(
// https://www.goatcounter.com/help/pixel
"https://error-pages.goatcounter.com/count?p=/use-template/%s&t=%s", tpl, tpl,
), http.NoBody)
if reqErr != nil {
return
}
req.Header.Set("User-Agent", fmt.Sprintf("Mozilla/5.0 (error-pages, rnd:%d)", time.Now().UnixNano()))
resp, respErr := (&http.Client{Timeout: 10 * time.Second}).Do(req) //nolint:mnd // don't care about the response
if respErr != nil {
log.Debug("Cannot send a request to increment the template usage counter", logger.Error(respErr))
return
} else if resp != nil {
_ = resp.Body.Close()
}
}()
// start HTTP server in separate goroutine
go func(errCh chan<- error) {
defer close(errCh)
var now = time.Now()
var fields = []zap.Field{
zap.String("addr", ip),
zap.Uint16("port", port),
zap.String("default error page", opt.Default.PageCode),
zap.Uint16("default HTTP response code", opt.Default.HTTPCode),
zap.Strings("proxy headers", opt.ProxyHTTPHeaders),
zap.Bool("show request details", opt.ShowDetails),
zap.Bool("localization disabled", opt.L10n.Disabled),
zap.Bool("catch all enabled", opt.CatchAll),
}
defer func() {
log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond)))
}()
if readBufferSize > 0 {
fields = append(fields, zap.Uint("read buffer size", readBufferSize))
}
log.Info("HTTP server starting",
logger.String("addr", cmd.opt.http.addr),
logger.Uint16("port", cmd.opt.http.port),
)
log.Info("Server starting", fields...)
if err := server.Start(ip, port); err != nil {
if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}(startingErrCh)
@ -279,16 +378,11 @@ func (cmd *command) Run( //nolint:funlen
return err
case <-ctx.Done(): // ..or context cancellation
log.Info("Gracefully server stopping", zap.Duration("uptime", time.Since(startedAt)))
const shutdownTimeout = 5 * time.Second
if p, ok := picker.(interface{ Close() error }); ok {
if err := p.Close(); err != nil {
return err
}
}
log.Info("HTTP server stopping", logger.Duration("with timeout", shutdownTimeout))
// stop the server using created context above
if err := server.Stop(); err != nil {
if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
return err
}
}

View File

@ -1,7 +1,101 @@
package serve_test
import "testing"
import (
"context"
"fmt"
"net"
"strconv"
"testing"
"time"
func TestNothing(t *testing.T) {
t.Skip("tests for this package have not been implemented yet")
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/cli/serve"
"gh.tarampamp.am/error-pages/internal/logger"
)
func TestCommand_Run(t *testing.T) {
t.Parallel()
var (
port = getFreeTcpPort(t)
cmd = serve.NewCommand(logger.NewNop())
)
var ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var ch = make(chan error, 1)
go func() {
defer close(ch)
ch <- cmd.Run(ctx, []string{
"serve",
"--port", strconv.Itoa(int(port)),
"--add-template", "./testdata/foo-template.html",
"--disable-template", "ghost",
"--disable-template", "<unknown>",
"--add-code", "200=Code/Description",
"--json-format", "json format",
"--xml-format", "xml format",
"--plaintext-format", "plaintext format",
"--template-name", "foo-template",
"--disable-l10n",
"--default-error-page", "503",
"--send-same-http-code",
"--show-details",
"--proxy-headers", "X-Forwarded-For,X-Forwarded-Proto",
"--rotation-mode", "random-on-each-request",
})
}()
var connected bool
for {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
if err == nil {
connected = true
require.NoError(t, conn.Close())
break
} else {
t.Log(err)
}
select {
case <-ctx.Done():
t.Fatal("timeout")
case chErr := <-ch:
require.NoError(t, chErr)
case <-time.After(10 * time.Millisecond):
}
}
require.True(t, connected, "server is not running")
}
// getFreeTcpPort is a helper function to get a free TCP port number.
func getFreeTcpPort(t *testing.T) uint16 {
t.Helper()
l, lErr := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, lErr)
port := l.Addr().(*net.TCPAddr).Port
require.NoError(t, l.Close())
// make sure port is closed
for {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
break
}
require.NoError(t, conn.Close())
<-time.After(5 * time.Millisecond)
}
return uint16(port)
}

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
</html>

View File

@ -1,31 +1,148 @@
package shared
import (
"github.com/urfave/cli/v2"
"fmt"
"net"
"os"
"strings"
"gh.tarampamp.am/error-pages/internal/env"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/config"
)
var ConfigFileFlag = &cli.StringFlag{ //nolint:gochecknoglobals
Name: "config-file",
Aliases: []string{"c"},
Usage: "path to the config file (yaml)",
Value: "./error-pages.yml",
EnvVars: []string{env.ConfigFilePath.String()},
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
var ListenAddrFlag = cli.StringFlag{
Name: "listen",
Aliases: []string{"l"},
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 {
if ip == "" {
return fmt.Errorf("missing IP address")
}
if net.ParseIP(ip) == nil {
return fmt.Errorf("wrong IP address [%s] for listening", ip)
}
return nil
},
}
var ListenAddrFlag = &cli.StringFlag{ //nolint:gochecknoglobals
Name: "listen",
Aliases: []string{"l"},
Usage: "IP (v4 or v6) address to Listen on",
Value: "0.0.0.0",
EnvVars: []string{env.ListenAddr.String()},
var ListenPortFlag = cli.UintFlag{
Name: "port",
Aliases: []string{"p"},
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 {
return fmt.Errorf("wrong TCP port number [%d]", port)
}
return nil
},
}
var ListenPortFlag = &cli.UintFlag{ //nolint:gochecknoglobals
Name: "port",
Aliases: []string{"p"},
Usage: "TCP port number",
Value: 8080, //nolint:gomnd
EnvVars: []string{env.ListenPort.String()},
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 " +
"will be used as the template name)",
Config: cli.StringConfig{TrimSpace: true},
Category: CategoryTemplates,
Validator: func(paths []string) error {
for _, path := range paths {
if path == "" {
return fmt.Errorf("missing template path")
}
if stat, err := os.Stat(path); err != nil || stat.IsDir() {
return fmt.Errorf("wrong template path [%s]", path)
}
}
return nil
},
}
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,
}
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 " +
"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,
Validator: func(codes map[string]string) error {
for code, msgAndDesc := range codes {
if code == "" {
return fmt.Errorf("missing HTTP code")
} else if len(code) != 3 {
return fmt.Errorf("wrong HTTP code [%s]: it should be 3 characters long", code)
}
if parts := strings.SplitN(msgAndDesc, "/", 3); len(parts) < 1 || len(parts) > 2 {
return fmt.Errorf("wrong message/description format for HTTP code [%s]: %s", code, msgAndDesc)
} else if parts[0] == "" {
return fmt.Errorf("missing message for HTTP code [%s]", code)
}
}
return nil
},
}
// ParseHTTPCodes converts a map of HTTP status codes and their messages/descriptions into a map of codes and
// descriptions. Should be used together with [AddHTTPCodesFlag].
func ParseHTTPCodes(codes map[string]string) map[string]config.CodeDescription {
var result = make(map[string]config.CodeDescription, len(codes))
for code, msgAndDesc := range codes {
var (
parts = strings.SplitN(msgAndDesc, "/", 2)
desc config.CodeDescription
)
desc.Message = strings.TrimSpace(parts[0])
if len(parts) > 1 {
desc.Description = strings.TrimSpace(parts[1])
}
result[code] = desc
}
return result
}
var DisableL10nFlag = cli.BoolFlag{
Name: "disable-l10n",
Usage: "Disable localization of error pages (if the template supports localization)",
Sources: cli.EnvVars("DISABLE_L10N"),
Category: CategoryOther,
OnlyOnce: true,
}

View File

@ -0,0 +1,218 @@
package shared_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config"
)
func TestListenAddrFlag(t *testing.T) {
t.Parallel()
var flag = shared.ListenAddrFlag
assert.Equal(t, "listen", flag.Name)
assert.Equal(t, "0.0.0.0", flag.Value)
assert.Contains(t, flag.Sources.String(), "LISTEN_ADDR")
for giveValue, wantErrMsg := range map[string]string{
flag.Value: "", // default value
// ipv4
"0.0.0.0": "",
"127.0.0.1": "",
"255.255.255.255": "",
// ipv6
"::": "",
"::1": "",
"2001:0db8:85a3:0000:0000:8a2e:0370:7334": "",
"2001:db8:85a3:0:0:8a2e:370:7334": "",
"2001:db8:85a3::8a2e:370:7334": "",
"2001:db8::8a2e:370:7334": "",
"2001:db8::7334": "",
"2001:db8::": "",
"2001:db8:0:0:1::1": "",
"2001:db8:0:0:1::": "",
// invalid
"": "missing IP address",
"255.255.255.256": "wrong IP address [255.255.255.256] for listening",
"example.com": "wrong IP address [example.com] for listening",
"123.123.abc.123": "wrong IP address [123.123.abc.123] for listening",
"foo:123:321": "wrong IP address [foo:123:321] for listening",
"2001:db8:0:0:1:": "wrong IP address [2001:db8:0:0:1:] for listening",
} {
t.Run(fmt.Sprintf("%s: %s", giveValue, wantErrMsg), func(t *testing.T) {
if err := flag.Validator(giveValue); wantErrMsg != "" {
assert.ErrorContains(t, err, wantErrMsg)
} else {
assert.NoError(t, err)
}
})
}
}
func TestListenPortFlag(t *testing.T) {
t.Parallel()
var flag = shared.ListenPortFlag
assert.Equal(t, "port", flag.Name)
assert.Equal(t, uint64(8080), flag.Value)
assert.Contains(t, flag.Sources.String(), "LISTEN_PORT")
for giveValue, wantErrMsg := range map[uint64]string{
flag.Value: "", // default value
1: "",
8080: "",
65535: "",
0: "wrong TCP port number [0]",
65536: "wrong TCP port number [65536]",
} {
t.Run(fmt.Sprintf("%d: %s", giveValue, wantErrMsg), func(t *testing.T) {
if err := flag.Validator(giveValue); wantErrMsg != "" {
assert.ErrorContains(t, err, wantErrMsg)
} else {
assert.NoError(t, err)
}
})
}
}
func TestAddTemplatesFlag(t *testing.T) {
t.Parallel()
var flag = shared.AddTemplatesFlag
assert.Equal(t, "add-template", flag.Name)
for wantErrMsg, giveValue := range map[string][]string{
"missing template path": {""},
"wrong template path [.]": {".", "./"},
"wrong template path [..]": {"..", "../"},
"wrong template path [foo]": {"foo"},
"": {"./flags.go"},
} {
t.Run(fmt.Sprintf("%s: %s", giveValue, wantErrMsg), func(t *testing.T) {
if err := flag.Validator(giveValue); wantErrMsg != "" {
assert.ErrorContains(t, err, wantErrMsg)
} else {
assert.NoError(t, err)
}
})
}
}
func TestDisableTemplateNamesFlag(t *testing.T) {
t.Parallel()
var flag = shared.DisableTemplateNamesFlag
assert.Equal(t, "disable-template", flag.Name)
}
func TestAddHTTPCodesFlag(t *testing.T) {
t.Parallel()
var flag = shared.AddHTTPCodesFlag
assert.Equal(t, "add-code", flag.Name)
for name, tt := range map[string]struct {
giveValue map[string]string
wantErrMsg string
}{
"common": {
giveValue: map[string]string{
"200": "foo/bar",
"404": "foo",
"2**": "baz",
},
},
"missing HTTP code": {
giveValue: map[string]string{"": "foo/bar"},
wantErrMsg: "missing HTTP code",
},
"wrong HTTP code [6]": {
giveValue: map[string]string{"6": "foo"},
wantErrMsg: "wrong HTTP code [6]: it should be 3 characters long",
},
"wrong HTTP code [66]": {
giveValue: map[string]string{"66": "foo"},
wantErrMsg: "wrong HTTP code [66]: it should be 3 characters long",
},
"wrong HTTP code [1000]": {
giveValue: map[string]string{"1000": "foo"},
wantErrMsg: "wrong HTTP code [1000]: it should be 3 characters long",
},
"missing message and description": {
giveValue: map[string]string{"200": "//"},
wantErrMsg: "wrong message/description format for HTTP code [200]: //",
},
"missing message": {
giveValue: map[string]string{"200": "/bar"},
wantErrMsg: "missing message for HTTP code [200]",
},
} {
t.Run(name, func(t *testing.T) {
if err := flag.Validator(tt.giveValue); tt.wantErrMsg != "" {
assert.ErrorContains(t, err, tt.wantErrMsg)
} else {
assert.NoError(t, err)
}
})
}
}
func TestParseHTTPCodes(t *testing.T) {
t.Parallel()
assert.Equal(t, shared.ParseHTTPCodes(nil), map[string]config.CodeDescription{})
assert.Equal(t,
shared.ParseHTTPCodes(map[string]string{"200": "msg"}),
map[string]config.CodeDescription{"200": {Message: "msg", Description: ""}},
)
assert.Equal(t,
shared.ParseHTTPCodes(map[string]string{"200": "/aaa"}),
map[string]config.CodeDescription{"200": {Message: "", Description: "aaa"}},
)
assert.Equal(t, // not sure here
shared.ParseHTTPCodes(map[string]string{"aa": "////aaa"}),
map[string]config.CodeDescription{"aa": {Message: "", Description: "///aaa"}},
)
assert.Equal(t,
shared.ParseHTTPCodes(map[string]string{"200": "msg/desc"}),
map[string]config.CodeDescription{"200": {Message: "msg", Description: "desc"}},
)
assert.Equal(t,
shared.ParseHTTPCodes(map[string]string{
"200": "msg/desc",
"foo": "Word word/Desc desc // adsadas",
}),
map[string]config.CodeDescription{
"200": {Message: "msg", Description: "desc"},
"foo": {Message: "Word word", Description: "Desc desc // adsadas"},
},
)
}
func TestDisableL10nFlag(t *testing.T) {
t.Parallel()
var flag = shared.DisableL10nFlag
assert.Equal(t, "disable-l10n", flag.Name)
assert.Contains(t, flag.Sources.String(), "DISABLE_L10N")
}

View File

@ -0,0 +1,24 @@
//go:build ignore
// +build ignore
package main
import (
"os"
cliDocs "github.com/urfave/cli-docs/v3"
"gh.tarampamp.am/error-pages/internal/cli"
)
func main() {
const readmePath = "../../README.md"
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 if err != nil {
println("readme file not found, cli docs not updated:", err.Error())
}
}

124
internal/config/codes.go Normal file
View File

@ -0,0 +1,124 @@
package config
import (
"slices"
"strconv"
)
type (
CodeDescription struct {
// Message is a short description of the HTTP error.
Message string
// Description is a longer description of the HTTP error.
Description string
}
// Codes is a map of HTTP codes to their descriptions.
//
// The codes may be written in a non-strict manner. For example, they may be "4xx", "4XX", or "4**".
// If the map contains both "404" and "4xx" keys, and we search for "404", the "404" key will be returned.
// However, if we search for "405", "400", or any non-existing code that starts with "4" and its length is 3,
// the value under the key "4xx" will be retrieved.
//
// The length of the code (in string format) is matter.
Codes map[string]CodeDescription // map[http_code]description
)
// Find searches the closest match for the given HTTP code, written in a non-strict manner. Read [Codes] for more
// information.
func (c Codes) Find(httpCode uint16) (CodeDescription, bool) { //nolint:funlen,gocyclo
if len(c) == 0 { // empty map, fast return
return CodeDescription{}, false
}
var code = strconv.FormatUint(uint64(httpCode), 10)
if desc, ok := c[code]; ok { // search for the exact match
return desc, true
}
var (
keysMap = make(map[string][]rune, len(c))
codeRunes = []rune(code)
)
for key := range c { // take only the keys that are the same length and start with the same character or a wildcard
if kr := []rune(key); len(kr) > 0 && len(kr) == len(codeRunes) && isWildcardOr(kr[0], codeRunes[0]) {
keysMap[key] = kr
}
}
if len(keysMap) == 0 { // no matches found using the first rune comparison
return CodeDescription{}, false
}
var matchedMap = make(map[string]uint16, len(keysMap)) // map[mapKey]wildcardMatchedCount
for mapKey, keyRunes := range keysMap { // search for the closest match
var wildcardMatchedCount uint16 = 0
for i := 0; i < len(codeRunes); i++ { // loop through each httpCode rune
var keyRune, codeRune = keyRunes[i], codeRunes[i]
if wm := isWildcard(keyRune); wm || keyRune == codeRune {
if wm {
wildcardMatchedCount++
}
if i == len(codeRunes)-1 { // is the last rune?
matchedMap[mapKey] = wildcardMatchedCount
}
continue
}
break
}
}
if len(matchedMap) == 0 { // no matches found
return CodeDescription{}, false
} else if len(matchedMap) == 1 { // only one match found
for mapKey := range matchedMap {
return c[mapKey], true
}
}
// multiple matches found, find the most specific one based on the wildcard matched count (pick the one with the
// least wildcards)
var (
minCount uint16
key string
)
for mapKey, count := range matchedMap {
if minCount == 0 || count < minCount {
minCount, key = count, mapKey
}
}
return c[key], true
}
func isWildcard(r rune) bool { return r == '*' || r == 'x' || r == 'X' }
func isWildcardOr(r, or rune) bool { return isWildcard(r) || r == or }
// Codes returns all HTTP codes sorted alphabetically.
func (c Codes) Codes() []string {
var codes = make([]string, 0, len(c))
for code := range c {
codes = append(codes, code)
}
slices.Sort(codes)
return codes
}
// Has checks if the HTTP code exists.
func (c Codes) Has(code string) (found bool) { _, found = c[code]; return } //nolint:nlreturn
// Get returns the HTTP code description by the specified code, if it exists.
func (c Codes) Get(code string) (data CodeDescription, ok bool) { data, ok = c[code]; return } //nolint:nlreturn

View File

@ -0,0 +1,131 @@
package config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/config"
)
func TestCodes_Common(t *testing.T) {
t.Parallel()
var codes = make(config.Codes)
t.Run("initial state", func(t *testing.T) {
require.Empty(t, codes.Codes())
require.Empty(t, codes.Has("404"))
var got, ok = codes.Get("404")
require.Empty(t, got)
require.False(t, ok)
})
t.Run("add a code", func(t *testing.T) {
codes["404"] = config.CodeDescription{Message: "Not Found"}
assert.True(t, codes.Has("404"))
assert.Equal(t, []string{"404"}, codes.Codes())
var got, ok = codes.Get("404")
assert.Equal(t, got.Message, "Not Found")
assert.True(t, ok)
})
}
func TestCodes_Find(t *testing.T) {
t.Parallel()
//nolint:typecheck
var common = config.Codes{
"101": {Message: "Upgrade"}, // 101
"1xx": {Message: "Informational"}, // 102-199
"200": {Message: "OK"}, // 200
"20*": {Message: "Success"}, // 201-209
"2**": {Message: "Success, but..."}, // 210-299
"3**": {Message: "Redirection"}, // 300-399
"404": {Message: "Not Found"}, // 404
"405": {Message: "Method Not Allowed"}, // 405
"500": {Message: "Internal Server Error"}, // 500
"501": {Message: "Not Implemented"}, // 501
"502": {Message: "Bad Gateway"}, // 502
"503": {Message: "Service Unavailable"}, // 503
"5XX": {Message: "Server Error"}, // 504-599
}
var ladder = config.Codes{
"123": {Message: "Full triple"},
"***": {Message: "Triple"},
"12": {Message: "Full double"},
"**": {Message: "Double"},
"1": {Message: "Full single"},
"*": {Message: "Single"},
}
for name, tt := range map[string]struct {
giveCodes config.Codes
giveCode uint16
wantMessage string
wantNotFound bool
}{
"101 - exact match": {giveCodes: common, giveCode: 101, wantMessage: "Upgrade"},
"102 - multi-wildcard match": {giveCodes: common, giveCode: 102, wantMessage: "Informational"},
"110 - multi-wildcard match": {giveCodes: common, giveCode: 110, wantMessage: "Informational"},
"111 - multi-wildcard match": {giveCodes: common, giveCode: 111, wantMessage: "Informational"},
"199 - multi-wildcard match": {giveCodes: common, giveCode: 199, wantMessage: "Informational"},
"200 - exact match": {giveCodes: common, giveCode: 200, wantMessage: "OK"},
"201 - single-wildcard match": {giveCodes: common, giveCode: 201, wantMessage: "Success"},
"209 - single-wildcard match": {giveCodes: common, giveCode: 209, wantMessage: "Success"},
"210 - multi-wildcard match": {giveCodes: common, giveCode: 210, wantMessage: "Success, but..."},
"234 - multi-wildcard match": {giveCodes: common, giveCode: 234, wantMessage: "Success, but..."},
"299 - multi-wildcard match": {giveCodes: common, giveCode: 299, wantMessage: "Success, but..."},
"300 - multi-wildcard match": {giveCodes: common, giveCode: 300, wantMessage: "Redirection"},
"301 - multi-wildcard match": {giveCodes: common, giveCode: 301, wantMessage: "Redirection"},
"311 - multi-wildcard match": {giveCodes: common, giveCode: 311, wantMessage: "Redirection"},
"399 - multi-wildcard match": {giveCodes: common, giveCode: 399, wantMessage: "Redirection"},
"400 - not found": {giveCodes: common, giveCode: 400, wantNotFound: true},
"403 - not found": {giveCodes: common, giveCode: 403, wantNotFound: true},
"404 - exact match": {giveCodes: common, giveCode: 404, wantMessage: "Not Found"},
"405 - exact match": {giveCodes: common, giveCode: 405, wantMessage: "Method Not Allowed"},
"410 - not found": {giveCodes: common, giveCode: 410, wantNotFound: true},
"450 - not found": {giveCodes: common, giveCode: 450, wantNotFound: true},
"499 - not found": {giveCodes: common, giveCode: 499, wantNotFound: true},
"500 - exact match": {giveCodes: common, giveCode: 500, wantMessage: "Internal Server Error"},
"501 - exact match": {giveCodes: common, giveCode: 501, wantMessage: "Not Implemented"},
"502 - exact match": {giveCodes: common, giveCode: 502, wantMessage: "Bad Gateway"},
"503 - exact match": {giveCodes: common, giveCode: 503, wantMessage: "Service Unavailable"},
"504 - multi-wildcard match": {giveCodes: common, giveCode: 504, wantMessage: "Server Error"},
"505 - multi-wildcard match": {giveCodes: common, giveCode: 505, wantMessage: "Server Error"},
"599 - multi-wildcard match": {giveCodes: common, giveCode: 599, wantMessage: "Server Error"},
"600 - not found": {giveCodes: common, giveCode: 600, wantNotFound: true},
"ladder - strict triple match": {giveCodes: ladder, giveCode: 123, wantMessage: "Full triple"},
"ladder - triple wildcard": {giveCodes: ladder, giveCode: 321, wantMessage: "Triple"},
"ladder - strict double match": {giveCodes: ladder, giveCode: 12, wantMessage: "Full double"},
"ladder - double wildcard": {giveCodes: ladder, giveCode: 21, wantMessage: "Double"},
"ladder - strict single match": {giveCodes: ladder, giveCode: 1, wantMessage: "Full single"},
"ladder - single wildcard": {giveCodes: ladder, giveCode: 2, wantMessage: "Single"},
"empty map": {giveCodes: config.Codes{}, giveCode: 404, wantNotFound: true},
"zero code": {giveCodes: common, giveCode: 0, wantNotFound: true},
} {
t.Run(name, func(t *testing.T) {
for i := 0; i < 100; i++ { // repeat the test to ensure the function is idempotent
var desc, found = tt.giveCodes.Find(tt.giveCode)
if !tt.wantNotFound {
require.Truef(t, found, "should have found something")
require.Equal(t, tt.wantMessage, desc.Message)
} else {
require.Falsef(t, found, "should not have found anything, but got: %v", desc)
require.Empty(t, desc)
}
}
})
}
}

View File

@ -1,255 +1,175 @@
package config
import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
"maps"
"net/http"
"slices"
"github.com/a8m/envsubst"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
builtinTemplates "gh.tarampamp.am/error-pages/templates"
)
// Config is a main (exportable) config struct.
type Config struct {
Templates []Template
Pages map[string]Page // map key is a page code
Formats map[string]Format // map key is a format name
// Templates hold all templates, with the key being the template name and the value being the template content
// in HTML format (Go templates are supported here).
Templates templates
// Formats contain alternative response formats (e.g., if a client requests a response in one of these formats,
// we will render the response using the specified format instead of HTML; Go templates are supported).
Formats struct {
JSON string
XML string
PlainText string
}
// Codes hold descriptions for HTTP codes (e.g., 404: "Not Found / The server can not find the requested page").
Codes Codes
// TemplateName is the name of the template to use for rendering error pages. The template must be present in the
// Templates map.
TemplateName string
// ProxyHeaders contains a list of HTTP headers that will be proxied from the incoming request to the
// error page response.
ProxyHeaders []string
// L10n contains localization settings.
L10n struct {
// Disable the localization of error pages.
Disable bool
}
// DefaultCodeToRender is the code for the default error page to be displayed. It is used when the requested
// code is not defined in the incoming request (i.e., the code to render as the index page).
DefaultCodeToRender uint16
// RespondWithSameHTTPCode determines whether the response should have the same HTTP status code as the requested
// error page.
// In other words, if set to true and the requested error page has a code of 404, the HTTP response will also have
// a status code of 404. If set to false, the HTTP response will have a status code of 200 regardless of the
// requested error page's status code.
RespondWithSameHTTPCode bool
// RotationMode allows to set the rotation mode for templates to switch between them automatically on startup,
// on each request, daily, hourly and so on.
RotationMode RotationMode
// ShowDetails determines whether to show additional details in the error response, extracted from the
// incoming request (if supported by the template).
ShowDetails bool
}
// Template returns a Template with the passes name.
func (c *Config) Template(name string) (*Template, bool) {
for i := 0; i < len(c.Templates); i++ {
if c.Templates[i].name == name {
return &c.Templates[i], true
}
}
const defaultJSONFormat string = `{
"error": true,
"code": {{ code | json }},
"message": {{ message | json }},
"description": {{ description | json }}{{ if show_details }},
"details": {
"host": {{ host | json }},
"original_uri": {{ original_uri | json }},
"forwarded_for": {{ forwarded_for | json }},
"namespace": {{ namespace | json }},
"ingress_name": {{ ingress_name | json }},
"service_name": {{ service_name | json }},
"service_port": {{ service_port | json }},
"request_id": {{ request_id | json }},
"timestamp": {{ nowUnix }}
}{{ end }}
}
` // an empty line at the end is important for better UX
return &Template{}, false
const defaultXMLFormat string = `<?xml version="1.0" encoding="utf-8"?>
<error>
<code>{{ code }}</code>
<message>{{ message }}</message>
<description>{{ description }}</description>{{ if show_details }}
<details>
<host>{{ host }}</host>
<originalURI>{{ original_uri }}</originalURI>
<forwardedFor>{{ forwarded_for }}</forwardedFor>
<namespace>{{ namespace }}</namespace>
<ingressName>{{ ingress_name }}</ingressName>
<serviceName>{{ service_name }}</serviceName>
<servicePort>{{ service_port }}</servicePort>
<requestID>{{ request_id }}</requestID>
<timestamp>{{ nowUnix }}</timestamp>
</details>{{ end }}
</error>
` // an empty line at the end is important for better UX
const defaultPlainTextFormat string = `Error {{ code }}: {{ message }}{{ if description }}
{{ description }}{{ end }}{{ if show_details }}
Host: {{ host }}
Original URI: {{ original_uri }}
Forwarded For: {{ forwarded_for }}
Namespace: {{ namespace }}
Ingress Name: {{ ingress_name }}
Service Name: {{ service_name }}
Service Port: {{ service_port }}
Request ID: {{ request_id }}
Timestamp: {{ nowUnix }}{{ end }}
` // an empty line at the end is important for better UX
//nolint:lll
var defaultCodes = Codes{ //nolint:gochecknoglobals
"400": {"Bad Request", "The server did not understand the request"},
"401": {"Unauthorized", "The requested page needs a username and a password"},
"403": {"Forbidden", "Access is forbidden to the requested page"},
"404": {"Not Found", "The server can not find the requested page"},
"405": {"Method Not Allowed", "The method specified in the request is not allowed"},
"407": {"Proxy Authentication Required", "You must authenticate with a proxy server before this request can be served"},
"408": {"Request Timeout", "The request took longer than the server was prepared to wait"},
"409": {"Conflict", "The request could not be completed because of a conflict"},
"410": {"Gone", "The requested page is no longer available"},
"411": {"Length Required", "The \"Content-Length\" is not defined. The server will not accept the request without it"},
"412": {"Precondition Failed", "The pre condition given in the request evaluated to false by the server"},
"413": {"Payload Too Large", "The server will not accept the request, because the request entity is too large"},
"416": {"Requested Range Not Satisfiable", "The requested byte range is not available and is out of bounds"},
"418": {"I'm a teapot", "Attempt to brew coffee with a teapot is not supported"},
"429": {"Too Many Requests", "Too many requests in a given amount of time"},
"500": {"Internal Server Error", "The server met an unexpected condition"},
"502": {"Bad Gateway", "The server received an invalid response from the upstream server"},
"503": {"Service Unavailable", "The server is temporarily overloading or down"},
"504": {"Gateway Timeout", "The gateway has timed out"},
"505": {"HTTP Version Not Supported", "The server does not support the \"http protocol\" version"},
}
func (c *Config) JSONFormat() (*Format, bool) { return c.format("json") }
func (c *Config) XMLFormat() (*Format, bool) { return c.format("xml") }
func (c *Config) format(name string) (*Format, bool) {
if f, ok := c.Formats[name]; ok {
if len(f.content) > 0 {
return &f, true
}
}
return &Format{}, false
var defaultProxyHeaders = []string{ //nolint:gochecknoglobals
// "Traceparent", // W3C Trace Context
// "Tracestate", // W3C Trace Context
"X-Request-Id", // unofficial HTTP header, used to trace individual HTTP requests
"X-Trace-Id", // same as above
"X-Amzn-Trace-Id", // to track HTTP requests from clients to targets or other AWS services
}
// TemplateNames returns all template names.
func (c *Config) TemplateNames() []string {
n := make([]string, len(c.Templates))
for i, t := range c.Templates {
n[i] = t.name
// New creates a new configuration with default values.
func New() Config {
var cfg = Config{
Templates: make(templates), // allocate memory for templates
Codes: maps.Clone(defaultCodes), // copy default codes
}
return n
}
// Template describes HTTP error page template.
type Template struct {
name string
content []byte
}
// Name returns the name of the template.
func (t Template) Name() string { return t.name }
// Content returns the template content.
func (t Template) Content() []byte { return t.content }
func (t *Template) loadContentFromFile(filePath string) (err error) {
if t.content, err = os.ReadFile(filePath); err != nil {
return errors.Wrap(err, "cannot load content for the template "+t.Name()+" from file "+filePath)
}
return
}
// Page describes error page.
type Page struct {
code string
message string
description string
}
// Code returns the code of the Page.
func (p Page) Code() string { return p.code }
// Message returns the message of the Page.
func (p Page) Message() string { return p.message }
// Description returns the description of the Page.
func (p Page) Description() string { return p.description }
// Format describes different response formats.
type Format struct {
name string
content []byte
}
// Name returns the name of the format.
func (f Format) Name() string { return f.name }
// Content returns the format content.
func (f Format) Content() []byte { return f.content }
// config is internal struct for marshaling/unmarshaling configuration file content.
type config struct {
Templates []struct {
Path string `yaml:"path"`
Name string `yaml:"name"`
Content string `yaml:"content"`
} `yaml:"templates"`
Formats map[string]struct {
Content string `yaml:"content"`
} `yaml:"formats"`
Pages map[string]struct {
Message string `yaml:"message"`
Description string `yaml:"description"`
} `yaml:"pages"`
}
// Validate the config struct and return an error if something is wrong.
func (c config) Validate() error {
if len(c.Templates) == 0 {
return errors.New("empty templates list")
} else {
for i := 0; i < len(c.Templates); i++ {
if c.Templates[i].Name == "" && c.Templates[i].Path == "" {
return errors.New("empty path and name with index " + strconv.Itoa(i))
}
if c.Templates[i].Path == "" && c.Templates[i].Content == "" {
return errors.New("empty path and template content with index " + strconv.Itoa(i))
}
}
}
if len(c.Pages) == 0 {
return errors.New("empty pages list")
} else {
for code := range c.Pages {
if code == "" {
return errors.New("empty page code")
}
if strings.ContainsRune(code, ' ') {
return errors.New("code should not contain whitespaces")
}
}
}
if len(c.Formats) > 0 {
for name := range c.Formats {
if name == "" {
return errors.New("empty format name")
}
if strings.ContainsRune(name, ' ') {
return errors.New("format should not contain whitespaces")
}
}
}
return nil
}
// Export the config struct into Config.
func (c *config) Export() (*Config, error) {
cfg := &Config{}
cfg.Templates = make([]Template, 0, len(c.Templates))
for i := 0; i < len(c.Templates); i++ {
tpl := Template{name: c.Templates[i].Name}
if c.Templates[i].Content == "" {
if c.Templates[i].Path == "" {
return nil, errors.New("path to the template " + c.Templates[i].Name + " not provided")
}
if err := tpl.loadContentFromFile(c.Templates[i].Path); err != nil {
return nil, err
}
} else {
tpl.content = []byte(c.Templates[i].Content)
}
cfg.Templates = append(cfg.Templates, tpl)
}
cfg.Pages = make(map[string]Page, len(c.Pages))
for code, p := range c.Pages {
cfg.Pages[code] = Page{code: code, message: p.Message, description: p.Description}
}
cfg.Formats = make(map[string]Format, len(c.Formats))
for name, f := range c.Formats {
cfg.Formats[name] = Format{name: name, content: []byte(strings.TrimSpace(f.Content))}
}
return cfg, nil
}
// FromYaml creates new Config instance using YAML-structured content.
func FromYaml(in []byte) (_ *Config, err error) {
in, err = envsubst.Bytes(in)
if err != nil {
return nil, err
}
c := &config{}
if err = yaml.Unmarshal(in, c); err != nil {
return nil, errors.Wrap(err, "cannot parse configuration file")
}
var basename string
for i := 0; i < len(c.Templates); i++ {
if c.Templates[i].Name == "" { // set the template name from file path
basename = filepath.Base(c.Templates[i].Path)
c.Templates[i].Name = strings.TrimSuffix(basename, filepath.Ext(basename))
}
}
if err = c.Validate(); err != nil {
return nil, err
}
return c.Export()
}
// FromYamlFile creates new Config instance using YAML file.
func FromYamlFile(filepath string) (*Config, error) {
bytes, err := os.ReadFile(filepath)
if err != nil {
return nil, errors.Wrap(err, "cannot read configuration file")
}
// the following code makes it possible to use the relative links in the config file (`.` means "directory with
// the config file")
cwd, err := os.Getwd()
if err == nil {
if err = os.Chdir(path.Dir(filepath)); err != nil {
return nil, err
}
defer func() { _ = os.Chdir(cwd) }()
}
return FromYaml(bytes)
cfg.Formats.JSON = defaultJSONFormat
cfg.Formats.XML = defaultXMLFormat
cfg.Formats.PlainText = defaultPlainTextFormat
// add built-in templates
for name, content := range builtinTemplates.BuiltIn() {
cfg.Templates[name] = content
}
// set first template as default
for _, name := range cfg.Templates.Names() {
cfg.TemplateName = name
break
}
// set default HTTP headers to proxy
cfg.ProxyHeaders = slices.Clone(defaultProxyHeaders)
// set defaults
cfg.DefaultCodeToRender = http.StatusNotFound
return cfg
}

View File

@ -1,196 +1,57 @@
package config_test
import (
"os"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/template"
)
func TestFromYaml(t *testing.T) {
var cases = map[string]struct { //nolint:maligned
giveYaml []byte
giveEnv map[string]string
wantErr bool
checkResultFn func(*testing.T, *config.Config)
}{
"with all possible values": {
giveEnv: map[string]string{
"__FOO_TPL_PATH": "./testdata/foo-tpl.html",
"__FOO_TPL_NAME": "Foo Template",
},
giveYaml: []byte(`
templates:
- path: ${__FOO_TPL_PATH}
name: ${__FOO_TPL_NAME:-default_value} # name is optional
- path: ./testdata/bar-tpl.html
- name: Baz
content: |
Some content {{ code }}
New line
func TestNew(t *testing.T) {
t.Parallel()
formats:
json:
content: |
{"code": "{{code}}"}
Avada_Kedavra:
content: "{{ message }}"
t.Run("default config", func(t *testing.T) {
var cfg = config.New()
pages:
400:
message: Bad Request
description: The server did not understand the request
assert.NotEmpty(t, cfg.Formats.XML)
assert.NotEmpty(t, cfg.Formats.JSON)
assert.NotEmpty(t, cfg.Formats.PlainText)
assert.True(t, len(cfg.Codes) >= 19)
assert.True(t, len(cfg.Templates) >= 1)
assert.NotEmpty(t, cfg.TemplateName)
assert.True(t, cfg.Templates.Has(cfg.TemplateName))
assert.Equal(t, uint16(http.StatusNotFound), cfg.DefaultCodeToRender)
})
401:
message: Unauthorized
description: The requested page needs a username and a password
`),
wantErr: false,
checkResultFn: func(t *testing.T, cfg *config.Config) {
assert.Len(t, cfg.Templates, 3)
t.Run("changing cfg1 should not affect cfg2", func(t *testing.T) {
var cfg1, cfg2 = config.New(), config.New()
tpl, found := cfg.Template("Foo Template")
assert.True(t, found)
assert.Equal(t, "Foo Template", tpl.Name())
assert.Equal(t, "<html><body>foo {{ code }}</body></html>\n", string(tpl.Content()))
cfg1.Codes["400"] = config.CodeDescription{Message: "foo", Description: "bar"}
tpl, found = cfg.Template("bar-tpl")
assert.True(t, found)
assert.Equal(t, "bar-tpl", tpl.Name())
assert.Equal(t, "<html><body>bar {{ code }}</body></html>\n", string(tpl.Content()))
assert.NotEqual(t, cfg1.Codes["400"], cfg2.Codes["400"])
tpl, found = cfg.Template("Baz")
assert.True(t, found)
assert.Equal(t, "Baz", tpl.Name())
assert.Equal(t, "Some content {{ code }}\nNew line\n", string(tpl.Content()))
cfg1.ProxyHeaders = append(cfg1.ProxyHeaders, "foo")
tpl, found = cfg.Template("NonExists")
assert.False(t, found)
assert.Equal(t, "", tpl.Name())
assert.Equal(t, "", string(tpl.Content()))
assert.NotEqual(t, cfg1.ProxyHeaders, cfg2.ProxyHeaders)
})
assert.Len(t, cfg.Formats, 2)
t.Run("render default format templates", func(t *testing.T) {
var cfg = config.New()
format, found := cfg.Formats["json"]
assert.True(t, found)
assert.Equal(t, `{"code": "{{code}}"}`, string(format.Content()))
for _, content := range []string{cfg.Formats.JSON, cfg.Formats.XML, cfg.Formats.PlainText} {
var result, err = template.Render(content, template.Props{
ShowRequestDetails: true,
Code: 404,
Message: "Not Found",
})
format, found = cfg.Formats["Avada_Kedavra"]
assert.True(t, found)
assert.Equal(t, "{{ message }}", string(format.Content()))
assert.NotEmpty(t, result)
assert.NoError(t, err)
assert.Len(t, cfg.Pages, 2)
errPage, found := cfg.Pages["400"]
assert.True(t, found)
assert.Equal(t, "400", errPage.Code())
assert.Equal(t, "Bad Request", errPage.Message())
assert.Equal(t, "The server did not understand the request", errPage.Description())
errPage, found = cfg.Pages["401"]
assert.True(t, found)
assert.Equal(t, "401", errPage.Code())
assert.Equal(t, "Unauthorized", errPage.Message())
assert.Equal(t, "The requested page needs a username and a password", errPage.Description())
errPage, found = cfg.Pages["666"]
assert.False(t, found)
assert.Equal(t, "", errPage.Message())
assert.Equal(t, "", errPage.Code())
assert.Equal(t, "", errPage.Description())
},
},
"broken yaml": {
giveYaml: []byte(`foo bar`),
wantErr: true,
},
}
for name, tt := range cases {
t.Run(name, func(t *testing.T) {
if tt.giveEnv != nil {
for key, value := range tt.giveEnv {
assert.NoError(t, os.Setenv(key, value))
}
}
conf, err := config.FromYaml(tt.giveYaml)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.Nil(t, err)
tt.checkResultFn(t, conf)
}
if tt.giveEnv != nil {
for key := range tt.giveEnv {
assert.NoError(t, os.Unsetenv(key))
}
}
})
}
}
func TestFromYamlFile(t *testing.T) {
var cases = map[string]struct { //nolint:maligned
giveYamlFilePath string
wantErr bool
checkResultFn func(*testing.T, *config.Config)
}{
"with all possible values": {
giveYamlFilePath: "./testdata/simple.yml",
wantErr: false,
checkResultFn: func(t *testing.T, cfg *config.Config) {
assert.Len(t, cfg.Templates, 2)
tpl, found := cfg.Template("ghost")
assert.True(t, found)
assert.Equal(t, "ghost", tpl.Name())
assert.Equal(t, "<html><body>foo {{ code }}</body></html>\n", string(tpl.Content()))
tpl, found = cfg.Template("bar-tpl")
assert.True(t, found)
assert.Equal(t, "bar-tpl", tpl.Name())
assert.Equal(t, "<html><body>bar {{ code }}</body></html>\n", string(tpl.Content()))
assert.Len(t, cfg.Pages, 2)
errPage, found := cfg.Pages["400"]
assert.True(t, found)
assert.Equal(t, "400", errPage.Code())
assert.Equal(t, "Bad Request", errPage.Message())
assert.Equal(t, "The server did not understand the request", errPage.Description())
errPage, found = cfg.Pages["401"]
assert.True(t, found)
assert.Equal(t, "401", errPage.Code())
assert.Equal(t, "Unauthorized", errPage.Message())
assert.Equal(t, "The requested page needs a username and a password", errPage.Description())
},
},
"broken yaml": {
giveYamlFilePath: "./testdata/broken.yml",
wantErr: true,
},
"wrong file path": {
giveYamlFilePath: "foo bar",
wantErr: true,
},
}
for name, tt := range cases {
t.Run(name, func(t *testing.T) {
conf, err := config.FromYamlFile(tt.giveYamlFilePath)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.Nil(t, err)
tt.checkResultFn(t, conf)
}
})
}
t.Log(result)
}
})
}

View File

@ -0,0 +1,87 @@
package config
import (
"fmt"
"strings"
)
// RotationMode represents the rotation mode for templates.
type RotationMode byte
const (
RotationModeDisabled RotationMode = iota // do not rotate templates, default
RotationModeRandomOnStartup // pick a random template on startup
RotationModeRandomOnEachRequest // pick a random template on each request
RotationModeRandomHourly // once an hour switch to a random template
RotationModeRandomDaily // once a day switch to a random template
)
// String returns a human-readable representation of the rotation mode.
func (rm RotationMode) String() string {
switch rm {
case RotationModeDisabled:
return "disabled"
case RotationModeRandomOnStartup:
return "random-on-startup"
case RotationModeRandomOnEachRequest:
return "random-on-each-request"
case RotationModeRandomHourly:
return "random-hourly"
case RotationModeRandomDaily:
return "random-daily"
}
return fmt.Sprintf("RotationMode(%d)", rm)
}
// RotationModes returns a slice of all rotation modes.
func RotationModes() []RotationMode {
return []RotationMode{
RotationModeDisabled,
RotationModeRandomOnStartup,
RotationModeRandomOnEachRequest,
RotationModeRandomHourly,
RotationModeRandomDaily,
}
}
// RotationModeStrings returns a slice of all rotation modes as strings.
func RotationModeStrings() []string {
var (
modes = RotationModes()
result = make([]string, len(modes))
)
for i := range modes {
result[i] = modes[i].String()
}
return result
}
// ParseRotationMode parses a rotation mode (case is ignored) based on the ASCII representation of the rotation mode.
// If the provided ASCII representation is invalid an error is returned.
func ParseRotationMode[T string | []byte](text T) (RotationMode, error) {
var mode string
if s, ok := any(text).(string); ok {
mode = s
} else {
mode = string(any(text).([]byte))
}
switch strings.ToLower(mode) {
case RotationModeDisabled.String(), "":
return RotationModeDisabled, nil // the empty string makes sense
case RotationModeRandomOnStartup.String():
return RotationModeRandomOnStartup, nil
case RotationModeRandomOnEachRequest.String():
return RotationModeRandomOnEachRequest, nil
case RotationModeRandomHourly.String():
return RotationModeRandomHourly, nil
case RotationModeRandomDaily.String():
return RotationModeRandomDaily, nil
}
return RotationModeDisabled, fmt.Errorf("unrecognized rotation mode: %q", mode)
}

View File

@ -0,0 +1,90 @@
package config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/config"
)
func TestRotationMode_String(t *testing.T) {
t.Parallel()
assert.Equal(t, "disabled", config.RotationModeDisabled.String())
assert.Equal(t, "random-on-startup", config.RotationModeRandomOnStartup.String())
assert.Equal(t, "random-on-each-request", config.RotationModeRandomOnEachRequest.String())
assert.Equal(t, "random-daily", config.RotationModeRandomDaily.String())
assert.Equal(t, "random-hourly", config.RotationModeRandomHourly.String())
assert.Equal(t, "RotationMode(255)", config.RotationMode(255).String())
}
func TestRotationModes(t *testing.T) {
t.Parallel()
assert.Equal(t, []config.RotationMode{
config.RotationModeDisabled,
config.RotationModeRandomOnStartup,
config.RotationModeRandomOnEachRequest,
config.RotationModeRandomHourly,
config.RotationModeRandomDaily,
}, config.RotationModes())
}
func TestRotationModeStrings(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{
"disabled",
"random-on-startup",
"random-on-each-request",
"random-hourly",
"random-daily",
}, config.RotationModeStrings())
}
func TestParseRotationMode(t *testing.T) {
t.Parallel()
for name, _tt := range map[string]struct {
giveBytes []byte
giveString string
wantMode config.RotationMode
wantErrorMsg string
}{
"<empty string>": {giveString: "", wantMode: config.RotationModeDisabled},
"<empty bytes>": {giveBytes: []byte(""), wantMode: config.RotationModeDisabled},
"disabled": {giveString: "disabled", wantMode: config.RotationModeDisabled},
"disabled (bytes)": {giveBytes: []byte("disabled"), wantMode: config.RotationModeDisabled},
"random-on-startup": {giveString: "random-on-startup", wantMode: config.RotationModeRandomOnStartup},
"random-on-startup (bytes)": {giveBytes: []byte("random-on-startup"), wantMode: config.RotationModeRandomOnStartup},
"on-each-request": {giveString: "random-on-each-request", wantMode: config.RotationModeRandomOnEachRequest},
"daily": {giveString: "random-daily", wantMode: config.RotationModeRandomDaily},
"hourly": {giveString: "random-hourly", wantMode: config.RotationModeRandomHourly},
"foobar": {giveString: "foobar", wantErrorMsg: "unrecognized rotation mode: \"foobar\""},
} {
tt := _tt
t.Run(name, func(t *testing.T) {
var (
mode config.RotationMode
err error
)
if tt.giveString != "" || tt.giveBytes == nil {
mode, err = config.ParseRotationMode(tt.giveString)
} else {
mode, err = config.ParseRotationMode(tt.giveBytes)
}
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
assert.Equal(t, tt.wantMode, mode)
} else {
assert.ErrorContains(t, err, tt.wantErrorMsg)
}
})
}
}

View File

@ -0,0 +1,105 @@
package config
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
type templates map[string]string // map[name]content
// Add adds a new template.
func (tpl templates) Add(name, content string) error {
if name == "" {
return fmt.Errorf("template name cannot be empty")
}
tpl[name] = content
return nil
}
// AddFromFile reads the file content and adds it as a new template.
func (tpl templates) AddFromFile(path string, name ...string) (addedTemplateName string, _ error) {
// check if the file exists and is not a directory
if stat, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("file %s not found", path)
}
return "", err
} else if stat.IsDir() {
return "", fmt.Errorf("%s is not a file", path)
}
// read the file content
var content, err = os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("cannot read file %s: %w", path, err)
}
var templateName string
if len(name) > 0 && name[0] != "" { // if the name is provided, use it
templateName = name[0]
} else { // otherwise, use the file name without the extension
var (
fileName = filepath.Base(path)
ext = filepath.Ext(fileName)
)
if ext != "" && fileName != ext {
templateName = strings.TrimSuffix(fileName, ext)
} else {
templateName = fileName
}
}
// add the template to the config
tpl[templateName] = string(content)
return templateName, nil
}
// Names returns all template names sorted alphabetically.
func (tpl templates) Names() []string {
var names = make([]string, 0, len(tpl))
for name := range tpl {
names = append(names, name)
}
slices.Sort(names)
return names
}
// Has checks if the template with the specified name exists.
func (tpl templates) Has(name string) (found bool) { _, found = tpl[name]; return } //nolint:nlreturn
// Get returns the template content by the specified name, if it exists.
func (tpl templates) Get(name string) (data string, ok bool) { data, ok = tpl[name]; return } //nolint:nlreturn
// Remove deletes the template by the specified name.
func (tpl templates) Remove(name string) (ok bool) {
if _, ok = tpl[name]; ok {
delete(tpl, name)
}
return
}
// RandomName picks a random template name. It returns an empty string if there are no templates.
func (tpl templates) RandomName() string {
if len(tpl) == 0 {
return ""
}
for name := range tpl { // map iteration order is unpredictable (random) by design
return name
}
return ""
}

View File

@ -0,0 +1,164 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTemplates_Common(t *testing.T) {
t.Parallel()
var tpl = make(templates)
t.Run("initial state", func(t *testing.T) {
assert.Empty(t, tpl.Names())
assert.False(t, tpl.Has("test"))
var got, ok = tpl.Get("test")
assert.Empty(t, got)
assert.False(t, ok)
})
t.Run("add a template from variable", func(t *testing.T) {
const testContent = "content"
assert.NoError(t, tpl.Add("test", testContent))
assert.True(t, tpl.Has("test"))
var got, ok = tpl.Get("test")
assert.Equal(t, got, testContent)
assert.True(t, ok)
assert.Equal(t, []string{"test"}, tpl.Names())
assert.False(t, tpl.Has("_test99"))
assert.NoError(t, tpl.Add("_test99", ""))
assert.NoError(t, tpl.Add("_test11", ""))
assert.Equal(t, []string{"_test11", "_test99", "test"}, tpl.Names()) // sorted
assert.True(t, tpl.Has("_test99"))
assert.True(t, tpl.Remove("_test99"))
assert.False(t, tpl.Has("_test99"))
assert.False(t, tpl.Remove("_test99"))
})
t.Run("adding template without a name should fail", func(t *testing.T) {
assert.ErrorContains(t, tpl.Add("", "content"), "template name cannot be empty")
})
}
func TestTemplates_AddFromFile(t *testing.T) {
t.Parallel()
for name, _tt := range map[string]struct {
givePath string
giveName func() []string
wantError string
wantThisName string
wantThisContent string
}{
"dotfile": {
givePath: "./testdata/.dotfile",
wantThisName: ".dotfile",
},
"dotfile with extension": {
givePath: "./testdata/.dotfile_with.ext",
wantThisName: ".dotfile_with",
},
"empty file": {
givePath: "./testdata/empty.html",
wantThisName: "empty",
},
"file with multiple dots but without a name": {
givePath: "./testdata/file.with.multiple.dots",
wantThisName: "file.with.multiple",
},
"name with spaces": {
givePath: "./testdata/name with spaces.txt",
wantThisName: "name with spaces",
},
"with content and a name": {
givePath: "./testdata/with-content.htm",
giveName: func() []string { return []string{"test name"} },
wantThisName: "test name",
wantThisContent: "<!DOCTYPE html><html lang=\"en\"></html>\n",
},
"with content but without a name": {
givePath: "./testdata/with-content.htm",
wantThisName: "with-content",
wantThisContent: "<!DOCTYPE html><html lang=\"en\"></html>\n",
},
"filename with no extension": {
givePath: "./testdata/without_extension",
wantThisName: "without_extension",
},
"file not found": {
givePath: "./testdata/not-found",
wantError: "file ./testdata/not-found not found",
},
"directory": {
givePath: "./testdata",
wantError: "./testdata is not a file",
},
} {
var tt = _tt
t.Run(name, func(t *testing.T) {
t.Parallel()
var (
tpl = make(templates)
giveName []string
)
if tt.giveName != nil {
giveName = tt.giveName()
}
var addedName, err = tpl.AddFromFile(tt.givePath, giveName...)
if tt.wantError == "" {
assert.NoError(t, err)
assert.True(t, tpl.Has(tt.wantThisName))
assert.Equal(t, addedName, tt.wantThisName)
var content, _ = tpl.Get(tt.wantThisName)
assert.Equal(t, content, tt.wantThisContent)
} else {
assert.ErrorContains(t, err, tt.wantError)
assert.False(t, tpl.Has(tt.wantThisName))
}
})
}
}
func TestTemplates_RandomName(t *testing.T) {
t.Parallel()
var (
tpl = templates{"test": "content", "test2": "content", "test3": "content"}
lastName = tpl.RandomName()
changedCount int
)
for range 1_000 {
var name = tpl.RandomName()
if name != lastName {
changedCount++
}
lastName = name
}
// I expect at least 100 different names in 1000 iterations
assert.True(t, changedCount > 200)
}

0
internal/config/testdata/.dotfile vendored Normal file
View File

View File

View File

@ -1 +0,0 @@
<html><body>bar {{ code }}</body></html>

View File

@ -1 +0,0 @@
foo bar

0
internal/config/testdata/empty.html vendored Normal file
View File

View File

View File

@ -1 +0,0 @@
<html><body>foo {{ code }}</body></html>

View File

View File

@ -1,13 +0,0 @@
templates:
- path: ./foo-tpl.html
name: ghost # name is optional
- path: ./bar-tpl.html
pages:
400:
message: Bad Request
description: The server did not understand the request
401:
message: Unauthorized
description: The requested page needs a username and a password

View File

@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"></html>

View File

31
internal/env/env.go vendored
View File

@ -1,31 +0,0 @@
// Package env contains all about environment variables, that can be used by current application.
package env
import "os"
type envVariable string
const (
LogLevel envVariable = "LOG_LEVEL" // logging level
LogFormat envVariable = "LOG_FORMAT" // logging format (json|console)
ListenAddr envVariable = "LISTEN_ADDR" // IP address for listening
ListenPort envVariable = "LISTEN_PORT" // port number for listening
TemplateName envVariable = "TEMPLATE_NAME" // template name
ConfigFilePath envVariable = "CONFIG_FILE" // path to the config file
DefaultErrorPage envVariable = "DEFAULT_ERROR_PAGE" // default error page (code)
DefaultHTTPCode envVariable = "DEFAULT_HTTP_CODE" // default HTTP response code
ShowDetails envVariable = "SHOW_DETAILS" // show request details in response
ProxyHTTPHeaders envVariable = "PROXY_HTTP_HEADERS" // proxy HTTP request headers list (request -> response)
DisableL10n envVariable = "DISABLE_L10N" // disable pages localization
CatchAll envVariable = "CATCH_ALL" // catch all pages
ReadBufferSize envVariable = "READ_BUFFER_SIZE" // https://github.com/tarampampam/error-pages/issues/238
)
// String returns environment variable name in the string representation.
func (e envVariable) String() string { return string(e) }
// Lookup retrieves the value of the environment variable. If the variable is present in the environment the value
// (which may be empty) is returned and the boolean is true. Otherwise the returned value will be empty and the
// boolean will be false.
func (e envVariable) Lookup() (string, bool) { return os.LookupEnv(string(e)) }

View File

@ -1,59 +0,0 @@
package env
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConstants(t *testing.T) {
assert.Equal(t, "LISTEN_ADDR", string(ListenAddr))
assert.Equal(t, "LISTEN_PORT", string(ListenPort))
assert.Equal(t, "TEMPLATE_NAME", string(TemplateName))
assert.Equal(t, "CONFIG_FILE", string(ConfigFilePath))
assert.Equal(t, "DEFAULT_ERROR_PAGE", string(DefaultErrorPage))
assert.Equal(t, "DEFAULT_HTTP_CODE", string(DefaultHTTPCode))
assert.Equal(t, "SHOW_DETAILS", string(ShowDetails))
assert.Equal(t, "PROXY_HTTP_HEADERS", string(ProxyHTTPHeaders))
assert.Equal(t, "DISABLE_L10N", string(DisableL10n))
assert.Equal(t, "CATCH_ALL", string(CatchAll))
assert.Equal(t, "READ_BUFFER_SIZE", string(ReadBufferSize))
}
func TestEnvVariable_Lookup(t *testing.T) {
cases := []struct {
giveEnv envVariable
}{
{giveEnv: ListenAddr},
{giveEnv: ListenPort},
{giveEnv: TemplateName},
{giveEnv: ConfigFilePath},
{giveEnv: DefaultErrorPage},
{giveEnv: DefaultHTTPCode},
{giveEnv: ShowDetails},
{giveEnv: ProxyHTTPHeaders},
{giveEnv: DisableL10n},
{giveEnv: CatchAll},
{giveEnv: ReadBufferSize},
}
for _, tt := range cases {
tt := tt
t.Run(tt.giveEnv.String(), func(t *testing.T) {
assert.NoError(t, os.Unsetenv(tt.giveEnv.String())) // make sure that env is unset for test
defer func() { assert.NoError(t, os.Unsetenv(tt.giveEnv.String())) }()
value, exists := tt.giveEnv.Lookup()
assert.False(t, exists)
assert.Empty(t, value)
assert.NoError(t, os.Setenv(tt.giveEnv.String(), "foo"))
value, exists = tt.giveEnv.Lookup()
assert.True(t, exists)
assert.Equal(t, "foo", value)
})
}
}

View File

@ -1,68 +0,0 @@
package common
import (
"strings"
"time"
"github.com/valyala/fasthttp"
"go.uber.org/zap"
)
func LogRequest(h fasthttp.RequestHandler, log *zap.Logger) fasthttp.RequestHandler {
const headersSeparator = ": "
return func(ctx *fasthttp.RequestCtx) {
var ua = string(ctx.UserAgent())
if strings.Contains(strings.ToLower(ua), "healthcheck") { // skip healthcheck requests logging
h(ctx)
return
}
var reqHeaders = make([]string, 0, 24) //nolint:gomnd
ctx.Request.Header.VisitAll(func(key, value []byte) {
reqHeaders = append(reqHeaders, string(key)+headersSeparator+string(value))
})
var startedAt = time.Now()
h(ctx)
var respHeaders = make([]string, 0, 16) //nolint:gomnd
ctx.Response.Header.VisitAll(func(key, value []byte) {
respHeaders = append(respHeaders, string(key)+headersSeparator+string(value))
})
log.Info("HTTP request processed",
zap.String("useragent", ua),
zap.String("method", string(ctx.Method())),
zap.String("url", string(ctx.RequestURI())),
zap.String("referer", string(ctx.Referer())),
zap.Int("status_code", ctx.Response.StatusCode()),
zap.String("content_type", string(ctx.Response.Header.ContentType())),
zap.Bool("connection_close", ctx.Response.ConnectionClose()),
zap.Duration("duration", time.Since(startedAt)),
zap.Strings("request_headers", reqHeaders),
zap.Strings("response_headers", respHeaders),
)
}
}
type metrics interface {
IncrementTotalRequests()
ObserveRequestDuration(t time.Duration)
}
func DurationMetrics(h fasthttp.RequestHandler, m metrics) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
var startedAt = time.Now()
h(ctx)
m.IncrementTotalRequests()
m.ObserveRequestDuration(time.Since(startedAt))
}
}

View File

@ -1,7 +0,0 @@
package common_test
import "testing"
func TestNothing2(t *testing.T) {
t.Skip("tests for this package have not been implemented yet")
}

View File

@ -1,127 +0,0 @@
package core
import (
"strconv"
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/options"
"gh.tarampamp.am/error-pages/internal/tpl"
)
type templatePicker interface {
// Pick the template name for responding.
Pick() string
}
type renderer interface {
Render(content []byte, props tpl.Properties) ([]byte, error)
}
func RespondWithErrorPage( //nolint:funlen,gocyclo
ctx *fasthttp.RequestCtx,
cfg *config.Config,
p templatePicker,
rdr renderer,
pageCode string,
httpCode int,
opt options.ErrorPage,
) {
ctx.Response.Header.Set("X-Robots-Tag", "noindex") // block Search indexing
var (
clientWant = ClientWantFormat(ctx)
json, canJSON = cfg.JSONFormat()
xml, canXML = cfg.XMLFormat()
props = tpl.Properties{
Code: pageCode,
ShowRequestDetails: opt.ShowDetails,
L10nDisabled: opt.L10n.Disabled,
}
)
if opt.ShowDetails {
props.OriginalURI = string(ctx.Request.Header.Peek(OriginalURI))
props.Namespace = string(ctx.Request.Header.Peek(Namespace))
props.IngressName = string(ctx.Request.Header.Peek(IngressName))
props.ServiceName = string(ctx.Request.Header.Peek(ServiceName))
props.ServicePort = string(ctx.Request.Header.Peek(ServicePort))
props.RequestID = string(ctx.Request.Header.Peek(RequestID))
props.ForwardedFor = string(ctx.Request.Header.Peek(ForwardedFor))
props.Host = string(ctx.Request.Header.Peek(Host))
}
if page, exists := cfg.Pages[pageCode]; exists {
props.Message = page.Message()
props.Description = page.Description()
} else if c, err := strconv.Atoi(pageCode); err == nil {
if s := fasthttp.StatusMessage(c); s != "Unknown Status Code" { // as a fallback
props.Message = s
}
}
SetClientFormat(ctx, PlainTextContentType) // set default content type
if props.Message == "" {
ctx.SetStatusCode(fasthttp.StatusNotFound)
_, _ = ctx.WriteString("requested pageCode (" + pageCode + ") not available")
return
}
// proxy required HTTP headers from the request to the response
for _, headerToProxy := range opt.ProxyHTTPHeaders {
if reqHeader := ctx.Request.Header.Peek(headerToProxy); len(reqHeader) > 0 {
ctx.Response.Header.SetBytesV(headerToProxy, reqHeader)
}
}
switch {
case clientWant == JSONContentType && canJSON: // JSON
{
SetClientFormat(ctx, JSONContentType)
if content, err := rdr.Render(json.Content(), props); err == nil {
ctx.SetStatusCode(httpCode)
_, _ = ctx.Write(content)
} else {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
_, _ = ctx.WriteString("cannot render JSON template: " + err.Error())
}
}
case clientWant == XMLContentType && canXML: // XML
{
SetClientFormat(ctx, XMLContentType)
if content, err := rdr.Render(xml.Content(), props); err == nil {
ctx.SetStatusCode(httpCode)
_, _ = ctx.Write(content)
} else {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
_, _ = ctx.WriteString("cannot render XML template: " + err.Error())
}
}
default: // HTML
{
SetClientFormat(ctx, HTMLContentType)
var templateName = p.Pick()
if template, exists := cfg.Template(templateName); exists {
if content, err := rdr.Render(template.Content(), props); err == nil {
ctx.SetStatusCode(httpCode)
_, _ = ctx.Write(content)
} else {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
_, _ = ctx.WriteString("cannot render HTML template: " + err.Error())
}
} else {
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
_, _ = ctx.WriteString("template " + templateName + " not exists")
}
}
}
}

View File

@ -1,102 +0,0 @@
package core
import (
"bytes"
"sort"
"strconv"
"github.com/valyala/fasthttp"
)
type ContentType = byte
const (
UnknownContentType ContentType = iota // should be first
JSONContentType
XMLContentType
HTMLContentType
PlainTextContentType
)
func ClientWantFormat(ctx *fasthttp.RequestCtx) ContentType {
// parse "Content-Type" header (e.g.: `application/json;charset=UTF-8`)
if ct := bytes.ToLower(ctx.Request.Header.ContentType()); len(ct) > 4 { //nolint:gomnd
return mimeTypeToContentType(ct)
}
// parse `X-Format` header (aka `Accept`) for the Ingress support
// e.g.: `text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8`
if h := bytes.ToLower(bytes.TrimSpace(ctx.Request.Header.Peek(FormatHeader))); len(h) > 2 { //nolint:gomnd,nestif
type format struct {
mimeType []byte
weight float32
}
var formats = make([]format, 0, 8) //nolint:gomnd
for _, b := range bytes.FieldsFunc(h, func(r rune) bool { return r == ',' }) {
if idx := bytes.Index(b, []byte(";q=")); idx > 0 && idx < len(b) {
f := format{b[0:idx], 0}
if len(b) > idx+3 {
if weight, err := strconv.ParseFloat(string(b[idx+3:]), 32); err == nil {
f.weight = float32(weight)
}
}
formats = append(formats, f)
} else {
formats = append(formats, format{b, 1})
}
}
switch l := len(formats); {
case l == 0:
return UnknownContentType
case l == 1:
return mimeTypeToContentType(formats[0].mimeType)
default:
sort.SliceStable(formats, func(i, j int) bool { return formats[i].weight > formats[j].weight })
return mimeTypeToContentType(formats[0].mimeType)
}
}
return UnknownContentType
}
func mimeTypeToContentType(mimeType []byte) ContentType {
switch {
case bytes.Contains(mimeType, []byte("application/json")), bytes.Contains(mimeType, []byte("text/json")):
return JSONContentType
case bytes.Contains(mimeType, []byte("application/xml")), bytes.Contains(mimeType, []byte("text/xml")):
return XMLContentType
case bytes.Contains(mimeType, []byte("text/html")):
return HTMLContentType
case bytes.Contains(mimeType, []byte("text/plain")):
return PlainTextContentType
}
return UnknownContentType
}
func SetClientFormat(ctx *fasthttp.RequestCtx, t ContentType) {
switch t {
case JSONContentType:
ctx.SetContentType("application/json; charset=utf-8")
case XMLContentType:
ctx.SetContentType("application/xml; charset=utf-8")
case HTMLContentType:
ctx.SetContentType("text/html; charset=utf-8")
case PlainTextContentType:
ctx.SetContentType("text/plain; charset=utf-8")
}
}

View File

@ -1,118 +0,0 @@
package core_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/http/core"
)
func TestClientWantFormat(t *testing.T) {
for name, tt := range map[string]struct {
giveContentTypeHeader string
giveFormatHeader string
giveReqCtx func() *fasthttp.RequestCtx
wantFormat core.ContentType
}{
"priority": {
giveFormatHeader: "application/xml",
giveContentTypeHeader: "text/plain",
wantFormat: core.PlainTextContentType,
},
"format respects weight": {
giveFormatHeader: "text/html;q=0.5,application/xhtml+xml;q=0.9,application/xml;q=1,*/*;q=0.8",
wantFormat: core.XMLContentType,
},
"wrong format value": {
giveFormatHeader: ";q=foobar,bar/baz;;;;;application/xml",
wantFormat: core.UnknownContentType,
},
"content type - application/json": {
giveContentTypeHeader: "application/jsoN; charset=utf-8", wantFormat: core.JSONContentType,
},
"content type - text/json": {
giveContentTypeHeader: "text/Json; charset=utf-8", wantFormat: core.JSONContentType,
},
"format - json": {
giveFormatHeader: "application/jsoN,*/*;q=0.8", wantFormat: core.JSONContentType,
},
"content type - application/xml": {
giveContentTypeHeader: "application/xmL; charset=utf-8", wantFormat: core.XMLContentType,
},
"content type - text/xml": {
giveContentTypeHeader: "text/Xml; charset=utf-8", wantFormat: core.XMLContentType,
},
"format - xml": {
giveFormatHeader: "text/Xml", wantFormat: core.XMLContentType,
},
"content type - text/html": {
giveContentTypeHeader: "text/htMl; charset=utf-8", wantFormat: core.HTMLContentType,
},
"format - html": {
giveFormatHeader: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
wantFormat: core.HTMLContentType,
},
"content type - text/plain": {
giveContentTypeHeader: "text/plaiN; charset=utf-8", wantFormat: core.PlainTextContentType,
},
"format - plain": {
giveFormatHeader: "text/plaiN,text/html,application/xml;q=0.9,,,*/*;q=0.8", wantFormat: core.PlainTextContentType,
},
"unknown on empty": {
wantFormat: core.UnknownContentType,
},
"unknown on foo/bar": {
giveContentTypeHeader: "foo/bar; charset=utf-8",
giveFormatHeader: "foo/bar; charset=utf-8",
wantFormat: core.UnknownContentType,
},
} {
t.Run(name, func(t *testing.T) {
h := &fasthttp.RequestHeader{}
h.Set(fasthttp.HeaderContentType, tt.giveContentTypeHeader)
h.Set(core.FormatHeader, tt.giveFormatHeader)
ctx := &fasthttp.RequestCtx{
Request: fasthttp.Request{
Header: *h, //nolint:govet
},
}
assert.Equal(t, tt.wantFormat, core.ClientWantFormat(ctx))
})
}
}
func TestSetClientFormat(t *testing.T) {
for name, tt := range map[string]struct {
giveContentType core.ContentType
wantHeaderValue string
}{
"plain on unknown": {giveContentType: core.UnknownContentType, wantHeaderValue: "text/plain; charset=utf-8"},
"json": {giveContentType: core.JSONContentType, wantHeaderValue: "application/json; charset=utf-8"},
"xml": {giveContentType: core.XMLContentType, wantHeaderValue: "application/xml; charset=utf-8"},
"html": {giveContentType: core.HTMLContentType, wantHeaderValue: "text/html; charset=utf-8"},
"plain": {giveContentType: core.PlainTextContentType, wantHeaderValue: "text/plain; charset=utf-8"},
} {
t.Run(name, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{
Response: fasthttp.Response{
Header: fasthttp.ResponseHeader{},
},
}
assert.Empty(t, "", ctx.Response.Header.Peek(fasthttp.HeaderContentType))
core.SetClientFormat(ctx, tt.giveContentType)
assert.Equal(t, tt.wantHeaderValue, string(ctx.Response.Header.Peek(fasthttp.HeaderContentType)))
})
}
}

View File

@ -1,33 +0,0 @@
package core
const (
// FormatHeader name of the header used to extract the format
FormatHeader = "X-Format"
// CodeHeader name of the header used as source of the HTTP status code to return
CodeHeader = "X-Code"
// OriginalURI name of the header with the original URL from NGINX
OriginalURI = "X-Original-URI"
// Namespace name of the header that contains information about the Ingress namespace
Namespace = "X-Namespace"
// IngressName name of the header that contains the matched Ingress
IngressName = "X-Ingress-Name"
// ServiceName name of the header that contains the matched Service in the Ingress
ServiceName = "X-Service-Name"
// ServicePort name of the header that contains the matched Service port in the Ingress
ServicePort = "X-Service-Port"
// RequestID is a unique ID that identifies the request - same as for backend service
RequestID = "X-Request-ID"
// ForwardedFor identifies the user of this session
ForwardedFor = "X-Forwarded-For"
// Host identifies the hosts origin
Host = "Host"
)

View File

@ -0,0 +1,111 @@
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
}

View File

@ -0,0 +1,86 @@
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
}

View File

@ -0,0 +1,62 @@
package error_page
import (
"path/filepath"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
// extractCodeFromURL extracts the error code from the given URL.
func extractCodeFromURL(url string) (uint16, bool) {
var parts = strings.SplitN(strings.TrimLeft(url, "/"), "/", 1)
if len(parts) == 0 {
return 0, false
}
var (
fileName = strings.ToLower(parts[0])
ext = filepath.Ext(fileName) // ".html", ".htm", ".%something%" or an empty string
)
if ext != "" && ext != ".html" && ext != ".htm" {
return 0, false
} else if ext != "" {
fileName = strings.TrimSuffix(fileName, ext)
}
if code, err := strconv.ParseUint(fileName, 10, 16); err == nil && code > 0 && code < 999 {
return uint16(code), true
}
return 0, false
}
// URLContainsCode checks if the given URL contains an error code.
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
}
// 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
}
}
return 0, false
}
// HeadersContainCode checks if the given headers contain an error code.
func HeadersContainCode(headers *fasthttp.RequestHeader) (ok bool) {
_, ok = extractCodeFromHeaders(headers)
return
}

View File

@ -0,0 +1,66 @@
package error_page_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
)
func TestURLContainsCode(t *testing.T) {
t.Parallel()
for giveUrl, wantOk := range map[string]bool{
"/404": true,
"/404.htm": true,
"/404.HTM": true,
"/404.html": true,
"/404.HtmL": true,
"/404.css": false,
"/foo/404": false,
"/foo/404.html": false,
"/error": false,
"/": false,
"/////": false,
"///404//": false,
"": false,
} {
t.Run(giveUrl, func(t *testing.T) {
assert.Equal(t, wantOk, error_page.URLContainsCode(giveUrl))
})
}
}
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
wantOk bool
}{
"with code": {giveHeaders: mkHeaders("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")},
} {
tt := _tt
t.Run(name, func(t *testing.T) {
assert.Equal(t, tt.wantOk, error_page.HeadersContainCode(tt.giveHeaders))
})
}
}

View File

@ -0,0 +1,135 @@
package error_page
import (
"math"
"slices"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
type preferredFormat = byte
const (
unknownFormat preferredFormat = iota // should be first, no format detected
jsonFormat // json
xmlFormat // xml
htmlFormat // html
plainTextFormat // plain text
)
// 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
var contentType, accept string
if contentTypeHeader := strings.TrimSpace(string(headers.Peek("Content-Type"))); contentTypeHeader != "" { //nolint:nestif,lll
// https://developer.mozilla.org/docs/Web/HTTP/Headers/Content-Type
// text/html; charset=utf-8
// multipart/form-data; boundary=something
// application/json
if parts := strings.SplitN(contentTypeHeader, ";", 2); len(parts) > 1 { //nolint:mnd
// take only the first part of the content type:
// text/html; charset=utf-8
// ^^^^^^^^^ - will be taken
contentType = strings.TrimSpace(parts[0])
} else {
// take the whole value
contentType = contentTypeHeader
}
} else if xFormatHeader := strings.TrimSpace(string(headers.Peek("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 != "" {
// 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
// image/*
// */*
// text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
accept = acceptHeader
} else {
return unknownFormat
}
switch {
case contentType != "":
return mimeTypeToPreferredFormat(contentType)
case accept != "":
type piece struct {
mimeType string
weight int // to avoid float32 comparison (weight 1.0 = 1_0, 0.9 = 0_9, 0.8 = 0_8, etc.)
}
var pieces = make([]piece, 0, strings.Count(accept, ",")+1)
// split application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 into parts:
// ^^^^^^^^^ - segment #3
// ^^^^^^^^^^^^^^^^^^^^^ - segment #2
// ^^^^^^^^^^^^^^^^^^^^^ - segment #1
for _, segment := range strings.FieldsFunc(accept, func(r rune) bool { return r == ',' }) {
// split segment into parts:
//
// application/xhtml+xml
// ^^^^^^^^^^^^^^^^^^^^^ - part #1
//
// application/xml;q=0.9
// ^^^^^ - part #2
// ^^^^^^^^^^^^^^^ - part #1
//
// */*;q=0.8
// ^^^^^ - part #2
// ^^^ - part #1
if parts := strings.SplitN(strings.TrimSpace(segment), ";", 2); len(parts) > 0 { //nolint:mnd,nestif
if parts[0] == "*/*" {
continue // skip the wildcard
}
var p = piece{mimeType: parts[0], weight: 1_0} //nolint:mnd // by default the weight is 10 (1.0 in float)
if len(parts) > 1 { // we need to extract the weight
// trim the `q=` prefix and try to parse the weight value
if weight, err := strconv.ParseFloat(strings.TrimPrefix(strings.ToLower(parts[1]), "q="), 32); err == nil {
if weight = math.Round(weight*100) / 100; weight <= 1 && weight >= 0 { //nolint:mnd
p.weight = int(weight * 10) //nolint:mnd
} else {
p.weight = 0 // invalid weight, set it to 0
}
}
}
pieces = append(pieces, p)
}
}
if len(pieces) > 0 {
slices.SortStableFunc(pieces, func(a, b piece) int { return b.weight - a.weight })
return mimeTypeToPreferredFormat(pieces[0].mimeType)
}
}
return unknownFormat
}
// mimeTypeToPreferredFormat converts a MIME type to a preferred format, using non-string comparison.
func mimeTypeToPreferredFormat(mimeType string) preferredFormat {
switch value := strings.ToLower(mimeType); {
case strings.Contains(value, "/json"): // application/json text/json
return jsonFormat
case strings.Contains(value, "/xml"): // application/xml text/xml
return xmlFormat
case strings.Contains(value, "+xml"): // application/xhtml+xml
return xmlFormat
case strings.Contains(value, "/html"): // text/html
return htmlFormat
case strings.Contains(value, "/plain"): // text/plain
return plainTextFormat
}
return unknownFormat
}

View File

@ -0,0 +1,114 @@
package error_page
import (
"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
wantFormat preferredFormat
}{
"content type json": {
giveHeaders: map[string][]string{"Content-Type": {"application/jSoN"}},
wantFormat: jsonFormat,
},
"content type xml": {
giveHeaders: map[string][]string{"Content-Type": {"application/xml; charset=UTF-8"}},
wantFormat: xmlFormat,
},
"content type html": {
giveHeaders: map[string][]string{"Content-Type": {"text/hTmL; charset=utf-8"}},
wantFormat: htmlFormat,
},
"content type plain": {
giveHeaders: map[string][]string{"Content-Type": {"text/plaIN"}},
wantFormat: plainTextFormat,
},
"accept json": {
giveHeaders: map[string][]string{"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"}},
wantFormat: xmlFormat,
},
"accept json, depends on weight": {
giveHeaders: map[string][]string{"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"}},
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"}},
wantFormat: htmlFormat,
},
"accept plain": {
giveHeaders: map[string][]string{"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"}},
wantFormat: jsonFormat,
},
"x-format json, depends on weight": {
giveHeaders: map[string][]string{"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"}},
wantFormat: xmlFormat,
},
"content type has priority over accept": {
giveHeaders: map[string][]string{"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"}},
wantFormat: plainTextFormat,
},
"empty headers": {
giveHeaders: nil,
},
"empty content type": {
giveHeaders: map[string][]string{"Content-Type": {" "}},
},
"wrong content type": {
giveHeaders: map[string][]string{"Content-Type": {"multipart/form-data; boundary=something"}},
},
"wrong accept": {
giveHeaders: map[string][]string{"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"}},
},
"completely unknown": {
giveHeaders: map[string][]string{"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))
})
}
}

View File

@ -0,0 +1,276 @@
package error_page
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
var (
cache, stopCh = NewRenderedCache(cacheTtl), make(chan struct{})
stopOnce sync.Once
)
// 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 {
code = fromUrl
} else if fromHeader, okHeaders := extractCodeFromHeaders(reqHeaders); okHeaders {
code = fromHeader
} else {
code = cfg.DefaultCodeToRender
}
var httpCode int
if cfg.RespondWithSameHTTPCode {
httpCode = int(code)
} else {
httpCode = http.StatusOK
}
var format = detectPreferredFormatForClient(reqHeaders)
{ // deal with the headers
switch format {
case jsonFormat:
ctx.SetContentType("application/json; charset=utf-8")
case xmlFormat:
ctx.SetContentType("application/xml; charset=utf-8")
case htmlFormat:
ctx.SetContentType("text/html; charset=utf-8")
default:
ctx.SetContentType("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")
switch code {
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
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")
}
// 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)
}
}
}
ctx.SetStatusCode(httpCode)
// prepare the template properties for rendering
var tplProps = template.Props{
Code: code, // http status code
ShowRequestDetails: cfg.ShowDetails, // status message
L10nDisabled: cfg.L10n.Disable, // status description
}
//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
}
// try to find the code message and description in the config and if not - use the standard status text or fallback
if desc, found := cfg.Codes.Find(code); found {
tplProps.Message = desc.Message
tplProps.Description = desc.Description
} else if stdlibStatusText := http.StatusText(int(code)); stdlibStatusText != "" {
tplProps.Message = stdlibStatusText
} else {
tplProps.Message = "Unknown Status Code" // fallback
}
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
}
}
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)
}
}
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 {
cache.Put(tpl, tplProps, []byte(content))
write(ctx, 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,
))
}
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)
}
}
} else {
write(ctx, 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
`)
}
}
}, func() { stopOnce.Do(func() { close(stopCh) }) }
}
var (
templateChangedAt atomic.Pointer[time.Time] //nolint:gochecknoglobals // the time when the theme was changed last time
pickedTemplate atomic.Pointer[string] //nolint:gochecknoglobals // the name of the randomly picked template
)
// templateToUse decides which template to use based on the rotation mode and the last time the template was changed.
func templateToUse(cfg *config.Config) string {
switch rotationMode := cfg.RotationMode; rotationMode {
case config.RotationModeDisabled:
return cfg.TemplateName // not needed to do anything
case config.RotationModeRandomOnStartup:
return cfg.TemplateName // do nothing, the scope of this rotation mode is not here
case config.RotationModeRandomOnEachRequest:
return cfg.Templates.RandomName() // pick a random template on each request
case config.RotationModeRandomHourly, config.RotationModeRandomDaily:
var now, rndTemplate = time.Now(), cfg.Templates.RandomName()
if changedAt := templateChangedAt.Load(); changedAt == nil {
// the template was not changed yet (first request)
templateChangedAt.Store(&now)
pickedTemplate.Store(&rndTemplate)
return rndTemplate
} else {
// is it time to change the template?
if (rotationMode == config.RotationModeRandomHourly && changedAt.Hour() != now.Hour()) ||
(rotationMode == config.RotationModeRandomDaily && changedAt.Day() != now.Day()) {
templateChangedAt.Store(&now)
pickedTemplate.Store(&rndTemplate)
return rndTemplate
} else if lastUsed := pickedTemplate.Load(); lastUsed != nil {
// time to change the template has not come yet, so use the last picked template
return *lastUsed
} else {
// in case if the last picked template is not set, pick a random one and store it
templateChangedAt.Store(&now)
pickedTemplate.Store(&rndTemplate)
return rndTemplate
}
}
}
return cfg.TemplateName // the fallback of the fallback :D
}
// 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) {
var data []byte
if s, ok := any(content).(string); ok {
data = []byte(s)
} else {
data = any(content).([]byte)
}
if _, err := ctx.Write(data); err != nil && log != nil {
log.Error("failed to write the response body",
logger.String("content", string(data)),
logger.Error(err),
)
}
}

View File

@ -0,0 +1,226 @@
package error_page_test
import (
"net/http"
"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"
)
func TestHandler(t *testing.T) {
t.Parallel()
for name, tt := range map[string]struct {
giveConfig func() *config.Config
giveUrl string
giveHeaders map[string]string
wantStatusCode int
wantHeaders map[string]string
wantBodyIncludes []string
}{
"common, plain text": {
giveConfig: func() *config.Config { cfg := config.New(); return &cfg },
giveUrl: "http://testing/",
giveHeaders: map[string]string{"Content-Type": "text/plain"},
wantStatusCode: http.StatusOK,
wantHeaders: map[string]string{"Content-Type": "text/plain; charset=utf-8"},
wantBodyIncludes: []string{"Error 404", "Not Found"},
},
"common, html": {
giveConfig: func() *config.Config {
cfg := config.New()
cfg.TemplateName = "ghost"
return &cfg
},
giveUrl: "http://testing/",
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>",
"<title>407: Proxy Authentication Required",
"Proxy Authentication Required",
},
},
"common, json": {
giveConfig: func() *config.Config {
cfg := config.New()
cfg.RespondWithSameHTTPCode = true
return &cfg
},
giveUrl: "http://testing/503.html?rnd=123",
giveHeaders: map[string]string{"Accept": "application/json", "X-FooBar": "baz"},
wantStatusCode: http.StatusServiceUnavailable,
wantHeaders: map[string]string{
"Content-Type": "application/json; charset=utf-8",
"X-FooBar": "", // is not in the list of proxy headers
},
wantBodyIncludes: []string{"503", "Service Unavailable"},
},
"common, xml": {
giveConfig: func() *config.Config {
cfg := config.New()
cfg.ProxyHeaders = append(cfg.ProxyHeaders, "X-FooBar")
return &cfg
},
giveUrl: "http://testing/500",
giveHeaders: map[string]string{"Accept": "application/xml", "X-FooBar": "baz"},
wantStatusCode: http.StatusOK,
wantHeaders: map[string]string{
"Content-Type": "application/xml; charset=utf-8",
"X-FooBar": "baz",
},
wantBodyIncludes: []string{"500", "Internal Server Error"},
},
"show details": {
giveConfig: func() *config.Config {
cfg := config.New()
cfg.ShowDetails = true
return &cfg
},
giveUrl: "http://example.com/503",
giveHeaders: map[string]string{
"Accept": "application/json",
"X-Original-URI": "/foo/bar",
"X-Namespace": "some-Namespace",
"X-Ingress-Name": "ingress-name",
"X-Service-Name": "service-name",
"X-Service-Port": "666",
"X-Request-ID": "req-id-777",
"X-Forwarded-For": "123.123.123.123:12312",
},
wantStatusCode: http.StatusOK,
wantHeaders: map[string]string{"Content-Type": "application/json; charset=utf-8"},
wantBodyIncludes: []string{
"503",
"Service Unavailable",
"details",
"/foo/bar",
"some-Namespace",
"ingress-name",
"service-name",
"666",
"req-id-777",
"123.123.123.123:12312",
"example.com",
},
},
"fallback to StatusText if code is not found": {
giveConfig: func() *config.Config {
cfg := config.New()
cfg.Codes = config.Codes{}
return &cfg
},
giveUrl: "http://testing/100",
giveHeaders: map[string]string{"Accept": "application/json"},
wantStatusCode: http.StatusOK,
wantHeaders: map[string]string{"Content-Type": "application/json; charset=utf-8"},
wantBodyIncludes: []string{"100", "Continue"},
},
"unknown code": {
giveConfig: func() *config.Config {
cfg := config.New()
cfg.Codes = config.Codes{}
return &cfg
},
giveUrl: "http://testing/1",
giveHeaders: map[string]string{"Accept": "application/json"},
wantStatusCode: http.StatusOK,
wantHeaders: map[string]string{"Content-Type": "application/json; charset=utf-8"},
wantBodyIncludes: []string{"1", "Unknown Status Code"},
},
} {
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)
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)
for hName, hWant := range tt.wantHeaders {
for hGot := range headers {
if hGot == hName {
assert.Contains(t, hWant, headers.Get(hGot))
}
}
}
for _, wantBodyInclude := range tt.wantBodyIncludes {
assert.Contains(t, body, wantBodyInclude)
}
})
})
}
}
func TestRotationModeOnEachRequest(t *testing.T) {
t.Parallel()
var cfg = config.New()
cfg.RotationMode = config.RotationModeRandomOnEachRequest
cfg.Templates = map[string]string{
"foo": "foo",
"bar": "bar",
}
var (
lastResponseBody string
changedTimes int
handler, closeCache = 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)
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
}
})
}
assert.True(t, changedTimes > 30, "the template should be changed at least 30 times")
}

View File

@ -1,35 +0,0 @@
package errorpage
import (
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/http/core"
"gh.tarampamp.am/error-pages/internal/options"
"gh.tarampamp.am/error-pages/internal/tpl"
)
type (
templatePicker interface {
// Pick the template name for responding.
Pick() string
}
renderer interface {
Render(content []byte, props tpl.Properties) ([]byte, error)
}
)
// NewHandler creates handler for error pages serving.
func NewHandler(cfg *config.Config, p templatePicker, rdr renderer, opt options.ErrorPage) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
core.SetClientFormat(ctx, core.PlainTextContentType) // default content type
if code, ok := ctx.UserValue("code").(string); ok {
core.RespondWithErrorPage(ctx, cfg, p, rdr, code, fasthttp.StatusOK, opt)
} else { // will never occur
ctx.SetStatusCode(fasthttp.StatusInternalServerError)
_, _ = ctx.WriteString("cannot extract requested code from the request")
}
}
}

View File

@ -1,7 +0,0 @@
package errorpage_test
import "testing"
func TestNothing(t *testing.T) {
t.Skip("tests for this package have not been implemented yet")
}

View File

@ -1,24 +0,0 @@
package healthz
import "github.com/valyala/fasthttp"
// checker allows to check some service part.
type checker interface {
// Check makes a check and return error only if something is wrong.
Check() error
}
// NewHandler creates healthcheck handler.
func NewHandler(checker checker) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
if err := checker.Check(); err != nil {
ctx.SetStatusCode(fasthttp.StatusServiceUnavailable)
_, _ = ctx.WriteString(err.Error())
return
}
ctx.SetStatusCode(fasthttp.StatusOK)
_, _ = ctx.WriteString("OK")
}
}

View File

@ -1,7 +0,0 @@
package healthz_test
import "testing"
func TestNothing(t *testing.T) {
t.Skip("tests for this package have not been implemented yet")
}

View File

@ -1,50 +0,0 @@
package index
import (
"strconv"
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/http/core"
"gh.tarampamp.am/error-pages/internal/options"
"gh.tarampamp.am/error-pages/internal/tpl"
)
type (
templatePicker interface {
// Pick the template name for responding.
Pick() string
}
renderer interface {
Render(content []byte, props tpl.Properties) ([]byte, error)
}
)
// NewHandler creates handler for the index page serving.
func NewHandler(cfg *config.Config, p templatePicker, rdr renderer, opt options.ErrorPage) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
pageCode, httpCode := opt.Default.PageCode, int(opt.Default.HTTPCode)
if returnCode, ok := extractCodeToReturn(ctx); ok {
pageCode, httpCode = strconv.Itoa(returnCode), returnCode
}
core.RespondWithErrorPage(ctx, cfg, p, rdr, pageCode, httpCode, opt)
}
}
func extractCodeToReturn(ctx *fasthttp.RequestCtx) (int, bool) { // for the Ingress support
var ch = ctx.Request.Header.Peek(core.CodeHeader)
if len(ch) > 0 && len(ch) <= 3 {
if code, err := strconv.Atoi(string(ch)); err == nil {
if code > 0 && code <= 599 {
return code, true
}
}
}
return 0, false
}

View File

@ -1,7 +0,0 @@
package index_test
import "testing"
func TestNothing(t *testing.T) {
t.Skip("tests for this package have not been implemented yet")
}

View File

@ -0,0 +1,30 @@
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"
)
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)
case fasthttp.MethodHead:
ctx.SetStatusCode(http.StatusOK)
default:
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
}
}
}

View File

@ -0,0 +1,52 @@
package live_test
import (
"net/http"
"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
)
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)
})
})
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)
})
})
t.Run("method not allowed", func(t *testing.T) {
for _, method := range []string{
http.MethodDelete,
http.MethodPatch,
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)
})
}
})
}

View File

@ -1,16 +0,0 @@
// Package metrics contains HTTP handler for application metrics (prometheus format) generation.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpadaptor"
)
// NewHandler creates metrics handler.
func NewHandler(registry prometheus.Gatherer) fasthttp.RequestHandler {
return fasthttpadaptor.NewFastHTTPHandler(
promhttp.HandlerFor(registry, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}),
)
}

View File

@ -1,7 +0,0 @@
package metrics_test
import "testing"
func TestNothing(t *testing.T) {
t.Skip("tests for this package have not been implemented yet")
}

View File

@ -1,28 +0,0 @@
package notfound
import (
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/http/core"
"gh.tarampamp.am/error-pages/internal/options"
"gh.tarampamp.am/error-pages/internal/tpl"
)
type (
templatePicker interface {
// Pick the template name for responding.
Pick() string
}
renderer interface {
Render(content []byte, props tpl.Properties) ([]byte, error)
}
)
// NewHandler creates handler missing requests handling.
func NewHandler(cfg *config.Config, p templatePicker, rdr renderer, opt options.ErrorPage) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
core.RespondWithErrorPage(ctx, cfg, p, rdr, "404", fasthttp.StatusNotFound, opt)
}
}

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