diff --git a/.codecov.yml b/.codecov.yml deleted file mode 100644 index 0e9b4e14..00000000 --- a/.codecov.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Docs: - -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 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..68e3bde2 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} diff --git a/.dockerignore b/.dockerignore index d3cdf734..bc06aa35 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,6 @@ ## Except the following files and directories !/cmd !/internal +!/l10n !/templates -!/error-pages.yml !/go.* diff --git a/.editorconfig b/.editorconfig index 34a56c72..27a58b55 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 44f1ee2c..00000000 --- a/.gitattributes +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index d5fa74c9..13fb26f9 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,4 +1,5 @@ -# Docs: +# 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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e5451ce5..9a63c692 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,5 @@ -# Docs: +# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json +# docs: https://git.io/JP3tm blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 8a1397e8..cc8c7841 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,4 +1,5 @@ -# Docs: +# 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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 148e1df3..dc23e1ad 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,5 @@ -# Docs: +# 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 diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..2a0b405a --- /dev/null +++ b/.github/release.yml @@ -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: ['*'] diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml index 1a9f9cc5..53955f6f 100644 --- a/.github/workflows/dependabot.yml +++ b/.github/workflows/dependabot.yml @@ -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 }} diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 53e64214..b988f751 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -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: + - uses: peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKER_LOGIN }} password: ${{ secrets.DOCKER_USER_PASSWORD }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3345e0ba..66084365 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: types: [published] jobs: - purge-cdn-cache: - name: Purge jsDelivr CDN cache - runs-on: ubuntu-latest - steps: - - uses: gacts/purge-jsdelivr-cache@v1 # Action page: - 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: - - - uses: docker/setup-buildx-action@v3 # Action page: - - - uses: docker/login-action@v3 # Action page: + - 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: + - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/build-push-action@v5 # Action page: + - 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 }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ff44411..2598d53f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: +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: + - {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: - - - 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: - - - 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: 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: 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: + - 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: - 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 diff --git a/.gitignore b/.gitignore index 9dc7412f..4a6abfa0 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.golangci.yml b/.golangci.yml index 5252c3a2..80ed7613 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,26 +1,32 @@ -# Documentation: +# 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: disable-all: true @@ -50,40 +63,65 @@ linters: # All available linters list: > /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 - -COPY . . +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 -# 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: + # 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 + +ENV LOG_LEVEL="warn" -# Docs: -HEALTHCHECK --interval=7s --timeout=2s CMD ["/bin/error-pages", "--log-json", "healthcheck"] +# 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"] diff --git a/Makefile b/Makefile index 00fea399..69ac3bdb 100644 --- a/Makefile +++ b/Makefile @@ -1,64 +1,38 @@ #!/usr/bin/make -# Makefile readme (ru): -# Makefile readme (en): - -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 ...`'; - -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 - -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/ - -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 - -lint: ## Run app linters - docker-compose run --rm --no-deps golint 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: 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 \ + " + +.PHONY: down +down: ## Stop the application + docker compose down --remove-orphans + +.PHONY: shell +shell: ## Start shell into development environment + docker compose run -ti $(DC_RUN_ARGS) develop bash + +.PHONY: test +test: ## Run tests + docker compose run $(DC_RUN_ARGS) develop gotestsum --format pkgname -- -race -timeout 2m ./... + +.PHONY: lint +lint: ## Run linters + docker compose run $(DC_RUN_ARGS) develop golangci-lint run + +.PHONY: gen +gen: ## Generate code + docker compose run $(DC_RUN_ARGS) develop go generate ./... diff --git a/README.md b/README.md index e3ea8918..63e186f2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@

- @@ -12,148 +11,612 @@

-

- 22 feb. 2022 - โšก Our Docker image was downloaded one MILLION times from the docker hub! โšก
- 10 apr. 2023 - โšก Two million times from the docker hub and one million from the ghcr! โšก -

+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 + +### HTTP server starting, utilizing either a binary file or Docker image + +First, ensure you have a precompiled binary file on your machine or have Docker/Podman installed. Next, start the +server with the following command: + +```bash +./error-pages serve +# or +docker run --rm -p '8080:8080/tcp' tarampampam/error-pages serve +``` + +That's it! The server will begin running and listen on address `0.0.0.0` and port `8080`. Access error pages using +URLs like `http://127.0.0.1:8080/{page_code}.html`. + +To retrieve different error page codes using a static URL, use the `X-Code` HTTP header: + +```bash +curl -H 'X-Code: 500' http://127.0.0.1:8080/ +``` + +The server respects the `Content-Type` HTTP header (and `X-Format`), delivering responses in requested formats +such as HTML, XML, JSON, and PlainText. Customization of these formats is possible via CLI flags or environment +variables. -Please, take a look at [our Wiki][wiki] for the common usage stories: +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. -- [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] +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). + +
+ ๐Ÿš€ Generate a set of error pages using built-in or my own template + +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 + + + + {{ code }} + + +

{{ message }}: {{ description }}

+ + +``` + +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 + + + + 403 + + +

Forbidden: Access is forbidden to the requested page

+ + +``` + +
+ +
+ ๐Ÿš€ Customize error pages within your own Nginx Docker image -[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 +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 +``` + +
+ +
+ ๐Ÿš€ Usage with Traefik and local Docker Compose + +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..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? ๐Ÿ˜€ + +
+ +
+ ๐Ÿš€ Kubernetes (K8s) & Ingress Nginx + +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' +``` + +
## ๐Ÿฆพ Performance -Used hardware: +Hardware used: + +- 12th Gen Intelยฎ Coreโ„ข i7-1260P (16 cores) +- 32 GiB RAM -- Intelยฎ Coreโ„ข i7-10510U CPU @ 1.80GHz ร— 8 -- 16 GiB RAM +RPS: **~180k** ๐Ÿ”ฅ requests served without any errors, with peak memory usage ~60 MiB under the default configuration + +
+ Performance test details (click to expand) ```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 -
- FS & memory usage stats during the test +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 +``` -

- -

-## ๐Ÿช‚ Templates - -| Name | Preview | -|:-----------------:|:------------------------------------------------------------------:| -| `ghost` | [![ghost][ghost-screen]][ghost-link] | -| `l7-light` | [![l7-light][l7-light-screen]][l7-light-link] | -| `l7-dark` | [![l7-dark][l7-dark-screen]][l7-dark-link] | -| `shuffle` | [![shuffle][shuffle-screen]][shuffle-link] | -| `noise` | [![noise][noise-screen]][noise-link] | -| `hacker-terminal` | [![hacker-terminal][hacker-terminal-screen]][hacker-terminal-link] | -| `cats` | [![cats][cats-screen]][cats-link] | -| `lost-in-space` | [![lost-in-space][lost-in-space-screen]][lost-in-space-link] | -| `app-down` | [![app-down][app-down-screen]][app-down-link] | -| `connection` | [![connection][connection-screen]][connection-link] | -| `matrix` | [![matrix][matrix-screen]][matrix-link] | -| `orient` | [![orient][orient-screen]][orient-link] | - -> Note: `noise` template highly uses the CPU, be careful - -[ghost-screen]:https://hsto.org/webt/oj/cl/4k/ojcl4ko_cvusy5xuki6efffzsyo.gif -[ghost-link]:https://tarampampam.github.io/error-pages/ghost/404.html -[l7-light-screen]:https://hsto.org/webt/hx/ca/mm/hxcammfm7qjmogtvsjxcidgf7c8.png -[l7-light-link]:https://tarampampam.github.io/error-pages/l7-light/404.html -[l7-dark-screen]:https://hsto.org/webt/s1/ih/yr/s1ihyrqs_y-sgraoimfhk6ypney.png -[l7-dark-link]:https://tarampampam.github.io/error-pages/l7-dark/404.html -[shuffle-screen]:https://hsto.org/webt/7w/rk/3m/7wrk3mrzz3y8qfqwovmuvacu-bs.gif -[shuffle-link]:https://tarampampam.github.io/error-pages/shuffle/404.html -[noise-screen]:https://hsto.org/webt/42/oq/8y/42oq8yok_i-arrafjt6hds_7ahy.gif -[noise-link]:https://tarampampam.github.io/error-pages/noise/404.html -[hacker-terminal-screen]:https://hsto.org/webt/5s/l0/p1/5sl0p1_ud_nalzjzsj5slz6dfda.gif -[hacker-terminal-link]:https://tarampampam.github.io/error-pages/hacker-terminal/404.html -[cats-screen]:https://hsto.org/webt/_g/y-/ke/_gy-keqinz-3867jbw36v37-iwe.jpeg -[cats-link]:https://tarampampam.github.io/error-pages/cats/404.html -[lost-in-space-screen]:https://hsto.org/webt/lf/ln/x8/lflnx8fuy4rofxju34ttskijdsu.gif -[lost-in-space-link]:https://tarampampam.github.io/error-pages/lost-in-space/404.html -[app-down-screen]:https://habrastorage.org/webt/j2/la/fj/j2lafjvu_xjflzrvhiixobxy_ca.png -[app-down-link]:https://tarampampam.github.io/error-pages/app-down/404.html -[connection-screen]:https://hsto.org/webt/x4/ah/jb/x4ahjboo4-arm3bxpaash_sflmw.png -[connection-link]:https://tarampampam.github.io/error-pages/connection/404.html -[matrix-screen]:https://hsto.org/webt/ng/tf/oi/ngtfoiolvmq6hf15kimcxmhprhk.gif -[matrix-link]:https://tarampampam.github.io/error-pages/matrix/404.html -[orient-screen]:https://hsto.org/webt/pz/eu/v_/pzeuv_lyeqr0xpusa4zfrtgk7sa.png -[orient-link]:https://tarampampam.github.io/error-pages/orient/404.html + + +## CLI interface + +Usage: + +```bash +$ error-pages [GLOBAL FLAGS] [COMMAND] [COMMAND FLAGS] [ARGUMENTS...] +``` + +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` | + + + +## ๐Ÿช‚ 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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TemplatePreview
+ app-down

+ used times +
+ + + + +
+ cats

+ used times +
+ + + + +
+ connection

+ used times +
+ + + + +
+ ghost

+ used times +
+ + + + +
+ hacker-terminal

+ used times +
+ + + +
+ l7

+ used times +
+ + + + +
+ lost-in-space

+ used times +
+ + + + +
+ noise

+ used times +
+ + + +
+ orient

+ used times +
+ + + + +
+ shuffle

+ used times +
+ + + + +
+ +> [!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 diff --git a/cmd/error-pages/main.go b/cmd/error-pages/main.go index 443d3328..0b9a42d3 100644 --- a/cmd/error-pages/main.go +++ b/cmd/error-pages/main.go @@ -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) {})) -// run this CLI application. -// Exit codes documentation: -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()) + if err := run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err.Error()) - return 1 + os.Exit(1) } +} + +// run this CLI application. +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 0 + return (cli.NewApp(filepath.Base(os.Args[0]))).Run(ctx, os.Args) } diff --git a/cmd/error-pages/main_test.go b/cmd/error-pages/main_test.go deleted file mode 100644 index 3b7532a6..00000000 --- a/cmd/error-pages/main_test.go +++ /dev/null @@ -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:") -} diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..b7753c41 --- /dev/null +++ b/compose.yml @@ -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: {} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index fffcd1a8..00000000 --- a/docker-compose.yml +++ /dev/null @@ -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 - 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: - environment: - GOLANGCI_LINT_CACHE: /tmp/golint # - 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] diff --git a/error-pages.yml b/error-pages.yml deleted file mode 100644 index c23b6f6d..00000000 --- a/error-pages.yml +++ /dev/null @@ -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: | - - - {{ code }} - {{ message }} - {{ description }}{{ if show_details }} -
- {{ host }} - {{ original_uri }} - {{ forwarded_for }} - {{ namespace }} - {{ ingress_name }} - {{ service_name }} - {{ service_port }} - {{ request_id }} - {{ now.Unix }} -
{{ end }} -
- -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 diff --git a/go.mod b/go.mod index 954e2b3b..467baece 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 267d8526..26ec34a1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/appmeta/doc.go b/internal/appmeta/doc.go new file mode 100644 index 00000000..6798c806 --- /dev/null +++ b/internal/appmeta/doc.go @@ -0,0 +1,2 @@ +// Package appmeta provides the application metadata, such as version. +package appmeta diff --git a/internal/version/version.go b/internal/appmeta/version.go similarity index 70% rename from internal/version/version.go rename to internal/appmeta/version.go index e5db9220..d34bdb2a 100644 --- a/internal/version/version.go +++ b/internal/appmeta/version.go @@ -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:] diff --git a/internal/version/version_test.go b/internal/appmeta/version_test.go similarity index 92% rename from internal/version/version_test.go rename to internal/appmeta/version_test.go index bed3e5b8..d78bcc0b 100644 --- a/internal/version/version_test.go +++ b/internal/appmeta/version_test.go @@ -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", diff --git a/internal/breaker/os_signal.go b/internal/breaker/os_signal.go deleted file mode 100644 index ede28864..00000000 --- a/internal/breaker/os_signal.go +++ /dev/null @@ -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) -} diff --git a/internal/breaker/os_signal_test.go b/internal/breaker/os_signal_test.go deleted file mode 100644 index b3dd4c25..00000000 --- a/internal/breaker/os_signal_test.go +++ /dev/null @@ -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) -} diff --git a/internal/checkers/health.go b/internal/checkers/health.go deleted file mode 100644 index fc4724a3..00000000 --- a/internal/checkers/health.go +++ /dev/null @@ -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 -} diff --git a/internal/checkers/health_test.go b/internal/checkers/health_test.go deleted file mode 100644 index 63311c61..00000000 --- a/internal/checkers/health_test.go +++ /dev/null @@ -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") -} diff --git a/internal/checkers/live.go b/internal/checkers/live.go deleted file mode 100644 index 10c666c4..00000000 --- a/internal/checkers/live.go +++ /dev/null @@ -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 } diff --git a/internal/checkers/live_test.go b/internal/checkers/live_test.go deleted file mode 100644 index 3e811cff..00000000 --- a/internal/checkers/live_test.go +++ /dev/null @@ -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()) -} diff --git a/internal/cli/app.go b/internal/cli/app.go index 9b22fc14..ecf807d5 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -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 - ) - - // create "default" logger (will be overwritten later with customized) - var log, _ = logger.New(defaultLogLevel, defaultLogFormat) // error will never occurs - - return &cli.App{ - Usage: appName, - Before: func(c *cli.Context) (err error) { - _ = log.Sync() // sync previous logger instance - - 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 { +// 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 } - } - if c.Bool(logJSONFlagName) { - logFormat = logger.JSONFormat - } else { - // parse logging format - if logFormat, err = logger.ParseFormat(c.String(logFormatFlagName)); err != nil { + 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 } - } - configured, err := logger.New(logLevel, logFormat) // create new logger instance + return nil + }, + } + ) + + // create a "default" logger (will be swapped later with customized) + var log, _ = logger.New(logger.InfoLevel, logger.ConsoleFormat) // error will never occur + + 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)) // --//-- + ) + + 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, }, } } diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 7515bb96..152bae99 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -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{""})) } diff --git a/internal/cli/build/command.go b/internal/cli/build/command.go index 7ca19420..657f3123 100644 --- a/internal/cli/build/command.go +++ b/internal/cli/build/command.go @@ -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{} - - const ( - generateIndexFlagName = "index" - disableL10nFlagName = "disable-l10n" +func NewCommand(log *logger.Logger) *cli.Command { //nolint:funlen,gocognit + var ( + cmd command + cfg = config.New() + + 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 ", - 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") - } - - log.Info("output directory preparing", zap.String("path", outDirectoryPath)) + var history = make(map[string][]historyItem, len(cfg.Codes)*len(cfg.Templates)) // map[template_name]codes - if err := cmd.createDirectory(outDirectoryPath, outDirPerm); err != nil { - return errors.Wrap(err, "cannot prepare output directory") - } + for templateName, templateContent := range cfg.Templates { + log.Debug("Processing template", logger.String("name", templateName)) - history, renderer := newBuildingHistory(), tpl.NewTemplateRenderer() - defer func() { _ = renderer.Close() }() + 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) + } - for _, template := range cfg.Templates { - log.Debug("template processing", zap.String("name", template.Name())) + var codeAsUint, codeParsingErr = strconv.ParseUint(code, 10, 32) + if codeParsingErr != nil { + log.Warn("Cannot parse code", logger.String("code", code)) - for _, page := range cfg.Pages { - if err := cmd.createDirectory(path.Join(outDirectoryPath, template.Name()), outDirPerm); err != nil { - return err + continue } - var ( - fileName = page.Code() + outHTMLFileExt - filePath = path.Join(outDirectoryPath, template.Name(), fileName) - ) + var outFilePath = path.Join(cmd.opt.targetDirAbsPath, templateName, code+".html") - content, renderingErr := renderer.Render(template.Content(), tpl.Properties{ - Code: page.Code(), - Message: page.Message(), - Description: page.Description(), + 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, - }) - if renderingErr != nil { - return renderingErr - } - - if err := os.WriteFile(filePath, content, outFilePerm); err != nil { - return err + }); 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 rendered", zap.String("path", filePath)) + log.Debug("Page built", logger.String("template", templateName), logger.String("code", code)) - if generateIndex { - history.Append( - template.Name(), - page.Code(), - page.Message(), - path.Join(template.Name(), fileName), - ) - } + history[templateName] = append(history[templateName], historyItem{ + Code: code, + Message: codeDescription.Message, + RelativePath: "." + strings.TrimPrefix(outFilePath, cmd.opt.targetDirAbsPath), // to make it relative + }) } } - if generateIndex { - var filepath = path.Join(outDirectoryPath, outIndexFileName+outHTMLFileExt) + if cmd.opt.createIndex { + log.Debug("Creating the index file") + + for name := range history { + slices.SortFunc(history[name], func(a, b historyItem) int { return strings.Compare(a.Code, b.Code) }) + } + + indexTpl, tplErr := template.New("index").Parse(indexHtml) + if tplErr != nil { + return tplErr + } - log.Info("index file generation", zap.String("path", filepath)) + var buf strings.Builder - if err := history.WriteIndexFile(filepath, outFilePerm); err != nil { + 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 diff --git a/internal/cli/build/command_test.go b/internal/cli/build/command_test.go deleted file mode 100644 index 5ad4e29e..00000000 --- a/internal/cli/build/command_test.go +++ /dev/null @@ -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") -} diff --git a/internal/cli/build/history.go b/internal/cli/build/history.go deleted file mode 100644 index 2baa73ee..00000000 --- a/internal/cli/build/history.go +++ /dev/null @@ -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) -} diff --git a/internal/cli/build/index.html b/internal/cli/build/index.html new file mode 100644 index 00000000..0374c235 --- /dev/null +++ b/internal/cli/build/index.html @@ -0,0 +1,122 @@ + + + + + + Error pages list + + + + +
+
+

Error pages index

+
+ + + + +
+ + diff --git a/internal/cli/build/index.tpl.html b/internal/cli/build/index.tpl.html deleted file mode 100644 index f69035b4..00000000 --- a/internal/cli/build/index.tpl.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - Error pages list - - - - -
-
-
- -

Error pages index

-
- {{- range $template, $item := . -}} -

Template name: {{ $template }}

- - {{ end }} -
-
- - - diff --git a/internal/cli/healthcheck/checker.go b/internal/cli/healthcheck/checker.go new file mode 100644 index 00000000..b6ff46c0 --- /dev/null +++ b/internal/cli/healthcheck/checker.go @@ -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 +} diff --git a/internal/cli/healthcheck/checker_test.go b/internal/cli/healthcheck/checker_test.go new file mode 100644 index 00000000..66a97461 --- /dev/null +++ b/internal/cli/healthcheck/checker_test.go @@ -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)) + }) + } +} diff --git a/internal/cli/healthcheck/command.go b/internal/cli/healthcheck/command.go index 87a5be1c..3d5aae4d 100644 --- a/internal/cli/healthcheck/command.go +++ b/internal/cli/healthcheck/command.go @@ -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, }, } } diff --git a/internal/cli/healthcheck/command_test.go b/internal/cli/healthcheck/command_test.go index 301dc11d..e805e505 100644 --- a/internal/cli/healthcheck/command_test.go +++ b/internal/cli/healthcheck/command_test.go @@ -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) +} + +type fakeHealthChecker struct { + t *testing.T + wantAddress string + giveErr error } -func TestCommandRun(t *testing.T) { - cmd := healthcheck.NewCommand(&fakeChecker{err: nil}) +func (m *fakeHealthChecker) Check(_ context.Context, addr string) error { + assert.Equal(m.t, m.wantAddress, addr) - assert.NoError(t, cmd.Run(cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil))) + return m.giveErr } -func TestCommandRunFailed(t *testing.T) { - cmd := healthcheck.NewCommand(&fakeChecker{err: errors.New("foo err")}) +func TestCommand_RunSuccess(t *testing.T) { + var cmd = healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{ + t: t, + wantAddress: "http://127.0.0.1:1234", + }) - assert.ErrorContains(t, cmd.Run(cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil)), "foo err") + require.NoError(t, cmd.Run(context.Background(), []string{"", "--port", "1234"})) } -func TestPortFlagWrongArgument(t *testing.T) { - cmd := healthcheck.NewCommand(&fakeChecker{err: nil}) +func TestCommand_RunFail(t *testing.T) { + cmd := healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{ + t: t, + wantAddress: "http://127.0.0.1:4321", + giveErr: assert.AnError, + }) - err := cmd.Run( - cli.NewContext(cli.NewApp(), &flag.FlagSet{}, nil), - "", "-p", "65536", + assert.ErrorIs(t, + cmd.Run(context.Background(), []string{"", "--port", "4321"}), + assert.AnError, ) - - assert.ErrorContains(t, err, "port value out of range") } diff --git a/internal/cli/perftest/command.go b/internal/cli/perftest/command.go new file mode 100644 index 00000000..c6f2abe4 --- /dev/null +++ b/internal/cli/perftest/command.go @@ -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 +} diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 32315c59..4a4cf618 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -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" -) +// NewCommand creates `serve` 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} + ) -const ( - useRandomTemplate = "random" - useRandomTemplateOnEachRequest = "i-said-random" - useRandomTemplateDaily = "random-daily" - useRandomTemplateHourly = "random-hourly" -) + 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) + } -// NewCommand creates `serve` command. -func NewCommand(log *zap.Logger) *cli.Command { //nolint:funlen - var cmd = command{} + 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 - - 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 - } + 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) + + { // override default JSON, XML, and PlainText formats + if c.IsSet(jsonFormatFlag.Name) { + cfg.Formats.JSON = strings.TrimSpace(c.String(jsonFormatFlag.Name)) + } - var ( - ip = c.String(shared.ListenAddrFlag.Name) - port = uint16(c.Uint(shared.ListenPortFlag.Name)) - o options.ErrorPage - ) + if c.IsSet(xmlFormatFlag.Name) { + cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name)) + } - if net.ParseIP(ip) == nil { - return fmt.Errorf("wrong IP address [%s] for listening", ip) + if c.IsSet(plainTextFormatFlag.Name) { + cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name)) + } } - { // 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{}{} - } - } + // 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), + ) } + } + } + + // 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) + } + } - // convert map into slice - o.ProxyHTTPHeaders = make([]string, 0, len(m)) - for h := range m { - o.ProxyHTTPHeaders = append(o.ProxyHTTPHeaders, h) + // 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)) } } } - if o.Default.HTTPCode > 599 { //nolint:gomnd - return fmt.Errorf("wrong default HTTP response code [%d]", o.Default.HTTPCode) + // 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(), + ) + } } - return cmd.Run(c.Context, log, cfg, ip, port, c.Uint(readBufferSizeFlagName), o) + 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 - ) - - // 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 - }() +func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error { //nolint:funlen + var srv = appHttp.NewServer(log, cmd.opt.http.readBufferSize) - var ( - 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) + if err := srv.Register(cfg); err != nil { + return err + } - 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) + 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 } - } - // create HTTP server - server := appHttp.NewServer(log, readBufferSize) + req.Header.Set("User-Agent", fmt.Sprintf("Mozilla/5.0 (error-pages, rnd:%d)", time.Now().UnixNano())) - // register server routes, middlewares, etc. - if err := server.Register(cfg, picker, opt); err != nil { - return err - } + 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)) - startedAt, startingErrCh := time.Now(), make(chan error, 1) // channel for server starting error + return + } else if resp != nil { + _ = resp.Body.Close() + } + }() // start HTTP server in separate goroutine go func(errCh chan<- error) { - defer close(errCh) - - 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), - } + var now = time.Now() - if readBufferSize > 0 { - fields = append(fields, zap.Uint("read buffer size", readBufferSize)) - } + defer func() { + log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond))) + }() - log.Info("Server starting", fields...) + log.Info("HTTP server starting", + logger.String("addr", cmd.opt.http.addr), + logger.Uint16("port", cmd.opt.http.port), + ) - 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 } } diff --git a/internal/cli/serve/command_test.go b/internal/cli/serve/command_test.go index 2882055c..bd21b65a 100644 --- a/internal/cli/serve/command_test.go +++ b/internal/cli/serve/command_test.go @@ -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", "", + "--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) } diff --git a/internal/cli/serve/testdata/foo-template.html b/internal/cli/serve/testdata/foo-template.html new file mode 100644 index 00000000..04e75bc5 --- /dev/null +++ b/internal/cli/serve/testdata/foo-template.html @@ -0,0 +1,10 @@ + + + + + Title + + + + + diff --git a/internal/cli/shared/flags.go b/internal/cli/shared/flags.go index 6f7be21e..a2d2a8a4 100644 --- a/internal/cli/shared/flags.go +++ b/internal/cli/shared/flags.go @@ -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" +) + +const ( + CategoryHTTP = "HTTP:" + CategoryTemplates = "TEMPLATES:" + CategoryCodes = "HTTP CODES:" + CategoryFormats = "FORMATS:" + CategoryBuild = "BUILD:" + CategoryOther = "OTHER:" ) -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()}, +// 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 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 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 + }, } -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()}, +// 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 ListenPortFlag = &cli.UintFlag{ //nolint:gochecknoglobals - Name: "port", - Aliases: []string{"p"}, - Usage: "TCP port number", - Value: 8080, //nolint:gomnd - EnvVars: []string{env.ListenPort.String()}, +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, } diff --git a/internal/cli/shared/flags_test.go b/internal/cli/shared/flags_test.go new file mode 100644 index 00000000..3528b396 --- /dev/null +++ b/internal/cli/shared/flags_test.go @@ -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") +} diff --git a/internal/cli/update_readme.go b/internal/cli/update_readme.go new file mode 100644 index 00000000..c108ca3f --- /dev/null +++ b/internal/cli/update_readme.go @@ -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()) + } +} diff --git a/internal/config/codes.go b/internal/config/codes.go new file mode 100644 index 00000000..7960b02d --- /dev/null +++ b/internal/config/codes.go @@ -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 diff --git a/internal/config/codes_test.go b/internal/config/codes_test.go new file mode 100644 index 00000000..74297a3d --- /dev/null +++ b/internal/config/codes_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index eba89f9c..032cc4f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,255 +1,175 @@ package config import ( - "os" - "path" - "path/filepath" - "strconv" - "strings" - - "github.com/a8m/envsubst" - "github.com/pkg/errors" - "gopkg.in/yaml.v3" + "maps" + "net/http" + "slices" + + 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 -// 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 - } + // 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 } - return &Template{}, false -} - -func (c *Config) JSONFormat() (*Format, bool) { return c.format("json") } -func (c *Config) XMLFormat() (*Format, bool) { return c.format("xml") } + // Codes hold descriptions for HTTP codes (e.g., 404: "Not Found / The server can not find the requested page"). + Codes Codes -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 -} + // TemplateName is the name of the template to use for rendering error pages. The template must be present in the + // Templates map. + TemplateName string -// TemplateNames returns all template names. -func (c *Config) TemplateNames() []string { - n := make([]string, len(c.Templates)) + // ProxyHeaders contains a list of HTTP headers that will be proxied from the incoming request to the + // error page response. + ProxyHeaders []string - for i, t := range c.Templates { - n[i] = t.name + // L10n contains localization settings. + L10n struct { + // Disable the localization of error pages. + Disable bool } - return n -} - -// Template describes HTTP error page template. -type Template struct { - name string - content []byte -} + // 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 -// Name returns the name of the template. -func (t Template) Name() string { return t.name } + // 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 -// Content returns the template content. -func (t Template) Content() []byte { return t.content } + // 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 -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 + // ShowDetails determines whether to show additional details in the error response, extracted from the + // incoming request (if supported by the template). + ShowDetails bool } -// Page describes error page. -type Page struct { - code string - message string - description string +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 }} } - -// 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 +` // an empty line at the end is important for better UX + +const defaultXMLFormat string = ` + + {{ code }} + {{ message }} + {{ description }}{{ if show_details }} +
+ {{ host }} + {{ original_uri }} + {{ forwarded_for }} + {{ namespace }} + {{ ingress_name }} + {{ service_name }} + {{ service_port }} + {{ request_id }} + {{ nowUnix }} +
{{ end }} +
+` // 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"}, } -// 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"` +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 } -// 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)) - } - } +// 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 } - 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") - } + cfg.Formats.JSON = defaultJSONFormat + cfg.Formats.XML = defaultXMLFormat + cfg.Formats.PlainText = defaultPlainTextFormat - if strings.ContainsRune(name, ' ') { - return errors.New("format should not contain whitespaces") - } - } + // add built-in templates + for name, content := range builtinTemplates.BuiltIn() { + cfg.Templates[name] = content } - 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} + // set first template as default + for _, name := range cfg.Templates.Names() { + cfg.TemplateName = 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) + break } - 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))} - } + // set default HTTP headers to proxy + cfg.ProxyHeaders = slices.Clone(defaultProxyHeaders) - 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) }() - } + // set defaults + cfg.DefaultCodeToRender = http.StatusNotFound - return FromYaml(bytes) + return cfg } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 44cd2d7b..f1416478 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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, "foo {{ code }}\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, "bar {{ code }}\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, "foo {{ code }}\n", string(tpl.Content())) - - tpl, found = cfg.Template("bar-tpl") - assert.True(t, found) - assert.Equal(t, "bar-tpl", tpl.Name()) - assert.Equal(t, "bar {{ code }}\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) + } + }) } diff --git a/internal/config/rotation_mode.go b/internal/config/rotation_mode.go new file mode 100644 index 00000000..e9bbb388 --- /dev/null +++ b/internal/config/rotation_mode.go @@ -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) +} diff --git a/internal/config/rotation_mode_test.go b/internal/config/rotation_mode_test.go new file mode 100644 index 00000000..05d5b19d --- /dev/null +++ b/internal/config/rotation_mode_test.go @@ -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 + }{ + "": {giveString: "", wantMode: config.RotationModeDisabled}, + "": {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) + } + }) + } +} diff --git a/internal/config/templates.go b/internal/config/templates.go new file mode 100644 index 00000000..9ee3a7eb --- /dev/null +++ b/internal/config/templates.go @@ -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 "" +} diff --git a/internal/config/templates_test.go b/internal/config/templates_test.go new file mode 100644 index 00000000..0065cf02 --- /dev/null +++ b/internal/config/templates_test.go @@ -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: "\n", + }, + "with content but without a name": { + givePath: "./testdata/with-content.htm", + wantThisName: "with-content", + wantThisContent: "\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) +} diff --git a/internal/config/testdata/.dotfile b/internal/config/testdata/.dotfile new file mode 100644 index 00000000..e69de29b diff --git a/internal/config/testdata/.dotfile_with.ext b/internal/config/testdata/.dotfile_with.ext new file mode 100644 index 00000000..e69de29b diff --git a/internal/config/testdata/bar-tpl.html b/internal/config/testdata/bar-tpl.html deleted file mode 100644 index ffc4022e..00000000 --- a/internal/config/testdata/bar-tpl.html +++ /dev/null @@ -1 +0,0 @@ -bar {{ code }} diff --git a/internal/config/testdata/broken.yml b/internal/config/testdata/broken.yml deleted file mode 100644 index d675fa44..00000000 --- a/internal/config/testdata/broken.yml +++ /dev/null @@ -1 +0,0 @@ -foo bar diff --git a/internal/config/testdata/empty.html b/internal/config/testdata/empty.html new file mode 100644 index 00000000..e69de29b diff --git a/internal/config/testdata/file.with.multiple.dots b/internal/config/testdata/file.with.multiple.dots new file mode 100644 index 00000000..e69de29b diff --git a/internal/config/testdata/foo-tpl.html b/internal/config/testdata/foo-tpl.html deleted file mode 100644 index db5ed80e..00000000 --- a/internal/config/testdata/foo-tpl.html +++ /dev/null @@ -1 +0,0 @@ -foo {{ code }} diff --git a/internal/config/testdata/name with spaces.txt b/internal/config/testdata/name with spaces.txt new file mode 100644 index 00000000..e69de29b diff --git a/internal/config/testdata/simple.yml b/internal/config/testdata/simple.yml deleted file mode 100644 index 0ed3e7f0..00000000 --- a/internal/config/testdata/simple.yml +++ /dev/null @@ -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 diff --git a/internal/config/testdata/with-content.htm b/internal/config/testdata/with-content.htm new file mode 100644 index 00000000..41026118 --- /dev/null +++ b/internal/config/testdata/with-content.htm @@ -0,0 +1 @@ + diff --git a/internal/config/testdata/without_extension b/internal/config/testdata/without_extension new file mode 100644 index 00000000..e69de29b diff --git a/internal/env/env.go b/internal/env/env.go deleted file mode 100644 index 21348f74..00000000 --- a/internal/env/env.go +++ /dev/null @@ -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)) } diff --git a/internal/env/env_test.go b/internal/env/env_test.go deleted file mode 100644 index 0873d7a4..00000000 --- a/internal/env/env_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/internal/http/common/middlewares.go b/internal/http/common/middlewares.go deleted file mode 100644 index 5c087e46..00000000 --- a/internal/http/common/middlewares.go +++ /dev/null @@ -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)) - } -} diff --git a/internal/http/common/middlewares_test.go b/internal/http/common/middlewares_test.go deleted file mode 100644 index f65e6b8d..00000000 --- a/internal/http/common/middlewares_test.go +++ /dev/null @@ -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") -} diff --git a/internal/http/core/errorpage.go b/internal/http/core/errorpage.go deleted file mode 100644 index c72b6bf6..00000000 --- a/internal/http/core/errorpage.go +++ /dev/null @@ -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") - } - } - } -} diff --git a/internal/http/core/formats.go b/internal/http/core/formats.go deleted file mode 100644 index 06da587e..00000000 --- a/internal/http/core/formats.go +++ /dev/null @@ -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") - } -} diff --git a/internal/http/core/formats_test.go b/internal/http/core/formats_test.go deleted file mode 100644 index 4e7dda68..00000000 --- a/internal/http/core/formats_test.go +++ /dev/null @@ -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))) - }) - } -} diff --git a/internal/http/core/headers.go b/internal/http/core/headers.go deleted file mode 100644 index bdab0eff..00000000 --- a/internal/http/core/headers.go +++ /dev/null @@ -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" -) diff --git a/internal/http/handlers/error_page/cache.go b/internal/http/handlers/error_page/cache.go new file mode 100644 index 00000000..04f42b29 --- /dev/null +++ b/internal/http/handlers/error_page/cache.go @@ -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 +} diff --git a/internal/http/handlers/error_page/cache_test.go b/internal/http/handlers/error_page/cache_test.go new file mode 100644 index 00000000..0ebbfc9f --- /dev/null +++ b/internal/http/handlers/error_page/cache_test.go @@ -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 +} diff --git a/internal/http/handlers/error_page/code.go b/internal/http/handlers/error_page/code.go new file mode 100644 index 00000000..2a5745dc --- /dev/null +++ b/internal/http/handlers/error_page/code.go @@ -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 +} diff --git a/internal/http/handlers/error_page/code_test.go b/internal/http/handlers/error_page/code_test.go new file mode 100644 index 00000000..3f4c6918 --- /dev/null +++ b/internal/http/handlers/error_page/code_test.go @@ -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)) + }) + } +} diff --git a/internal/http/handlers/error_page/format.go b/internal/http/handlers/error_page/format.go new file mode 100644 index 00000000..3be430bf --- /dev/null +++ b/internal/http/handlers/error_page/format.go @@ -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 +} diff --git a/internal/http/handlers/error_page/format_test.go b/internal/http/handlers/error_page/format_test.go new file mode 100644 index 00000000..c8b31fcb --- /dev/null +++ b/internal/http/handlers/error_page/format_test.go @@ -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)) + }) + } +} diff --git a/internal/http/handlers/error_page/handler.go b/internal/http/handlers/error_page/handler.go new file mode 100644 index 00000000..d698bfb9 --- /dev/null +++ b/internal/http/handlers/error_page/handler.go @@ -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( + "\nFailed to render the XML template: %s\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( + "\nFailed to render the HTML template %s: %s\n", + templateName, + err.Error(), + )) + } else { + cache.Put(tpl, tplProps, []byte(content)) + + write(ctx, log, content) + } + } + } else { + write(ctx, log, fmt.Sprintf( + "\nTemplate %s not found and cannot be used\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), + ) + } +} diff --git a/internal/http/handlers/error_page/handler_test.go b/internal/http/handlers/error_page/handler_test.go new file mode 100644 index 00000000..c2747232 --- /dev/null +++ b/internal/http/handlers/error_page/handler_test.go @@ -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{ + "", + "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") +} diff --git a/internal/http/handlers/errorpage/handler.go b/internal/http/handlers/errorpage/handler.go deleted file mode 100644 index addc44de..00000000 --- a/internal/http/handlers/errorpage/handler.go +++ /dev/null @@ -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") - } - } -} diff --git a/internal/http/handlers/errorpage/handler_test.go b/internal/http/handlers/errorpage/handler_test.go deleted file mode 100644 index 5c974ff7..00000000 --- a/internal/http/handlers/errorpage/handler_test.go +++ /dev/null @@ -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") -} diff --git a/internal/http/handlers/healthz/handler.go b/internal/http/handlers/healthz/handler.go deleted file mode 100644 index a44a02b4..00000000 --- a/internal/http/handlers/healthz/handler.go +++ /dev/null @@ -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") - } -} diff --git a/internal/http/handlers/healthz/handler_test.go b/internal/http/handlers/healthz/handler_test.go deleted file mode 100644 index fbc25f26..00000000 --- a/internal/http/handlers/healthz/handler_test.go +++ /dev/null @@ -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") -} diff --git a/internal/http/handlers/index/handler.go b/internal/http/handlers/index/handler.go deleted file mode 100644 index bbd295b8..00000000 --- a/internal/http/handlers/index/handler.go +++ /dev/null @@ -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 -} diff --git a/internal/http/handlers/index/handler_test.go b/internal/http/handlers/index/handler_test.go deleted file mode 100644 index 45435379..00000000 --- a/internal/http/handlers/index/handler_test.go +++ /dev/null @@ -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") -} diff --git a/internal/http/handlers/live/handler.go b/internal/http/handlers/live/handler.go new file mode 100644 index 00000000..a2f2dd33 --- /dev/null +++ b/internal/http/handlers/live/handler.go @@ -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) + } + } +} diff --git a/internal/http/handlers/live/handler_test.go b/internal/http/handlers/live/handler_test.go new file mode 100644 index 00000000..8cfc47f9 --- /dev/null +++ b/internal/http/handlers/live/handler_test.go @@ -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) + }) + } + }) +} diff --git a/internal/http/handlers/metrics/handler.go b/internal/http/handlers/metrics/handler.go deleted file mode 100644 index 64518971..00000000 --- a/internal/http/handlers/metrics/handler.go +++ /dev/null @@ -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}), - ) -} diff --git a/internal/http/handlers/metrics/handler_test.go b/internal/http/handlers/metrics/handler_test.go deleted file mode 100644 index c6a679f2..00000000 --- a/internal/http/handlers/metrics/handler_test.go +++ /dev/null @@ -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") -} diff --git a/internal/http/handlers/notfound/handler.go b/internal/http/handlers/notfound/handler.go deleted file mode 100644 index e57db41a..00000000 --- a/internal/http/handlers/notfound/handler.go +++ /dev/null @@ -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) - } -} diff --git a/internal/http/handlers/notfound/handler_test.go b/internal/http/handlers/notfound/handler_test.go deleted file mode 100644 index 3daed4df..00000000 --- a/internal/http/handlers/notfound/handler_test.go +++ /dev/null @@ -1,7 +0,0 @@ -package notfound_test - -import "testing" - -func TestNothing(t *testing.T) { - t.Skip("tests for this package have not been implemented yet") -} diff --git a/internal/http/handlers/static/favicon.ico b/internal/http/handlers/static/favicon.ico new file mode 100644 index 00000000..1eb79d59 Binary files /dev/null and b/internal/http/handlers/static/favicon.ico differ diff --git a/internal/http/handlers/static/handler.go b/internal/http/handlers/static/handler.go new file mode 100644 index 00000000..815a8f4c --- /dev/null +++ b/internal/http/handlers/static/handler.go @@ -0,0 +1,31 @@ +package static + +import ( + _ "embed" + "net/http" + + "github.com/valyala/fasthttp" +) + +//go:embed favicon.ico +var Favicon []byte + +// New creates a new handler that returns the provided content for GET and HEAD requests. +func New(content []byte) fasthttp.RequestHandler { + var notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n" + + return func(ctx *fasthttp.RequestCtx) { + switch string(ctx.Method()) { + case fasthttp.MethodGet: + ctx.SetContentType(http.DetectContentType(content)) + ctx.SetStatusCode(http.StatusOK) + _, _ = ctx.Write(content) + + case fasthttp.MethodHead: + ctx.SetStatusCode(http.StatusOK) + + default: + ctx.Error(notAllowed, http.StatusMethodNotAllowed) + } + } +} diff --git a/internal/http/handlers/static/handler_test.go b/internal/http/handlers/static/handler_test.go new file mode 100644 index 00000000..bbdffcd7 --- /dev/null +++ b/internal/http/handlers/static/handler_test.go @@ -0,0 +1,68 @@ +package static_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/http/handlers/static" + "gh.tarampamp.am/error-pages/internal/http/httptest" +) + +func TestServeHTTP(t *testing.T) { + t.Parallel() + + var ( + handler = static.New([]byte{1, 2, 3}) + url = "http://testing" + body = http.NoBody + ) + + t.Run("get", func(t *testing.T) { + httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) { + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, "application/octet-stream", headers.Get("Content-Type")) + assert.Equal(t, []byte{1, 2, 3}, []byte(body)) + }) + }) + + 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) + }) + } + }) +} + +func TestServeHTTP_Favicon(t *testing.T) { + t.Parallel() + + httptest.HandleFast(t, + static.New(static.Favicon), + http.MethodGet, + "http://testing", + http.NoBody, + func(status int, body string, headers http.Header) { + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, "image/x-icon", headers.Get("Content-Type")) + assert.Equal(t, static.Favicon, []byte(body)) + }, + ) +} diff --git a/internal/http/handlers/version/handler.go b/internal/http/handlers/version/handler.go index 7a8f3768..ecd66a0f 100644 --- a/internal/http/handlers/version/handler.go +++ b/internal/http/handlers/version/handler.go @@ -2,25 +2,34 @@ package version import ( "encoding/json" + "net/http" + "strings" "github.com/valyala/fasthttp" ) -// NewHandler creates version handler. -func NewHandler(ver string) fasthttp.RequestHandler { - var cache []byte +// New creates a handler that returns the version of the service in JSON format. +func New(ver string) fasthttp.RequestHandler { + var body, _ = json.Marshal(struct { //nolint:errchkjson + Version string `json:"version"` + }{ + Version: strings.TrimSpace(ver), + }) + + var notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n" return func(ctx *fasthttp.RequestCtx) { - if cache == nil { - cache, _ = json.Marshal(struct { - Version string `json:"version"` - }{ - Version: ver, - }) - } + switch string(ctx.Method()) { + case fasthttp.MethodGet: + ctx.SetContentType("application/json; charset=utf-8") + ctx.SetStatusCode(http.StatusOK) + _, _ = ctx.Write(body) - ctx.SetContentType("application/json") - ctx.SetStatusCode(fasthttp.StatusOK) - _, _ = ctx.Write(cache) + case fasthttp.MethodHead: + ctx.SetStatusCode(http.StatusOK) + + default: + ctx.Error(notAllowed, http.StatusMethodNotAllowed) + } } } diff --git a/internal/http/handlers/version/handler_test.go b/internal/http/handlers/version/handler_test.go index 72c102ba..0b50f269 100644 --- a/internal/http/handlers/version/handler_test.go +++ b/internal/http/handlers/version/handler_test.go @@ -1,7 +1,52 @@ package version_test -import "testing" +import ( + "net/http" + "testing" -func TestNothing(t *testing.T) { - t.Skip("tests for this package have not been implemented yet") + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/http/handlers/version" + "gh.tarampamp.am/error-pages/internal/http/httptest" +) + +func TestServeHTTP(t *testing.T) { + t.Parallel() + + var ( + handler = version.New("\t\n foo@bar ") + url = "http://testing" + body = http.NoBody + ) + + t.Run("get", func(t *testing.T) { + httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) { + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, "application/json; charset=utf-8", headers.Get("Content-Type")) + assert.Equal(t, `{"version":"foo@bar"}`, body) + }) + }) + + 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) + }) + } + }) } diff --git a/internal/http/httptest/httptest.go b/internal/http/httptest/httptest.go new file mode 100644 index 00000000..32026629 --- /dev/null +++ b/internal/http/httptest/httptest.go @@ -0,0 +1,69 @@ +// Package httptest provides utilities for (fast-)HTTP testing. +package httptest + +import ( + "context" + "io" + "net" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" + "github.com/valyala/fasthttp/fasthttputil" +) + +// HandleFastRequest serves http request using provided fasthttp handler and HTTP request. +func HandleFastRequest( + t *testing.T, + handler fasthttp.RequestHandler, + req *http.Request, + check func(status int, body string, _ http.Header), +) { + t.Helper() + + // create in-memory listener + var ln = fasthttputil.NewInmemoryListener() + defer func() { require.NoError(t, ln.Close()) }() + + // start fasthttp server + go func() { require.NoError(t, fasthttp.Serve(ln, handler)) }() + + // send http request + resp, respErr := (&http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return ln.Dial() }, + }, + }).Do(req) + require.NoError(t, respErr) + + // close response body after the test + defer func() { assert.NoError(t, resp.Body.Close()) }() + + // read response body + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // check the response + check(resp.StatusCode, string(respBody), resp.Header) +} + +// HandleFast serves http request using provided fasthttp handler. +func HandleFast( + t *testing.T, + handler fasthttp.RequestHandler, + method string, + url string, + body io.Reader, + check func(status int, body string, _ http.Header), +) { + t.Helper() + + // create http request + req, reqErr := http.NewRequest(method, url, body) + require.NoError(t, reqErr) + + // serve http request + HandleFastRequest(t, handler, req, check) +} diff --git a/internal/http/middleware/logreq/middleware.go b/internal/http/middleware/logreq/middleware.go new file mode 100644 index 00000000..9d2caa25 --- /dev/null +++ b/internal/http/middleware/logreq/middleware.go @@ -0,0 +1,61 @@ +package logreq + +import ( + "time" + + "github.com/valyala/fasthttp" + + "gh.tarampamp.am/error-pages/internal/logger" +) + +// New creates a middleware that logs every incoming request. +// +// The skipper function should return true if the request should be skipped. It's ok to pass nil. +func New( + log *logger.Logger, + skipper func(*fasthttp.RequestCtx) bool, +) func(fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { + return func(ctx *fasthttp.RequestCtx) { + if skipper != nil && skipper(ctx) { + next(ctx) + + return + } + + var now = time.Now() + + defer func() { + var fields = []logger.Attr{ + logger.Int("status code", ctx.Response.StatusCode()), + logger.String("useragent", string(ctx.UserAgent())), + logger.String("method", string(ctx.Method())), + logger.String("url", string(ctx.RequestURI())), + logger.String("referer", string(ctx.Referer())), + logger.String("content type", string(ctx.Response.Header.ContentType())), + logger.String("remote addr", ctx.RemoteAddr().String()), + logger.Duration("duration", time.Since(now).Round(time.Microsecond)), + } + + if log.Level() <= logger.DebugLevel { + var ( + reqHeaders = make(map[string]string) + respHeaders = make(map[string]string) + ) + + ctx.Request.Header.VisitAll(func(key, value []byte) { reqHeaders[string(key)] = string(value) }) + ctx.Response.Header.VisitAll(func(key, value []byte) { respHeaders[string(key)] = string(value) }) + + fields = append(fields, + logger.Any("request headers", reqHeaders), + logger.Any("response headers", respHeaders), + ) + } + + log.Info("HTTP request processed", fields...) + }() + + next(ctx) + } + } +} diff --git a/internal/http/middleware/logreq/middleware_test.go b/internal/http/middleware/logreq/middleware_test.go new file mode 100644 index 00000000..0bef30cc --- /dev/null +++ b/internal/http/middleware/logreq/middleware_test.go @@ -0,0 +1,46 @@ +package logreq_test + +import ( + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/valyala/fasthttp" + + "gh.tarampamp.am/error-pages/internal/http/httptest" + "gh.tarampamp.am/error-pages/internal/http/middleware/logreq" + "gh.tarampamp.am/error-pages/internal/logger" +) + +func TestNew(t *testing.T) { + t.Parallel() + + var ( + buf bytes.Buffer + log, _ = logger.New(logger.DebugLevel, logger.JSONFormat, &buf) + + mw = logreq.New(log, nil) + req, _ = http.NewRequest(http.MethodPut, "http://testing/foo/bar", http.NoBody) + ) + + req.Header.Set("User-Agent", "test") + req.Header.Set("Referer", "https://example.com") + req.Header.Set("Content-Type", "application/json") + + httptest.HandleFastRequest(t, + mw(func(ctx *fasthttp.RequestCtx) { ctx.SetStatusCode(http.StatusOK) }), + req, + func(status int, body string, _ http.Header) { assert.Equal(t, http.StatusOK, status) }, + ) + + var logRecord = buf.String() + + assert.Contains(t, logRecord, `"level":"info"`) + assert.Contains(t, logRecord, `"msg":"HTTP request processed"`) + assert.Contains(t, logRecord, `"useragent":"test"`) + assert.Contains(t, logRecord, `"method":"PUT"`) + assert.Contains(t, logRecord, `"url":"/foo/bar"`) + assert.Contains(t, logRecord, `"referer":"https://example.com"`) + assert.Contains(t, logRecord, `application/json`) +} diff --git a/internal/http/server.go b/internal/http/server.go index 9f0c733f..7edc6147 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -1,65 +1,120 @@ package http import ( + "context" "errors" "fmt" "net" + "net/http" "strings" "time" - "github.com/fasthttp/router" "github.com/valyala/fasthttp" - "go.uber.org/zap" - "gh.tarampamp.am/error-pages/internal/checkers" + "gh.tarampamp.am/error-pages/internal/appmeta" "gh.tarampamp.am/error-pages/internal/config" - "gh.tarampamp.am/error-pages/internal/http/common" - errorpageHandler "gh.tarampamp.am/error-pages/internal/http/handlers/errorpage" - healthzHandler "gh.tarampamp.am/error-pages/internal/http/handlers/healthz" - indexHandler "gh.tarampamp.am/error-pages/internal/http/handlers/index" - metricsHandler "gh.tarampamp.am/error-pages/internal/http/handlers/metrics" - notfoundHandler "gh.tarampamp.am/error-pages/internal/http/handlers/notfound" - versionHandler "gh.tarampamp.am/error-pages/internal/http/handlers/version" - "gh.tarampamp.am/error-pages/internal/metrics" - "gh.tarampamp.am/error-pages/internal/options" - "gh.tarampamp.am/error-pages/internal/tpl" - "gh.tarampamp.am/error-pages/internal/version" + ep "gh.tarampamp.am/error-pages/internal/http/handlers/error_page" + "gh.tarampamp.am/error-pages/internal/http/handlers/live" + "gh.tarampamp.am/error-pages/internal/http/handlers/static" + "gh.tarampamp.am/error-pages/internal/http/handlers/version" + "gh.tarampamp.am/error-pages/internal/http/middleware/logreq" + "gh.tarampamp.am/error-pages/internal/logger" ) +// Server is an HTTP server for serving error pages. type Server struct { - log *zap.Logger - fast *fasthttp.Server - router *router.Router - rdr *tpl.TemplateRenderer + log *logger.Logger + server *fasthttp.Server + beforeStop func() } -const ( - defaultWriteTimeout = time.Second * 4 - defaultReadTimeout = time.Second * 4 - defaultIdleTimeout = time.Second * 6 -) - -func NewServer(log *zap.Logger, readBufferSize uint) Server { - rdr := tpl.NewTemplateRenderer() +// NewServer creates a new HTTP server. +func NewServer(log *logger.Logger, readBufferSize uint) Server { + const ( + readTimeout = 30 * time.Second + writeTimeout = readTimeout + 10*time.Second // should be bigger than the read timeout + ) return Server{ - // fasthttp docs: <https://github.com/valyala/fasthttp> - fast: &fasthttp.Server{ - WriteTimeout: defaultWriteTimeout, - ReadBufferSize: int(readBufferSize), - ReadTimeout: defaultReadTimeout, - IdleTimeout: defaultIdleTimeout, - NoDefaultServerHeader: true, - ReduceMemoryUsage: true, - CloseOnShutdown: true, - Logger: zap.NewStdLog(log), + log: log, + server: &fasthttp.Server{ + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + ReadBufferSize: int(readBufferSize), + DisablePreParseMultipartForm: true, + NoDefaultServerHeader: true, + CloseOnShutdown: true, + Logger: logger.NewStdLog(log), }, - router: router.New(), - log: log, - rdr: rdr, + beforeStop: func() {}, // noop } } +// Register server handlers, middlewares, etc. +func (s *Server) Register(cfg *config.Config) error { //nolint:funlen + var ( + liveHandler = live.New() + versionHandler = version.New(appmeta.Version()) + faviconHandler = static.New(static.Favicon) + + errorPagesHandler, closeCache = ep.New(cfg, s.log) + + notFound = http.StatusText(http.StatusNotFound) + "\n" + notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n" + ) + + // wrap the before shutdown function to close the cache + s.beforeStop = closeCache + + s.server.Handler = func(ctx *fasthttp.RequestCtx) { + var url, method = string(ctx.Path()), string(ctx.Method()) + + switch { + // live endpoints + case url == "/healthz" || url == "/health/live" || url == "/health" || url == "/live": + liveHandler(ctx) + + // version endpoint + case url == "/version": + versionHandler(ctx) + + // favicon.ico endpoint + case url == "/favicon.ico": + faviconHandler(ctx) + + // error pages endpoints: + // - / + // - /{code}.html + // - /{code}.htm + // - /{code} + // + // the HTTP method is not limited to GET and HEAD - it can be any + case url == "/" || ep.URLContainsCode(url) || ep.HeadersContainCode(&ctx.Request.Header): + errorPagesHandler(ctx) + + // wrong requests handling + default: + switch { + case method == fasthttp.MethodHead: + ctx.Error(notAllowed, fasthttp.StatusNotFound) + case method == fasthttp.MethodGet: + ctx.Error(notFound, fasthttp.StatusNotFound) + default: + ctx.Error(notAllowed, fasthttp.StatusMethodNotAllowed) + } + } + } + + // apply middleware + s.server.Handler = logreq.New(s.log, func(ctx *fasthttp.RequestCtx) bool { + // skip logging healthcheck and .ico (favicon) requests + return strings.Contains(strings.ToLower(string(ctx.UserAgent())), "healthcheck") || + strings.HasSuffix(string(ctx.Path()), ".ico") + })(s.server.Handler) + + return nil +} + // Start server. func (s *Server) Start(ip string, port uint16) (err error) { if net.ParseIP(ip) == nil { @@ -68,7 +123,7 @@ func (s *Server) Start(ip string, port uint16) (err error) { var ln net.Listener - if strings.Count(ip, ":") >= 2 { //nolint:gomnd // ipv6 + if strings.Count(ip, ":") >= 2 { //nolint:mnd // ipv6 if ln, err = net.Listen("tcp6", fmt.Sprintf("[%s]:%d", ip, port)); err != nil { return err } @@ -78,54 +133,15 @@ func (s *Server) Start(ip string, port uint16) (err error) { } } - return s.fast.Serve(ln) -} - -type templatePicker interface { - // Pick the template name for responding. - Pick() string + return s.server.Serve(ln) } -// Register server routes, middlewares, etc. -// Router docs: <https://github.com/fasthttp/router> -func (s *Server) Register(cfg *config.Config, templatePicker templatePicker, opt options.ErrorPage) error { - reg, m := metrics.NewRegistry(), metrics.NewMetrics() - - if err := m.Register(reg); err != nil { - return err - } +// Stop server gracefully. +func (s *Server) Stop(timeout time.Duration) error { + var ctx, cancel = context.WithTimeout(context.Background(), timeout) + defer cancel() - s.fast.Handler = common.DurationMetrics(common.LogRequest(s.router.Handler, s.log), &m) - - s.router.GET("/", indexHandler.NewHandler(cfg, templatePicker, s.rdr, opt)) - s.router.GET("/{code}.html", errorpageHandler.NewHandler(cfg, templatePicker, s.rdr, opt)) - - s.router.GET("/version", versionHandler.NewHandler(version.Version())) - - liveHandler := healthzHandler.NewHandler(checkers.NewLiveChecker()) - s.router.ANY("/healthz", liveHandler) - s.router.ANY("/health/live", liveHandler) // deprecated - - s.router.GET("/metrics", metricsHandler.NewHandler(reg)) - - // use index handler to catch all paths? Uses DEFAULT_ERROR_PAGE - if opt.CatchAll { - s.router.NotFound = indexHandler.NewHandler(cfg, templatePicker, s.rdr, opt) - } else { - // use default not found handler - s.router.NotFound = notfoundHandler.NewHandler(cfg, templatePicker, s.rdr, opt) - } - - return nil -} - -// Stop server. -func (s *Server) Stop() error { - if err := s.rdr.Close(); err != nil { - defer func() { _ = s.fast.Shutdown() }() - - return err - } + s.beforeStop() - return s.fast.Shutdown() + return s.server.ShutdownWithContext(ctx) } diff --git a/internal/http/server_test.go b/internal/http/server_test.go index 66c29922..1ca8dad0 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -1,7 +1,396 @@ -package http +package http_test -import "testing" +import ( + "errors" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" -func TestNothing(t *testing.T) { - t.Skip("tests for this package have not been implemented yet") + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/error-pages/internal/config" + appHttp "gh.tarampamp.am/error-pages/internal/http" + "gh.tarampamp.am/error-pages/internal/logger" +) + +// TestRouting in fact is a test for the whole server, because it tests all the routes and their handlers. +func TestRouting(t *testing.T) { + var ( + srv = appHttp.NewServer(logger.NewNop(), 1025*5) + cfg = config.New() + ) + + assert.NoError(t, cfg.Templates.Add("unit-test", `<!DOCTYPE html> +<html lang="en"> + <h1>Error {{ code }}: {{ message }}</h1>{{ if description }} + <h2>{{ description }}</h2>{{ end }}{{ if show_details }} + + <pre> + 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 }} + </pre>{{ end }} +</html>`)) + + cfg.TemplateName = "unit-test" + + require.NoError(t, srv.Register(&cfg)) + + var baseUrl, stopServer = startServer(t, &srv) + + defer stopServer() + + t.Run("health", func(t *testing.T) { + var routes = []string{"/health/live", "/health", "/healthz", "/live"} + + t.Run("success (get)", func(t *testing.T) { + for _, route := range routes { + status, body, headers := sendRequest(t, http.MethodGet, baseUrl+route) + + assert.Equal(t, http.StatusOK, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + + t.Run("success (head)", func(t *testing.T) { + for _, route := range routes { + status, body, headers := sendRequest(t, http.MethodHead, baseUrl+route) + + assert.Equal(t, http.StatusOK, status) + assert.Empty(t, body) + assert.Empty(t, headers.Get("Content-Type")) + } + }) + + t.Run("method not allowed", func(t *testing.T) { + for _, route := range routes { + var url = baseUrl + route + + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + status, body, headers := sendRequest(t, method, url) + + assert.Equal(t, http.StatusMethodNotAllowed, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + } + }) + }) + + t.Run("version", func(t *testing.T) { + var url = baseUrl + "/version" + + t.Run("success (get)", func(t *testing.T) { + status, body, headers := sendRequest(t, http.MethodGet, url) + + assert.Equal(t, http.StatusOK, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "application/json") + }) + + t.Run("success (head)", func(t *testing.T) { + status, body, headers := sendRequest(t, http.MethodHead, url) + + assert.Equal(t, http.StatusOK, status) + assert.Empty(t, body) + assert.Empty(t, headers.Get("Content-Type")) + }) + + t.Run("method not allowed", func(t *testing.T) { + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + status, body, headers := sendRequest(t, method, url) + + assert.Equal(t, http.StatusMethodNotAllowed, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + }) + + t.Run("error page", func(t *testing.T) { + t.Run("success", func(t *testing.T) { + t.Run("index, default (plain text by default)", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/") + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "404: Not Found") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("index, default (json format)", func(t *testing.T) { + var status, body, headers = sendRequest(t, + http.MethodGet, baseUrl+"/", map[string]string{"Accept": "application/json"}, + ) + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), `"code": 404`) + assert.Contains(t, headers.Get("Content-Type"), "application/json") + }) + + t.Run("index, default (xml format)", func(t *testing.T) { + var status, body, headers = sendRequest(t, + http.MethodGet, baseUrl+"/", map[string]string{"Accept": "application/xml"}, + ) + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), `<code>404</code>`) + assert.Contains(t, headers.Get("Content-Type"), "application/xml") + }) + + t.Run("index, default (html format)", func(t *testing.T) { + var status, body, headers = sendRequest(t, + http.MethodGet, baseUrl+"/", map[string]string{"Content-Type": "text/html"}, + ) + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), `<h1>Error 404: Not Found</h1>`) + assert.Contains(t, headers.Get("Content-Type"), "text/html") + }) + + t.Run("index, code in HTTP header", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "404"}) + + assert.Equal(t, http.StatusOK, status) // because of [cfg.RespondWithSameHTTPCode] is false by default + assert.Contains(t, string(body), "404: Not Found") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("code in URL, .html", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/500.html") + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "500: Internal Server Error") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("code in URL, .htm", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/409.htm") + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "409: Conflict") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("code in URL, without extension", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/405") + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "405: Method Not Allowed") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("code in the URL have higher priority than in the headers", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/405", map[string]string{"X-Code": "404"}) + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "405: Method Not Allowed") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("invalid code in HTTP header (with a string)", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "foobar"}) + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "404: Not Found") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("invalid code in HTTP header (too small)", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "0"}) + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "404: Not Found") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("invalid code in HTTP header (too big)", func(t *testing.T) { + var status, body, headers = sendRequest(t, http.MethodGet, baseUrl+"/", map[string]string{"X-Code": "1000"}) + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "404: Not Found") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + }) + + t.Run("other HTTP methods", func(t *testing.T) { + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + var status, body, headers = sendRequest(t, method, baseUrl+"/404.html") + + assert.Equal(t, http.StatusOK, status) + assert.Contains(t, string(body), "404: Not Found") + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + }) + + t.Run("failure", func(t *testing.T) { + var assertIsNotErrorPage = func(t *testing.T, body []byte) { + t.Helper() + + assert.NotContains(t, string(body), "error page") // FIXME + } + + t.Run("invalid code in URL (too small)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/0.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (too big)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/1000.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (with a string suffix)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/404foobar.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (with a string prefix)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/foobar404.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + + t.Run("invalid code in URL (with a string)", func(t *testing.T) { + var status, body, _ = sendRequest(t, http.MethodGet, baseUrl+"/foobar.html") + + assert.Equal(t, http.StatusNotFound, status) + assertIsNotErrorPage(t, body) + }) + }) + }) + + t.Run("errors handling", func(t *testing.T) { + var missingRoutes = []string{"/not-found", "/not-found/", "/not-found.html"} + + t.Run("not found (get)", func(t *testing.T) { + for _, path := range missingRoutes { + status, body, headers := sendRequest(t, http.MethodGet, baseUrl+path) + + assert.Equal(t, http.StatusNotFound, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + + t.Run("not found (head)", func(t *testing.T) { + for _, path := range missingRoutes { + status, body, headers := sendRequest(t, http.MethodHead, baseUrl+path) + + assert.Equal(t, http.StatusNotFound, status) + assert.Empty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + }) + + t.Run("methods not allowed", func(t *testing.T) { + for _, path := range missingRoutes { + for _, method := range []string{http.MethodDelete, http.MethodPatch, http.MethodPost, http.MethodPut} { + status, body, headers := sendRequest(t, method, baseUrl+path) + + assert.Equal(t, http.StatusMethodNotAllowed, status) + assert.NotEmpty(t, body) + assert.Contains(t, headers.Get("Content-Type"), "text/plain") + } + } + }) + }) +} + +// sendRequest is a helper function to send an HTTP request and return its status code, body, and headers. +func sendRequest(t *testing.T, method, url string, headers ...map[string]string) ( + status int, + body []byte, + _ http.Header, +) { + t.Helper() + + req, reqErr := http.NewRequest(method, url, nil) + + require.NoError(t, reqErr) + + if len(headers) > 0 { + for key, value := range headers[0] { + req.Header.Add(key, value) + } + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + body, _ = io.ReadAll(resp.Body) + + require.NoError(t, resp.Body.Close()) + + return resp.StatusCode, body, resp.Header +} + +// startServer is a helper function to start an HTTP server and return its base URL and a stop function. +func startServer(t *testing.T, srv *appHttp.Server) (_ string, stop func()) { + t.Helper() + + var ( + port = getFreeTcpPort(t) + hostPort = fmt.Sprintf("%s:%d", "127.0.0.1", port) + ) + + go func() { + if err := srv.Start("127.0.0.1", port); err != nil && !errors.Is(err, http.ErrServerClosed) { + assert.NoError(t, err) + } + }() + + // wait until the server starts + for { + if conn, err := net.DialTimeout("tcp", hostPort, time.Second); err == nil { + require.NoError(t, conn.Close()) + + break + } + + <-time.After(5 * time.Millisecond) + } + + return fmt.Sprintf("http://%s", hostPort), func() { assert.NoError(t, srv.Stop(350*time.Millisecond)) } +} + +// 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) } diff --git a/internal/logger/attr.go b/internal/logger/attr.go new file mode 100644 index 00000000..8965b5ed --- /dev/null +++ b/internal/logger/attr.go @@ -0,0 +1,45 @@ +package logger + +import ( + "log/slog" + "time" +) + +// An Attr is a key-value pair. +type Attr = slog.Attr + +// String returns an Attr for a string value. +func String(key, value string) Attr { return slog.String(key, value) } + +// Strings returns an Attr for a slice of strings. +func Strings(key string, value ...string) Attr { return slog.Any(key, value) } + +// Int64 returns an Attr for an int64. +func Int64(key string, value int64) Attr { return slog.Int64(key, value) } + +// Int converts an int to an int64 and returns an Attr with that value. +func Int(key string, value int) Attr { return slog.Int(key, value) } + +// Uint64 returns an Attr for an uint64. +func Uint64(key string, v uint64) Attr { return slog.Uint64(key, v) } + +// Uint16 returns an Attr for an uint16. +func Uint16(key string, v uint16) Attr { return slog.Uint64(key, uint64(v)) } + +// Float64 returns an Attr for a floating-point number. +func Float64(key string, v float64) Attr { return slog.Float64(key, v) } + +// Bool returns an Attr for a bool. +func Bool(key string, v bool) Attr { return slog.Bool(key, v) } + +// Time returns an Attr for a [time.Time]. It discards the monotonic portion. +func Time(key string, v time.Time) Attr { return slog.Time(key, v) } + +// Duration returns an Attr for a [time.Duration]. +func Duration(key string, v time.Duration) Attr { return slog.Duration(key, v) } + +// Error returns an Attr for an error. +func Error(err error) Attr { return slog.String("error", err.Error()) } + +// Any returns an Attr for any value. +func Any(key string, v any) Attr { return slog.Any(key, v) } diff --git a/internal/logger/attr_test.go b/internal/logger/attr_test.go new file mode 100644 index 00000000..81c66f92 --- /dev/null +++ b/internal/logger/attr_test.go @@ -0,0 +1,48 @@ +package logger_test + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/logger" +) + +func TestAttrs(t *testing.T) { + t.Parallel() + + var ( + someTime, _ = time.Parse(time.RFC3339, "2021-01-01T00:00:00Z") + someErr = fmt.Errorf("foo: %w", errors.New("bar")) + ) + + for name, tt := range map[string]struct { + giveAttr logger.Attr + + wantKey string + wantValue any + }{ + "String": {logger.String("key", "value"), "key", "value"}, + "Strings": {logger.Strings("key", "value1", "value2"), "key", []string{"value1", "value2"}}, + "Int64": {logger.Int64("key", 42), "key", int64(42)}, + "Int": {logger.Int("key", 42), "key", int64(42)}, + "Uint64": {logger.Uint64("key", 42), "key", uint64(42)}, + "Uint16": {logger.Uint16("key", 42), "key", uint64(42)}, + "Float64": {logger.Float64("key", 42.42), "key", 42.42}, + "Bool": {logger.Bool("key", true), "key", true}, + "Time": {logger.Time("key", someTime), "key", someTime}, + "Duration": {logger.Duration("key", time.Second), "key", time.Second}, + "Error": {logger.Error(someErr), "error", "foo: bar"}, + "Any": {logger.Any("key", "value"), "key", "value"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.wantKey, tt.giveAttr.Key) + assert.Equal(t, tt.wantValue, tt.giveAttr.Value.Any()) + }) + } +} diff --git a/internal/logger/format_test.go b/internal/logger/format_test.go index acac7f11..e3d44488 100644 --- a/internal/logger/format_test.go +++ b/internal/logger/format_test.go @@ -60,3 +60,11 @@ func TestParseFormat(t *testing.T) { }) } } + +func TestFormats(t *testing.T) { + require.Equal(t, []logger.Format{logger.ConsoleFormat, logger.JSONFormat}, logger.Formats()) +} + +func TestFormatStrings(t *testing.T) { + require.Equal(t, []string{"console", "json"}, logger.FormatStrings()) +} diff --git a/internal/logger/level.go b/internal/logger/level.go index 5977add3..772f4aaf 100644 --- a/internal/logger/level.go +++ b/internal/logger/level.go @@ -13,7 +13,6 @@ const ( InfoLevel // default level (zero-value) WarnLevel ErrorLevel - FatalLevel ) // String returns a lower-case ASCII representation of the log level. @@ -27,8 +26,6 @@ func (l Level) String() string { return "warn" case ErrorLevel: return "error" - case FatalLevel: - return "fatal" } return fmt.Sprintf("level(%d)", l) @@ -36,7 +33,7 @@ func (l Level) String() string { // Levels returns a slice of all logging levels. func Levels() []Level { - return []Level{DebugLevel, InfoLevel, WarnLevel, ErrorLevel, FatalLevel} + return []Level{DebugLevel, InfoLevel, WarnLevel, ErrorLevel} } // LevelStrings returns a slice of all logging levels as strings. @@ -75,8 +72,6 @@ func ParseLevel[T string | []byte](text T) (Level, error) { return WarnLevel, nil case "error": return ErrorLevel, nil - case "fatal": - return FatalLevel, nil } return Level(0), fmt.Errorf("unrecognized logging level: %q", text) diff --git a/internal/logger/level_test.go b/internal/logger/level_test.go index 6b4042a4..81ff904c 100644 --- a/internal/logger/level_test.go +++ b/internal/logger/level_test.go @@ -18,7 +18,6 @@ func TestLevel_String(t *testing.T) { "info": {giveLevel: logger.InfoLevel, wantString: "info"}, "warn": {giveLevel: logger.WarnLevel, wantString: "warn"}, "error": {giveLevel: logger.ErrorLevel, wantString: "error"}, - "fatal": {giveLevel: logger.FatalLevel, wantString: "fatal"}, "<unknown>": {giveLevel: logger.Level(127), wantString: "level(127)"}, } { t.Run(name, func(t *testing.T) { @@ -43,8 +42,6 @@ func TestParseLevel(t *testing.T) { "info": {giveBytes: []byte("info"), wantLevel: logger.InfoLevel}, "warn": {giveBytes: []byte("warn"), wantLevel: logger.WarnLevel}, "error": {giveBytes: []byte("error"), wantLevel: logger.ErrorLevel}, - "fatal": {giveBytes: []byte("fatal"), wantLevel: logger.FatalLevel}, - "fatal (string)": {giveString: "fatal", wantLevel: logger.FatalLevel}, "foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging level: \"foobar\"")}, //nolint:lll } { t.Run(name, func(t *testing.T) { @@ -75,10 +72,9 @@ func TestLevels(t *testing.T) { logger.InfoLevel, logger.WarnLevel, logger.ErrorLevel, - logger.FatalLevel, }, logger.Levels()) } func TestLevelStrings(t *testing.T) { - require.Equal(t, []string{"debug", "info", "warn", "error", "fatal"}, logger.LevelStrings()) + require.Equal(t, []string{"debug", "info", "warn", "error"}, logger.LevelStrings()) } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 67cfe209..a91afe09 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -1,61 +1,126 @@ -// Package logger contains functions for a working with application logging. package logger import ( + "context" "errors" - - "go.uber.org/zap" - "go.uber.org/zap/zapcore" + "io" + "log/slog" + "os" + "strings" + "time" ) -// New creates new "zap" logger with a small customization. -func New(l Level, f Format) (*zap.Logger, error) { - var config zap.Config - - switch f { - case ConsoleFormat: - config = zap.NewDevelopmentConfig() - config.EncoderConfig.EncodeLevel = zapcore.LowercaseColorLevelEncoder - config.EncoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05") +// internalAttrKeyLoggerName is used to store the logger name in the logger context (attributes). +const internalAttrKeyLoggerName = "named_logger" - case JSONFormat: - config = zap.NewProductionConfig() // json encoder is used by default +var ( + // consoleFormatAttrReplacer is a replacer for console format. It replaces some attributes with more + // human-readable ones. + consoleFormatAttrReplacer = func(_ []string, a slog.Attr) slog.Attr { //nolint:gochecknoglobals + switch a.Key { + case internalAttrKeyLoggerName: + return slog.String("logger", a.Value.String()) + case "level": + return slog.String(a.Key, strings.ToLower(a.Value.String())) + default: + if ts, ok := a.Value.Any().(time.Time); ok && a.Key == "time" { + return slog.String(a.Key, ts.Format("15:04:05")) + } + } - default: - return nil, errors.New("unsupported logging format") + return a } - // default configuration for all encoders - config.Level = zap.NewAtomicLevelAt(zap.InfoLevel) - config.Development = false - config.DisableStacktrace = true - config.DisableCaller = true - - // enable additional features for debugging - if l <= DebugLevel { - config.Development = true - config.DisableStacktrace = false - config.DisableCaller = false + // jsonFormatAttrReplacer is a replacer for JSON format. It replaces some attributes with more + // machine-readable ones. + jsonFormatAttrReplacer = func(_ []string, a slog.Attr) slog.Attr { //nolint:gochecknoglobals + switch a.Key { + case internalAttrKeyLoggerName: + return slog.String("logger", a.Value.String()) + case "level": + return slog.String(a.Key, strings.ToLower(a.Value.String())) + default: + if ts, ok := a.Value.Any().(time.Time); ok && a.Key == "time" { + return slog.Float64("ts", float64(ts.Unix())+float64(ts.Nanosecond())/1e9) + } + } + + return a } +) + +// Logger is a simple logger that wraps [slog.Logger]. It provides a more convenient API for logging and +// formatting messages. +type Logger struct { + ctx context.Context + slog *slog.Logger + lvl Level +} - var zapLvl zapcore.Level +// New creates a new logger with the given level and format. Optionally, you can specify the writer to write logs to. +func New(l Level, f Format, writer ...io.Writer) (*Logger, error) { + var options slog.HandlerOptions - switch l { // convert level to zap.Level + switch l { case DebugLevel: - zapLvl = zap.DebugLevel + options.Level = slog.LevelDebug case InfoLevel: - zapLvl = zap.InfoLevel + options.Level = slog.LevelInfo case WarnLevel: - zapLvl = zap.WarnLevel + options.Level = slog.LevelWarn case ErrorLevel: - zapLvl = zap.ErrorLevel - case FatalLevel: - zapLvl = zap.FatalLevel + options.Level = slog.LevelError default: return nil, errors.New("unsupported logging level") } - config.Level = zap.NewAtomicLevelAt(zapLvl) + var ( + handler slog.Handler + target io.Writer + ) + + if len(writer) > 0 && writer[0] != nil { + target = writer[0] + } else { + target = os.Stderr + } + + switch f { + case ConsoleFormat: + options.ReplaceAttr = consoleFormatAttrReplacer + + handler = slog.NewTextHandler(target, &options) + case JSONFormat: + options.ReplaceAttr = jsonFormatAttrReplacer + + handler = slog.NewJSONHandler(target, &options) + default: + return nil, errors.New("unsupported logging format") + } + + return &Logger{ctx: context.Background(), slog: slog.New(handler), lvl: l}, nil +} + +// Level returns the logger level. +func (l *Logger) Level() Level { return l.lvl } - return config.Build() +// Named creates a new logger with the same properties as the original logger and the given name. +func (l *Logger) Named(name string) *Logger { + return &Logger{ + ctx: l.ctx, + slog: l.slog.With(slog.String(internalAttrKeyLoggerName, name)), + lvl: l.lvl, + } } + +// Debug logs a message at DebugLevel. +func (l *Logger) Debug(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelDebug, msg, f...) } + +// Info logs a message at InfoLevel. +func (l *Logger) Info(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelInfo, msg, f...) } + +// Warn logs a message at WarnLevel. +func (l *Logger) Warn(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelWarn, msg, f...) } + +// Error logs a message at ErrorLevel. +func (l *Logger) Error(msg string, f ...Attr) { l.slog.LogAttrs(l.ctx, slog.LevelError, msg, f...) } diff --git a/internal/logger/logger_noop.go b/internal/logger/logger_noop.go new file mode 100644 index 00000000..03147fbc --- /dev/null +++ b/internal/logger/logger_noop.go @@ -0,0 +1,21 @@ +package logger + +import ( + "context" + "log/slog" +) + +// NewNop returns a no-op Logger. It never writes out logs or internal errors. The common use case is to use it +// in tests. +func NewNop() *Logger { + return &Logger{ctx: context.Background(), slog: slog.New(&noopHandler{}), lvl: DebugLevel} +} + +type noopHandler struct{} + +var _ slog.Handler = (*noopHandler)(nil) // verify interface implementation + +func (noopHandler) Enabled(context.Context, slog.Level) bool { return true } +func (noopHandler) Handle(context.Context, slog.Record) error { return nil } +func (noopHandler) WithAttrs([]slog.Attr) slog.Handler { return noopHandler{} } +func (noopHandler) WithGroup(string) slog.Handler { return noopHandler{} } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 499a1def..361cbefc 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -1,75 +1,235 @@ package logger_test import ( - "regexp" - "strings" + "bytes" + "strconv" "testing" "time" - "github.com/kami-zh/go-capturer" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gh.tarampamp.am/error-pages/internal/logger" ) -func TestNewDebugLevelConsoleFormat(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(logger.DebugLevel, logger.ConsoleFormat) - require.NoError(t, err) - - log.Debug("dbg msg") - log.Info("inf msg") - log.Error("err msg") - }) - - assert.Contains(t, output, time.Now().Format("15:04:05")) - assert.Regexp(t, `\t.+info.+\tinf msg`, output) - assert.Regexp(t, `\t.+info.+\t.+logger_test\.go:\d+\tinf msg`, output) - assert.Contains(t, output, "dbg msg") - assert.Contains(t, output, "err msg") +func TestNewErrors(t *testing.T) { + log, err := logger.New(logger.Level(127), logger.ConsoleFormat) + require.Nil(t, log) + require.EqualError(t, err, "unsupported logging level") + + log, err = logger.New(logger.WarnLevel, logger.Format(255)) + require.Nil(t, log) + require.EqualError(t, err, "unsupported logging format") +} + +func TestLogger_ConsoleFormat(t *testing.T) { + var ( + buf bytes.Buffer + log, logErr = logger.New(logger.DebugLevel, logger.ConsoleFormat, &buf) + + now = time.Now() + ) + + require.NoError(t, logErr) + assert.Equal(t, logger.DebugLevel, log.Level()) + + log.Debug("debug message", + logger.String("String", "value"), + logger.Strings("Strings", "foo", "bar", ""), + logger.Int64("Int64", 0), + logger.Int("Int", 1), + logger.Uint64("Uint64", 2), + logger.Float64("Float64", 3.14), + logger.Bool("Bool", true), + logger.Time("Time", now), + logger.Duration("Duration", time.Millisecond), + ) + + var output = buf.String() + + assert.Contains(t, output, `time=`+now.Format("15:04:")) // match without seconds + assert.Contains(t, output, `level=debug`) + assert.Contains(t, output, `msg="debug message"`) + assert.Contains(t, output, "String=value") + assert.Contains(t, output, `Strings="[foo bar ]"`) + assert.Contains(t, output, "Int64=0") + assert.Contains(t, output, "Int=1") + assert.Contains(t, output, "Uint64=2") + assert.Contains(t, output, "Float64=3.14") + assert.Contains(t, output, "Bool=true") + assert.Contains(t, output, "Time="+now.Format("2006-01-02T15:04:05.000Z07:00")) + assert.Contains(t, output, "Duration=1ms") +} + +func TestLogger_JSONFormat(t *testing.T) { + var ( + buf bytes.Buffer + log, logErr = logger.New(logger.DebugLevel, logger.JSONFormat, &buf) + + now = time.Now() + ) + + require.NoError(t, logErr) + assert.Equal(t, logger.DebugLevel, log.Level()) + + log.Debug("debug message", + logger.String("String", "value"), + logger.Strings("Strings", "foo", "bar", ""), + logger.Int64("Int64", 0), + logger.Int("Int", 1), + logger.Uint64("Uint64", 2), + logger.Float64("Float64", 3.14), + logger.Bool("Bool", true), + logger.Time("Time", now), + logger.Duration("Duration", time.Millisecond), + ) + + var output = buf.String() + + assert.Contains(t, output, `"ts":`+strconv.Itoa(int(now.Unix()))+".") // match without nanoseconds + assert.Contains(t, output, `"level":"debug"`) + assert.Contains(t, output, `"msg":"debug message"`) + assert.Contains(t, output, `"String":"value"`) + assert.Contains(t, output, `"Strings":["foo","bar",""]`) + assert.Contains(t, output, `"Int64":0`) + assert.Contains(t, output, `"Int":1`) + assert.Contains(t, output, `"Uint64":2`) + assert.Contains(t, output, `"Float64":3.14`) + assert.Contains(t, output, `"Bool":true`) + assert.Contains(t, output, `"Time":"`+now.Format("2006-01-02T15:04:05.000")) // omit nano seconds + assert.Contains(t, output, `"Duration":1000000`) } -func TestNewErrorLevelConsoleFormat(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(logger.ErrorLevel, logger.ConsoleFormat) - require.NoError(t, err) +func TestLogger_Debug(t *testing.T) { + var ( + buf bytes.Buffer + log, logErr = logger.New(logger.DebugLevel, logger.JSONFormat, &buf) + ) + + require.NoError(t, logErr) + assert.Equal(t, logger.DebugLevel, log.Level()) + + log.Debug("debug message") + log.Info("info message") + log.Warn("warn message") + log.Error("error message") - log.Debug("dbg msg") - log.Info("inf msg") - log.Error("err msg") - }) + var output = buf.String() - assert.NotContains(t, output, "inf msg") - assert.NotContains(t, output, "dbg msg") - assert.Contains(t, output, "err msg") + assert.Contains(t, output, "debug message") + assert.Contains(t, output, "info message") + assert.Contains(t, output, "warn message") + assert.Contains(t, output, "error message") } -func TestNewWarnLevelJSONFormat(t *testing.T) { - output := capturer.CaptureStderr(func() { - log, err := logger.New(logger.WarnLevel, logger.JSONFormat) - require.NoError(t, err) +func TestLogger_Info(t *testing.T) { + var ( + buf bytes.Buffer + log, logErr = logger.New(logger.InfoLevel, logger.JSONFormat, &buf) + ) - log.Debug("dbg msg") - log.Info("inf msg") - log.Warn("warn msg") - log.Error("err msg") - }) + require.NoError(t, logErr) + assert.Equal(t, logger.InfoLevel, log.Level()) - // replace timestamp field with fixed value - fakeTimestamp := regexp.MustCompile(`"ts":\d+\.\d+,`) - output = fakeTimestamp.ReplaceAllString(output, `"ts":0.1,`) + log.Debug("debug message") + log.Info("info message") + log.Warn("warn message") + log.Error("error message") - lines := strings.Split(strings.Trim(output, "\n"), "\n") + var output = buf.String() - assert.JSONEq(t, `{"level":"warn","ts":0.1,"msg":"warn msg"}`, lines[0]) - assert.JSONEq(t, `{"level":"error","ts":0.1,"msg":"err msg"}`, lines[1]) + assert.NotContains(t, output, "debug message") + assert.Contains(t, output, "info message") + assert.Contains(t, output, "warn message") + assert.Contains(t, output, "error message") } -func TestNewErrors(t *testing.T) { - _, err := logger.New(logger.Level(127), logger.ConsoleFormat) - require.EqualError(t, err, "unsupported logging level") +func TestLogger_Warn(t *testing.T) { + var ( + buf bytes.Buffer + log, logErr = logger.New(logger.WarnLevel, logger.JSONFormat, &buf) + ) - _, err = logger.New(logger.WarnLevel, logger.Format(255)) - require.EqualError(t, err, "unsupported logging format") + require.NoError(t, logErr) + assert.Equal(t, logger.WarnLevel, log.Level()) + + log.Debug("debug message") + log.Info("info message") + log.Warn("warn message") + log.Error("error message") + + var output = buf.String() + + assert.NotContains(t, output, "debug message") + assert.NotContains(t, output, "info message") + assert.Contains(t, output, "warn message") + assert.Contains(t, output, "error message") +} + +func TestLogger_Error(t *testing.T) { + var ( + buf bytes.Buffer + log, logErr = logger.New(logger.ErrorLevel, logger.JSONFormat, &buf) + ) + + require.NoError(t, logErr) + assert.Equal(t, logger.ErrorLevel, log.Level()) + + log.Debug("debug message") + log.Info("info message") + log.Warn("warn message") + log.Error("error message") + + var output = buf.String() + + assert.NotContains(t, output, "debug message") + assert.NotContains(t, output, "info message") + assert.NotContains(t, output, "warn message") + assert.Contains(t, output, "error message") +} + +func TestLogger_Named_JSONFormat(t *testing.T) { + var ( + buf bytes.Buffer + log, _ = logger.New(logger.DebugLevel, logger.JSONFormat, &buf) + named = log.Named("test_name") + ) + + log.Debug("debug message") + + var output = buf.String() + + assert.Contains(t, output, `"msg":"debug message"`) + assert.NotContains(t, output, `"logger":"`) + + buf.Reset() + named.Debug("named log message") + + output = buf.String() + + assert.Contains(t, output, `"msg":"named log message"`) + assert.Contains(t, output, `"logger":"test_name"`) +} + +func TestLogger_Named_ConsoleFormat(t *testing.T) { + var ( + buf bytes.Buffer + log, _ = logger.New(logger.DebugLevel, logger.ConsoleFormat, &buf) + named = log.Named("test_name") + ) + + log.Debug("debug message") + + var output = buf.String() + + assert.Contains(t, output, `msg="debug message"`) + assert.NotContains(t, output, `logger=`) + + buf.Reset() + named.Debug("named log message") + + output = buf.String() + + assert.Contains(t, output, `msg="named log message"`) + assert.Contains(t, output, `logger=test_name`) } diff --git a/internal/logger/std.go b/internal/logger/std.go new file mode 100644 index 00000000..61339a98 --- /dev/null +++ b/internal/logger/std.go @@ -0,0 +1,14 @@ +package logger + +import ( + stdLog "log" +) + +// NewStdLog returns a *[log.Logger] which writes to the supplied [Logger] at [InfoLevel]. +func NewStdLog(log *Logger) *stdLog.Logger { + return stdLog.New(&loggerWriter{log} /* prefix */, "" /* flags */, 0) +} + +type loggerWriter struct{ log *Logger } + +func (lw *loggerWriter) Write(p []byte) (int, error) { lw.log.Info(string(p)); return len(p), nil } //nolint:nlreturn diff --git a/internal/logger/std_test.go b/internal/logger/std_test.go new file mode 100644 index 00000000..a196abcd --- /dev/null +++ b/internal/logger/std_test.go @@ -0,0 +1,23 @@ +package logger_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/logger" +) + +func TestNewStdLog(t *testing.T) { + var ( + buf bytes.Buffer + log, _ = logger.New(logger.InfoLevel, logger.JSONFormat, &buf) + + std = logger.NewStdLog(log) + ) + + std.Print("test") + + assert.Contains(t, buf.String(), "test") +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go deleted file mode 100644 index 2fee2279..00000000 --- a/internal/metrics/metrics.go +++ /dev/null @@ -1,52 +0,0 @@ -package metrics - -import ( - "time" - - "github.com/prometheus/client_golang/prometheus" -) - -type Metrics struct { - total prometheus.Counter - duration prometheus.Histogram -} - -// NewMetrics creates new Metrics collector. -func NewMetrics() Metrics { - const namespace, subsystem = "http", "requests" - - return Metrics{ - total: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "total_count", - Help: "Counter of HTTP requests made.", - }), - duration: prometheus.NewHistogram(prometheus.HistogramOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "duration_milliseconds", - Help: "Histogram of the time (in milliseconds) each request took.", - Buckets: append([]float64{.001, .003}, prometheus.DefBuckets...), - }), - } -} - -// IncrementTotalRequests increments total requests counter. -func (w *Metrics) IncrementTotalRequests() { w.total.Inc() } - -// ObserveRequestDuration observer requests duration histogram. -func (w *Metrics) ObserveRequestDuration(t time.Duration) { w.duration.Observe(t.Seconds()) } - -// Register metrics with registerer. -func (w *Metrics) Register(reg prometheus.Registerer) error { - if err := reg.Register(w.total); err != nil { - return err - } - - if err := reg.Register(w.duration); err != nil { - return err - } - - return nil -} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go deleted file mode 100644 index a675a880..00000000 --- a/internal/metrics/metrics_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package metrics_test - -import ( - "testing" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/testutil" - dto "github.com/prometheus/client_model/go" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/error-pages/internal/metrics" -) - -func TestMetrics_Register(t *testing.T) { - var ( - registry = prometheus.NewRegistry() - m = metrics.NewMetrics() - ) - - assert.NoError(t, m.Register(registry)) - - count, err := testutil.GatherAndCount(registry, - "http_requests_total_count", - "http_requests_duration_milliseconds", - ) - assert.NoError(t, err) - - assert.Equal(t, 2, count) -} - -func TestMetrics_IncrementTotalRequests(t *testing.T) { - p := metrics.NewMetrics() - - p.IncrementTotalRequests() - - metric := getMetric(t, &p, "http_requests_total_count") - assert.Equal(t, float64(1), metric.Counter.GetValue()) -} - -func TestMetrics_ObserveRequestDuration(t *testing.T) { - p := metrics.NewMetrics() - - p.ObserveRequestDuration(time.Second) - - metric := getMetric(t, &p, "http_requests_duration_milliseconds") - assert.Equal(t, float64(1), metric.Histogram.GetSampleSum()) -} - -type registerer interface { - Register(prometheus.Registerer) error -} - -func getMetric(t *testing.T, reg registerer, name string) *dto.Metric { - t.Helper() - - registry := prometheus.NewRegistry() - _ = reg.Register(registry) - - families, _ := registry.Gather() - - for _, family := range families { - if family.GetName() == name { - return family.Metric[0] - } - } - - assert.FailNowf(t, "cannot resolve metric for: %s", name) - - return nil -} diff --git a/internal/metrics/registry.go b/internal/metrics/registry.go deleted file mode 100644 index d64017a2..00000000 --- a/internal/metrics/registry.go +++ /dev/null @@ -1,20 +0,0 @@ -// Package metrics contains custom prometheus metrics and registry factories. -package metrics - -import ( - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/collectors" -) - -// NewRegistry creates new prometheus registry with pre-registered common collectors. -func NewRegistry() *prometheus.Registry { - registry := prometheus.NewRegistry() - - // register common metric collectors - registry.MustRegister( - // collectors.NewGoCollector(), - collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), - ) - - return registry -} diff --git a/internal/metrics/registry_test.go b/internal/metrics/registry_test.go deleted file mode 100644 index 749fa516..00000000 --- a/internal/metrics/registry_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package metrics_test - -import ( - "testing" - - "github.com/prometheus/client_golang/prometheus/testutil" - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/error-pages/internal/metrics" -) - -func TestNewRegistry(t *testing.T) { - registry := metrics.NewRegistry() - - count, err := testutil.GatherAndCount(registry) - - assert.NoError(t, err) - assert.True(t, count >= 6, "not enough common metrics") -} diff --git a/internal/options/errorpage.go b/internal/options/errorpage.go deleted file mode 100644 index 7516bcf9..00000000 --- a/internal/options/errorpage.go +++ /dev/null @@ -1,17 +0,0 @@ -package options - -type ErrorPage struct { - Default struct { - PageCode string // default error page code - HTTPCode uint16 // default HTTP response code - } - L10n struct { - Disabled bool // disable error pages localization - } - Template struct { - Name string // template name - } - ShowDetails bool // show request details in response - ProxyHTTPHeaders []string // proxy HTTP request headers list - CatchAll bool // catch all pages -} diff --git a/internal/pick/picker.go b/internal/pick/picker.go deleted file mode 100644 index 8f5183fc..00000000 --- a/internal/pick/picker.go +++ /dev/null @@ -1,88 +0,0 @@ -package pick - -import ( - "math/rand" - "sync" - "time" -) - -type pickMode = byte - -const ( - First pickMode = 1 + iota // Always pick the first element (index = 0) - RandomOnce // Pick random element once (any future Pick calls will return the same element) - RandomEveryTime // Always Pick the random element -) - -type picker struct { - mode pickMode - rand *rand.Rand // will be nil for the First pick mode - maxIdx uint32 - - mu sync.Mutex - lastIdx uint32 -} - -const unsetIdx uint32 = 4294967295 - -func NewPicker(maxIdx uint32, mode pickMode) *picker { - var p = &picker{ - maxIdx: maxIdx, - mode: mode, - lastIdx: unsetIdx, - } - - if mode != First { - p.rand = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec - } - - return p -} - -// NextIndex returns an index for the next element (based on pickMode). -func (p *picker) NextIndex() uint32 { - if p.maxIdx == 0 { - return 0 - } - - switch p.mode { - case First: - return 0 - - case RandomOnce: - if p.lastIdx == unsetIdx { - return p.randomizeNext() - } - - return p.lastIdx - - case RandomEveryTime: - return p.randomizeNext() - - default: - panic("picker.NextIndex(): unsupported mode") - } -} - -func (p *picker) randomizeNext() uint32 { - var idx = uint32(p.rand.Intn(int(p.maxIdx + 1))) - - p.mu.Lock() - defer p.mu.Unlock() - - if idx == p.lastIdx { - p.lastIdx++ - } else { - p.lastIdx = idx - } - - if p.lastIdx > p.maxIdx { // overflow? - p.lastIdx = 0 - } - - if p.lastIdx == unsetIdx { - p.lastIdx-- - } - - return p.lastIdx -} diff --git a/internal/pick/picker_test.go b/internal/pick/picker_test.go deleted file mode 100644 index 92d64cb5..00000000 --- a/internal/pick/picker_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package pick_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/error-pages/internal/pick" -) - -func TestPicker_NextIndex_First(t *testing.T) { - for i := uint32(0); i < 100; i++ { - p := pick.NewPicker(i, pick.First) - - for j := uint8(0); j < 100; j++ { - assert.Equal(t, uint32(0), p.NextIndex()) - } - } -} - -func TestPicker_NextIndex_RandomOnce(t *testing.T) { - for i := uint8(0); i < 10; i++ { - assert.Equal(t, uint32(0), pick.NewPicker(0, pick.RandomOnce).NextIndex()) - } - - for i := uint8(10); i < 100; i++ { - p := pick.NewPicker(uint32(i), pick.RandomOnce) - - next := p.NextIndex() - assert.LessOrEqual(t, next, uint32(i)) - - for j := uint8(0); j < 100; j++ { - assert.Equal(t, next, p.NextIndex()) - } - } -} - -func TestPicker_NextIndex_RandomEveryTime(t *testing.T) { - for i := uint8(0); i < 10; i++ { - assert.Equal(t, uint32(0), pick.NewPicker(0, pick.RandomEveryTime).NextIndex()) - } - - for i := uint8(1); i < 100; i++ { - p := pick.NewPicker(uint32(i), pick.RandomEveryTime) - - for j := uint8(0); j < 100; j++ { - one, two := p.NextIndex(), p.NextIndex() - - assert.LessOrEqual(t, one, uint32(i)) - assert.LessOrEqual(t, two, uint32(i)) - assert.NotEqual(t, one, two) - } - } -} - -func TestPicker_NextIndex_Unsupported(t *testing.T) { - assert.Panics(t, func() { pick.NewPicker(1, 255).NextIndex() }) -} diff --git a/internal/pick/strings_slice.go b/internal/pick/strings_slice.go deleted file mode 100644 index 114aa87c..00000000 --- a/internal/pick/strings_slice.go +++ /dev/null @@ -1,135 +0,0 @@ -package pick - -import ( - "errors" - "sync" - "time" -) - -type StringsSlice struct { - s []string - p *picker -} - -// NewStringsSlice creates new StringsSlice. -func NewStringsSlice(items []string, mode pickMode) *StringsSlice { - maxIdx := len(items) - 1 - - if maxIdx < 0 { - maxIdx = 0 - } - - return &StringsSlice{s: items, p: NewPicker(uint32(maxIdx), mode)} -} - -// Pick an element from the strings slice. -func (s *StringsSlice) Pick() string { - if len(s.s) == 0 { - return "" - } - - return s.s[s.p.NextIndex()] -} - -type StringsSliceWithInterval struct { - s []string - p *picker - d time.Duration - - idxMu sync.RWMutex - idx uint32 - - close chan struct{} - closedMu sync.RWMutex - closed bool -} - -// NewStringsSliceWithInterval creates new StringsSliceWithInterval. -func NewStringsSliceWithInterval(items []string, mode pickMode, interval time.Duration) *StringsSliceWithInterval { - maxIdx := len(items) - 1 - - if maxIdx < 0 { - maxIdx = 0 - } - - if interval <= time.Duration(0) { - panic("NewStringsSliceWithInterval: wrong interval") - } - - s := &StringsSliceWithInterval{ - s: items, - p: NewPicker(uint32(maxIdx), mode), - d: interval, - close: make(chan struct{}, 1), - } - - s.next() - - go s.rotate() - - return s -} - -func (s *StringsSliceWithInterval) rotate() { - defer close(s.close) - - timer := time.NewTimer(s.d) - defer timer.Stop() - - for { - select { - case <-s.close: - return - - case <-timer.C: - s.next() - timer.Reset(s.d) - } - } -} - -func (s *StringsSliceWithInterval) next() { - idx := s.p.NextIndex() - - s.idxMu.Lock() - s.idx = idx - s.idxMu.Unlock() -} - -// Pick an element from the strings slice. -func (s *StringsSliceWithInterval) Pick() string { - if s.isClosed() { - panic("StringsSliceWithInterval.Pick(): closed") - } - - if len(s.s) == 0 { - return "" - } - - s.idxMu.RLock() - defer s.idxMu.RUnlock() - - return s.s[s.idx] -} - -func (s *StringsSliceWithInterval) isClosed() (closed bool) { - s.closedMu.RLock() - closed = s.closed - s.closedMu.RUnlock() - - return -} - -func (s *StringsSliceWithInterval) Close() error { - if s.isClosed() { - return errors.New("closed") - } - - s.closedMu.Lock() - s.closed = true - s.closedMu.Unlock() - - s.close <- struct{}{} - - return nil -} diff --git a/internal/pick/strings_slice_test.go b/internal/pick/strings_slice_test.go deleted file mode 100644 index d7e61526..00000000 --- a/internal/pick/strings_slice_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package pick_test - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/error-pages/internal/pick" -) - -func TestStringsSlice_Pick(t *testing.T) { - t.Run("first", func(t *testing.T) { - for i := uint8(0); i < 100; i++ { - assert.Equal(t, "", pick.NewStringsSlice([]string{}, pick.First).Pick()) - } - - p := pick.NewStringsSlice([]string{"foo", "bar", "baz"}, pick.First) - - for i := uint8(0); i < 100; i++ { - assert.Equal(t, "foo", p.Pick()) - } - }) - - t.Run("random once", func(t *testing.T) { - for i := uint8(0); i < 100; i++ { - assert.Equal(t, "", pick.NewStringsSlice([]string{}, pick.RandomOnce).Pick()) - } - - var ( - p = pick.NewStringsSlice([]string{"foo", "bar", "baz"}, pick.RandomOnce) - picked = p.Pick() - ) - - for i := uint8(0); i < 100; i++ { - assert.Equal(t, picked, p.Pick()) - } - }) - - t.Run("random every time", func(t *testing.T) { - for i := uint8(0); i < 100; i++ { - assert.Equal(t, "", pick.NewStringsSlice([]string{}, pick.RandomEveryTime).Pick()) - } - - for i := uint8(0); i < 100; i++ { - p := pick.NewStringsSlice([]string{"foo", "bar", "baz"}, pick.RandomEveryTime) - - assert.NotEqual(t, p.Pick(), p.Pick()) - } - }) -} - -func TestNewStringsSliceWithInterval_Pick(t *testing.T) { - t.Run("first", func(t *testing.T) { - for i := uint8(0); i < 50; i++ { - p := pick.NewStringsSliceWithInterval([]string{}, pick.First, time.Millisecond) - assert.Equal(t, "", p.Pick()) - assert.NoError(t, p.Close()) - assert.Panics(t, func() { p.Pick() }) - } - - p := pick.NewStringsSliceWithInterval([]string{"foo", "bar", "baz"}, pick.First, time.Millisecond) - - for i := uint8(0); i < 50; i++ { - assert.Equal(t, "foo", p.Pick()) - - <-time.After(time.Millisecond * 2) - } - - assert.NoError(t, p.Close()) - assert.Error(t, p.Close()) - assert.Panics(t, func() { p.Pick() }) - }) - - t.Run("random once", func(t *testing.T) { - for i := uint8(0); i < 50; i++ { - p := pick.NewStringsSliceWithInterval([]string{}, pick.RandomOnce, time.Millisecond) - assert.Equal(t, "", p.Pick()) - assert.NoError(t, p.Close()) - assert.Panics(t, func() { p.Pick() }) - } - - var ( - p = pick.NewStringsSliceWithInterval([]string{"foo", "bar", "baz"}, pick.RandomOnce, time.Millisecond) - picked = p.Pick() - ) - - for i := uint8(0); i < 50; i++ { - assert.Equal(t, picked, p.Pick()) - - <-time.After(time.Millisecond * 2) - } - - assert.NoError(t, p.Close()) - assert.Error(t, p.Close()) - assert.Panics(t, func() { p.Pick() }) - }) - - t.Run("random every time", func(t *testing.T) { - for i := uint8(0); i < 50; i++ { - p := pick.NewStringsSliceWithInterval([]string{}, pick.RandomEveryTime, time.Millisecond) - assert.Equal(t, "", p.Pick()) - assert.NoError(t, p.Close()) - assert.Panics(t, func() { p.Pick() }) - } - - var changed int - - for i := uint8(0); i < 50; i++ { - p := pick.NewStringsSliceWithInterval([]string{"foo", "bar", "baz"}, pick.RandomEveryTime, time.Millisecond) //nolint:lll - - one, two := p.Pick(), p.Pick() - assert.Equal(t, one, two) - - <-time.After(time.Millisecond * 2) - - three, four := p.Pick(), p.Pick() - assert.Equal(t, three, four) - - if one != three { - changed++ - } - - assert.NoError(t, p.Close()) - assert.Error(t, p.Close()) - assert.Panics(t, func() { p.Pick() }) - } - - assert.GreaterOrEqual(t, changed, 25) - }) -} diff --git a/internal/template/props.go b/internal/template/props.go new file mode 100644 index 00000000..bccd908b --- /dev/null +++ b/internal/template/props.go @@ -0,0 +1,33 @@ +package template + +import "reflect" + +//nolint:lll +type Props struct { + Code uint16 `token:"code"` // http status code + Message string `token:"message"` // status message + Description string `token:"description"` // status description + OriginalURI string `token:"original_uri"` // (ingress-nginx) URI that caused the error + Namespace string `token:"namespace"` // (ingress-nginx) namespace where the backend Service is located + IngressName string `token:"ingress_name"` // (ingress-nginx) name of the Ingress where the backend is defined + ServiceName string `token:"service_name"` // (ingress-nginx) name of the Service backing the backend + ServicePort string `token:"service_port"` // (ingress-nginx) port number of the Service backing the backend + RequestID string `token:"request_id"` // (ingress-nginx) unique ID that identifies the request - same as for backend service + ForwardedFor string `token:"forwarded_for"` // the value of the `X-Forwarded-For` header + Host string `token:"host"` // the value of the `Host` header + ShowRequestDetails bool `token:"show_details"` // (config) show request details? + L10nDisabled bool `token:"l10n_disabled"` // (config) disable localization feature? +} + +// Values convert the Props struct into a map where each key is a token associated with its corresponding value. +func (p Props) Values() map[string]any { + var result = make(map[string]any, reflect.ValueOf(p).NumField()) + + for i, v := 0, reflect.ValueOf(p); i < v.NumField(); i++ { + if token, tagExists := v.Type().Field(i).Tag.Lookup("token"); tagExists { + result[token] = v.Field(i).Interface() + } + } + + return result +} diff --git a/internal/template/props_test.go b/internal/template/props_test.go new file mode 100644 index 00000000..a41eb2f2 --- /dev/null +++ b/internal/template/props_test.go @@ -0,0 +1,42 @@ +package template_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/internal/template" +) + +func TestProps_Values(t *testing.T) { + t.Parallel() + + assert.Equal(t, template.Props{ + Code: 1, + Message: "b", + Description: "c", + OriginalURI: "d", + Namespace: "e", + IngressName: "f", + ServiceName: "g", + ServicePort: "h", + RequestID: "i", + ForwardedFor: "j", + L10nDisabled: true, + ShowRequestDetails: false, + }.Values(), map[string]any{ + "code": uint16(1), + "message": "b", + "description": "c", + "original_uri": "d", + "namespace": "e", + "ingress_name": "f", + "service_name": "g", + "service_port": "h", + "request_id": "i", + "forwarded_for": "j", + "host": "", // empty because it's not set + "l10n_disabled": true, + "show_details": false, + }) +} diff --git a/internal/template/template.go b/internal/template/template.go new file mode 100644 index 00000000..694ea9d8 --- /dev/null +++ b/internal/template/template.go @@ -0,0 +1,139 @@ +package template + +import ( + "encoding/json" + "fmt" + "html" + "maps" + "os" + "strconv" + "strings" + "text/template" + "time" + + "gh.tarampamp.am/error-pages/internal/appmeta" + "gh.tarampamp.am/error-pages/l10n" +) + +var builtInFunctions = template.FuncMap{ //nolint:gochecknoglobals + // the current time in unix format (seconds since 1970 UTC): + // `{{ nowUnix }}` // `1631610000` + "nowUnix": func() int64 { return time.Now().Unix() }, + + // current hostname: + // `{{ hostname }}` // `localhost` + "hostname": func() string { h, _ := os.Hostname(); return h }, //nolint:nlreturn + + // json-serialized value (safe to use with any type): + // `{{ json "test" }}` // `"test"` + // `{{ json 42 }}` // `42` + "json": func(v any) string { b, _ := json.Marshal(v); return string(b) }, //nolint:nlreturn,errchkjson + + // cast any type to int, or return 0 if it's not possible: + // `{{ int "42" }}` // `42` + // `{{ int 42 }}` // `42` + // `{{ int 3.14 }}` // `3` + // `{{ int "test" }}` // `0` + // `{{ int "42test" }}` // `0` + "int": func(v any) int { // cast any type to int, or return 0 if it's not possible + switch v := v.(type) { + case string: + if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return i + } + case int: + return v + case int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + if i, err := strconv.Atoi(fmt.Sprintf("%d", v)); err == nil { // not effective, but safe + return i + } + case float32, float64: + if i, err := strconv.ParseFloat(fmt.Sprintf("%f", v), 32); err == nil { // not effective, but safe + return int(i) + } + case fmt.Stringer: + if i, err := strconv.Atoi(v.String()); err == nil { + return i + } + } + + return 0 + }, + + // current application version: + // `{{ version }}` // `1.0.0` + "version": appmeta.Version, + + // counts the number of non-overlapping instances of substr in s: + // `{{ strCount "test" "t" }}` // `2` + "strCount": strings.Count, + + // reports whether substr is within s: + // `{{ strContains "test" "es" }}` // `true` + // `{{ strContains "test" "ez" }}` // `false` + "strContains": strings.Contains, + + // returns a slice of the string s, with all leading and trailing white space removed: + // `{{ strTrimSpace " test " }}` // `test` + "strTrimSpace": strings.TrimSpace, + + // returns s without the provided leading prefix string: + // `{{ strTrimPrefix "test" "te" }}` // `st` + "strTrimPrefix": strings.TrimPrefix, + + // returns s without the provided trailing suffix string: + // `{{ strTrimSuffix "test" "st" }}` // `te` + "strTrimSuffix": strings.TrimSuffix, + + // returns a copy of the string s with all non-overlapping instances of old replaced by new: + // `{{ strReplace "test" "t" "z" }}` // `zesz` + "strReplace": strings.ReplaceAll, + + // returns the index of the first instance of substr in s, or -1 if substr is not present in s: + // `{{ strIndex "barfoobaz" "foo" }}` // `3` + "strIndex": strings.Index, + + // splits the string s around each instance of one or more consecutive white space characters: + // `{{ strFields "foo bar baz" }}` // `[foo bar baz]` + "strFields": strings.Fields, + + // retrieves the value of the environment variable named by the key: + // `{{ env "SHELL" }}` // `/bin/bash` + "env": os.Getenv, + + // escapes special characters like "<" to become "<": + // `{{ escape "<test>" }}` // `<test>` + "escape": html.EscapeString, + + // returns the content of the JS file with a script for automatic error page localization: + // `{{ l10nScript }}` // `Object.defineProperty(window, ...` + "l10nScript": l10n.L10n, +} + +func Render(content string, props Props) (string, error) { + var fns = maps.Clone(builtInFunctions) + + maps.Copy(fns, template.FuncMap{ // add custom functions + "hide_details": func() bool { return !props.ShowRequestDetails }, // inverted logic + "l10n_enabled": func() bool { return !props.L10nDisabled }, // inverted logic + }) + + // allow the direct access to the properties tokens, e.g. `{{ service_port | json }}` + // instead of `{{ .service_port | json }}` + for k, v := range props.Values() { + fns[k] = func() any { return v } + } + + tmpl, tErr := template.New("template").Funcs(fns).Parse(content) + if tErr != nil { + return "", fmt.Errorf("failed to parse template: %w", tErr) + } + + var buf strings.Builder + + if err := tmpl.Execute(&buf, props); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/internal/template/template_test.go b/internal/template/template_test.go new file mode 100644 index 00000000..a11ae15d --- /dev/null +++ b/internal/template/template_test.go @@ -0,0 +1,233 @@ +package template_test + +import ( + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gh.tarampamp.am/error-pages/internal/appmeta" + "gh.tarampamp.am/error-pages/internal/template" + "gh.tarampamp.am/error-pages/l10n" +) + +func TestRender_BuiltInFunction(t *testing.T) { + t.Parallel() + + var hostname, hErr = os.Hostname() + + require.NoError(t, hErr) + + for name, tt := range map[string]struct { + giveTemplate string + wantResult string + wantErrMsg string + }{ + "now (unix)": { + giveTemplate: `{{ nowUnix }}`, + wantResult: strconv.Itoa(int(time.Now().Unix())), + }, + "hostname": {giveTemplate: `{{ hostname }}`, wantResult: hostname}, + "json (string)": {giveTemplate: `{{ json "test" }}`, wantResult: `"test"`}, + "json (int)": {giveTemplate: `{{ json 42 }}`, wantResult: `42`}, + "json (func result)": {giveTemplate: `{{ json hostname }}`, wantResult: `"` + hostname + `"`}, + "int (string)": {giveTemplate: `{{ int "42" }}`, wantResult: `42`}, + "int (int)": {giveTemplate: `{{ int 42 }}`, wantResult: `42`}, + "int (float)": {giveTemplate: `{{ int 3.14 }}`, wantResult: `3`}, + "int (wrong string)": {giveTemplate: `{{ int "test" }}`, wantResult: `0`}, + "int (string with numbers)": {giveTemplate: `{{ int "42test" }}`, wantResult: `0`}, + "version": {giveTemplate: `{{ version }}`, wantResult: appmeta.Version()}, + "strCount": {giveTemplate: `{{ strCount "test" "t" }}`, wantResult: `2`}, + "strContains (true)": {giveTemplate: `{{ strContains "test" "es" }}`, wantResult: `true`}, + "strContains (false)": {giveTemplate: `{{ strContains "test" "ez" }}`, wantResult: `false`}, + "strTrimSpace": {giveTemplate: `{{ strTrimSpace " test " }}`, wantResult: `test`}, + "strTrimPrefix": {giveTemplate: `{{ strTrimPrefix "test" "te" }}`, wantResult: `st`}, + "strTrimSuffix": {giveTemplate: `{{ strTrimSuffix "test" "st" }}`, wantResult: `te`}, + "strReplace": {giveTemplate: `{{ strReplace "test" "t" "z" }}`, wantResult: `zesz`}, + "strIndex": {giveTemplate: `{{ strIndex "barfoobaz" "foo" }}`, wantResult: `3`}, + "strFields": {giveTemplate: `{{ strFields "foo bar baz" }}`, wantResult: `[foo bar baz]`}, + "env (ok)": {giveTemplate: `{{ env "TEST_ENV_VAR" }}`, wantResult: "unit-test"}, + "env (not found)": {giveTemplate: `{{ env "NOT_FOUND_ENV_VAR" }}`, wantResult: ""}, + "l10nScript": {giveTemplate: `{{ l10nScript }}`, wantResult: l10n.L10n()}, + "escape": { + giveTemplate: `{{ escape "<script>alert('XSS' + \"HERE\")</script>" }}`, + wantResult: "<script>alert('XSS' + "HERE")</script>", + }, + } { + t.Run(name, func(t *testing.T) { + require.NoError(t, os.Setenv("TEST_ENV_VAR", "unit-test")) + + defer func() { require.NoError(t, os.Unsetenv("TEST_ENV_VAR")) }() + + var result, err = template.Render(tt.giveTemplate, template.Props{}) + + if tt.wantErrMsg != "" { + assert.ErrorContains(t, err, tt.wantErrMsg) + assert.Empty(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + }) + } +} + +func TestRender(t *testing.T) { + t.Parallel() + + for name, tt := range map[string]struct { + giveTemplate string + giveProps template.Props + wantResult string + wantErrMsg string + }{ + "common case": { + giveTemplate: "{{code}}: {{ message }} {{description}}", + giveProps: template.Props{Code: 404, Message: "Not found", Description: "Blah"}, + wantResult: "404: Not found Blah", + }, + "html markup": { + giveTemplate: "<!-- comment --><html><body>{{code}}: {{ message }} {{description}}</body></html>", + giveProps: template.Props{Code: 201, Message: "lorem ipsum"}, + wantResult: "<!-- comment --><html><body>201: lorem ipsum </body></html>", + }, + "with line breakers": { + giveTemplate: "\t {{code | json}}: {{ message }} {{description}}\n", + giveProps: template.Props{}, + wantResult: "\t 0: \n", + }, + "golang template": { + giveTemplate: "\t {{code}} {{ .Code }}{{ if .Message }} Yeah {{end}}", + giveProps: template.Props{Code: 201, Message: "lorem ipsum"}, + wantResult: "\t 201 201 Yeah ", + }, + + "json common case": { + giveTemplate: `{"code": {{code | json}}, "message": {"here":[ {{ message | json }} ]}, "desc": "{{description}}"}`, + giveProps: template.Props{Code: 404, Message: "'\"{Not found\t\r\n"}, + wantResult: `{"code": 404, "message": {"here":[ "'\"{Not found\t\r\n" ]}, "desc": ""}`, + }, + "json golang template": { + giveTemplate: `{"code": "{{code}}", "message": {"here":[ "{{ if .Message }} Yeah {{end}}" ]}}`, + giveProps: template.Props{Code: 201, Message: "lorem ipsum"}, + wantResult: `{"code": "201", "message": {"here":[ " Yeah " ]}}`, + }, + + "fn l10n_enabled": { + giveTemplate: "{{ if l10n_enabled }}Y{{ else }}N{{ end }}", + giveProps: template.Props{L10nDisabled: true}, + wantResult: "N", + }, + "fn l10n_disabled": { + giveTemplate: "{{ if l10n_disabled }}Y{{ else }}N{{ end }}", + giveProps: template.Props{L10nDisabled: true}, + wantResult: "Y", + }, + + "complete example with every property and function": { + giveProps: template.Props{ + Code: 404, + Message: "Not found", + Description: "Blah", + OriginalURI: "/test", + Namespace: "default", + IngressName: "test-ingress", + ServiceName: "test-service", + ServicePort: "80", + RequestID: "123456", + ForwardedFor: "123.123.123.123:321", + Host: "test-host", + ShowRequestDetails: true, + L10nDisabled: false, + }, + giveTemplate: ` + == Props as functions == + code: {{code}} + message: {{message}} + description: {{description}} + original_uri: {{original_uri}} + namespace: {{namespace}} + ingress_name: {{ingress_name}} + service_name: {{service_name}} + service_port: {{service_port}} + request_id: {{request_id}} + forwarded_for: {{forwarded_for}} + host: {{host}} + show_details: {{show_details}} + l10n_disabled: {{l10n_disabled}} + + == Props as properties == + .Code: {{ .Code }} + .Message: {{ .Message }} + .Description: {{ .Description }} + .OriginalURI: {{ .OriginalURI }} + .Namespace: {{ .Namespace }} + .IngressName: {{ .IngressName }} + .ServiceName: {{ .ServiceName }} + .ServicePort: {{ .ServicePort }} + .RequestID: {{ .RequestID }} + .ForwardedFor: {{ .ForwardedFor }} + .Host: {{ .Host }} + .ShowRequestDetails: {{ .ShowRequestDetails }} + .L10nDisabled: {{ .L10nDisabled }} + + == Custom functions == + hide_details: {{ hide_details }} + l10n_enabled: {{ l10n_enabled }} +`, + wantResult: ` + == Props as functions == + code: 404 + message: Not found + description: Blah + original_uri: /test + namespace: default + ingress_name: test-ingress + service_name: test-service + service_port: 80 + request_id: 123456 + forwarded_for: 123.123.123.123:321 + host: test-host + show_details: true + l10n_disabled: false + + == Props as properties == + .Code: 404 + .Message: Not found + .Description: Blah + .OriginalURI: /test + .Namespace: default + .IngressName: test-ingress + .ServiceName: test-service + .ServicePort: 80 + .RequestID: 123456 + .ForwardedFor: 123.123.123.123:321 + .Host: test-host + .ShowRequestDetails: true + .L10nDisabled: false + + == Custom functions == + hide_details: false + l10n_enabled: true +`, + }, + + "wrong template": {giveTemplate: `{{ foo() }}`, wantErrMsg: `function "foo" not defined`}, + "wrong template #2": {giveTemplate: `{{ fo`, wantErrMsg: "failed to parse template"}, + } { + t.Run(name, func(t *testing.T) { + var result, err = template.Render(tt.giveTemplate, tt.giveProps) + + if tt.wantErrMsg != "" { + assert.ErrorContains(t, err, tt.wantErrMsg) + assert.Empty(t, result) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + }) + } +} diff --git a/internal/tpl/hasher.go b/internal/tpl/hasher.go deleted file mode 100644 index 00f12afe..00000000 --- a/internal/tpl/hasher.go +++ /dev/null @@ -1,25 +0,0 @@ -package tpl - -import ( - "bytes" - "crypto/md5" //nolint:gosec - "encoding/gob" -) - -const hashLength = 16 // md5 hash length - -type Hash [hashLength]byte - -func HashStruct(s interface{}) (Hash, error) { - var b bytes.Buffer - - if err := gob.NewEncoder(&b).Encode(s); err != nil { - return Hash{}, err - } - - return md5.Sum(b.Bytes()), nil //nolint:gosec -} - -func HashBytes(b []byte) Hash { - return md5.Sum(b) //nolint:gosec -} diff --git a/internal/tpl/hasher_test.go b/internal/tpl/hasher_test.go deleted file mode 100644 index 31de7d05..00000000 --- a/internal/tpl/hasher_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package tpl_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/error-pages/internal/tpl" -) - -func TestHashBytes(t *testing.T) { - assert.NotEqual(t, tpl.HashBytes([]byte{1}), tpl.HashBytes([]byte{2})) -} - -func TestHashStruct(t *testing.T) { - type s struct { - S string - I int - B bool - } - - h1, err1 := tpl.HashStruct(s{S: "foo", I: 1, B: false}) - assert.NoError(t, err1) - - h2, err2 := tpl.HashStruct(s{S: "bar", I: 2, B: true}) - assert.NoError(t, err2) - - assert.NotEqual(t, h1, h2) - - type p struct { // no exported fields - any string - } - - _, err := tpl.HashStruct(p{any: "foo"}) - assert.Error(t, err) -} diff --git a/internal/tpl/properties.go b/internal/tpl/properties.go deleted file mode 100644 index caae3b48..00000000 --- a/internal/tpl/properties.go +++ /dev/null @@ -1,40 +0,0 @@ -package tpl - -import ( - "reflect" -) - -type Properties struct { // only string properties with a "token" tag, please - Code string `token:"code"` - Message string `token:"message"` - Description string `token:"description"` - OriginalURI string `token:"original_uri"` - Namespace string `token:"namespace"` - IngressName string `token:"ingress_name"` - ServiceName string `token:"service_name"` - ServicePort string `token:"service_port"` - RequestID string `token:"request_id"` - ForwardedFor string `token:"forwarded_for"` - Host string `token:"host"` - L10nDisabled bool - ShowRequestDetails bool -} - -// Replaces return a map with strings for the replacing, where the map key is a token. -func (p *Properties) Replaces() map[string]string { - var replaces = make(map[string]string, reflect.ValueOf(*p).NumField()) - - for i, v := 0, reflect.ValueOf(*p); i < v.NumField(); i++ { - if keyword, tagExists := v.Type().Field(i).Tag.Lookup("token"); tagExists { - if sv, isString := v.Field(i).Interface().(string); isString && len(sv) > 0 { - replaces[keyword] = sv - } else { - replaces[keyword] = "" - } - } - } - - return replaces -} - -func (p *Properties) Hash() (Hash, error) { return HashStruct(p) } diff --git a/internal/tpl/properties_test.go b/internal/tpl/properties_test.go deleted file mode 100644 index 5ab81a4c..00000000 --- a/internal/tpl/properties_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package tpl_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "gh.tarampamp.am/error-pages/internal/tpl" -) - -func TestProperties_Replaces(t *testing.T) { - props := tpl.Properties{ - Code: "foo", - Message: "bar", - Description: "baz", - OriginalURI: "aaa", - Namespace: "bbb", - IngressName: "ccc", - ServiceName: "ddd", - ServicePort: "eee", - RequestID: "fff", - ForwardedFor: "ggg", - Host: "hhh", - } - - r := props.Replaces() - - assert.Equal(t, "foo", r["code"]) - assert.Equal(t, "bar", r["message"]) - assert.Equal(t, "baz", r["description"]) - assert.Equal(t, "aaa", r["original_uri"]) - assert.Equal(t, "bbb", r["namespace"]) - assert.Equal(t, "ccc", r["ingress_name"]) - assert.Equal(t, "ddd", r["service_name"]) - assert.Equal(t, "eee", r["service_port"]) - assert.Equal(t, "fff", r["request_id"]) - assert.Equal(t, "ggg", r["forwarded_for"]) - assert.Equal(t, "hhh", r["host"]) - - props.Code, props.Message, props.Description = "", "", "" - - r = props.Replaces() - - assert.Equal(t, "", r["code"]) - assert.Equal(t, "", r["message"]) - assert.Equal(t, "", r["description"]) -} - -func TestProperties_Hash(t *testing.T) { - props1 := tpl.Properties{Code: "123"} - props2 := tpl.Properties{Code: "123"} - - hash1, err := props1.Hash() - assert.NoError(t, err) - - hash2, err := props2.Hash() - assert.NoError(t, err) - - assert.Equal(t, hash1, hash2) - - props2.Code = "321" - - hash2, err = props2.Hash() - assert.NoError(t, err) - - assert.NotEqual(t, hash1, hash2) -} diff --git a/internal/tpl/render.go b/internal/tpl/render.go deleted file mode 100644 index 75a7fa74..00000000 --- a/internal/tpl/render.go +++ /dev/null @@ -1,220 +0,0 @@ -package tpl - -import ( - "bytes" - "encoding/json" - "os" - "strconv" - "sync" - "text/template" - "time" - - "github.com/pkg/errors" - - "gh.tarampamp.am/error-pages/internal/version" -) - -// These functions are always allowed in the templates. -var tplFnMap = template.FuncMap{ //nolint:gochecknoglobals - "now": time.Now, - "hostname": os.Hostname, - "json": func(v interface{}) string { b, _ := json.Marshal(v); return string(b) }, //nolint:nlreturn - "version": version.Version, - "int": func(v interface{}) int { - if s, ok := v.(string); ok { - if i, err := strconv.Atoi(s); err == nil { - return i - } - } else if i, ok := v.(int); ok { - return i - } - - return 0 - }, - "env": os.Getenv, -} - -var ErrClosed = errors.New("closed") - -type TemplateRenderer struct { - cacheMu sync.RWMutex - cache map[cacheEntryHash]cacheItem // map key is a unique hash - - cacheCleanupInterval time.Duration - cacheItemLifetime time.Duration - - close chan struct{} - closedMu sync.RWMutex - closed bool -} - -type ( - cacheEntryHash = [hashLength * 2]byte // two md5 hashes - cacheItem struct { - data []byte - expiresAtNano int64 - } -) - -const ( - cacheCleanupInterval = time.Second - cacheItemLifetime = time.Second * 2 -) - -// NewTemplateRenderer returns new template renderer. Don't forget to call Close() function! -func NewTemplateRenderer() *TemplateRenderer { - tr := &TemplateRenderer{ - cache: make(map[cacheEntryHash]cacheItem), - cacheCleanupInterval: cacheCleanupInterval, - cacheItemLifetime: cacheItemLifetime, - close: make(chan struct{}, 1), - } - - go tr.cleanup() - - return tr -} - -func (tr *TemplateRenderer) cleanup() { - defer close(tr.close) - - timer := time.NewTimer(tr.cacheCleanupInterval) - defer timer.Stop() - - for { - select { - case <-tr.close: - tr.cacheMu.Lock() - for hash := range tr.cache { - delete(tr.cache, hash) - } - tr.cacheMu.Unlock() - - return - - case <-timer.C: - tr.cacheMu.Lock() - var now = time.Now().UnixNano() - - for hash, item := range tr.cache { - if now > item.expiresAtNano { - delete(tr.cache, hash) - } - } - tr.cacheMu.Unlock() - - timer.Reset(tr.cacheCleanupInterval) - } - } -} - -func (tr *TemplateRenderer) Render(content []byte, props Properties) ([]byte, error) { //nolint:funlen - if tr.isClosed() { - return nil, ErrClosed - } - - if len(content) == 0 { - return content, nil - } - - var ( - cacheKey cacheEntryHash - cacheKeyInit bool - ) - - if propsHash, err := props.Hash(); err == nil { - cacheKeyInit, cacheKey = true, tr.mixHashes(propsHash, HashBytes(content)) - - tr.cacheMu.RLock() - item, hit := tr.cache[cacheKey] - tr.cacheMu.RUnlock() - - if hit { - // cache item has been expired? - if time.Now().UnixNano() > item.expiresAtNano { - tr.cacheMu.Lock() - delete(tr.cache, cacheKey) - tr.cacheMu.Unlock() - } else { - return item.data, nil - } - } - } - - var funcMap = template.FuncMap{ - "show_details": func() bool { return props.ShowRequestDetails }, - "hide_details": func() bool { return !props.ShowRequestDetails }, - "l10n_disabled": func() bool { return props.L10nDisabled }, - "l10n_enabled": func() bool { return !props.L10nDisabled }, - } - - // make a copy of template functions map - for s, i := range tplFnMap { - funcMap[s] = i - } - - // and allow the direct calling of Properties tokens, e.g. `{{ code | json }}` - for what, with := range props.Replaces() { - var n, s = what, with - - funcMap[n] = func() string { return s } - } - - t, err := template.New("").Funcs(funcMap).Parse(string(content)) - if err != nil { - return nil, err - } - - var buf bytes.Buffer - - if err = t.Execute(&buf, props); err != nil { - return nil, err - } - - b := buf.Bytes() - - if cacheKeyInit { - tr.cacheMu.Lock() - tr.cache[cacheKey] = cacheItem{ - data: b, - expiresAtNano: time.Now().UnixNano() + tr.cacheItemLifetime.Nanoseconds(), - } - tr.cacheMu.Unlock() - } - - return b, nil -} - -func (tr *TemplateRenderer) isClosed() (closed bool) { - tr.closedMu.RLock() - closed = tr.closed - tr.closedMu.RUnlock() - - return -} - -func (tr *TemplateRenderer) Close() error { - if tr.isClosed() { - return ErrClosed - } - - tr.closedMu.Lock() - tr.closed = true - tr.closedMu.Unlock() - - tr.close <- struct{}{} - - return nil -} - -func (tr *TemplateRenderer) mixHashes(a, b Hash) (result cacheEntryHash) { - for i := 0; i < len(a); i++ { //nolint:gosimple - result[i] = a[i] - } - - for i := 0; i < len(b); i++ { - result[i+len(a)] = b[i] - } - - return -} diff --git a/internal/tpl/render_test.go b/internal/tpl/render_test.go deleted file mode 100644 index 418e483f..00000000 --- a/internal/tpl/render_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package tpl_test - -import ( - "math/rand" - "os" - "strconv" - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "gh.tarampamp.am/error-pages/internal/tpl" -) - -func Test_Render(t *testing.T) { - renderer := tpl.NewTemplateRenderer() - defer func() { _ = renderer.Close() }() - - require.NoError(t, os.Setenv("TEST_ENV_VAR", "unit-test")) - - defer func() { require.NoError(t, os.Unsetenv("TEST_ENV_VAR")) }() - - for name, tt := range map[string]struct { - giveContent string - giveProps tpl.Properties - wantContent string - wantError bool - }{ - "common case": { - giveContent: "{{code}}: {{ message }} {{description}}", - giveProps: tpl.Properties{Code: "404", Message: "Not found", Description: "Blah"}, - wantContent: "404: Not found Blah", - }, - "html markup": { - giveContent: "<!-- comment --><html><body>{{code}}: {{ message }} {{description}}</body></html>", - giveProps: tpl.Properties{Code: "201", Message: "lorem ipsum"}, - wantContent: "<!-- comment --><html><body>201: lorem ipsum </body></html>", - }, - "with line breakers": { - giveContent: "\t {{code}}: {{ message }} {{description}}\n", - giveProps: tpl.Properties{}, - wantContent: "\t : \n", - }, - "golang template": { - giveContent: "\t {{code}} {{ .Code }}{{ if .Message }} Yeah {{end}}", - giveProps: tpl.Properties{Code: "201", Message: "lorem ipsum"}, - wantContent: "\t 201 201 Yeah ", - }, - "wrong golang template": { - giveContent: "{{ if foo() }} Test {{ end }}", - giveProps: tpl.Properties{}, - wantError: true, - }, - - "json common case": { - giveContent: `{"code": {{code | json}}, "message": {"here":[ {{ message | json }} ]}, "desc": "{{description}}"}`, - giveProps: tpl.Properties{Code: `404'"{`, Message: "Not found\t\r\n"}, - wantContent: `{"code": "404'\"{", "message": {"here":[ "Not found\t\r\n" ]}, "desc": ""}`, - }, - "json golang template": { - giveContent: `{"code": "{{code}}", "message": {"here":[ "{{ if .Message }} Yeah {{end}}" ]}}`, - giveProps: tpl.Properties{Code: "201", Message: "lorem ipsum"}, - wantContent: `{"code": "201", "message": {"here":[ " Yeah " ]}}`, - }, - - "fn l10n_enabled": { - giveContent: "{{ if l10n_enabled }}Y{{ else }}N{{ end }}", - giveProps: tpl.Properties{L10nDisabled: true}, - wantContent: "N", - }, - "fn l10n_disabled": { - giveContent: "{{ if l10n_disabled }}Y{{ else }}N{{ end }}", - giveProps: tpl.Properties{L10nDisabled: true}, - wantContent: "Y", - }, - - "env": { - giveContent: `{{ env "TEST_ENV_VAR" }}`, - wantContent: "unit-test", - }, - } { - t.Run(name, func(t *testing.T) { - content, err := renderer.Render([]byte(tt.giveContent), tt.giveProps) - - if tt.wantError == true { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.wantContent, string(content)) - } - }) - } -} - -func TestTemplateRenderer_Render_Concurrent(t *testing.T) { - renderer := tpl.NewTemplateRenderer() - - var wg sync.WaitGroup - - for i := 0; i < 100; i++ { - wg.Add(1) - - go func() { - defer wg.Done() - - props := tpl.Properties{ - Code: strconv.Itoa(rand.Intn(599-300+1) + 300), //nolint:gosec - Message: "Not found", - Description: "Blah", - } - - content, err := renderer.Render([]byte("{{code}}: {{ message }} {{description}}"), props) - - assert.NoError(t, err) - assert.NotEmpty(t, content) - }() - } - - wg.Wait() - - assert.NoError(t, renderer.Close()) - assert.EqualError(t, renderer.Close(), tpl.ErrClosed.Error()) - - content, err := renderer.Render([]byte{}, tpl.Properties{}) - assert.Nil(t, content) - assert.EqualError(t, err, tpl.ErrClosed.Error()) -} - -func BenchmarkRenderHTML(b *testing.B) { - b.ReportAllocs() - - renderer := tpl.NewTemplateRenderer() - defer func() { _ = renderer.Close() }() - - for i := 0; i < b.N; i++ { - _, _ = renderer.Render( - []byte("{{code}}: {{ message }} {{description}}"), - tpl.Properties{Code: "404", Message: "Not found", Description: "Blah"}, - ) - } -} diff --git a/l10n/.eslintrc.json b/l10n/.eslintrc.json deleted file mode 100644 index d73d14f3..00000000 --- a/l10n/.eslintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": [ - "eslint:recommended" - ], - "parserOptions": { - "ecmaVersion": 2017 - }, - "env": { - "browser": true - } -} diff --git a/l10n/embed_test.go b/l10n/embed_test.go new file mode 100644 index 00000000..854c2250 --- /dev/null +++ b/l10n/embed_test.go @@ -0,0 +1,14 @@ +package l10n_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/l10n" +) + +func TestL10n(t *testing.T) { + assert.NotEmpty(t, l10n.L10n()) + assert.Contains(t, l10n.L10n(), "data-l10n") +} diff --git a/l10n/enbed.go b/l10n/enbed.go new file mode 100644 index 00000000..93d6fe96 --- /dev/null +++ b/l10n/enbed.go @@ -0,0 +1,9 @@ +package l10n + +import _ "embed" + +//go:embed l10n.js +var content string + +// L10n returns the content of the JS file with a script for automatic error page localization. +func L10n() string { return content } diff --git a/l10n/l10n.js b/l10n/l10n.js index 4f8ec22e..2fffbe93 100644 --- a/l10n/l10n.js +++ b/l10n/l10n.js @@ -1,942 +1,962 @@ +// the very first line should be kept as a comment to avoid unexpected commenting when embedding the script into the HTML Object.defineProperty(window, 'l10n', { - value: new function () { - // language codes list: <https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes> - const data = { // all keys should be in english (it is default/main locale) - 'Error': { - fr: 'Erreur', - ru: 'ะžัˆะธะฑะบะฐ', - uk: 'ะŸะพะผะธะปะบะฐ', - pt: 'Erro', - nl: 'Fout', - de: 'Fehler', - es: 'Error', - zh: '้”™่ฏฏ', - id: 'Kesalahan', - pl: 'Bล‚ฤ…d', - }, - 'Good luck': { - fr: 'Bonne chance', - ru: 'ะฃะดะฐั‡ะธ', - uk: 'ะฃัะฟั–ั…ั–ะฒ', - pt: 'Boa sorte', - nl: 'Veel succes', - de: 'Viel Glรผck', - es: 'Buena Suerte', - zh: '็ฅๅฅฝ่ฟ', - id: 'Semoga berhasil!', - pl: 'Powodzenia', - }, - 'UH OH': { - fr: 'Oups', - ru: 'ะžั…', - uk: 'ะฃะฟั', - pt: 'Ops', - nl: 'Oeps', - de: 'Hoppla', - es: 'Uy', - zh: 'ๅ“Žๅ‘€', - id: 'Ups', - pl: 'Ojej', - }, - 'Request details': { - fr: 'Dรฉtails de la requรชte', - ru: 'ะ”ะตั‚ะฐะปะธ ะทะฐะฟั€ะพัะฐ', - uk: 'ะ”ะตั‚ะฐะปั– ะทะฐะฟะธั‚ัƒ', - pt: 'Detalhes da solicitaรงรฃo', - nl: 'Details van verzoek', - de: 'Details der Anfrage', - es: 'Detalles de la peticiรณn', - zh: '่ฏทๆฑ‚่ฏฆๆƒ…', - id: 'Rincian permintaan', - pl: 'Poproล› o szczegรณล‚y', - }, - 'Double-check the URL': { - fr: 'Vรฉrifiez lโ€™URL', - ru: 'ะ”ะฒะฐะถะดั‹ ะฟั€ะพะฒะตั€ัŒั‚ะต URL', - uk: 'ะ”ะฒั–ั‡ั– ะฟะตั€ะตะฒั–ั€ัะนั‚ะต URL-ะฐะดั€ะตััƒ', - pt: 'Verifique novamente a URL', - nl: 'Controleer de URL', - de: 'รœberprรผfen Sie die URL', - es: 'Verifique la url', - zh: '่ฏทๅ†ๆฌกๆฃ€ๆŸฅๅœฐๅ€', - id: 'Periksa URL', - pl: 'Sprawdลบ adres URL', - }, - 'Alternatively, go back': { - fr: 'Essayer de revenir en arriรจre', - ru: 'ะ˜ะปะธ ะผะพะถะตั‚ะต ะฒะตั€ะฝัƒั‚ัŒัั ะฝะฐะทะฐะด', - uk: 'ะะฑะพ ะผะพะถะตั‚ะต ะฟะพะฒะตั€ะฝัƒั‚ะธัั ะฝะฐะทะฐะด', - pt: "Como alternativa, tente voltar", - nl: 'Of ga terug', - de: 'Alternativ gehen Sie zurรผck', - es: 'Como alternativa, vuelva atrรกs', - zh: 'ๆˆ–่ฟ”ๅ›žไธŠไธ€้กต', - id: 'Atau, kembali', - pl: 'Alternatywnie wrรณฤ‡', - }, - 'Here\'s what might have happened': { - fr: 'Voici ce qui aurait pu se passer', - ru: 'ะ˜ะท-ะทะฐ ั‡ะตะณะพ ัั‚ะพ ะผะพะณะปะพ ัะปัƒั‡ะธั‚ัŒัั', - uk: 'ะžััŒ ั‰ะพ ะผะพะณะปะพ ั‚ั€ะฐะฟะธั‚ะธัั', - pt: 'Aqui estรก o que pode ter acontecido', - nl: 'Wat er gebeurd kan zijn', - de: 'Folgendes kรถnnte passiert sein', - es: 'Esto es lo que ha podido pasar', - zh: 'ๅฏ่ƒฝๅŽŸๅ› ๆœ‰', - id: 'Inilah yang bisa saja terjadi', - pl: 'Oto, co mogล‚o siฤ™ wydarzyฤ‡', - }, - 'You may have mistyped the URL': { - fr: 'Vous avez peut-รชtre mal tapรฉ lโ€™URL', - ru: 'ะ’ั‹ ะผะพะณะปะธ ะพัˆะธะฑะธั‚ัŒัั ะฒ URL', - uk: 'ะ’ะธ ะผะพะณะปะธ ะฟะพะผะธะปะธั‚ะธัั ะฒ URL-ะฐะดั€ะตัั–', - pt: 'Vocรช pode ter digitado incorretamente a URL', - nl: 'De URL bevat een typefout', - de: 'Mรถglicherweise haben Sie die URL falsch eingegeben', - es: 'Quizรก ha escrito mal la URL', - zh: 'ๆ‚จๅฏ่ƒฝ่พ“ๅ…ฅไบ†้”™่ฏฏ็š„ๅœฐๅ€', - id: 'Anda mungkin tersalah memasukkan URL', - pl: 'Byฤ‡ moลผe bล‚ฤ™dnie wpisaล‚eล› adres URL', - }, - 'The site was moved': { - fr: 'Le site a รฉtรฉ dรฉplacรฉ', - ru: 'ะกะฐะนั‚ ะฑั‹ะป ะฟะตั€ะตะผะตั‰ั‘ะฝ', - uk: 'ะกะฐะนั‚ ะฑัƒะฒ ะฟะตั€ะตะผั–ั‰ะตะฝะธะน', - pt: 'O site foi movido', - nl: 'De site is verplaatst', - de: 'Die Seite wurde verschoben', - es: 'El sitio se ha trasladado', - zh: '็ซ™็‚นๅทฒ่ขซ่ฝฌ็งป', - id: 'Halaman dipindahkan', - pl: 'Witryna zostaล‚a przeniesiona', - }, - 'It was never here': { - fr: 'Il nโ€™a jamais รฉtรฉ ici', - ru: 'ะžะฝ ะฝะธะบะพะณะดะฐ ะฝะต ะฑั‹ะป ะทะดะตััŒ', - uk: 'ะ’ั–ะฝ ะฝั–ะบะพะปะธ ะฝะต ะฑัƒะฒ ั‚ัƒั‚', - pt: 'Nunca esteve aqui', - nl: 'Het was hier nooit', - de: 'Es war nie hier', - es: 'Nunca ha estado aquรญ', - zh: '็ซ™็‚นไปŽๆœชๅญ˜ๅœจ', - id: 'Itu Tidak pernah di sini', - pl: 'Nigdy jej nie byล‚o', - }, - 'Bad Request': { - fr: 'Mauvaise requรชte', - ru: 'ะะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะทะฐะฟั€ะพั', - uk: 'ะฅะธะฑะฝะธะน ะทะฐะฟะธั‚', - pt: 'Requisiรงรฃo invรกlida', - nl: 'Foutieve anvraag', - de: 'Fehlerhafte Anfrage', - es: 'Peticiรณn invรกlida', - zh: '้”™่ฏฏ่ฏทๆฑ‚', - id: 'Permintaan yang salah', - pl: 'Nieprawidล‚owe ลผฤ…danie', - }, - 'The server did not understand the request': { - fr: 'Le serveur ne comprend pas la requรชte', - ru: 'ะกะตั€ะฒะตั€ ะฝะต ัะผะพะณ ะพะฑั€ะฐะฑะพั‚ะฐั‚ัŒ ะทะฐะฟั€ะพั ะธะท-ะทะฐ ะพัˆะธะฑะบะธ ะฒ ะฝั‘ะผ', - uk: 'ะกะตั€ะฒะตั€ ะฝะต ะทะผั–ะณ ะพะฑั€ะพะฑะธั‚ะธ ะทะฐะฟะธั‚ ั‡ะตั€ะตะท ะฟะพะผะธะปะบัƒ ะฒ ะฝัŒะพะผัƒ', - pt: 'O servidor nรฃo entendeu a solicitaรงรฃo', - nl: 'De server begreep het verzoek niet', - de: 'Der Server hat die Anfrage nicht verstanden', - es: 'El servidor no entendiรณ la peticiรณn', - zh: 'ๆœๅŠกๅ™จไธ็†่งฃ่ฏฅ่ฏทๆฑ‚', - id: 'Server tidak memahami permintaan', - pl: 'Serwer nie zrozumiaล‚ ลผฤ…dania', - }, - 'Unauthorized': { - fr: 'Non autorisรฉ', - ru: 'ะ—ะฐะฟั€ะพั ะฝะต ะฐะฒั‚ะพั€ะธะทะพะฒะฐะฝ', - uk: 'ะะตัะฐะฝะบั†ั–ะพะฝะพะฒะฐะฝะธะน ะดะพัั‚ัƒะฟ', - pt: 'Nรฃo autorizado', - nl: 'Niet geautoriseerd', - de: 'Nicht autorisiert', - es: 'No autorizado', - zh: 'ๆœช็ปๆŽˆๆƒ', - id: 'Tidak diotorisasi', - pl: 'Nieautoryzowany', - }, - 'The requested page needs a username and a password': { - fr: 'La page demandรฉe nรฉcessite un nom dโ€™utilisateur et un mot de passe', - ru: 'ะ”ะปั ะดะพัั‚ัƒะฟะฐ ะบ ัั‚ั€ะฐะฝะธั†ะต ั‚ั€ะตะฑัƒะตั‚ัั ะปะพะณะธะฝ ะธ ะฟะฐั€ะพะปัŒ', - uk: 'ะฉะพะฑ ะพั‚ั€ะธะผะฐั‚ะธ ะดะพัั‚ัƒะฟ ะดะพ ัั‚ะพั€ั–ะฝะบะธ, ะฟะพั‚ั€ั–ะฑะฝะธะน ะปะพะณั–ะฝ ั‚ะฐ ะฟะฐั€ะพะปัŒ', - pt: 'A pรกgina solicitada precisa de um nome de usuรกrio e uma senha', - nl: 'De pagina heeft een gebruikersnaam en wachtwoord nodig', - de: 'Die angeforderte Seite benรถtigt einen Benutzernamen und ein Passwort', - es: 'La pรกgina solicitada necesita un usuario y una contraseรฑa', - zh: '่ฏทๆฑ‚็š„้กต้ข้œ€่ฆ็”จๆˆทๅๅ’Œๅฏ†็ ', - id: 'Halaman yang diminta membutuhkan nama pengguna dan kata sandi', - pl: 'ลปฤ…dana strona wymaga podania nazwy uลผytkownika i hasล‚a', - }, - 'Forbidden': { - fr: 'Interdit', - ru: 'ะ—ะฐะฟั€ะตั‰ะตะฝะพ', - uk: 'ะ—ะฐะฑะพั€ะพะฝะตะฝะพ', - pt: 'Proibido', - nl: 'Verboden', - de: 'Verboten', - es: 'Prohibido', - zh: '็ฆๆญข่ฎฟ้—ฎ', - id: 'Dilarang', - pl: 'Zabroniony', - }, - 'Access is forbidden to the requested page': { - fr: 'Accรจs interdit ร  la page demandรฉe', - ru: 'ะ”ะพัั‚ัƒะฟ ะบ ัั‚ั€ะฐะฝะธั†ะต ะทะฐะฟั€ะตั‰ั‘ะฝ', - uk: 'ะ”ะพัั‚ัƒะฟ ะดะพ ัั‚ะพั€ั–ะฝะบะธ ะทะฐะฑะพั€ะพะฝะตะฝะพ', - pt: 'ร‰ proibido o acesso ร  pรกgina solicitada', - nl: 'Toegang tot de pagina is verboden', - de: 'Der Zugriff auf die angeforderte Seite ist verboten', - es: 'El acceso estรก prohibido para la pรกgina solicitada', - zh: '็ฆๆญข่ฎฟ้—ฎ่ฏทๆฑ‚็š„้กต้ข', - id: 'Akses dilarang ke halaman yang diminta', - pl: 'Dostฤ™p do ลผฤ…danej strony jest zabroniony', - }, - 'Not Found': { - fr: 'Introuvable', - ru: 'ะกั‚ั€ะฐะฝะธั†ะฐ ะฝะต ะฝะฐะนะดะตะฝะฐ', - uk: 'ะกั‚ะพั€ั–ะฝะบัƒ ะฝะต ะทะฝะฐะนะดะตะฝะพ', - pt: 'Nรฃo encontrado', - nl: 'Niet gevonden', - de: 'Nicht gefunden', - es: 'No encontrado', - zh: 'ๆœชๆ‰พๅˆฐ', - id: 'Tidak ditemukan', - pl: 'Nie znaleziono', - }, - 'The server can not find the requested page': { - fr: 'Le serveur ne peut trouver la page demandรฉe', - ru: 'ะกะตั€ะฒะตั€ ะฝะต ัะผะพะณ ะฝะฐะนั‚ะธ ะทะฐะฟั€ะฐัˆะธะฒะฐะตะผัƒัŽ ัั‚ั€ะฐะฝะธั†ัƒ', - uk: 'ะกะตั€ะฒะตั€ ะฝะต ะทะผั–ะณ ะทะฝะฐะนั‚ะธ ะทะฐะฟะธั‚ะฐะฝัƒ ัั‚ะพั€ั–ะฝะบัƒ', - pt: 'O servidor nรฃo consegue encontrar a pรกgina solicitada', - nl: 'De server kan de pagina niet vinden', - de: 'Der Server kann die angeforderte Seite nicht finden', - es: 'El servidor no puede encontrar la pรกgina solicitada', - zh: 'ๆœๅŠกๅ™จๆ‰พไธๅˆฐ่ฏทๆฑ‚็š„้กต้ข', - id: 'Server tidak dapat menemukan halaman yang diminta', - pl: 'Serwer nie moลผe znaleลบฤ‡ ลผฤ…danej strony', - }, - 'Method Not Allowed': { - fr: 'Mรฉthode Non Autorisรฉe', - ru: 'ะœะตั‚ะพะด ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั', - uk: 'ะะตะฟั€ะธะฟัƒัั‚ะธะผะธะน ะผะตั‚ะพะด', - pt: 'Mรฉtodo nรฃo permitido', - nl: 'Methode niet toegestaan', - de: 'Methode nicht erlaubt', - es: 'Mรฉtodo no permitido', - zh: 'ๆ–นๆณ•ไธ่ขซๅ…่ฎธ', - id: 'Metode tidak diizinkan', - pl: 'Niedozwolona metoda', - }, - 'The method specified in the request is not allowed': { - fr: 'La mรฉthode spรฉcifiรฉe dans la requรชte nโ€™est pas autorisรฉe', - ru: 'ะฃะบะฐะทะฐะฝะฝั‹ะน ะฒ ะทะฐะฟั€ะพัะต ะผะตั‚ะพะด ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั', - uk: 'ะœะตั‚ะพะด, ะทะฐะทะฝะฐั‡ะตะฝะธะน ัƒ ะทะฐะฟะธั‚ั–, ะฝะต ะฟั–ะดั‚ั€ะธะผัƒั”ั‚ัŒัั', - pt: 'O mรฉtodo especificado na solicitaรงรฃo nรฃo รฉ permitido', - nl: 'De methode in het verzoek is niet toegestaan', - de: 'Die in der Anfrage angegebene Methode ist nicht zulรคssig', - es: 'El mรฉtodo especificado en la peticiรณn no estรก permitido', - zh: '่ฏทๆฑ‚ๆŒ‡ๅฎš็š„ๆ–นๆณ•ไธ่ขซๅ…่ฎธ', - id: 'Metode dalam permintaan tidak diizinkan', - pl: 'Metoda okreล›lona w ลผฤ…daniu jest niedozwolona', - }, - 'Proxy Authentication Required': { - fr: 'Authentification proxy requise', - ru: 'ะัƒะถะฝะฐ ะฐัƒั‚ะตะฝั‚ะธั„ะธะบะฐั†ะธั ะฟั€ะพะบัะธ', - uk: 'ะŸะพั‚ั€ั–ะฑะฝะฐ ั–ะดะตะฝั‚ะธั„ั–ะบะฐั†ั–ั ะฟั€ะพะบัั–', - pt: 'Autenticaรงรฃo de proxy necessรกria', - nl: 'Authenticatie op de proxyserver verplicht', - de: 'Proxy-Authentifizierung benรถtigt', - es: 'Autenticaciรณn de proxy requerida', - zh: '้œ€่ฆไปฃ็†ๆœๅŠกๅ™จ่บซไปฝ้ชŒ่ฏ', - id: 'Diperlukan otentikasi proxy', - pl: 'Wymagane uwierzytelnianie proxy', - }, - 'You must authenticate with a proxy server before this request can be served': { - fr: 'Vous devez vous authentifier avec un serveur proxy avant que cette requรชte puisse รชtre servie', - ru: 'ะ’ั‹ ะดะพะปะถะฝั‹ ะฑั‹ั‚ัŒ ะฐะฒั‚ะพั€ะธะทะพะฒะฐะฝั‹ ะฝะฐ ะฟั€ะพะบัะธ ัะตั€ะฒะตั€ะต ะดะปั ะพะฑั€ะฐะฑะพั‚ะบะธ ัั‚ะพะณะพ ะทะฐะฟั€ะพัะฐ', - uk: 'ะ’ะธ ะฟะพะฒะธะฝะฝั– ัƒะฒั–ะนั‚ะธ ะดะพ ะฟั€ะพะบัั–-ัะตั€ะฒะตั€ะฐ ะดะปั ะพะฑั€ะพะฑะบะธ ั†ัŒะพะณะพ ะทะฐะฟะธั‚ัƒ', - pt: 'Vocรช deve se autenticar com um servidor proxy antes que esta solicitaรงรฃo possa ser atendida', - nl: 'Je moet authenticeren bij een proxyserver voordat dit verzoek uitgevoerd kan worden', - de: 'Sie mรผssen sich bei einem Proxy-Server authentifizieren, bevor diese Anfrage bedient werden kann', - es: 'Debes autentificarte con un servidor proxy antes de que esta peticiรณn pueda ser atendida', - zh: 'ๆ‚จๅฟ…้กปๅฏนไปฃ็†ๆœๅŠกๅ™จ่ฟ›่กŒ่บซไปฝ้ชŒ่ฏ๏ผŒ็„ถๅŽๆ‰่ƒฝ่ฎฉ่ฏทๆฑ‚ๅพ—ๅˆฐๅค„็†', - id: 'Anda harus mengautentikasi dengan server proxy sebelum permintaan ini dapat dilayani', - pl: 'Musisz uwierzytelniฤ‡ siฤ™ na serwerze proxy, zanim to ลผฤ…danie bฤ™dzie mogล‚o zostaฤ‡ obsล‚uลผone', - }, - 'Request Timeout': { - fr: 'Requรชte expirรฉ', - ru: 'ะ˜ัั‚ะตะบะปะพ ะฒั€ะตะผั ะพะถะธะดะฐะฝะธั', - uk: 'ะ’ะธั‡ะตั€ะฟะฐะฝะพ ั‡ะฐั ะพั‡ั–ะบัƒะฒะฐะฝะฝั', - pt: 'Tempo limite de solicitaรงรฃo excedido', - nl: 'Aanvraagtijd verstreken', - de: 'Zeitรผberschreitung der Anforderung', - es: 'Tiempo lรญmite de la peticiรณn excedido', - zh: '่ฏทๆฑ‚่ถ…ๆ—ถ', - id: 'Meminta batas waktu', - pl: 'Przekroczenie limitu czasu ลผฤ…dania', - }, - 'The request took longer than the server was prepared to wait': { - fr: 'La requรชte prend plus de temps que prรฉvu', - ru: 'ะžั‚ะฟั€ะฐะฒะบะฐ ะทะฐะฟั€ะพัะฐ ะทะฐะฝัะปะฐ ัะปะธัˆะบะพะผ ะผะฝะพะณะพ ะฒั€ะตะผะตะฝะธ', - uk: 'ะะฐะดัะธะปะฐะฝะฝั ะทะฐะฟะธั‚ัƒ ะทะฐะนะฝัะปะพ ะฝะฐะดั‚ะพ ะฑะฐะณะฐั‚ะพ ั‡ะฐััƒ', - pt: 'A solicitaรงรฃo demorou mais do que o servidor estava preparado para esperar', - nl: 'Het verzoek duurde langer dan de server wilde wachten', - de: 'Die Anfrage hat lรคnger gedauert, als der Server bereit war zu warten', - es: 'La peticiรณn esta tardando mรกs de lo que el servidor estaba preparado para esperar', - zh: '่ฏทๆฑ‚็”จๆ—ถ่ถ…่ฟ‡ไบ†ๆœๅŠกๅ™จ่ฎพ็ฝฎ็š„ๆœ€้•ฟ็ญ‰ๅพ…ๆ—ถ้—ด', - id: 'Permintaan memakan waktu lebih lama dari yang bisa ditunggu oleh server', - pl: 'ลปฤ…danie trwaล‚o dล‚uลผej niลผ serwer byล‚ gotowy czekaฤ‡', - }, - 'Conflict': { - fr: 'Conflit', - ru: 'ะšะพะฝั„ะปะธะบั‚', - uk: 'ะšะพะฝั„ะปั–ะบั‚', - pt: 'Conflito', - nl: 'Conflict', - de: 'Konflikt', - es: 'Conflicto', - zh: 'ๅ†ฒ็ช', - id: 'Konflik', - pl: 'Konflikt', - }, - 'The request could not be completed because of a conflict': { - fr: 'La requรชte nโ€™a pas pu รชtre complรฉtรฉe ร  cause dโ€™un conflit', - ru: 'ะ—ะฐะฟั€ะพั ะฝะต ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะพะฑั€ะฐะฑะพั‚ะฐะฝ ะธะท-ะทะฐ ะบะพะฝั„ะปะธะบั‚ะฐ', - uk: 'ะ—ะฐะฟะธั‚ ะฝะต ะผะพะถะต ะฑัƒั‚ะธ ะพะฑั€ะพะฑะปะตะฝะธะน ั‡ะตั€ะตะท ะบะพะฝั„ะปั–ะบั‚', - pt: 'A solicitaรงรฃo nรฃo pรดde ser concluรญda devido a um conflito', - nl: 'Het verzoek kon niet worden verwerkt vanwege een conflict', - de: 'Die Anfrage konnte aufgrund eines Konflikts nicht abgeschlossen werden', - es: 'La peticiรณn no ha podido ser completada por un conflicto', - zh: '็”ฑไบŽๅ†ฒ็ช๏ผŒ่ฏทๆฑ‚ๆ— ๆณ•ๅฎŒๆˆ', - id: 'Permintaan tidak dapat diselesaikan karena adanya konflik', - pl: 'ลปฤ…danie nie mogล‚o zostaฤ‡ wykonane z powodu konfliktu', - }, - 'Gone': { - fr: 'Supprimรฉ', - ru: 'ะฃะดะฐะปะตะฝะพ', - uk: 'ะ’ะธะปัƒั‡ะตะฝะธะน', - pt: 'Removido', - nl: 'Verdwenen', - de: 'Verschwunden', - es: 'Eliminado', - zh: 'ๅทฒ็งป้™ค', - id: 'Menghilang', - pl: 'Usuniฤ™to', - }, - 'The requested page is no longer available': { - fr: 'La page demandรฉe nโ€™est plus disponible', - ru: 'ะ—ะฐะฟั€ะพัˆะตะฝะฝะฐั ัั‚ั€ะฐะฝะธั†ะฐ ะฑั‹ะปะฐ ัƒะดะฐะปะตะฝะฐ', - uk: 'ะ—ะฐะฟะธั‚ัƒะฒะฐะฝะฐ ัั‚ะพั€ั–ะฝะบะฐ ะฑั–ะปัŒัˆะต ะฝะต ะดะพัั‚ัƒะฟะฝะฐ', - pt: 'A pรกgina solicitada nรฃo estรก mais disponรญvel', - nl: 'De pagina is niet langer beschikbaar', - de: 'Die angeforderte Seite ist nicht mehr verfรผgbar', - es: 'La pรกgina solicitada no estรก ya disponible', - zh: '่ฏทๆฑ‚็š„้กต้ขไธๅ†ๅฏ็”จ', - id: 'Halaman yang diminta tidak lagi tersedia', - pl: 'ลปฤ…dana strona nie jest juลผ dostฤ™pna', - }, - 'Length Required': { - fr: 'Longueur requise', - ru: 'ะะตะพะฑั…ะพะดะธะผะฐ ะดะปะธะฝะฐ', - uk: 'ะŸะพั‚ั€ั–ะฑะฝะพ ะฒะบะฐะทะฐั‚ะธ ะดะพะฒะถะธะฝัƒ', - pt: 'Content-Length necessรกrio', - nl: 'Lengte benodigd', - de: 'Lรคnge benรถtigt', - es: 'Longitud requerida', - zh: '้œ€่ฆ้•ฟๅบฆ', - id: 'Panjang yang diperlukan', - pl: 'Wymagana dล‚ugoล›ฤ‡', - }, - 'The "Content-Length" is not defined. The server will not accept the request without it': { - fr: 'Le "Content-Length" nโ€™est pas dรฉfini. Le serveur ne prendra pas en compte la requรชte', - ru: 'ะ—ะฐะณะพะปะพะฒะพะบ "Content-Length" ะฝะต ะฑั‹ะป ะฟะตั€ะตะดะฐะฝ. ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะตั‚ ะพะฑั€ะฐะฑะพั‚ะฐั‚ัŒ ะทะฐะฟั€ะพั ะฑะตะท ะฝะตะณะพ', - uk: 'ะ—ะฐะณะพะปะพะฒะพะบ "Content-Length" ะฝะต ะฑัƒะฒ ะฟะตั€ะตะดะฐะฝะธะน. ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะต ะพะฑั€ะพะฑะธั‚ะธ ะทะฐะฟะธั‚ ะฑะตะท ะฝัŒะพะณะพ', - pt: 'O "Content-Length" nรฃo estรก definido. O servidor nรฃo aceitarรก a solicitaรงรฃo sem ele', - nl: 'De "Content-Length" is niet gespecificeerd. De server accepteert het verzoek niet zonder', - de: 'Die "Content-Length" ist nicht definiert. Ohne sie akzeptiert der Server die Anfrage nicht', - es: 'El "Content-Legth" no eta definido. Este servidor no aceptarรก la peticiรณn sin รฉl', - zh: 'ๆœชๆŒ‡ๅฎšContent-Length(ๅ†…ๅฎน้•ฟๅบฆ)ใ€‚ๆœๅŠกๅ™จๅฐ†ไธๆŽฅๅ—ไธๅŒ…ๅซๆญคๅคดไฟกๆฏ็š„่ฏทๆฑ‚', - id: '"Content-Length" tidak ditentukan. Server tidak akan menerima permintaan tanpa itu', - pl: 'Wartoล›ฤ‡ "Content-Length" nie jest zdefiniowana. Serwer nie zaakceptuje ลผฤ…dania bez tego parametru', - }, - 'Precondition Failed': { - fr: 'ร‰chec de la condition prรฉalable', - ru: 'ะฃัะปะพะฒะธะต ะปะพะถะฝะพ', - uk: 'ะ—ะฑั–ะน ะฟั–ะด ั‡ะฐั ะพะฑั€ะพะฑะบะธ ะฟะพะฟะตั€ะตะดะฝัŒะพั— ัƒะผะพะฒะธ', - pt: 'Falha na prรฉ-condiรงรฃo', - nl: 'Niet voldaan aan vooraf gestelde voorwaarde', - de: 'Vorbedingung fehlgeschlagen', - es: 'Precondiciรณn fallida', - zh: 'ๅ‰็ฝฎๆกไปถๅˆคๅฎšๅคฑ่ดฅ', - pl: 'Niespeล‚nienie warunku wstฤ™pnego', - }, - 'The pre condition given in the request evaluated to false by the server': { - fr: 'La prรฉcondition donnรฉe dans la requรชte a รฉtรฉ รฉvaluรฉe comme รฉtant fausse par le serveur', - ru: 'ะะธ ะพะดะฝะพ ะธะท ัƒัะปะพะฒะฝั‹ั… ะฟะพะปะตะน ะทะฐะณะพะปะพะฒะบะฐ ะทะฐะฟั€ะพัะฐ ะฝะต ะฑั‹ะปะพ ะฒั‹ะฟะพะปะฝะตะฝะพ', - uk: 'ะ–ะพะดะฝะฐ ะท ะฟะตั€ะตะดัƒะผะพะฒ ะทะฐะฟะธั‚ัƒ ะฝะต ะฑัƒะปะฐ ะฒะธะบะพะฝะฐะฝะฐ', - pt: 'A prรฉ-condiรงรฃo dada na solicitaรงรฃo avaliada como falsa pelo servidor', - nl: 'De vooraf gestelde voorwaarde is afgewezen door de server', - de: 'Die in der Anfrage angegebene Vorbedingung wird vom Server als falsch bewertet', - es: 'La precondiciรณn ha sido evaluada como negativa para esta peticiรณn por el servidor', - zh: 'ๆœๅŠกๅ™จ่ฏ„ไผฐ่ฏทๆฑ‚ไธญ็ป™ๅ‡บ็š„ๅ‰็ฝฎๆกไปถ็š„็ป“ๆžœไธบfalse(ๅ‡)', - id: 'Prakondisi gagal', - pl: 'Warunek wstฤ™pny podany w ลผฤ…daniu zostaล‚ oceniony przez serwer jako nieprawidล‚owy', - }, - 'Payload Too Large': { - fr: 'Charge trop volumineuse', - ru: 'ะกะปะธัˆะบะพะผ ะฑะพะปัŒัˆะพะน ะทะฐะฟั€ะพั', - uk: 'ะ—ะฐะฝะฐะดั‚ะพ ะฒะตะปะธะบะธะน ะทะฐะฟะธั‚', - pt: 'Payload muito grande', - nl: 'Aanvraag te grood', - de: 'Anfrage zu groรŸ', - es: 'Carga demasiado grande', - zh: '่ฏทๆฑ‚ไฝ“่ฟ‡ๅคง', - id: 'Muatan terlalu besar', - pl: 'ลปฤ…danie jest zbyt duลผe', - }, - 'The server will not accept the request, because the request entity is too large': { - fr: 'Le serveur ne prendra pas en compte la requรชte, car lโ€™entitรฉ de la requรชte est trop volumineuse', - ru: 'ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะตั‚ ะพะฑั€ะฐะฑะพั‚ะฐั‚ัŒ ะทะฐะฟั€ะพั, ั‚ะฐะบ ะบะฐะบ ะพะฝ ัะปะธัˆะบะพะผ ะฑะพะปัŒัˆะพะน', - uk: 'ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะต ะพะฑั€ะพะฑะธั‚ะธ ะทะฐะฟะธั‚, ะพัะบั–ะปัŒะบะธ ะฒั–ะฝ ะทะฐะฝะฐะดั‚ะพ ะฒะตะปะธะบะธะน', - pt: 'O servidor nรฃo aceitarรก a solicitaรงรฃo porque a entidade da solicitaรงรฃo รฉ muito grande', - nl: 'De server accepteert het verzoek niet omdat de aanvraag te groot is', - de: 'Der Server akzeptiert die Anfrage nicht, da die Datenmenge zu groรŸ ist', - es: 'El servidor no aceptarรก esta peticiรณn, porque la carga es demasiado grande', - zh: '่ฏทๆฑ‚ไฝ“่ฟ‡ๅคง๏ผŒๆœๅŠกๅ™จๅฐ†ไธๆŽฅๅ—่ฏฅ่ฏทๆฑ‚', - id: 'Server tidak akan menerima permintaan, karena entitas permintaan terlalu besar', - pl: 'Serwer nie zaakceptuje ลผฤ…dania, poniewaลผ ลผฤ…danie jest zbyt duลผe', - }, - 'Requested Range Not Satisfiable': { - fr: 'Requรชte non satisfaisante', - ru: 'ะ”ะธะฐะฟะฐะทะพะฝ ะฝะต ะดะพัั‚ะธะถะธะผ', - uk: 'ะ—ะฐะฟะธั‚ัƒะฒะฐะฝะธะน ะดั–ะฐะฟะฐะทะพะฝ ะฝะตะดะพััะถะฝะธะน', - pt: 'Intervalo Solicitado Nรฃo Satisfatรณrio', - nl: 'Aangevraagd gedeelte niet opvraagbaar', - de: 'Anfrage-Bereich nicht erfรผllbar', - es: 'Intervalo solicitado no satisfactorio', - zh: 'ไธๆปก่ถณ่ฏทๆฑ‚่Œƒๅ›ด', - id: 'Rentang yang diminta tidak dapat dipenuhi', - pl: 'ลปฤ…dany zakres nie jest satysfakcjonujฤ…cy', - }, - 'The requested byte range is not available and is out of bounds': { - fr: 'Le byte range demandรฉ nโ€™est pas disponible et est hors des limites', - ru: 'ะ—ะฐะฟั€ะพัˆะตะฝะฝั‹ะน ะดะธะฐะฟะฐะทะพะฝ ะดะฐะฝะฝั‹ั… ะฝะตะดะพัั‚ัƒะฟะตะฝ ะธะปะธ ะฒะฝะต ะดะพะฟัƒัั‚ะธะผั‹ั… ะฟั€ะตะดะตะปะพะฒ', - uk: 'ะžะฟะธัะฐะฝะธะน ะดั–ะฐะฟะฐะทะพะฝ ะดะฐะฝะธั… ะฝะตะดะพัั‚ัƒะฟะฝะธะน ะฐะฑะพ ะฟะพะทะฐ ะดะพะฟัƒัั‚ะธะผะธะผะธ ะผะตะถะฐะผะธ', - pt: 'O intervalo de bytes solicitado nรฃo estรก disponรญvel e estรก fora dos limites', - nl: 'De aangevraagde bytes zijn buiten het limiet', - de: 'Der angefragte Teilbereich der Ressource existiert nicht oder ist ungรผltig', - es: 'El intervalo de bytes requerido no estรก disponible o se encuentra fuera de los lรญmites', - zh: '่ฏทๆฑ‚็š„ๅญ—่Š‚่Œƒๅ›ดไธๅฏ็”จ๏ผŒ่ถ…ๅ‡บ่พน็•Œ', - id: 'Rentang byte yang diminta tidak tersedia dan di luar batas', - pl: 'ลปฤ…dany zakres bajtรณw nie jest dostฤ™pny i znajduje siฤ™ poza zakresem', - }, - 'I\'m a teapot': { - fr: 'Je suis une thรฉiรจre', - ru: 'ะฏ ั‡ะฐะนะฝะธะบ', - uk: 'ะฏ ั‡ะฐะนะฝะธะบ', - pt: 'Eu sou um bule', - nl: 'Ik ben een theepot', - de: 'Ich bin eine Teekanne', - es: 'Soy una tetera', - zh: 'ๆˆ‘ๆ˜ฏไธ€ๅช่Œถๅฃถ', - id: 'Saya adalah teko', - pl: 'Jestem czajniczkiem', - }, - 'Attempt to brew coffee with a teapot is not supported': { - fr: 'Tenter de prรฉparer du cafรฉ avec une thรฉiรจre nโ€™est pas pris en charge', - ru: 'ะŸะพะฟั‹ั‚ะบะฐ ะทะฐะฒะฐั€ะธั‚ัŒ ะบะพั„ะต ะฒ ั‡ะฐะนะฝะธะบะต ะพะฑั€ะตั‡ะตะฝะฐ ะฝะฐ ั„ะธะฐัะบะพ', - uk: 'ะกะฟั€ะพะฑะฐ ะทะฐะฒะฐั€ะธั‚ะธ ะบะฐะฒัƒ ะฒ ั‡ะฐะนะฝะธะบัƒ ะฟั€ะธั€ะตั‡ะตะฝะฐ ะฝะฐ ั„ั–ะฐัะบะพ', - pt: 'A tentativa de preparar cafรฉ com um bule nรฃo รฉ suportada', - nl: 'Koffie maken met een theepot is niet ondersteund', - de: 'Der Versuch, Kaffee mit einer Teekanne zuzubereiten, wird nicht unterstรผtzt', - es: 'Intentar hacer un cafรฉ en una tetera no estรก soportado', - zh: '็”จ่Œถๅฃถๆณกๅ’–ๅ•กไธๅ—ๆ”ฏๆŒ', - id: 'Upaya menyeduh kopi dengan teko tidak didukung', - pl: 'Prรณba zaparzenia kawy za pomocฤ… czajniczka nie jest obsล‚ugiwana', - }, - 'Too Many Requests': { - fr: 'Trop de requรชtes', - ru: 'ะกะปะธัˆะบะพะผ ะผะฝะพะณะพ ะทะฐะฟั€ะพัะพะฒ', - uk: 'ะ—ะฐะฝะฐะดั‚ะพ ะฑะฐะณะฐั‚ะพ ะทะฐะฟะธั‚ั–ะฒ', - pt: 'Excesso de solicitaรงรตes', - nl: 'Te veel requests', - de: 'Zu viele Anfragen', - es: 'Demasiadas peticiones', - zh: '่ฏทๆฑ‚่ฟ‡ๅคš', - id: 'Terlalu banyak permintaan', - pl: 'Zbyt wiele ลผฤ…daล„', - }, - 'Too many requests in a given amount of time': { - fr: 'Trop de requรชtes dans un dรฉlai donnรฉ', - ru: 'ะžั‚ะฟั€ะฐะฒะปะตะฝะพ ัะปะธัˆะบะพะผ ะผะฝะพะณะพ ะทะฐะฟั€ะพัะพะฒ ะทะฐ ะบะพั€ะพั‚ะบะพะต ะฒั€ะตะผั', - uk: 'ะะฐะดั–ัะปะฐะฝะพ ะทะฐะฝะฐะดั‚ะพ ะฑะฐะณะฐั‚ะพ ะทะฐะฟะธั‚ั–ะฒ ะทะฐ ะบะพั€ะพั‚ะบะธะน ะฟั€ะพะผั–ะถะพะบ ั‡ะฐั', - pt: 'Excesso de solicitaรงรตes em um determinado perรญodo de tempo', - nl: 'Te veel verzoeken binnen een bepaalde tijd', - de: 'Der Client hat zu viele Anfragen in einem bestimmten Zeitraum gesendet', - es: 'Demasiadas peticiones en un determinado periodo de tiempo', - zh: 'ๅœจ็ป™ๅฎš็š„ๆ—ถ้—ดๅ†…ๅ‘้€ไบ†่ฟ‡ๅคš่ฏทๆฑ‚', - id: 'Terlalu banyak permintaan dalam waktu tertentu', - pl: 'Zbyt wiele ลผฤ…daล„ w okreล›lonym czasie', - }, - 'Internal Server Error': { - fr: 'Erreur interne du serveur', - ru: 'ะ’ะฝัƒั‚ั€ะตะฝะฝัั ะพัˆะธะฑะบะฐ ัะตั€ะฒะตั€ะฐ', - uk: 'ะ’ะฝัƒั‚ั€ั–ัˆะฝั ะฟะพะผะธะปะบะฐ ัะตั€ะฒะตั€ะฐ', - pt: 'Erro do Servidor Interno', - nl: 'Interne serverfout', - de: 'Interner Server-Fehler', - es: 'Error Interno', - zh: 'ๅ†…้ƒจๆœๅŠกๅ™จ้”™่ฏฏ', - id: 'Kesalahan server internal', - pl: 'Wewnฤ™trzny bล‚ฤ…d serwera', - }, - 'The server met an unexpected condition': { - fr: 'Le serveur a rencontrรฉ une condition inattendue', - ru: 'ะŸั€ะพะธะทะพัˆะปะพ ั‡ั‚ะพ-ั‚ะพ ะฝะตะพะถะธะดะฐะฝะฝะพะต ะฝะฐ ัะตั€ะฒะตั€ะต', - uk: 'ะะฐ ัะตั€ะฒะตั€ั– ะฒั–ะดะฑัƒะปะพััŒ ั‰ะพััŒ ะฝะตะพั‡ั–ะบัƒะฒะฐะฝะต', - pt: 'O servidor encontrou uma condiรงรฃo inesperada', - nl: 'De server ondervond een onverwachte conditie', - de: 'Der Server hat einen internen Fehler festgestellt', - es: 'El servidor ha encontrado una condiciรณn no esperada', - zh: 'ๆœๅŠกๅ™จ้‡ๅˆฐไบ†ๆ„ๅค–ๆƒ…ๅ†ต', - id: 'Server mengalami kondisi yang tidak terduga', - pl: 'Serwer napotkaล‚ nieoczekiwany stan', - }, - 'Bad Gateway': { - fr: 'Mauvaise passerelle', - ru: 'ะžัˆะธะฑะบะฐ ัˆะปัŽะทะฐ', - uk: 'ะŸะพะผะธะปะบะฐ ัˆะปัŽะทัƒ', - pt: 'Gateway invรกlido', - nl: 'Ongeldige Gateway', - de: 'Fehlerhaftes Gateway', - es: 'Puerta de enlace no valida', - zh: 'ๆ— ๆ•ˆ็ฝ‘ๅ…ณ', - id: 'Gateway yang buruk', - pl: 'Bล‚ฤ…d bramki', - }, - 'The server received an invalid response from the upstream server': { - fr: 'Le serveur a reรงu une rรฉponse invalide du serveur distant', - ru: 'ะกะตั€ะฒะตั€ ะฟะพะปัƒั‡ะธะป ะฝะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะพั‚ะฒะตั‚ ะพั‚ ะฒั‹ัˆะตัั‚ะพัั‰ะตะณะพ ัะตั€ะฒะตั€ะฐ', - uk: 'ะกะตั€ะฒะตั€ ะพั‚ั€ะธะผะฐะฒ ะฝะตะฒั–ั€ะฝัƒ ะฒั–ะดะฟะพะฒั–ะดัŒ ะฒั–ะด ะฟะพะฟะตั€ะตะดะฝัŒะพะณะพ ัะตั€ะฒะตั€ะฐ', - pt: 'O servidor recebeu uma resposta invรกlida do servidor upstream', - nl: 'De server ontving een ongeldig antwoord van een bovenliggende server', - de: 'Der Server hat eine ungรผltige Antwort vom Upstream-Server erhalten', - es: 'El servidor ha recibido una respuesta no vรกlida del servidor de origen', - zh: 'ๆœๅŠกๅ™จไปŽไธŠๆธธๆœๅŠกๅ™จๆ”ถๅˆฐไบ†ๆ— ๆ•ˆ็š„ๅ“ๅบ”', - id: 'Server menerima respons yang tidak valid dari server induk', - pl: 'Serwer otrzymaล‚ nieprawidล‚owฤ… odpowiedลบ od serwera nadrzฤ™dnego', - }, - 'Service Unavailable': { - fr: 'Service indisponible', - ru: 'ะกะตั€ะฒะธั ะฝะตะดะพัั‚ัƒะฟะตะฝ', - uk: 'ะกะตั€ะฒั–ั ะฝะตะดะพัั‚ัƒะฟะฝะธะน', - pt: 'Serviรงo nรฃo disponรญvel', - nl: 'Dienst niet beschikbaar', - de: 'Dienst nicht verfรผgbar', - es: 'Servicio no disponible', - zh: 'ๆœๅŠกไธๅฏ็”จ', - id: 'Layanan tidak tersedia', - pl: 'Serwis niedostฤ™pny', - }, - 'The server is temporarily overloading or down': { - fr: 'Le serveur est temporairement en surcharge ou indisponible', - ru: 'ะกะตั€ะฒะตั€ ะฒั€ะตะผะตะฝะฝะพ ะฝะต ะผะพะถะตั‚ ะพะฑั€ะฐะฑะฐั‚ั‹ะฒะฐั‚ัŒ ะทะฐะฟั€ะพัั‹ ะฟะพ ั‚ะตั…ะฝะธั‡ะตัะบะธะผ ะฟั€ะธั‡ะธะฝะฐะผ', - uk: 'ะกะตั€ะฒะตั€ ั‚ะธะผั‡ะฐัะพะฒะพ ะฝะต ะผะพะถะต ะพะฑั€ะพะฑะปัั‚ะธ ะทะฐะฟะธั‚ะธ ะท ั‚ะตั…ะฝั–ั‡ะฝะธั… ะฟั€ะธั‡ะธะฝ', - pt: 'O servidor estรก temporariamente sobrecarregado ou inativo', - nl: 'De server is tijdelijk overbelast of niet bereikbaar', - de: 'Der Server ist vorรผbergehend รผberlastet oder ausgefallen', - es: 'El servidor estรก temporalmente sobrecargado o inactivo', - zh: 'ๆœๅŠกๅ™จๆš‚ๆ—ถ่ฟ‡่ฝฝๆˆ–ไธๅฏ็”จ', - id: 'Server untuk sementara kelebihan beban atau tidak tersedia', - pl: 'Serwer jest tymczasowo przeciฤ…ลผony lub wyล‚ฤ…czony', - }, - 'Gateway Timeout': { - fr: 'Expiration Passerelle', - ru: 'ะจะปัŽะท ะฝะต ะพั‚ะฒะตั‡ะฐะตั‚', - uk: 'ะจะปัŽะท ะฝะต ะฒั–ะดะฟะพะฒั–ะดะฐั”', - pt: 'Tempo limite do gateway excedido', - nl: 'Gateway Verlopen', - de: 'Gateway Zeitรผberschreitung', - es: 'Tiempo lรญmite de puerta de enlace excedido', - zh: '็ฝ‘ๅ…ณ่ถ…ๆ—ถ', - id: 'Batas waktu gateway', - pl: 'Przekroczenie limitu czasu bramki', - }, - 'The gateway has timed out': { - fr: 'Le temps dโ€™attente de la passerelle est dรฉpassรฉ', - ru: 'ะกะตั€ะฒะตั€ ะฝะต ะดะพะถะดะฐะปัั ะพั‚ะฒะตั‚ะฐ ะพั‚ ะฒั‹ัˆะตัั‚ะพัั‰ะตะณะพ ัะตั€ะฒะตั€ะฐ', - uk: 'ะฃ ัˆะปัŽะทัƒ ะทะฐะบั–ะฝั‡ะธะฒัั ั‡ะฐั ะพั‡ั–ะบัƒะฒะฐะฝะฝั', - pt: 'O gateway esgotou o tempo limite', - nl: 'De verbinding naar de bovenliggende server is verlopen', - de: 'Das Zeitlimit fรผr den Verbindungsaufbau mit dem Upstream-Server ist abgelaufen', - es: 'La puerta de enlace ha sobrepasado el tiempo lรญmite', - zh: '็ฝ‘ๅ…ณๅ“ๅบ”ๅทฒ็ป่ถ…ๆ—ถ', - id: 'Sambungan ke server induk telah kedaluwarsa', - pl: 'Bramka przekroczyล‚a limit czasu', - }, - 'HTTP Version Not Supported': { - fr: 'Version HTTP non prise en charge', - ru: 'ะ’ะตั€ัะธั HTTP ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั', - uk: 'ะ’ะตั€ัั–ั ะะขะขะ  ะฝะต ะฟั–ะดั‚ั€ะธะผัƒั”ั‚ัŒัั', - pt: 'Versรฃo HTTP nรฃo suportada', - nl: 'HTTP-versie wordt niet ondersteunt', - de: 'HTTP-Version wird nicht unterstรผtzt', - es: 'Versiรณn de HTTP no soportada', - zh: 'HTTP็‰ˆๆœฌไธๅ—ๆ”ฏๆŒ', - id: 'Versi HTTP tidak didukung', - pl: 'Wersja HTTP nie jest obsล‚ugiwana', - }, - 'The server does not support the "http protocol" version': { - fr: 'Le serveur ne supporte pas la version du protocole HTTP', - ru: 'ะกะตั€ะฒะตั€ ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ ะทะฐะฟั€ะพัˆะตะฝะฝัƒัŽ ะฒะตั€ัะธัŽ HTTP ะฟั€ะพั‚ะพะบะพะปะฐ', - uk: 'ะกะตั€ะฒะตั€ ะฝะต ะฟั–ะดั‚ั€ะธะผัƒั” ะทะฐะฟะธั‚ะฐะฝัƒ ะฒะตั€ัั–ัŽ HTTP-ะฟั€ะพั‚ะพะบะพะปัƒ', - pt: 'O servidor nรฃo suporta a versรฃo do protocolo HTTP', - nl: 'De server ondersteunt deze HTTP-versie niet', - de: 'Der Server unterstรผtzt die HTTP-Protokoll-Version nicht', - es: 'El servidor no soporta la versiรณn del protocolo HTTP', - zh: 'ๆœๅŠกๅ™จไธๆ”ฏๆŒ่ฏฅHTTPๅ่ฎฎ็‰ˆๆœฌ', - id: 'Server tidak mendukung versi HTTP ini', - pl: 'Serwer nie obsล‚uguje wersji "protokoล‚u http"', - }, + value: new function () { + const tokenSerializationRe = /[^a-z0-9]/g; - 'Host': { - fr: 'Hรดte', - ru: 'ะฅะพัั‚', - uk: 'ะฅะพัั‚', - pt: 'Hospedeiro', - nl: 'Host', - de: 'Host', - es: 'Host', - zh: 'ไธปๆœบ', - id: 'Host', - pl: 'Host', - }, - 'Original URI': { - fr: 'URI dโ€™origine', - ru: 'ะ˜ัั…ะพะดะฝั‹ะน URI', - uk: 'ะ’ะธั…ั–ะดะฝะธะน URI', - pt: 'URI original', - nl: 'Originele URI', - de: 'Originale URI', - es: 'URI original', - zh: 'ๅŽŸๅง‹URI', - id: 'URL asli', - pl: 'Oryginalny URI', - }, - 'Forwarded for': { - fr: 'Transmis pour', - ru: 'ะŸะตั€ะตะฝะฐะฟั€ะฐะฒะปะตะฝ', - uk: 'ะŸะตั€ะตะฝะฐะฟั€ะฐะฒะปะตะฝะธะน', - pt: 'Encaminhado para', - nl: 'Doorgestuurd voor', - de: 'Weitergeleitet fรผr', - es: 'Remitido para', - zh: '่ฝฌๅ‘่‡ช', - id: 'Diteruskan untuk', - pl: 'Przekazane do', - }, - 'Namespace': { - fr: 'Espace de noms', - ru: 'ะŸั€ะพัั‚ั€ะฐะฝัั‚ะฒะพ ะธะผั‘ะฝ', - uk: 'ะŸั€ะพัั‚ั–ั€ ั–ะผะตะฝ', - pt: 'Namespace', - nl: 'Elementnaam', - de: 'Namensraum', - es: 'Namespace', - zh: 'ๅ‘ฝๅ็ฉบ้—ด', - id: 'Ruang nama', - pl: 'Przestrzeล„ nazw', - }, - 'Ingress name': { - fr: 'Nom ingress', - ru: 'ะ˜ะผั Ingress', - uk: 'ะ†ะผ\'ั ะฒั…ะพะดัƒ', - pt: 'Nome Ingress', - nl: 'Ingress naam', - de: 'Ingress Name', - es: 'Nombre Ingress', - zh: 'ๅ…ฅๅฃๅ', - id: 'Nama ingress', - pl: 'Nazwa wejล›cia', - }, - 'Service name': { - fr: 'Nom du service', - ru: 'ะ˜ะผั ัะตั€ะฒะธัะฐ', - uk: 'ะ†ะผ\'ั ัะตั€ะฒั–ััƒ', - pt: 'Nome do Serviรงo', - nl: 'Service naam', - de: 'Service Name', - es: 'Nombre del servicio', - zh: 'ๆœๅŠกๅ', - id: 'Nama layanan', - pl: 'Nazwa usล‚ugi', - }, - 'Service port': { - fr: 'Port du service', - ru: 'ะŸะพั€ั‚ ัะตั€ะฒะธัะฐ', - uk: 'ะŸะพั€ั‚ ัะตั€ะฒั–ััƒ', - pt: 'Porta do serviรงo', - nl: 'Service poort', - de: 'Service Port', - es: 'Puerto del servicio', - zh: 'ๆœๅŠก็ซฏๅฃ', - id: 'Port layanan', - pl: 'Port usล‚ugi', - }, - 'Request ID': { - fr: 'Identifiant de la requรชte', - ru: 'ID ะทะฐะฟั€ะพัะฐ', - uk: 'ID ะทะฐะฟะธั‚ัƒ', - pt: 'ID da solicitaรงรฃo', - nl: 'ID van het verzoek', - de: 'Anfrage ID', - es: 'ID de la peticiรณn', - zh: '่ฏทๆฑ‚ID', - id: 'ID permintaan', - pl: 'Identyfikator ลผฤ…dania', - }, - 'Timestamp': { - fr: 'Horodatage', - ru: 'ะ’ั€ะตะผะตะฝะฝะฐั ะผะตั‚ะบะฐ', - uk: 'ะœั–ั‚ะบะฐ ั‡ะฐััƒ', - pt: 'Timestamp', - nl: 'Tijdstempel', - de: 'Zeitstempel', - es: 'Timestamp', - zh: 'ๆ—ถ้—ดๆˆณ', - id: 'Cap waktu', - pl: 'Sygnatura czasowa', - }, + /** + * @param {string} token + * @return {string} + */ + const tkn = function (token) { + return token.toLowerCase().replaceAll(tokenSerializationRe, ''); + }; - 'client-side error': { - fr: 'Erreur Client', - ru: 'ะพัˆะธะฑะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝะต ะบะปะธะตะฝั‚ะฐ', - uk: 'ะฟะพะผะธะปะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝั– ะบะปั–ั”ะฝั‚ะฐ', - pt: 'erro do lado do cliente', - nl: 'fout aan de gebruikerskant', - de: 'Clientseitiger Fehler', - es: 'Error del lado del cliente', - zh: 'ๅฎขๆˆท็ซฏ้”™่ฏฏ', - id: 'Kesalahan sisi klien', - pl: 'bล‚ฤ…d po stronie klienta', - }, - 'server-side error': { - fr: 'Erreur Serveur', - ru: 'ะพัˆะธะฑะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝะต ัะตั€ะฒะตั€ะฐ', - uk: 'ะฟะพะผะธะปะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝั– ัะตั€ะฒะตั€ะฐ', - pt: 'erro do lado do servidor', - nl: 'fout aan de serverkant', - de: 'Serverseitiger Fehler', - es: 'Error del lado del servidor', - zh: 'ๆœๅŠก็ซฏ้”™่ฏฏ', - id: 'Kesalahan sisi server', - pl: 'bล‚ฤ…d po stronie serwera', - }, + /** + * Each **key** should be in English (this is the default/main locale). + * + * @link https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes language codes list (column `Set 1` or `ISO 639-1:2002`) + * + * @type {Map<string, Map<'fr'|'ru'|'uk'|'pt'|'nl'|'de'|'es'|'zh'|'id'|'pl', string>>} + */ + const data = Object.freeze(new Map([ + [tkn('Error'), new Map([ + ['fr', 'Erreur'], + ['ru', 'ะžัˆะธะฑะบะฐ'], + ['uk', 'ะŸะพะผะธะปะบะฐ'], + ['pt', 'Erro'], + ['nl', 'Fout'], + ['de', 'Fehler'], + ['es', 'Error'], + ['zh', '้”™่ฏฏ'], + ['id', 'Kesalahan'], + ['pl', 'Bล‚ฤ…d'], + ])], + [tkn('Good luck'), new Map([ + ['fr', 'Bonne chance'], + ['ru', 'ะฃะดะฐั‡ะธ'], + ['uk', 'ะฃัะฟั–ั…ั–ะฒ'], + ['pt', 'Boa sorte'], + ['nl', 'Veel succes'], + ['de', 'Viel Glรผck'], + ['es', 'Buena Suerte'], + ['zh', '็ฅๅฅฝ่ฟ'], + ['id', 'Semoga berhasil!'], + ['pl', 'Powodzenia'], + ])], + [tkn('UH OH'), new Map([ + ['fr', 'Oups'], + ['ru', 'ะžั…'], + ['uk', 'ะฃะฟั'], + ['pt', 'Ops'], + ['nl', 'Oeps'], + ['de', 'Hoppla'], + ['es', 'Uy'], + ['zh', 'ๅ“Žๅ‘€'], + ['id', 'Ups'], + ['pl', 'Ojej'], + ])], + [tkn('Request details'), new Map([ + ['fr', 'Dรฉtails de la requรชte'], + ['ru', 'ะ”ะตั‚ะฐะปะธ ะทะฐะฟั€ะพัะฐ'], + ['uk', 'ะ”ะตั‚ะฐะปั– ะทะฐะฟะธั‚ัƒ'], + ['pt', 'Detalhes da solicitaรงรฃo'], + ['nl', 'Details van verzoek'], + ['de', 'Details der Anfrage'], + ['es', 'Detalles de la peticiรณn'], + ['zh', '่ฏทๆฑ‚่ฏฆๆƒ…'], + ['id', 'Rincian permintaan'], + ['pl', 'Poproล› o szczegรณล‚y'], + ])], + [tkn('Double-check the URL'), new Map([ + ['fr', 'Vรฉrifiez lโ€™URL'], + ['ru', 'ะ”ะฒะฐะถะดั‹ ะฟั€ะพะฒะตั€ัŒั‚ะต URL'], + ['uk', 'ะ”ะฒั–ั‡ั– ะฟะตั€ะตะฒั–ั€ัะนั‚ะต URL-ะฐะดั€ะตััƒ'], + ['pt', 'Verifique novamente a URL'], + ['nl', 'Controleer de URL'], + ['de', 'รœberprรผfen Sie die URL'], + ['es', 'Verifique la url'], + ['zh', '่ฏทๅ†ๆฌกๆฃ€ๆŸฅๅœฐๅ€'], + ['id', 'Periksa URL'], + ['pl', 'Sprawdลบ adres URL'], + ])], + [tkn('Alternatively, go back'), new Map([ + ['fr', 'Essayer de revenir en arriรจre'], + ['ru', 'ะ˜ะปะธ ะผะพะถะตั‚ะต ะฒะตั€ะฝัƒั‚ัŒัั ะฝะฐะทะฐะด'], + ['uk', 'ะะฑะพ ะผะพะถะตั‚ะต ะฟะพะฒะตั€ะฝัƒั‚ะธัั ะฝะฐะทะฐะด'], + ['pt', "Como alternativa, tente voltar"], + ['nl', 'Of ga terug'], + ['de', 'Alternativ gehen Sie zurรผck'], + ['es', 'Como alternativa, vuelva atrรกs'], + ['zh', 'ๆˆ–่ฟ”ๅ›žไธŠไธ€้กต'], + ['id', 'Atau, kembali'], + ['pl', 'Alternatywnie wrรณฤ‡'], + ])], + [tkn("Here's what might have happened"), new Map([ + ['fr', 'Voici ce qui aurait pu se passer'], + ['ru', 'ะ˜ะท-ะทะฐ ั‡ะตะณะพ ัั‚ะพ ะผะพะณะปะพ ัะปัƒั‡ะธั‚ัŒัั'], + ['uk', 'ะžััŒ ั‰ะพ ะผะพะณะปะพ ั‚ั€ะฐะฟะธั‚ะธัั'], + ['pt', 'Aqui estรก o que pode ter acontecido'], + ['nl', 'Wat er gebeurd kan zijn'], + ['de', 'Folgendes kรถnnte passiert sein'], + ['es', 'Esto es lo que ha podido pasar'], + ['zh', 'ๅฏ่ƒฝๅŽŸๅ› ๆœ‰'], + ['id', 'Inilah yang bisa saja terjadi'], + ['pl', 'Oto, co mogล‚o siฤ™ wydarzyฤ‡'], + ])], + [tkn('You may have mistyped the URL'), new Map([ + ['fr', 'Vous avez peut-รชtre mal tapรฉ lโ€™URL'], + ['ru', 'ะ’ั‹ ะผะพะณะปะธ ะพัˆะธะฑะธั‚ัŒัั ะฒ URL'], + ['uk', 'ะ’ะธ ะผะพะณะปะธ ะฟะพะผะธะปะธั‚ะธัั ะฒ URL-ะฐะดั€ะตัั–'], + ['pt', 'Vocรช pode ter digitado incorretamente a URL'], + ['nl', 'De URL bevat een typefout'], + ['de', 'Mรถglicherweise haben Sie die URL falsch eingegeben'], + ['es', 'Quizรก ha escrito mal la URL'], + ['zh', 'ๆ‚จๅฏ่ƒฝ่พ“ๅ…ฅไบ†้”™่ฏฏ็š„ๅœฐๅ€'], + ['id', 'Anda mungkin tersalah memasukkan URL'], + ['pl', 'Byฤ‡ moลผe bล‚ฤ™dnie wpisaล‚eล› adres URL'], + ])], + [tkn('The site was moved'), new Map([ + ['fr', 'Le site a รฉtรฉ dรฉplacรฉ'], + ['ru', 'ะกะฐะนั‚ ะฑั‹ะป ะฟะตั€ะตะผะตั‰ั‘ะฝ'], + ['uk', 'ะกะฐะนั‚ ะฑัƒะฒ ะฟะตั€ะตะผั–ั‰ะตะฝะธะน'], + ['pt', 'O site foi movido'], + ['nl', 'De site is verplaatst'], + ['de', 'Die Seite wurde verschoben'], + ['es', 'El sitio se ha trasladado'], + ['zh', '็ซ™็‚นๅทฒ่ขซ่ฝฌ็งป'], + ['id', 'Halaman dipindahkan'], + ['pl', 'Witryna zostaล‚a przeniesiona'], + ])], + [tkn('It was never here'), new Map([ + ['fr', 'Il nโ€™a jamais รฉtรฉ ici'], + ['ru', 'ะžะฝ ะฝะธะบะพะณะดะฐ ะฝะต ะฑั‹ะป ะทะดะตััŒ'], + ['uk', 'ะ’ั–ะฝ ะฝั–ะบะพะปะธ ะฝะต ะฑัƒะฒ ั‚ัƒั‚'], + ['pt', 'Nunca esteve aqui'], + ['nl', 'Het was hier nooit'], + ['de', 'Es war nie hier'], + ['es', 'Nunca ha estado aquรญ'], + ['zh', '็ซ™็‚นไปŽๆœชๅญ˜ๅœจ'], + ['id', 'Itu Tidak pernah di sini'], + ['pl', 'Nigdy jej nie byล‚o'], + ])], + [tkn('Bad Request'), new Map([ + ['fr', 'Mauvaise requรชte'], + ['ru', 'ะะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะทะฐะฟั€ะพั'], + ['uk', 'ะฅะธะฑะฝะธะน ะทะฐะฟะธั‚'], + ['pt', 'Requisiรงรฃo invรกlida'], + ['nl', 'Foutieve anvraag'], + ['de', 'Fehlerhafte Anfrage'], + ['es', 'Peticiรณn invรกlida'], + ['zh', '้”™่ฏฏ่ฏทๆฑ‚'], + ['id', 'Permintaan yang salah'], + ['pl', 'Nieprawidล‚owe ลผฤ…danie'], + ])], + [tkn('The server did not understand the request'), new Map([ + ['fr', 'Le serveur ne comprend pas la requรชte'], + ['ru', 'ะกะตั€ะฒะตั€ ะฝะต ัะผะพะณ ะพะฑั€ะฐะฑะพั‚ะฐั‚ัŒ ะทะฐะฟั€ะพั ะธะท-ะทะฐ ะพัˆะธะฑะบะธ ะฒ ะฝั‘ะผ'], + ['uk', 'ะกะตั€ะฒะตั€ ะฝะต ะทะผั–ะณ ะพะฑั€ะพะฑะธั‚ะธ ะทะฐะฟะธั‚ ั‡ะตั€ะตะท ะฟะพะผะธะปะบัƒ ะฒ ะฝัŒะพะผัƒ'], + ['pt', 'O servidor nรฃo entendeu a solicitaรงรฃo'], + ['nl', 'De server begreep het verzoek niet'], + ['de', 'Der Server hat die Anfrage nicht verstanden'], + ['es', 'El servidor no entendiรณ la peticiรณn'], + ['zh', 'ๆœๅŠกๅ™จไธ็†่งฃ่ฏฅ่ฏทๆฑ‚'], + ['id', 'Server tidak memahami permintaan'], + ['pl', 'Serwer nie zrozumiaล‚ ลผฤ…dania'], + ])], + [tkn('Unauthorized'), new Map([ + ['fr', 'Non autorisรฉ'], + ['ru', 'ะ—ะฐะฟั€ะพั ะฝะต ะฐะฒั‚ะพั€ะธะทะพะฒะฐะฝ'], + ['uk', 'ะะตัะฐะฝะบั†ั–ะพะฝะพะฒะฐะฝะธะน ะดะพัั‚ัƒะฟ'], + ['pt', 'Nรฃo autorizado'], + ['nl', 'Niet geautoriseerd'], + ['de', 'Nicht autorisiert'], + ['es', 'No autorizado'], + ['zh', 'ๆœช็ปๆŽˆๆƒ'], + ['id', 'Tidak diotorisasi'], + ['pl', 'Nieautoryzowany'], + ])], + [tkn('The requested page needs a username and a password'), new Map([ + ['fr', 'La page demandรฉe nรฉcessite un nom dโ€™utilisateur et un mot de passe'], + ['ru', 'ะ”ะปั ะดะพัั‚ัƒะฟะฐ ะบ ัั‚ั€ะฐะฝะธั†ะต ั‚ั€ะตะฑัƒะตั‚ัั ะปะพะณะธะฝ ะธ ะฟะฐั€ะพะปัŒ'], + ['uk', 'ะฉะพะฑ ะพั‚ั€ะธะผะฐั‚ะธ ะดะพัั‚ัƒะฟ ะดะพ ัั‚ะพั€ั–ะฝะบะธ, ะฟะพั‚ั€ั–ะฑะฝะธะน ะปะพะณั–ะฝ ั‚ะฐ ะฟะฐั€ะพะปัŒ'], + ['pt', 'A pรกgina solicitada precisa de um nome de usuรกrio e uma senha'], + ['nl', 'De pagina heeft een gebruikersnaam en wachtwoord nodig'], + ['de', 'Die angeforderte Seite benรถtigt einen Benutzernamen und ein Passwort'], + ['es', 'La pรกgina solicitada necesita un usuario y una contraseรฑa'], + ['zh', '่ฏทๆฑ‚็š„้กต้ข้œ€่ฆ็”จๆˆทๅๅ’Œๅฏ†็ '], + ['id', 'Halaman yang diminta membutuhkan nama pengguna dan kata sandi'], + ['pl', 'ลปฤ…dana strona wymaga podania nazwy uลผytkownika i hasล‚a'], + ])], + [tkn('Forbidden'), new Map([ + ['fr', 'Interdit'], + ['ru', 'ะ—ะฐะฟั€ะตั‰ะตะฝะพ'], + ['uk', 'ะ—ะฐะฑะพั€ะพะฝะตะฝะพ'], + ['pt', 'Proibido'], + ['nl', 'Verboden'], + ['de', 'Verboten'], + ['es', 'Prohibido'], + ['zh', '็ฆๆญข่ฎฟ้—ฎ'], + ['id', 'Dilarang'], + ['pl', 'Zabroniony'], + ])], + [tkn('Access is forbidden to the requested page'), new Map([ + ['fr', 'Accรจs interdit ร  la page demandรฉe'], + ['ru', 'ะ”ะพัั‚ัƒะฟ ะบ ัั‚ั€ะฐะฝะธั†ะต ะทะฐะฟั€ะตั‰ั‘ะฝ'], + ['uk', 'ะ”ะพัั‚ัƒะฟ ะดะพ ัั‚ะพั€ั–ะฝะบะธ ะทะฐะฑะพั€ะพะฝะตะฝะพ'], + ['pt', 'ร‰ proibido o acesso ร  pรกgina solicitada'], + ['nl', 'Toegang tot de pagina is verboden'], + ['de', 'Der Zugriff auf die angeforderte Seite ist verboten'], + ['es', 'El acceso estรก prohibido para la pรกgina solicitada'], + ['zh', '็ฆๆญข่ฎฟ้—ฎ่ฏทๆฑ‚็š„้กต้ข'], + ['id', 'Akses dilarang ke halaman yang diminta'], + ['pl', 'Dostฤ™p do ลผฤ…danej strony jest zabroniony'], + ])], + [tkn('Not Found'), new Map([ + ['fr', 'Introuvable'], + ['ru', 'ะกั‚ั€ะฐะฝะธั†ะฐ ะฝะต ะฝะฐะนะดะตะฝะฐ'], + ['uk', 'ะกั‚ะพั€ั–ะฝะบัƒ ะฝะต ะทะฝะฐะนะดะตะฝะพ'], + ['pt', 'Nรฃo encontrado'], + ['nl', 'Niet gevonden'], + ['de', 'Nicht gefunden'], + ['es', 'No encontrado'], + ['zh', 'ๆœชๆ‰พๅˆฐ'], + ['id', 'Tidak ditemukan'], + ['pl', 'Nie znaleziono'], + ])], + [tkn('The server can not find the requested page'), new Map([ + ['fr', 'Le serveur ne peut trouver la page demandรฉe'], + ['ru', 'ะกะตั€ะฒะตั€ ะฝะต ัะผะพะณ ะฝะฐะนั‚ะธ ะทะฐะฟั€ะฐัˆะธะฒะฐะตะผัƒัŽ ัั‚ั€ะฐะฝะธั†ัƒ'], + ['uk', 'ะกะตั€ะฒะตั€ ะฝะต ะทะผั–ะณ ะทะฝะฐะนั‚ะธ ะทะฐะฟะธั‚ะฐะฝัƒ ัั‚ะพั€ั–ะฝะบัƒ'], + ['pt', 'O servidor nรฃo consegue encontrar a pรกgina solicitada'], + ['nl', 'De server kan de pagina niet vinden'], + ['de', 'Der Server kann die angeforderte Seite nicht finden'], + ['es', 'El servidor no puede encontrar la pรกgina solicitada'], + ['zh', 'ๆœๅŠกๅ™จๆ‰พไธๅˆฐ่ฏทๆฑ‚็š„้กต้ข'], + ['id', 'Server tidak dapat menemukan halaman yang diminta'], + ['pl', 'Serwer nie moลผe znaleลบฤ‡ ลผฤ…danej strony'], + ])], + [tkn('Method Not Allowed'), new Map([ + ['fr', 'Mรฉthode Non Autorisรฉe'], + ['ru', 'ะœะตั‚ะพะด ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั'], + ['uk', 'ะะตะฟั€ะธะฟัƒัั‚ะธะผะธะน ะผะตั‚ะพะด'], + ['pt', 'Mรฉtodo nรฃo permitido'], + ['nl', 'Methode niet toegestaan'], + ['de', 'Methode nicht erlaubt'], + ['es', 'Mรฉtodo no permitido'], + ['zh', 'ๆ–นๆณ•ไธ่ขซๅ…่ฎธ'], + ['id', 'Metode tidak diizinkan'], + ['pl', 'Niedozwolona metoda'], + ])], + [tkn('The method specified in the request is not allowed'), new Map([ + ['fr', 'La mรฉthode spรฉcifiรฉe dans la requรชte nโ€™est pas autorisรฉe'], + ['ru', 'ะฃะบะฐะทะฐะฝะฝั‹ะน ะฒ ะทะฐะฟั€ะพัะต ะผะตั‚ะพะด ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั'], + ['uk', 'ะœะตั‚ะพะด, ะทะฐะทะฝะฐั‡ะตะฝะธะน ัƒ ะทะฐะฟะธั‚ั–, ะฝะต ะฟั–ะดั‚ั€ะธะผัƒั”ั‚ัŒัั'], + ['pt', 'O mรฉtodo especificado na solicitaรงรฃo nรฃo รฉ permitido'], + ['nl', 'De methode in het verzoek is niet toegestaan'], + ['de', 'Die in der Anfrage angegebene Methode ist nicht zulรคssig'], + ['es', 'El mรฉtodo especificado en la peticiรณn no estรก permitido'], + ['zh', '่ฏทๆฑ‚ๆŒ‡ๅฎš็š„ๆ–นๆณ•ไธ่ขซๅ…่ฎธ'], + ['id', 'Metode dalam permintaan tidak diizinkan'], + ['pl', 'Metoda okreล›lona w ลผฤ…daniu jest niedozwolona'], + ])], + [tkn('Proxy Authentication Required'), new Map([ + ['fr', 'Authentification proxy requise'], + ['ru', 'ะัƒะถะฝะฐ ะฐัƒั‚ะตะฝั‚ะธั„ะธะบะฐั†ะธั ะฟั€ะพะบัะธ'], + ['uk', 'ะŸะพั‚ั€ั–ะฑะฝะฐ ั–ะดะตะฝั‚ะธั„ั–ะบะฐั†ั–ั ะฟั€ะพะบัั–'], + ['pt', 'Autenticaรงรฃo de proxy necessรกria'], + ['nl', 'Authenticatie op de proxyserver verplicht'], + ['de', 'Proxy-Authentifizierung benรถtigt'], + ['es', 'Autenticaciรณn de proxy requerida'], + ['zh', '้œ€่ฆไปฃ็†ๆœๅŠกๅ™จ่บซไปฝ้ชŒ่ฏ'], + ['id', 'Diperlukan otentikasi proxy'], + ['pl', 'Wymagane uwierzytelnianie proxy'], + ])], + [tkn('You must authenticate with a proxy server before this request can be served'), new Map([ + ['fr', 'Vous devez vous authentifier avec un serveur proxy avant que cette requรชte puisse รชtre servie'], + ['ru', 'ะ’ั‹ ะดะพะปะถะฝั‹ ะฑั‹ั‚ัŒ ะฐะฒั‚ะพั€ะธะทะพะฒะฐะฝั‹ ะฝะฐ ะฟั€ะพะบัะธ ัะตั€ะฒะตั€ะต ะดะปั ะพะฑั€ะฐะฑะพั‚ะบะธ ัั‚ะพะณะพ ะทะฐะฟั€ะพัะฐ'], + ['uk', 'ะ’ะธ ะฟะพะฒะธะฝะฝั– ัƒะฒั–ะนั‚ะธ ะดะพ ะฟั€ะพะบัั–-ัะตั€ะฒะตั€ะฐ ะดะปั ะพะฑั€ะพะฑะบะธ ั†ัŒะพะณะพ ะทะฐะฟะธั‚ัƒ'], + ['pt', 'Vocรช deve se autenticar com um servidor proxy antes que esta solicitaรงรฃo possa ser atendida'], + ['nl', 'Je moet authenticeren bij een proxyserver voordat dit verzoek uitgevoerd kan worden'], + ['de', 'Sie mรผssen sich bei einem Proxy-Server authentifizieren, bevor diese Anfrage bedient werden kann'], + ['es', 'Debes autentificarte con un servidor proxy antes de que esta peticiรณn pueda ser atendida'], + ['zh', 'ๆ‚จๅฟ…้กปๅฏนไปฃ็†ๆœๅŠกๅ™จ่ฟ›่กŒ่บซไปฝ้ชŒ่ฏ๏ผŒ็„ถๅŽๆ‰่ƒฝ่ฎฉ่ฏทๆฑ‚ๅพ—ๅˆฐๅค„็†'], + ['id', 'Anda harus mengautentikasi dengan server proxy sebelum permintaan ini dapat dilayani'], + ['pl', 'Musisz uwierzytelniฤ‡ siฤ™ na serwerze proxy, zanim to ลผฤ…danie bฤ™dzie mogล‚o zostaฤ‡ obsล‚uลผone'], + ])], + [tkn('Request Timeout'), new Map([ + ['fr', 'Requรชte expirรฉ'], + ['ru', 'ะ˜ัั‚ะตะบะปะพ ะฒั€ะตะผั ะพะถะธะดะฐะฝะธั'], + ['uk', 'ะ’ะธั‡ะตั€ะฟะฐะฝะพ ั‡ะฐั ะพั‡ั–ะบัƒะฒะฐะฝะฝั'], + ['pt', 'Tempo limite de solicitaรงรฃo excedido'], + ['nl', 'Aanvraagtijd verstreken'], + ['de', 'Zeitรผberschreitung der Anforderung'], + ['es', 'Tiempo lรญmite de la peticiรณn excedido'], + ['zh', '่ฏทๆฑ‚่ถ…ๆ—ถ'], + ['id', 'Meminta batas waktu'], + ['pl', 'Przekroczenie limitu czasu ลผฤ…dania'], + ])], + [tkn('The request took longer than the server was prepared to wait'), new Map([ + ['fr', 'La requรชte prend plus de temps que prรฉvu'], + ['ru', 'ะžั‚ะฟั€ะฐะฒะบะฐ ะทะฐะฟั€ะพัะฐ ะทะฐะฝัะปะฐ ัะปะธัˆะบะพะผ ะผะฝะพะณะพ ะฒั€ะตะผะตะฝะธ'], + ['uk', 'ะะฐะดัะธะปะฐะฝะฝั ะทะฐะฟะธั‚ัƒ ะทะฐะนะฝัะปะพ ะฝะฐะดั‚ะพ ะฑะฐะณะฐั‚ะพ ั‡ะฐััƒ'], + ['pt', 'A solicitaรงรฃo demorou mais do que o servidor estava preparado para esperar'], + ['nl', 'Het verzoek duurde langer dan de server wilde wachten'], + ['de', 'Die Anfrage hat lรคnger gedauert, als der Server bereit war zu warten'], + ['es', 'La peticiรณn esta tardando mรกs de lo que el servidor estaba preparado para esperar'], + ['zh', '่ฏทๆฑ‚็”จๆ—ถ่ถ…่ฟ‡ไบ†ๆœๅŠกๅ™จ่ฎพ็ฝฎ็š„ๆœ€้•ฟ็ญ‰ๅพ…ๆ—ถ้—ด'], + ['id', 'Permintaan memakan waktu lebih lama dari yang bisa ditunggu oleh server'], + ['pl', 'ลปฤ…danie trwaล‚o dล‚uลผej niลผ serwer byล‚ gotowy czekaฤ‡'], + ])], + [tkn('Conflict'), new Map([ + ['fr', 'Conflit'], + ['ru', 'ะšะพะฝั„ะปะธะบั‚'], + ['uk', 'ะšะพะฝั„ะปั–ะบั‚'], + ['pt', 'Conflito'], + ['nl', 'Conflict'], + ['de', 'Konflikt'], + ['es', 'Conflicto'], + ['zh', 'ๅ†ฒ็ช'], + ['id', 'Konflik'], + ['pl', 'Konflikt'], + ])], + [tkn('The request could not be completed because of a conflict'), new Map([ + ['fr', 'La requรชte nโ€™a pas pu รชtre complรฉtรฉe ร  cause dโ€™un conflit'], + ['ru', 'ะ—ะฐะฟั€ะพั ะฝะต ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะพะฑั€ะฐะฑะพั‚ะฐะฝ ะธะท-ะทะฐ ะบะพะฝั„ะปะธะบั‚ะฐ'], + ['uk', 'ะ—ะฐะฟะธั‚ ะฝะต ะผะพะถะต ะฑัƒั‚ะธ ะพะฑั€ะพะฑะปะตะฝะธะน ั‡ะตั€ะตะท ะบะพะฝั„ะปั–ะบั‚'], + ['pt', 'A solicitaรงรฃo nรฃo pรดde ser concluรญda devido a um conflito'], + ['nl', 'Het verzoek kon niet worden verwerkt vanwege een conflict'], + ['de', 'Die Anfrage konnte aufgrund eines Konflikts nicht abgeschlossen werden'], + ['es', 'La peticiรณn no ha podido ser completada por un conflicto'], + ['zh', '็”ฑไบŽๅ†ฒ็ช๏ผŒ่ฏทๆฑ‚ๆ— ๆณ•ๅฎŒๆˆ'], + ['id', 'Permintaan tidak dapat diselesaikan karena adanya konflik'], + ['pl', 'ลปฤ…danie nie mogล‚o zostaฤ‡ wykonane z powodu konfliktu'], + ])], + [tkn('Gone'), new Map([ + ['fr', 'Supprimรฉ'], + ['ru', 'ะฃะดะฐะปะตะฝะพ'], + ['uk', 'ะ’ะธะปัƒั‡ะตะฝะธะน'], + ['pt', 'Removido'], + ['nl', 'Verdwenen'], + ['de', 'Verschwunden'], + ['es', 'Eliminado'], + ['zh', 'ๅทฒ็งป้™ค'], + ['id', 'Menghilang'], + ['pl', 'Usuniฤ™to'], + ])], + [tkn('The requested page is no longer available'), new Map([ + ['fr', 'La page demandรฉe nโ€™est plus disponible'], + ['ru', 'ะ—ะฐะฟั€ะพัˆะตะฝะฝะฐั ัั‚ั€ะฐะฝะธั†ะฐ ะฑั‹ะปะฐ ัƒะดะฐะปะตะฝะฐ'], + ['uk', 'ะ—ะฐะฟะธั‚ัƒะฒะฐะฝะฐ ัั‚ะพั€ั–ะฝะบะฐ ะฑั–ะปัŒัˆะต ะฝะต ะดะพัั‚ัƒะฟะฝะฐ'], + ['pt', 'A pรกgina solicitada nรฃo estรก mais disponรญvel'], + ['nl', 'De pagina is niet langer beschikbaar'], + ['de', 'Die angeforderte Seite ist nicht mehr verfรผgbar'], + ['es', 'La pรกgina solicitada no estรก ya disponible'], + ['zh', '่ฏทๆฑ‚็š„้กต้ขไธๅ†ๅฏ็”จ'], + ['id', 'Halaman yang diminta tidak lagi tersedia'], + ['pl', 'ลปฤ…dana strona nie jest juลผ dostฤ™pna'], + ])], + [tkn('Length Required'), new Map([ + ['fr', 'Longueur requise'], + ['ru', 'ะะตะพะฑั…ะพะดะธะผะฐ ะดะปะธะฝะฐ'], + ['uk', 'ะŸะพั‚ั€ั–ะฑะฝะพ ะฒะบะฐะทะฐั‚ะธ ะดะพะฒะถะธะฝัƒ'], + ['pt', 'Content-Length necessรกrio'], + ['nl', 'Lengte benodigd'], + ['de', 'Lรคnge benรถtigt'], + ['es', 'Longitud requerida'], + ['zh', '้œ€่ฆ้•ฟๅบฆ'], + ['id', 'Panjang yang diperlukan'], + ['pl', 'Wymagana dล‚ugoล›ฤ‡'], + ])], + [tkn('The "Content-Length" is not defined. The server will not accept the request without it'), new Map([ + ['fr', 'Le "Content-Length" nโ€™est pas dรฉfini. Le serveur ne prendra pas en compte la requรชte'], + ['ru', 'ะ—ะฐะณะพะปะพะฒะพะบ "Content-Length" ะฝะต ะฑั‹ะป ะฟะตั€ะตะดะฐะฝ. ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะตั‚ ะพะฑั€ะฐะฑะพั‚ะฐั‚ัŒ ะทะฐะฟั€ะพั ะฑะตะท ะฝะตะณะพ'], + ['uk', 'ะ—ะฐะณะพะปะพะฒะพะบ "Content-Length" ะฝะต ะฑัƒะฒ ะฟะตั€ะตะดะฐะฝะธะน. ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะต ะพะฑั€ะพะฑะธั‚ะธ ะทะฐะฟะธั‚ ะฑะตะท ะฝัŒะพะณะพ'], + ['pt', 'O "Content-Length" nรฃo estรก definido. O servidor nรฃo aceitarรก a solicitaรงรฃo sem ele'], + ['nl', 'De "Content-Length" is niet gespecificeerd. De server accepteert het verzoek niet zonder'], + ['de', 'Die "Content-Length" ist nicht definiert. Ohne sie akzeptiert der Server die Anfrage nicht'], + ['es', 'El "Content-Legth" no eta definido. Este servidor no aceptarรก la peticiรณn sin รฉl'], + ['zh', 'ๆœชๆŒ‡ๅฎšContent-Length(ๅ†…ๅฎน้•ฟๅบฆ)ใ€‚ๆœๅŠกๅ™จๅฐ†ไธๆŽฅๅ—ไธๅŒ…ๅซๆญคๅคดไฟกๆฏ็š„่ฏทๆฑ‚'], + ['id', '"Content-Length" tidak ditentukan. Server tidak akan menerima permintaan tanpa itu'], + ['pl', 'Wartoล›ฤ‡ "Content-Length" nie jest zdefiniowana. Serwer nie zaakceptuje ลผฤ…dania bez tego parametru'], + ])], + [tkn('Precondition Failed'), new Map([ + ['fr', 'ร‰chec de la condition prรฉalable'], + ['ru', 'ะฃัะปะพะฒะธะต ะปะพะถะฝะพ'], + ['uk', 'ะ—ะฑั–ะน ะฟั–ะด ั‡ะฐั ะพะฑั€ะพะฑะบะธ ะฟะพะฟะตั€ะตะดะฝัŒะพั— ัƒะผะพะฒะธ'], + ['pt', 'Falha na prรฉ-condiรงรฃo'], + ['nl', 'Niet voldaan aan vooraf gestelde voorwaarde'], + ['de', 'Vorbedingung fehlgeschlagen'], + ['es', 'Precondiciรณn fallida'], + ['zh', 'ๅ‰็ฝฎๆกไปถๅˆคๅฎšๅคฑ่ดฅ'], + ['id', 'Prasyarat gagal'], + ['pl', 'Niespeล‚nienie warunku wstฤ™pnego'], + ])], + [tkn('The pre condition given in the request evaluated to false by the server'), new Map([ + ['fr', 'La prรฉcondition donnรฉe dans la requรชte a รฉtรฉ รฉvaluรฉe comme รฉtant fausse par le serveur'], + ['ru', 'ะะธ ะพะดะฝะพ ะธะท ัƒัะปะพะฒะฝั‹ั… ะฟะพะปะตะน ะทะฐะณะพะปะพะฒะบะฐ ะทะฐะฟั€ะพัะฐ ะฝะต ะฑั‹ะปะพ ะฒั‹ะฟะพะปะฝะตะฝะพ'], + ['uk', 'ะ–ะพะดะฝะฐ ะท ะฟะตั€ะตะดัƒะผะพะฒ ะทะฐะฟะธั‚ัƒ ะฝะต ะฑัƒะปะฐ ะฒะธะบะพะฝะฐะฝะฐ'], + ['pt', 'A prรฉ-condiรงรฃo dada na solicitaรงรฃo avaliada como falsa pelo servidor'], + ['nl', 'De vooraf gestelde voorwaarde is afgewezen door de server'], + ['de', 'Die in der Anfrage angegebene Vorbedingung wird vom Server als falsch bewertet'], + ['es', 'La precondiciรณn ha sido evaluada como negativa para esta peticiรณn por el servidor'], + ['zh', 'ๆœๅŠกๅ™จ่ฏ„ไผฐ่ฏทๆฑ‚ไธญ็ป™ๅ‡บ็š„ๅ‰็ฝฎๆกไปถ็š„็ป“ๆžœไธบfalse(ๅ‡)'], + ['id', 'Prakondisi gagal'], + ['pl', 'Warunek wstฤ™pny podany w ลผฤ…daniu zostaล‚ oceniony przez serwer jako nieprawidล‚owy'], + ])], + [tkn('Payload Too Large'), new Map([ + ['fr', 'Charge trop volumineuse'], + ['ru', 'ะกะปะธัˆะบะพะผ ะฑะพะปัŒัˆะพะน ะทะฐะฟั€ะพั'], + ['uk', 'ะ—ะฐะฝะฐะดั‚ะพ ะฒะตะปะธะบะธะน ะทะฐะฟะธั‚'], + ['pt', 'Payload muito grande'], + ['nl', 'Aanvraag te grood'], + ['de', 'Anfrage zu groรŸ'], + ['es', 'Carga demasiado grande'], + ['zh', '่ฏทๆฑ‚ไฝ“่ฟ‡ๅคง'], + ['id', 'Muatan terlalu besar'], + ['pl', 'ลปฤ…danie jest zbyt duลผe'], + ])], + [tkn('The server will not accept the request, because the request entity is too large'), new Map([ + ['fr', 'Le serveur ne prendra pas en compte la requรชte, car lโ€™entitรฉ de la requรชte est trop volumineuse'], + ['ru', 'ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะตั‚ ะพะฑั€ะฐะฑะพั‚ะฐั‚ัŒ ะทะฐะฟั€ะพั, ั‚ะฐะบ ะบะฐะบ ะพะฝ ัะปะธัˆะบะพะผ ะฑะพะปัŒัˆะพะน'], + ['uk', 'ะกะตั€ะฒะตั€ ะฝะต ะผะพะถะต ะพะฑั€ะพะฑะธั‚ะธ ะทะฐะฟะธั‚, ะพัะบั–ะปัŒะบะธ ะฒั–ะฝ ะทะฐะฝะฐะดั‚ะพ ะฒะตะปะธะบะธะน'], + ['pt', 'O servidor nรฃo aceitarรก a solicitaรงรฃo porque a entidade da solicitaรงรฃo รฉ muito grande'], + ['nl', 'De server accepteert het verzoek niet omdat de aanvraag te groot is'], + ['de', 'Der Server akzeptiert die Anfrage nicht, da die Datenmenge zu groรŸ ist'], + ['es', 'El servidor no aceptarรก esta peticiรณn, porque la carga es demasiado grande'], + ['zh', '่ฏทๆฑ‚ไฝ“่ฟ‡ๅคง๏ผŒๆœๅŠกๅ™จๅฐ†ไธๆŽฅๅ—่ฏฅ่ฏทๆฑ‚'], + ['id', 'Server tidak akan menerima permintaan, karena entitas permintaan terlalu besar'], + ['pl', 'Serwer nie zaakceptuje ลผฤ…dania, poniewaลผ ลผฤ…danie jest zbyt duลผe'], + ])], + [tkn('Requested Range Not Satisfiable'), new Map([ + ['fr', 'Requรชte non satisfaisante'], + ['ru', 'ะ”ะธะฐะฟะฐะทะพะฝ ะฝะต ะดะพัั‚ะธะถะธะผ'], + ['uk', 'ะ—ะฐะฟะธั‚ัƒะฒะฐะฝะธะน ะดั–ะฐะฟะฐะทะพะฝ ะฝะตะดะพััะถะฝะธะน'], + ['pt', 'Intervalo Solicitado Nรฃo Satisfatรณrio'], + ['nl', 'Aangevraagd gedeelte niet opvraagbaar'], + ['de', 'Anfrage-Bereich nicht erfรผllbar'], + ['es', 'Intervalo solicitado no satisfactorio'], + ['zh', 'ไธๆปก่ถณ่ฏทๆฑ‚่Œƒๅ›ด'], + ['id', 'Rentang yang diminta tidak dapat dipenuhi'], + ['pl', 'ลปฤ…dany zakres nie jest satysfakcjonujฤ…cy'], + ])], + [tkn('The requested byte range is not available and is out of bounds'), new Map([ + ['fr', 'Le byte range demandรฉ nโ€™est pas disponible et est hors des limites'], + ['ru', 'ะ—ะฐะฟั€ะพัˆะตะฝะฝั‹ะน ะดะธะฐะฟะฐะทะพะฝ ะดะฐะฝะฝั‹ั… ะฝะตะดะพัั‚ัƒะฟะตะฝ ะธะปะธ ะฒะฝะต ะดะพะฟัƒัั‚ะธะผั‹ั… ะฟั€ะตะดะตะปะพะฒ'], + ['uk', 'ะžะฟะธัะฐะฝะธะน ะดั–ะฐะฟะฐะทะพะฝ ะดะฐะฝะธั… ะฝะตะดะพัั‚ัƒะฟะฝะธะน ะฐะฑะพ ะฟะพะทะฐ ะดะพะฟัƒัั‚ะธะผะธะผะธ ะผะตะถะฐะผะธ'], + ['pt', 'O intervalo de bytes solicitado nรฃo estรก disponรญvel e estรก fora dos limites'], + ['nl', 'De aangevraagde bytes zijn buiten het limiet'], + ['de', 'Der angefragte Teilbereich der Ressource existiert nicht oder ist ungรผltig'], + ['es', 'El intervalo de bytes requerido no estรก disponible o se encuentra fuera de los lรญmites'], + ['zh', '่ฏทๆฑ‚็š„ๅญ—่Š‚่Œƒๅ›ดไธๅฏ็”จ๏ผŒ่ถ…ๅ‡บ่พน็•Œ'], + ['id', 'Rentang byte yang diminta tidak tersedia dan di luar batas'], + ['pl', 'ลปฤ…dany zakres bajtรณw nie jest dostฤ™pny i znajduje siฤ™ poza zakresem'], + ])], + [tkn("I'm a teapot"), new Map([ + ['fr', 'Je suis une thรฉiรจre'], + ['ru', 'ะฏ ั‡ะฐะนะฝะธะบ'], + ['uk', 'ะฏ ั‡ะฐะนะฝะธะบ'], + ['pt', 'Eu sou um bule'], + ['nl', 'Ik ben een theepot'], + ['de', 'Ich bin eine Teekanne'], + ['es', 'Soy una tetera'], + ['zh', 'ๆˆ‘ๆ˜ฏไธ€ๅช่Œถๅฃถ'], + ['id', 'Saya adalah teko'], + ['pl', 'Jestem czajniczkiem'], + ])], + [tkn('Attempt to brew coffee with a teapot is not supported'), new Map([ + ['fr', 'Tenter de prรฉparer du cafรฉ avec une thรฉiรจre nโ€™est pas pris en charge'], + ['ru', 'ะŸะพะฟั‹ั‚ะบะฐ ะทะฐะฒะฐั€ะธั‚ัŒ ะบะพั„ะต ะฒ ั‡ะฐะนะฝะธะบะต ะพะฑั€ะตั‡ะตะฝะฐ ะฝะฐ ั„ะธะฐัะบะพ'], + ['uk', 'ะกะฟั€ะพะฑะฐ ะทะฐะฒะฐั€ะธั‚ะธ ะบะฐะฒัƒ ะฒ ั‡ะฐะนะฝะธะบัƒ ะฟั€ะธั€ะตั‡ะตะฝะฐ ะฝะฐ ั„ั–ะฐัะบะพ'], + ['pt', 'A tentativa de preparar cafรฉ com um bule nรฃo รฉ suportada'], + ['nl', 'Koffie maken met een theepot is niet ondersteund'], + ['de', 'Der Versuch, Kaffee mit einer Teekanne zuzubereiten, wird nicht unterstรผtzt'], + ['es', 'Intentar hacer un cafรฉ en una tetera no estรก soportado'], + ['zh', '็”จ่Œถๅฃถๆณกๅ’–ๅ•กไธๅ—ๆ”ฏๆŒ'], + ['id', 'Upaya menyeduh kopi dengan teko tidak didukung'], + ['pl', 'Prรณba zaparzenia kawy za pomocฤ… czajniczka nie jest obsล‚ugiwana'], + ])], + [tkn('Too Many Requests'), new Map([ + ['fr', 'Trop de requรชtes'], + ['ru', 'ะกะปะธัˆะบะพะผ ะผะฝะพะณะพ ะทะฐะฟั€ะพัะพะฒ'], + ['uk', 'ะ—ะฐะฝะฐะดั‚ะพ ะฑะฐะณะฐั‚ะพ ะทะฐะฟะธั‚ั–ะฒ'], + ['pt', 'Excesso de solicitaรงรตes'], + ['nl', 'Te veel requests'], + ['de', 'Zu viele Anfragen'], + ['es', 'Demasiadas peticiones'], + ['zh', '่ฏทๆฑ‚่ฟ‡ๅคš'], + ['id', 'Terlalu banyak permintaan'], + ['pl', 'Zbyt wiele ลผฤ…daล„'], + ])], + [tkn('Too many requests in a given amount of time'), new Map([ + ['fr', 'Trop de requรชtes dans un dรฉlai donnรฉ'], + ['ru', 'ะžั‚ะฟั€ะฐะฒะปะตะฝะพ ัะปะธัˆะบะพะผ ะผะฝะพะณะพ ะทะฐะฟั€ะพัะพะฒ ะทะฐ ะบะพั€ะพั‚ะบะพะต ะฒั€ะตะผั'], + ['uk', 'ะะฐะดั–ัะปะฐะฝะพ ะทะฐะฝะฐะดั‚ะพ ะฑะฐะณะฐั‚ะพ ะทะฐะฟะธั‚ั–ะฒ ะทะฐ ะบะพั€ะพั‚ะบะธะน ะฟั€ะพะผั–ะถะพะบ ั‡ะฐั'], + ['pt', 'Excesso de solicitaรงรตes em um determinado perรญodo de tempo'], + ['nl', 'Te veel verzoeken binnen een bepaalde tijd'], + ['de', 'Der Client hat zu viele Anfragen in einem bestimmten Zeitraum gesendet'], + ['es', 'Demasiadas peticiones en un determinado periodo de tiempo'], + ['zh', 'ๅœจ็ป™ๅฎš็š„ๆ—ถ้—ดๅ†…ๅ‘้€ไบ†่ฟ‡ๅคš่ฏทๆฑ‚'], + ['id', 'Terlalu banyak permintaan dalam waktu tertentu'], + ['pl', 'Zbyt wiele ลผฤ…daล„ w okreล›lonym czasie'], + ])], + [tkn('Internal Server Error'), new Map([ + ['fr', 'Erreur interne du serveur'], + ['ru', 'ะ’ะฝัƒั‚ั€ะตะฝะฝัั ะพัˆะธะฑะบะฐ ัะตั€ะฒะตั€ะฐ'], + ['uk', 'ะ’ะฝัƒั‚ั€ั–ัˆะฝั ะฟะพะผะธะปะบะฐ ัะตั€ะฒะตั€ะฐ'], + ['pt', 'Erro do Servidor Interno'], + ['nl', 'Interne serverfout'], + ['de', 'Interner Server-Fehler'], + ['es', 'Error Interno'], + ['zh', 'ๅ†…้ƒจๆœๅŠกๅ™จ้”™่ฏฏ'], + ['id', 'Kesalahan server internal'], + ['pl', 'Wewnฤ™trzny bล‚ฤ…d serwera'], + ])], + [tkn('The server met an unexpected condition'), new Map([ + ['fr', 'Le serveur a rencontrรฉ une condition inattendue'], + ['ru', 'ะŸั€ะพะธะทะพัˆะปะพ ั‡ั‚ะพ-ั‚ะพ ะฝะตะพะถะธะดะฐะฝะฝะพะต ะฝะฐ ัะตั€ะฒะตั€ะต'], + ['uk', 'ะะฐ ัะตั€ะฒะตั€ั– ะฒั–ะดะฑัƒะปะพััŒ ั‰ะพััŒ ะฝะตะพั‡ั–ะบัƒะฒะฐะฝะต'], + ['pt', 'O servidor encontrou uma condiรงรฃo inesperada'], + ['nl', 'De server ondervond een onverwachte conditie'], + ['de', 'Der Server hat einen internen Fehler festgestellt'], + ['es', 'El servidor ha encontrado una condiciรณn no esperada'], + ['zh', 'ๆœๅŠกๅ™จ้‡ๅˆฐไบ†ๆ„ๅค–ๆƒ…ๅ†ต'], + ['id', 'Server mengalami kondisi yang tidak terduga'], + ['pl', 'Serwer napotkaล‚ nieoczekiwany stan'], + ])], + [tkn('Bad Gateway'), new Map([ + ['fr', 'Mauvaise passerelle'], + ['ru', 'ะžัˆะธะฑะบะฐ ัˆะปัŽะทะฐ'], + ['uk', 'ะŸะพะผะธะปะบะฐ ัˆะปัŽะทัƒ'], + ['pt', 'Gateway invรกlido'], + ['nl', 'Ongeldige Gateway'], + ['de', 'Fehlerhaftes Gateway'], + ['es', 'Puerta de enlace no valida'], + ['zh', 'ๆ— ๆ•ˆ็ฝ‘ๅ…ณ'], + ['id', 'Gateway yang buruk'], + ['pl', 'Bล‚ฤ…d bramki'], + ])], + [tkn('The server received an invalid response from the upstream server'), new Map([ + ['fr', 'Le serveur a reรงu une rรฉponse invalide du serveur distant'], + ['ru', 'ะกะตั€ะฒะตั€ ะฟะพะปัƒั‡ะธะป ะฝะตะบะพั€ั€ะตะบั‚ะฝั‹ะน ะพั‚ะฒะตั‚ ะพั‚ ะฒั‹ัˆะตัั‚ะพัั‰ะตะณะพ ัะตั€ะฒะตั€ะฐ'], + ['uk', 'ะกะตั€ะฒะตั€ ะพั‚ั€ะธะผะฐะฒ ะฝะตะฒั–ั€ะฝัƒ ะฒั–ะดะฟะพะฒั–ะดัŒ ะฒั–ะด ะฟะพะฟะตั€ะตะดะฝัŒะพะณะพ ัะตั€ะฒะตั€ะฐ'], + ['pt', 'O servidor recebeu uma resposta invรกlida do servidor upstream'], + ['nl', 'De server ontving een ongeldig antwoord van een bovenliggende server'], + ['de', 'Der Server hat eine ungรผltige Antwort vom Upstream-Server erhalten'], + ['es', 'El servidor ha recibido una respuesta no vรกlida del servidor de origen'], + ['zh', 'ๆœๅŠกๅ™จไปŽไธŠๆธธๆœๅŠกๅ™จๆ”ถๅˆฐไบ†ๆ— ๆ•ˆ็š„ๅ“ๅบ”'], + ['id', 'Server menerima respons yang tidak valid dari server induk'], + ['pl', 'Serwer otrzymaล‚ nieprawidล‚owฤ… odpowiedลบ od serwera nadrzฤ™dnego'], + ])], + [tkn('Service Unavailable'), new Map([ + ['fr', 'Service indisponible'], + ['ru', 'ะกะตั€ะฒะธั ะฝะตะดะพัั‚ัƒะฟะตะฝ'], + ['uk', 'ะกะตั€ะฒั–ั ะฝะตะดะพัั‚ัƒะฟะฝะธะน'], + ['pt', 'Serviรงo nรฃo disponรญvel'], + ['nl', 'Dienst niet beschikbaar'], + ['de', 'Dienst nicht verfรผgbar'], + ['es', 'Servicio no disponible'], + ['zh', 'ๆœๅŠกไธๅฏ็”จ'], + ['id', 'Layanan tidak tersedia'], + ['pl', 'Serwis niedostฤ™pny'], + ])], + [tkn('The server is temporarily overloading or down'), new Map([ + ['fr', 'Le serveur est temporairement en surcharge ou indisponible'], + ['ru', 'ะกะตั€ะฒะตั€ ะฒั€ะตะผะตะฝะฝะพ ะฝะต ะผะพะถะตั‚ ะพะฑั€ะฐะฑะฐั‚ั‹ะฒะฐั‚ัŒ ะทะฐะฟั€ะพัั‹ ะฟะพ ั‚ะตั…ะฝะธั‡ะตัะบะธะผ ะฟั€ะธั‡ะธะฝะฐะผ'], + ['uk', 'ะกะตั€ะฒะตั€ ั‚ะธะผั‡ะฐัะพะฒะพ ะฝะต ะผะพะถะต ะพะฑั€ะพะฑะปัั‚ะธ ะทะฐะฟะธั‚ะธ ะท ั‚ะตั…ะฝั–ั‡ะฝะธั… ะฟั€ะธั‡ะธะฝ'], + ['pt', 'O servidor estรก temporariamente sobrecarregado ou inativo'], + ['nl', 'De server is tijdelijk overbelast of niet bereikbaar'], + ['de', 'Der Server ist vorรผbergehend รผberlastet oder ausgefallen'], + ['es', 'El servidor estรก temporalmente sobrecargado o inactivo'], + ['zh', 'ๆœๅŠกๅ™จๆš‚ๆ—ถ่ฟ‡่ฝฝๆˆ–ไธๅฏ็”จ'], + ['id', 'Server untuk sementara kelebihan beban atau tidak tersedia'], + ['pl', 'Serwer jest tymczasowo przeciฤ…ลผony lub wyล‚ฤ…czony'], + ])], + [tkn('Gateway Timeout'), new Map([ + ['fr', 'Expiration Passerelle'], + ['ru', 'ะจะปัŽะท ะฝะต ะพั‚ะฒะตั‡ะฐะตั‚'], + ['uk', 'ะจะปัŽะท ะฝะต ะฒั–ะดะฟะพะฒั–ะดะฐั”'], + ['pt', 'Tempo limite do gateway excedido'], + ['nl', 'Gateway Verlopen'], + ['de', 'Gateway Zeitรผberschreitung'], + ['es', 'Tiempo lรญmite de puerta de enlace excedido'], + ['zh', '็ฝ‘ๅ…ณ่ถ…ๆ—ถ'], + ['id', 'Batas waktu gateway'], + ['pl', 'Przekroczenie limitu czasu bramki'], + ])], + [tkn('The gateway has timed out'), new Map([ + ['fr', 'Le temps dโ€™attente de la passerelle est dรฉpassรฉ'], + ['ru', 'ะกะตั€ะฒะตั€ ะฝะต ะดะพะถะดะฐะปัั ะพั‚ะฒะตั‚ะฐ ะพั‚ ะฒั‹ัˆะตัั‚ะพัั‰ะตะณะพ ัะตั€ะฒะตั€ะฐ'], + ['uk', 'ะฃ ัˆะปัŽะทัƒ ะทะฐะบั–ะฝั‡ะธะฒัั ั‡ะฐั ะพั‡ั–ะบัƒะฒะฐะฝะฝั'], + ['pt', 'O gateway esgotou o tempo limite'], + ['nl', 'De verbinding naar de bovenliggende server is verlopen'], + ['de', 'Das Zeitlimit fรผr den Verbindungsaufbau mit dem Upstream-Server ist abgelaufen'], + ['es', 'La puerta de enlace ha sobrepasado el tiempo lรญmite'], + ['zh', '็ฝ‘ๅ…ณๅ“ๅบ”ๅทฒ็ป่ถ…ๆ—ถ'], + ['id', 'Sambungan ke server induk telah kedaluwarsa'], + ['pl', 'Bramka przekroczyล‚a limit czasu'], + ])], + [tkn('HTTP Version Not Supported'), new Map([ + ['fr', 'Version HTTP non prise en charge'], + ['ru', 'ะ’ะตั€ัะธั HTTP ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ัั'], + ['uk', 'ะ’ะตั€ัั–ั ะะขะขะ  ะฝะต ะฟั–ะดั‚ั€ะธะผัƒั”ั‚ัŒัั'], + ['pt', 'Versรฃo HTTP nรฃo suportada'], + ['nl', 'HTTP-versie wordt niet ondersteunt'], + ['de', 'HTTP-Version wird nicht unterstรผtzt'], + ['es', 'Versiรณn de HTTP no soportada'], + ['zh', 'HTTP็‰ˆๆœฌไธๅ—ๆ”ฏๆŒ'], + ['id', 'Versi HTTP tidak didukung'], + ['pl', 'Wersja HTTP nie jest obsล‚ugiwana'], + ])], + [tkn('The server does not support the "http protocol" version'), new Map([ + ['fr', 'Le serveur ne supporte pas la version du protocole HTTP'], + ['ru', 'ะกะตั€ะฒะตั€ ะฝะต ะฟะพะดะดะตั€ะถะธะฒะฐะตั‚ ะทะฐะฟั€ะพัˆะตะฝะฝัƒัŽ ะฒะตั€ัะธัŽ HTTP ะฟั€ะพั‚ะพะบะพะปะฐ'], + ['uk', 'ะกะตั€ะฒะตั€ ะฝะต ะฟั–ะดั‚ั€ะธะผัƒั” ะทะฐะฟะธั‚ะฐะฝัƒ ะฒะตั€ัั–ัŽ HTTP-ะฟั€ะพั‚ะพะบะพะปัƒ'], + ['pt', 'O servidor nรฃo suporta a versรฃo do protocolo HTTP'], + ['nl', 'De server ondersteunt deze HTTP-versie niet'], + ['de', 'Der Server unterstรผtzt die HTTP-Protokoll-Version nicht'], + ['es', 'El servidor no soporta la versiรณn del protocolo HTTP'], + ['zh', 'ๆœๅŠกๅ™จไธๆ”ฏๆŒ่ฏฅHTTPๅ่ฎฎ็‰ˆๆœฌ'], + ['id', 'Server tidak mendukung versi HTTP ini'], + ['pl', 'Serwer nie obsล‚uguje wersji "protokoล‚u http"'], + ])], + [tkn('Host'), new Map([ + ['fr', 'Hรดte'], + ['ru', 'ะฅะพัั‚'], + ['uk', 'ะฅะพัั‚'], + ['pt', 'Hospedeiro'], + ['nl', 'Host'], + ['de', 'Host'], + ['es', 'Host'], + ['zh', 'ไธปๆœบ'], + ['id', 'Host'], + ['pl', 'Host'], + ])], + [tkn('Original URI'), new Map([ + ['fr', 'URI dโ€™origine'], + ['ru', 'ะ˜ัั…ะพะดะฝั‹ะน URI'], + ['uk', 'ะ’ะธั…ั–ะดะฝะธะน URI'], + ['pt', 'URI original'], + ['nl', 'Originele URI'], + ['de', 'Originale URI'], + ['es', 'URI original'], + ['zh', 'ๅŽŸๅง‹URI'], + ['id', 'URL asli'], + ['pl', 'Oryginalny URI'], + ])], + [tkn('Forwarded for'), new Map([ + ['fr', 'Transmis pour'], + ['ru', 'ะŸะตั€ะตะฝะฐะฟั€ะฐะฒะปะตะฝ'], + ['uk', 'ะŸะตั€ะตะฝะฐะฟั€ะฐะฒะปะตะฝะธะน'], + ['pt', 'Encaminhado para'], + ['nl', 'Doorgestuurd voor'], + ['de', 'Weitergeleitet fรผr'], + ['es', 'Remitido para'], + ['zh', '่ฝฌๅ‘่‡ช'], + ['id', 'Diteruskan untuk'], + ['pl', 'Przekazane do'], + ])], + [tkn('Namespace'), new Map([ + ['fr', 'Espace de noms'], + ['ru', 'ะŸั€ะพัั‚ั€ะฐะฝัั‚ะฒะพ ะธะผั‘ะฝ'], + ['uk', 'ะŸั€ะพัั‚ั–ั€ ั–ะผะตะฝ'], + ['pt', 'Namespace'], + ['nl', 'Elementnaam'], + ['de', 'Namensraum'], + ['es', 'Namespace'], + ['zh', 'ๅ‘ฝๅ็ฉบ้—ด'], + ['id', 'Ruang nama'], + ['pl', 'Przestrzeล„ nazw'], + ])], + [tkn('Ingress name'), new Map([ + ['fr', 'Nom ingress'], + ['ru', 'ะ˜ะผั Ingress'], + ['uk', "ะ†ะผ'ั ะฒั…ะพะดัƒ"], + ['pt', 'Nome Ingress'], + ['nl', 'Ingress naam'], + ['de', 'Ingress Name'], + ['es', 'Nombre Ingress'], + ['zh', 'ๅ…ฅๅฃๅ'], + ['id', 'Nama ingress'], + ['pl', 'Nazwa wejล›cia'], + ])], + [tkn('Service name'), new Map([ + ['fr', 'Nom du service'], + ['ru', 'ะ˜ะผั ัะตั€ะฒะธัะฐ'], + ['uk', "ะ†ะผ'ั ัะตั€ะฒั–ััƒ"], + ['pt', 'Nome do Serviรงo'], + ['nl', 'Service naam'], + ['de', 'Service Name'], + ['es', 'Nombre del servicio'], + ['zh', 'ๆœๅŠกๅ'], + ['id', 'Nama layanan'], + ['pl', 'Nazwa usล‚ugi'], + ])], + [tkn('Service port'), new Map([ + ['fr', 'Port du service'], + ['ru', 'ะŸะพั€ั‚ ัะตั€ะฒะธัะฐ'], + ['uk', 'ะŸะพั€ั‚ ัะตั€ะฒั–ััƒ'], + ['pt', 'Porta do serviรงo'], + ['nl', 'Service poort'], + ['de', 'Service Port'], + ['es', 'Puerto del servicio'], + ['zh', 'ๆœๅŠก็ซฏๅฃ'], + ['id', 'Port layanan'], + ['pl', 'Port usล‚ugi'], + ])], + [tkn('Request ID'), new Map([ + ['fr', 'Identifiant de la requรชte'], + ['ru', 'ID ะทะฐะฟั€ะพัะฐ'], + ['uk', 'ID ะทะฐะฟะธั‚ัƒ'], + ['pt', 'ID da solicitaรงรฃo'], + ['nl', 'ID van het verzoek'], + ['de', 'Anfrage ID'], + ['es', 'ID de la peticiรณn'], + ['zh', '่ฏทๆฑ‚ID'], + ['id', 'ID permintaan'], + ['pl', 'Identyfikator ลผฤ…dania'], + ])], + [tkn('Timestamp'), new Map([ + ['fr', 'Horodatage'], + ['ru', 'ะ’ั€ะตะผะตะฝะฝะฐั ะผะตั‚ะบะฐ'], + ['uk', 'ะœั–ั‚ะบะฐ ั‡ะฐััƒ'], + ['pt', 'Timestamp'], + ['nl', 'Tijdstempel'], + ['de', 'Zeitstempel'], + ['es', 'Timestamp'], + ['zh', 'ๆ—ถ้—ดๆˆณ'], + ['id', 'Cap waktu'], + ['pl', 'Sygnatura czasowa'], + ])], + [tkn('client-side error'), new Map([ + ['fr', 'Erreur Client'], + ['ru', 'ะพัˆะธะฑะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝะต ะบะปะธะตะฝั‚ะฐ'], + ['uk', 'ะฟะพะผะธะปะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝั– ะบะปั–ั”ะฝั‚ะฐ'], + ['pt', 'erro do lado do cliente'], + ['nl', 'fout aan de gebruikerskant'], + ['de', 'Clientseitiger Fehler'], + ['es', 'Error del lado del cliente'], + ['zh', 'ๅฎขๆˆท็ซฏ้”™่ฏฏ'], + ['id', 'Kesalahan sisi klien'], + ['pl', 'bล‚ฤ…d po stronie klienta'], + ])], + [tkn('server-side error'), new Map([ + ['fr', 'Erreur Serveur'], + ['ru', 'ะพัˆะธะฑะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝะต ัะตั€ะฒะตั€ะฐ'], + ['uk', 'ะฟะพะผะธะปะบะฐ ะฝะฐ ัั‚ะพั€ะพะฝั– ัะตั€ะฒะตั€ะฐ'], + ['pt', 'erro do lado do servidor'], + ['nl', 'fout aan de serverkant'], + ['de', 'Serverseitiger Fehler'], + ['es', 'Error del lado del servidor'], + ['zh', 'ๆœๅŠก็ซฏ้”™่ฏฏ'], + ['id', 'Kesalahan sisi server'], + ['pl', 'bล‚ฤ…d po stronie serwera'], + ])], + [tkn('Your Client'), new Map([ + ['fr', 'Votre Client'], + ['ru', 'ะ’ะฐัˆ ะ‘ั€ะฐัƒะทะตั€'], + ['uk', 'ะ’ะฐัˆ ะ‘ั€ะฐัƒะทะตั€'], + ['pt', 'Seu Cliente'], + ['nl', 'Jouw Client'], + ['de', 'Ihr Client'], + ['es', 'Tu Cliente'], + ['zh', 'ๆ‚จ็š„ๅฎขๆˆท็ซฏ'], + ['id', 'Klien Anda'], + ['pl', 'Klient'], + ])], + [tkn('Network'), new Map([ + ['fr', 'Rรฉseau'], + ['ru', 'ะกะตั‚ัŒ'], + ['uk', 'ะœะตั€ะตะถะฐ'], + ['pt', 'Rede'], + ['nl', 'Netwerk'], + ['de', 'Netzwerk'], + ['es', 'Red'], + ['zh', '็ฝ‘็ปœ'], + ['id', 'Jaringan'], + ['pl', 'Sieฤ‡'], + ])], + [tkn('Web Server'), new Map([ + ['fr', 'Serveur Web'], + ['ru', 'Web ะกะตั€ะฒะตั€'], + ['uk', 'Web-ัะตั€ะฒะตั€'], + ['pt', 'Servidor web'], + ['nl', 'Web Server'], + ['de', 'Webserver'], + ['es', 'Servidor Web'], + ['zh', 'WebๆœๅŠกๅ™จ'], + ['id', 'Server web'], + ['pl', 'Serwer WWW'], + ])], + [tkn('What happened?'), new Map([ + ['fr', 'Que sโ€™est-il passรฉ ?'], + ['ru', 'ะงั‚ะพ ะฟั€ะพะธะทะพัˆะปะพ?'], + ['uk', 'ะฉะพ ัั‚ะฐะปะพัั?'], + ['pt', 'O que aconteceu?'], + ['nl', 'Wat is er gebeurd?'], + ['de', 'Was ist passiert?'], + ['es', 'ยฟQue ha pasado?'], + ['zh', 'ๅ‘็”Ÿไบ†ไป€ไนˆ๏ผŸ'], + ['id', 'Apa yang terjadi?'], + ['pl', 'Co siฤ™ staล‚o?'], + ])], + [tkn('What can I do?'), new Map([ + ['fr', 'Que puis-je faire ?'], + ['ru', 'ะงั‚ะพ ะผะพะถะฝะพ ัะดะตะปะฐั‚ัŒ?'], + ['uk', 'ะฉะพ ะผะพะถะฝะฐ ะทั€ะพะฑะธั‚ะธ?'], + ['pt', 'O que eu posso fazer?'], + ['nl', 'Wat kan ik doen?'], + ['de', 'Was kann ich machen?'], + ['es', 'ยฟQue puedo hacer?'], + ['zh', 'ๆˆ‘่ƒฝๅšไป€ไนˆ๏ผŸ'], + ['id', 'Apa yang bisa saya lakukan?'], + ['pl', 'Co mogฤ™ zrobiฤ‡?'], + ])], + [tkn('Please try again in a few minutes'), new Map([ + ['fr', 'Veuillez rรฉessayer dans quelques minutes'], + ['ru', 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะฟั€ะพะฑัƒะนั‚ะต ะฟะพะฒั‚ะพั€ะธั‚ัŒ ะทะฐะฟั€ะพั ะตั‰ั‘ ั€ะฐะท ั‡ัƒั‚ัŒ ะฟะพะทะถะต'], + ['uk', 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ัะฟั€ะพะฑัƒะนั‚ะต ะฟะพะฒั‚ะพั€ะธั‚ะธ ะทะฐะฟะธั‚ ั‰ะต ั€ะฐะท ั‚ั€ะพั…ะธ ะฟั–ะทะฝั–ัˆะต'], + ['pt', 'Por favor, tente novamente em alguns minutos'], + ['nl', 'Probeer het alstublieft opnieuw over een paar minuten'], + ['de', 'Bitte versuchen Sie es in ein paar Minuten erneut'], + ['es', 'Por favor, intente nuevamente en unos minutos'], + ['zh', '่ฏทๅœจๅ‡ ๅˆ†้’ŸๅŽ้‡่ฏ•'], + ['id', 'Silakan coba lagi dalam beberapa menit'], + ['pl', 'Sprรณbuj ponownie za kilka minut'], + ])], + [tkn('Working'), new Map([ + ['fr', 'Opรฉrationnel'], + ['ru', 'ะ ะฐะฑะพั‚ะฐะตั‚'], + ['uk', 'ะŸั€ะฐั†ัŽั”'], + ['pt', 'Funcionando'], + ['nl', 'Functioneel'], + ['de', 'Funktioniert'], + ['es', 'Funcionando'], + ['zh', 'ๆญฃๅธธ่ฟ่กŒ'], + ['id', 'Fungsi'], + ['pl', 'Dziaล‚a'], + ])], + [tkn('Unknown'), new Map([ + ['fr', 'Inconnu'], + ['ru', 'ะะตะธะทะฒะตัั‚ะฝะพ'], + ['uk', 'ะะตะฒั–ะดะพะผะพ'], + ['pt', 'Desconhecido'], + ['nl', 'Onbekend'], + ['de', 'Unbekannt'], + ['es', 'Desconocido'], + ['zh', 'ๆœช็Ÿฅ'], + ['id', 'Tidak diketahui'], + ['pl', 'Nieznany'], + ])], + [tkn('Please try to change the request method, headers, payload, or URL'), new Map([ + ['fr', 'Veuillez essayer de changer la mรฉthode de requรชte, les en-tรชtes, le contenu ou lโ€™URL'], + ['ru', 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะฟั€ะพะฑัƒะนั‚ะต ะธะทะผะตะฝะธั‚ัŒ ะผะตั‚ะพะด ะทะฐะฟั€ะพัะฐ, ะทะฐะณะพะปะพะฒะบะธ, ะตะณะพ ัะพะดะตั€ะถะธะผะพะต ะธะปะธ URL'], + ['uk', 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ัะฟั€ะพะฑัƒะนั‚ะต ะทะผั–ะฝะธั‚ะธ ะผะตั‚ะพะด ะทะฐะฟะธั‚ัƒ, ะทะฐะณะพะปะพะฒะบะธ, ะนะพะณะพ ะฒะผั–ัั‚ ะฐะฑะพ URL-ะฐะดั€ะตััƒ'], + ['pt', 'Tente alterar o mรฉtodo de solicitaรงรฃo, cabeรงalhos, payload ou URL'], + ['nl', 'Probeer het opnieuw met een andere methode, headers, payload of URL'], + ['de', 'Bitte versuchen Sie, die Anfragemethode, Header, Payload oder URL zu รคndern'], + ['es', 'Por favor intente cambiar el mรฉtodo de la peticiรณn, cabeceras, carga o URL'], + ['zh', '่ฏทๅฐ่ฏ•ๆ›ดๆ”น่ฏทๆฑ‚ๆ–นๆณ•ใ€ๆ ‡ๅคดใ€ๆœ‰ๆ•ˆ่ดŸ่ฝฝๆˆ–URL'], + ['id', 'Coba lagi dengan metode, header, muatan, atau URL yang berbeda'], + ['pl', 'Sprรณbuj zmieniฤ‡ metodฤ™ ลผฤ…dania, nagล‚รณwki, ลผฤ…danie lub adres URL'], + ])], + [tkn('Please check your authorization data'), new Map([ + ['fr', 'Veuillez vรฉrifier vos donnรฉes dโ€™autorisation'], + ['ru', 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟั€ะพะฒะตั€ัŒั‚ะต ะดะฐะฝะฝั‹ะต ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ'], + ['uk', 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ะฟะตั€ะตะฒั–ั€ั‚ะต ะดะฐะฝั– ะฐะฒั‚ะพั€ะธะทะฐั†ั–ั—'], + ['pt', 'Verifique seus dados de autorizaรงรฃo'], + ['nl', 'Controleer de authenticatiegegevens'], + ['de', 'Bitte รผberprรผfen Sie Ihre Zugangsdaten'], + ['es', 'Verifique sus datos de autorizaciรณn'], + ['zh', '่ฏทๆฃ€ๆŸฅๆ‚จ็š„ๆŽˆๆƒๆ•ฐๆฎ'], + ['id', 'Memeriksa detail autentikasi'], + ['pl', 'Sprawdลบ swoje dane autoryzacyjne'], + ])], + [tkn('Please double-check the URL and try again'), new Map([ + ['fr', 'Veuillez vรฉrifier lโ€™URL et rรฉessayer'], + ['ru', 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะดะฒะฐะถะดั‹ ะฟั€ะพะฒะตั€ัŒั‚ะต URL ะธ ะฟะพะฟั€ะพะฑัƒะนั‚ะต ัะฝะพะฒะฐ'], + ['uk', 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ะดะฒั–ั‡ั– ะฟะตั€ะตะฒั–ั€ั‚ะต URL-ะฐะดั€ะตััƒ ั– ัะฟั€ะพะฑัƒะนั‚ะต ะทะฝะพะฒัƒ'], + ['pt', 'Verifique novamente o URL e tente novamente'], + ['nl', 'Controleer de URL en probeer het opnieuw'], + ['de', 'Bitte รผberprรผfen Sie die URL und versuchen Sie es erneut'], + ['es', 'Verifique de nuevo la URL y vuelva a probar'], + ['zh', '่ฏทๅ†ๆฌกๆฃ€ๆŸฅURLๅนถ้‡่ฏ•'], + ['id', 'Periksa URL dan coba lagi'], + ['pl', 'Sprawdลบ adres URL i sprรณbuj ponownie'], + ])], + ])); - 'Your Client': { - fr: 'Votre Client', - ru: 'ะ’ะฐัˆ ะ‘ั€ะฐัƒะทะตั€', - uk: 'ะ’ะฐัˆ ะ‘ั€ะฐัƒะทะตั€', - pt: 'Seu Cliente', - nl: 'Jouw Client', - de: 'Ihr Client', - es: 'Tu Cliente', - zh: 'ๆ‚จ็š„ๅฎขๆˆท็ซฏ', - id: 'Klien Anda', - pl: 'Klient', - }, - 'Network': { - fr: 'Rรฉseau', - ru: 'ะกะตั‚ัŒ', - uk: 'ะœะตั€ะตะถะฐ', - pt: 'Rede', - nl: 'Netwerk', - de: 'Netzwerk', - es: 'Red', - zh: '็ฝ‘็ปœ', - id: 'Jaringan', - pl: 'Sieฤ‡', - }, - 'Web Server': { - fr: 'Serveur Web', - ru: 'Web ะกะตั€ะฒะตั€', - uk: 'Web-ัะตั€ะฒะตั€', - pt: 'Servidor web', - nl: 'Web Server', - de: 'Webserver', - es: 'Servidor Web', - zh: 'WebๆœๅŠกๅ™จ', - id: 'Server web', - pl: 'Serwer WWW', - }, - 'What happened?': { - fr: 'Que sโ€™est-il passรฉ ?', - ru: 'ะงั‚ะพ ะฟั€ะพะธะทะพัˆะปะพ?', - uk: 'ะฉะพ ัั‚ะฐะปะพัั?', - pt: 'O que aconteceu?', - nl: 'Wat is er gebeurd?', - de: 'Was ist passiert?', - es: 'ยฟQue ha pasado?', - zh: 'ๅ‘็”Ÿไบ†ไป€ไนˆ๏ผŸ', - id: 'Apa yang terjadi?', - pl: 'Co siฤ™ staล‚o?', - }, - 'What can i do?': { - fr: 'Que puis-je faire ?', - ru: 'ะงั‚ะพ ะผะพะถะฝะพ ัะดะตะปะฐั‚ัŒ?', - uk: 'ะฉะพ ะผะพะถะฝะฐ ะทั€ะพะฑะธั‚ะธ?', - pt: 'O que eu posso fazer?', - nl: 'Wat kan ik doen?', - de: 'Was kann ich machen?', - es: 'ยฟQue puedo hacer?', - zh: 'ๆˆ‘่ƒฝๅšไป€ไนˆ๏ผŸ', - id: 'Apa yang bisa saya lakukan?', - pl: 'Co mogฤ™ zrobiฤ‡?', - }, - 'Please try again in a few minutes': { - fr: 'Veuillez rรฉessayer dans quelques minutes', - ru: 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะฟั€ะพะฑัƒะนั‚ะต ะฟะพะฒั‚ะพั€ะธั‚ัŒ ะทะฐะฟั€ะพั ะตั‰ั‘ ั€ะฐะท ั‡ัƒั‚ัŒ ะฟะพะทะถะต', - uk: 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ัะฟั€ะพะฑัƒะนั‚ะต ะฟะพะฒั‚ะพั€ะธั‚ะธ ะทะฐะฟะธั‚ ั‰ะต ั€ะฐะท ั‚ั€ะพั…ะธ ะฟั–ะทะฝั–ัˆะต', - pt: 'Por favor, tente novamente em alguns minutos', - nl: 'Probeer het alstublieft opnieuw over een paar minuten', - de: 'Bitte versuchen Sie es in ein paar Minuten erneut', - es: 'Por favor, intente nuevamente en unos minutos', - zh: '่ฏทๅœจๅ‡ ๅˆ†้’ŸๅŽ้‡่ฏ•', - id: 'Silakan coba lagi dalam beberapa menit', - pl: 'Sprรณbuj ponownie za kilka minut', - }, - 'Working': { - fr: 'Opรฉrationnel', - ru: 'ะ ะฐะฑะพั‚ะฐะตั‚', - uk: 'ะŸั€ะฐั†ัŽั”', - pt: 'Funcionando', - nl: 'Functioneel', - de: 'Funktioniert', - es: 'Funcionando', - zh: 'ๆญฃๅธธ่ฟ่กŒ', - id: 'Fungsi', - pl: 'Dziaล‚a', - }, - 'Unknown': { - fr: 'Inconnu', - ru: 'ะะตะธะทะฒะตัั‚ะฝะพ', - uk: 'ะะตะฒั–ะดะพะผะพ', - pt: 'Desconhecido', - nl: 'Onbekend', - de: 'Unbekannt', - es: 'Desconocido', - zh: 'ๆœช็Ÿฅ', - id: 'Tidak diketahui', - pl: 'Nieznany', - }, - 'Please try to change the request method, headers, payload, or URL': { - fr: 'Veuillez essayer de changer la mรฉthode de requรชte, les en-tรชtes, le contenu ou lโ€™URL', - ru: 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟะพะฟั€ะพะฑัƒะนั‚ะต ะธะทะผะตะฝะธั‚ัŒ ะผะตั‚ะพะด ะทะฐะฟั€ะพัะฐ, ะทะฐะณะพะปะพะฒะบะธ, ะตะณะพ ัะพะดะตั€ะถะธะผะพะต ะธะปะธ URL', - uk: 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ัะฟั€ะพะฑัƒะนั‚ะต ะทะผั–ะฝะธั‚ะธ ะผะตั‚ะพะด ะทะฐะฟะธั‚ัƒ, ะทะฐะณะพะปะพะฒะบะธ, ะนะพะณะพ ะฒะผั–ัั‚ ะฐะฑะพ URL-ะฐะดั€ะตััƒ', - pt: 'Tente alterar o mรฉtodo de solicitaรงรฃo, cabeรงalhos, payload ou URL', - nl: 'Probeer het opnieuw met een andere methode, headers, payload of URL', - de: 'Bitte versuchen Sie, die Anfragemethode, Header, Payload oder URL zu รคndern', - es: 'Por favor intente cambiar el mรฉtodo de la peticiรณn, cabeceras, carga o URL', - zh: '่ฏทๅฐ่ฏ•ๆ›ดๆ”น่ฏทๆฑ‚ๆ–นๆณ•ใ€ๆ ‡ๅคดใ€ๆœ‰ๆ•ˆ่ดŸ่ฝฝๆˆ–URL', - id: 'Coba lagi dengan metode, header, muatan, atau URL yang berbeda', - pl: 'Sprรณbuj zmieniฤ‡ metodฤ™ ลผฤ…dania, nagล‚รณwki, ลผฤ…danie lub adres URL', - }, - 'Please check your authorization data': { - fr: 'Veuillez vรฉrifier vos donnรฉes dโ€™autorisation', - ru: 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะฟั€ะพะฒะตั€ัŒั‚ะต ะดะฐะฝะฝั‹ะต ะฐะฒั‚ะพั€ะธะทะฐั†ะธะธ', - uk: 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ะฟะตั€ะตะฒั–ั€ั‚ะต ะดะฐะฝั– ะฐะฒั‚ะพั€ะธะทะฐั†ั–ั—', - pt: 'Verifique seus dados de autorizaรงรฃo', - nl: 'Controleer de authenticatiegegevens', - de: 'Bitte รผberprรผfen Sie Ihre Zugangsdaten', - es: 'Verifique sus datos de autorizaciรณn', - zh: '่ฏทๆฃ€ๆŸฅๆ‚จ็š„ๆŽˆๆƒๆ•ฐๆฎ', - id: 'Memeriksa detail autentikasi', - pl: 'Sprawdลบ swoje dane autoryzacyjne', - }, - 'Please double-check the URL and try again': { - fr: 'Veuillez vรฉrifier lโ€™URL et rรฉessayer', - ru: 'ะŸะพะถะฐะปัƒะนัั‚ะฐ, ะดะฒะฐะถะดั‹ ะฟั€ะพะฒะตั€ัŒั‚ะต URL ะธ ะฟะพะฟั€ะพะฑัƒะนั‚ะต ัะฝะพะฒะฐ', - uk: 'ะ‘ัƒะดัŒ ะปะฐัะบะฐ, ะดะฒั–ั‡ั– ะฟะตั€ะตะฒั–ั€ั‚ะต URL-ะฐะดั€ะตััƒ ั– ัะฟั€ะพะฑัƒะนั‚ะต ะทะฝะพะฒัƒ', - pt: 'Verifique novamente o URL e tente novamente', - nl: 'Controleer de URL en probeer het opnieuw', - de: 'Bitte รผberprรผfen Sie die URL und versuchen Sie es erneut', - es: 'Verifique de nuevo la URL y vuelva a probar', - zh: '่ฏทๅ†ๆฌกๆฃ€ๆŸฅURLๅนถ้‡่ฏ•', - id: 'Periksa URL dan coba lagi', - pl: 'Sprawdลบ adres URL i sprรณbuj ponownie', - }, - }; + // detect browser locale (take only 2 first symbols) + let activeLocale = navigator.language.substring(0, 2).toLowerCase(); - /** - * @param {string} token - * @return {string} - */ - const serializeToken = function (token) { - return token.toLowerCase().replaceAll(/[^a-z0-9]/g, ''); - }; + // noinspection JSUnusedGlobalSymbols + /** + * @param {string} locale + * @return {void} + */ + this.setLocale = function (locale) { + activeLocale = locale.toLowerCase(); + } - // normalize the data keys - for (const key in data) { - Object.defineProperty(data, serializeToken(key), Object.getOwnPropertyDescriptor(data, key)); - delete data[key]; - } - - // detect browser locale (take only 2 first symbols) - let activeLocale = navigator.language.substring(0, 2).toLowerCase(); + /** + * @param {string} token + * @param {string?} def + * @return {string|undefined} + */ + this.translate = function (token, def) { + const t = tkn(token); - /** - * @param {string} locale - */ - this.setLocale = function (locale) { - activeLocale = locale.toLowerCase(); - } + if (activeLocale === 'en' && Object.prototype.hasOwnProperty.call(data, t)) { + return token; + } - /** - * @param {string} token - * @param {string|undefined?} def - */ - this.translate = function (token, def) { - const t = serializeToken(token); + if (data.has(t) && data.get(t).has(activeLocale)) { + return data.get(t).get(activeLocale); + } - if (activeLocale === 'en' && Object.prototype.hasOwnProperty.call(data, t)) { - return token - } + return def; + }; - if (Object.prototype.hasOwnProperty.call(data, t) && Object.prototype.hasOwnProperty.call(data[t], activeLocale)) { - return data[t][activeLocale]; - } + /** + * Localize all elements with the HTML attribute `data-l10n`. + * The attribute value is used as a token to translate. + * + * @return {void} + */ + this.localizeDocument = function () { + if (activeLocale === 'en') { + return; // no need to translate + } - return def; - }; + const l10nAttr = 'data-l10n'; // using this attribute we understand that this element should be localized + const l10nOriginalTextAttr = 'data-l10n-original'; // to keep the original text - /** - * Localize all elements with HTML attribute `data-l10n`. - */ - this.localizeDocument = function () { - const dataAttributeName = 'data-l10n'; + // loop through all elements with the `data-l10n` attribute + Array.prototype.forEach.call(document.querySelectorAll('[' + l10nAttr + ']'), ($el) => { + if (!$el.hasAttribute(l10nAttr)) { + return; // skip elements without the `data-l10n` attribute + } - Array.prototype.forEach.call(document.querySelectorAll('[' + dataAttributeName + ']'), ($el) => { - const attr = $el.getAttribute(dataAttributeName).trim(), - token = attr.length > 0 ? attr : $el.innerText.trim(), - localized = this.translate(token, undefined); + // store the original text if not already stored + if (!$el.hasAttribute(l10nOriginalTextAttr)) { + $el.setAttribute(l10nOriginalTextAttr, $el.innerText); + } else { + $el.innerText = $el.getAttribute(l10nOriginalTextAttr); // restore the original text + } - if (attr.length === 0) { - $el.setAttribute(dataAttributeName, token); - } + const attr = $el.getAttribute(l10nAttr).trim(); // get the `data-l10n` attribute value + const token = attr ? attr : $el.innerText.trim(); // use the attribute value as a token, or the element text + const localized = this.translate(token); // translate the token - if (localized !== undefined) { - $el.innerText = localized; - } else { - console.debug(`Unsupported l10n token detected: "${token}" (locale "${activeLocale}")`, $el); - } - }); - }; - }, - writable: false, - enumerable: false, + if (localized) { + $el.innerText = localized; // set the translated text + } else { + console.debug(`Unsupported l10n token detected: "${token}" (locale "${activeLocale}")`, $el); + } + }); + }; + }, + writable: false, + enumerable: false, }); window.l10n.localizeDocument(); diff --git a/l10n/playground.html b/l10n/playground.html new file mode 100644 index 00000000..5d0dfb61 --- /dev/null +++ b/l10n/playground.html @@ -0,0 +1,138 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>L10n playground + + + +
    +
      + + + + diff --git a/l10n/readme.md b/l10n/readme.md index 8515bace..baa522ee 100644 --- a/l10n/readme.md +++ b/l10n/readme.md @@ -1,15 +1,22 @@ # ๐Ÿ”ค Localization -[![jsDelivr hits](https://img.shields.io/jsdelivr/gh/hm/tarampampam/error-pages)](https://www.jsdelivr.com/package/gh/tarampampam/error-pages) +This directory contains the file [l10n.js](l10n.js) for localizing error pages. Once the error page is loaded, +this script runs and translates the page content to the user's locale. -This directory contains file [l10n.js](l10n.js) for the error pages localization. The working logic is very simple - pages load this script using [jsdelivr.com](https://www.jsdelivr.com/) as a CDN for [versioned content from the GitHub repository](https://www.jsdelivr.com/features#gh), and it translates tag content with the special HTML attribute `data-l10n`. +> [!NOTE] +> In version `2.*`, the working logic was simpler: error pages loaded this script using +> [jsdelivr.com](https://www.jsdelivr.com/) as a CDN for +> [versioned content from the GitHub repository](https://www.jsdelivr.com/features#gh), and it translated +> tag content with the special HTML attribute `data-l10n`. -By default, pages markup contains strings in English (`en` locale). If you want to localize the error pages on the different locales, you should: +By default, the error page markup contains strings in English (`en` locale). To localize the error pages to +different locales, please follow these steps: -- Find your locale name on [this page](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (column `639-1`) -- Make a fork of this repository -- Edit file [l10n.js](l10n.js) in `data` section (append new localized strings) using locale name from the step 1 -- Make a PR with your changes +1. Find your locale name on [this page](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (column `Set 1` or `ISO 639-1:2002`) +2. Fork this repository +3. Edit the file [l10n.js](l10n.js) in the `data` map (append new localized strings) using the locale name from step 1 +4. Please add your locale to the [playground.html](playground.html) file to test the localization +5. Make a PR with your changes ## ๐Ÿ‘ Translators diff --git a/schemas/config/1.0.schema.json b/schemas/config/1.0.schema.json deleted file mode 100644 index 4f0ee3a1..00000000 --- a/schemas/config/1.0.schema.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Error-Pages config file schema", - "description": "Error-Pages config file schema.", - "type": "object", - "properties": { - "templates": { - "type": "array", - "description": "Templates list", - "items": { - "type": "object", - "description": "Template properties", - "properties": { - "path": { - "type": "string", - "description": "Path to the template file", - "examples": [ - "./templates/ghost.html", - "/opt/tpl/ghost.htm" - ] - }, - "name": { - "type": "string", - "description": "Template name (optional, if path is defined)", - "examples": [ - "ghost" - ] - }, - "content": { - "type": "string", - "description": "Template content, if path is not defined", - "examples": [ - "{{ code }}: {{ message }}" - ] - } - }, - "additionalProperties": false - } - }, - "formats": { - "type": "object", - "description": "Responses, based on requested content-type format", - "properties": { - "json": { - "type": "object", - "description": "JSON format", - "properties": { - "content": { - "type": "string", - "description": "JSON response body (template tags are allowed here)", - "examples": [ - "{\"error\": true, \"code\": {{ code | json }}, \"message\": {{ message | json }}}" - ] - } - }, - "additionalProperties": false - }, - "xml": { - "type": "object", - "description": "XML format", - "properties": { - "content": { - "type": "string", - "description": "XML response body (template tags are allowed here)", - "examples": [ - "{{ code }}{{ message }}" - ] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "pages": { - "type": "object", - "description": "Error pages (codes)", - "patternProperties": { - "^[a-zA-Z0-9_-]+$": { - "type": "object", - "description": "Error page (code)", - "properties": { - "message": { - "type": "string", - "description": "Error page message (title)", - "examples": [ - "Bad Request" - ] - }, - "description": { - "type": "string", - "description": "Error page description", - "examples": [ - "The server did not understand the request" - ] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "required": [ - "templates" - ] -} diff --git a/schemas/config/readme.md b/schemas/config/readme.md deleted file mode 100644 index d97bb81e..00000000 --- a/schemas/config/readme.md +++ /dev/null @@ -1,13 +0,0 @@ -# Config file schemas - -These schemas describe Error Pages configuration file and used by: - -- - -Schemas naming agreement: `..schema.json`. - -## Contributing guide - -If you want to modify the existing schema - your changes **MUST** be backward compatible. If your changes break backward compatibility - you **MUST** create a new schema file with a fresh version and "register" it in a [schemas catalog][schemas_catalog]. - -[schemas_catalog]:https://github.com/SchemaStore/schemastore/blob/master/src/api/json/catalog.json diff --git a/schemas/readme.md b/schemas/readme.md deleted file mode 100644 index 870bc143..00000000 --- a/schemas/readme.md +++ /dev/null @@ -1,15 +0,0 @@ -# Schemas - -This directory contains public schemas for the most important parts of application. - -**Do not rename or remove this directory or any file or directory inside.** - -- You can validate existing config file using the following command: - - ```bash - $ docker run --rm -v "$(pwd):/src" -w "/src" node:16-alpine sh -c \ - "npm install -g ajv-cli && \ - ajv validate --all-errors --verbose \ - -s ./schemas/config/1.0.schema.json \ - -d ./error-pages.y*ml" - ``` diff --git a/templates/app-down.html b/templates/app-down.html index 520fe2bc..4252a70d 100644 --- a/templates/app-down.html +++ b/templates/app-down.html @@ -1,244 +1,506 @@ - - - - - {{ message }} - - - - - + + + {{ message }} + + + + + + + + + + +
      -
      -

      {{ message }}

      -

      {{ description }}

      - -

      Double-check the URL.

      - {{ if show_details }} -
      -

      Request details:

      -
        - {{- if host }}
      • Host: {{ host }}
      • {{ end -}} - {{- if original_uri }}
      • Original URI: {{ original_uri }}
      • {{ end -}} - {{- if forwarded_for }}
      • Forwarded for: {{ forwarded_for }}
      • {{ end -}} - {{- if namespace }}
      • Namespace: {{ namespace }}
      • {{ end -}} - {{- if ingress_name }}
      • Ingress name: {{ ingress_name }}
      • {{ end -}} - {{- if service_name }}
      • Service name: {{ service_name }}
      • {{ end -}} - {{- if service_port }}
      • Service port: {{ service_port }}
      • {{ end -}} - {{- if request_id }}
      • Request ID: {{ request_id }}
      • {{ end -}} -
      • Timestamp: {{ now.Unix }}
      • -
      -
      - {{ end }} +
      +

      {{ message }}

      +

      {{ description }}

      + -
      - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ code }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

      + Double-check the URL. + +

      + +
      +

      Request 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 }}
      • +
      + +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ code }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + + + + + - diff --git a/templates/cats.html b/templates/cats.html index c925a8ea..8c3d0733 100644 --- a/templates/cats.html +++ b/templates/cats.html @@ -1,123 +1,163 @@ - - - - - {{ message }} - + /* {{ if show_details }} */ + table.details { + table-layout: fixed; + width: 100%; + opacity: .8; + padding-top: 1.5em; + } + + table.details td { + white-space: nowrap; + font-size: 0.7em; + } + + table.details .name, + table.details .value { + width: 50%; + } + + table.details .name::first-letter, + table.details .value::first-letter { + font-weight: bold; + } + + table.details .name { + text-align: right; + padding-right: .4em; + width: 50%; + } + + table.details .value { + text-align: left; + padding-left: .4em; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + } + + /* {{ end }} */ + -
      - -
      - {{ message }} - {{ if show_details }} -
      - - {{- if host }} - - - {{ end -}} - {{- if original_uri }} - - - {{ end -}} - {{- if forwarded_for }} - - - {{ end -}} - {{- if namespace }} - - - {{ end -}} - {{- if ingress_name }} - - - {{ end -}} - {{- if service_name }} - - - {{ end -}} - {{- if service_port }} - - - {{ end -}} - {{- if request_id }} - - - {{ end -}} - - - - -
      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{{ now.Unix }}
      -
      - {{ end }} -
      -
      - +
      + {{ message }} +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      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 }}
      + + + + + - diff --git a/templates/connection.html b/templates/connection.html index a9d43a01..48b3bc3f 100644 --- a/templates/connection.html +++ b/templates/connection.html @@ -1,273 +1,461 @@ - - - - - - {{ code }} | {{ message }} - - - - + + + {{ code }} | {{ message }} + + + + + + + + + + +
      -

      {{ code }}

      -

      {{ message }}

      +

      {{ code }}

      +

      {{ message }}

      -
      - - - - - - -
      Your Client
      -

      Unknown

      -
      - -
      - - - - - - - - - - -
      - -
      - - - - - - -
      Network
      -

      Working

      -
      - -
      - - - -
      - -
      - - - - - - -
      Web Server
      -

      Unknown

      -
      +
      + + + + + + +
      Your Client
      +

      Unknown

      +
      + +
      + + + + + + + + + + +
      + +
      + + + + + + +
      Network
      +

      Working

      +
      + +
      + + + +
      + +
      + + + + + + +
      Web Server
      +

      Unknown

      +
      -
      -

      What happened?

      -

      {{ description }}

      -
      -
      -

      What can I do?

      -

      Please try again in a few minutes

      -
      +
      +

      What happened?

      +

      {{ description }}

      +
      +
      +

      What can I do?

      +

      Please try again in a few minutes

      +
      -
      - {{ if show_details }} -
      -
        - {{- if host }}
      • Host: {{ host }}
      • {{ end -}} - {{- if original_uri }}
      • Original URI: {{ original_uri }}
      • {{ end -}} - {{- if forwarded_for }}
      • Forwarded for: {{ forwarded_for }}
      • {{ end -}} - {{- if namespace }}
      • Namespace: {{ namespace }}
      • {{ end -}} - {{- if ingress_name }}
      • Ingress name: {{ ingress_name }}
      • {{ end -}} - {{- if service_name }}
      • Service name: {{ service_name }}
      • {{ end -}} - {{- if service_port }}
      • Service port: {{ service_port }}
      • {{ end -}} - {{- if request_id }}
      • Request ID: {{ request_id }}
      • {{ end -}} -
      • Timestamp: {{ now.Unix }}
      • -
      -
      - {{ end }} + +
      +
        + +
      • 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 }}
      • +
      +
      +
      - + + + + + diff --git a/templates/embed.go b/templates/embed.go new file mode 100644 index 00000000..58a834c8 --- /dev/null +++ b/templates/embed.go @@ -0,0 +1,39 @@ +package templates + +import ( + "embed" + "io/fs" + "path/filepath" + "strings" +) + +//go:embed *.html +var content embed.FS + +// BuiltIn returns a map of built-in templates. The key is the template name and the value is the template content. +func BuiltIn() map[string]string { + var ( + list, _ = fs.ReadDir(content, ".") // error check is covered by unit tests + result = make(map[string]string, len(list)) + ) + + for _, file := range list { + if data, err := fs.ReadFile(content, file.Name()); err == nil { + var ( + fileName = filepath.Base(file.Name()) + ext = filepath.Ext(fileName) + templateName string + ) + + if ext != "" && fileName != ext { + templateName = strings.TrimSuffix(fileName, ext) + } else { + templateName = fileName + } + + result[templateName] = string(data) + } + } + + return result +} diff --git a/templates/embed_test.go b/templates/embed_test.go new file mode 100644 index 00000000..d4e65221 --- /dev/null +++ b/templates/embed_test.go @@ -0,0 +1,22 @@ +package templates_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "gh.tarampamp.am/error-pages/templates" +) + +func TestBuiltIn(t *testing.T) { + t.Parallel() + + var content = templates.BuiltIn() + + assert.True(t, len(content) > 0) + + for name, data := range content { + assert.Regexp(t, `^[a-z0-9_\.-]+$`, name) + assert.NotEmpty(t, data) + } +} diff --git a/templates/ghost.html b/templates/ghost.html index 822bc42a..94d22377 100644 --- a/templates/ghost.html +++ b/templates/ghost.html @@ -1,118 +1,249 @@ - - - - {{ code }}: {{ message }} - - - - - + + + {{ code }}: {{ message }} + + + + + + + + + + + -
      -
      - - - - - - - - - - - - -

      - - - -

      -

      Error {{ code }}

      -

      {{ description }}

      - {{ if show_details }} -
      - - {{- if host }} - - - {{ end -}} - {{- if original_uri }} - - - {{ end -}} - {{- if forwarded_for }} - - - {{ end -}} - {{- if namespace }} - - - {{ end -}} - {{- if ingress_name }} - - - {{ end -}} - {{- if service_name }} - - - {{ end -}} - {{- if service_port }} - - - {{ end -}} - {{- if request_id }} - - - {{ end -}} - - - - -
      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{{ now.Unix }}
      -
      - {{ end }} -
      -
      - +
      + + + + + + + + +

      + + + +

      + +

      Error {{ code }}

      +

      {{ description }}

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      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 }}
      + +
      + + + + - diff --git a/templates/hacker-terminal.html b/templates/hacker-terminal.html index 538a0438..e29c0865 100644 --- a/templates/hacker-terminal.html +++ b/templates/hacker-terminal.html @@ -1,183 +1,186 @@ - - - - - {{ message }} - - - - + + + {{ message }} + + + + + + + + + + +
      -
      -

      Error {{ code }}

      -

      {{ description }}.

      -

      Good luck.

      - {{ if show_details }} -
      - {{- if host }}

      Host: {{ host }}

      {{ end -}} - {{- if original_uri }}

      Original URI: {{ original_uri }}

      {{ end -}} - {{- if forwarded_for }}

      Forwarded for: {{ forwarded_for }}

      {{ end -}} - {{- if namespace }}

      Namespace: {{ namespace }}

      {{ end -}} - {{- if ingress_name }}

      Ingress name: {{ ingress_name }}

      {{ end -}} - {{- if service_name }}

      Service name: {{ service_name }}

      {{ end -}} - {{- if service_port }}

      Service port: {{ service_port }}

      {{ end -}} - {{- if request_id }}

      Request ID: {{ request_id }}

      {{ end -}} -

      Timestamp: {{ now.Unix }}

      -
      - {{ end }} -
      - + +
      +

      Error {{ code }}

      +

      {{ description }}.

      +

      Good luck.

      + +
      + +

      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 }}

      +
      + +
      + + + + - diff --git a/templates/l7-dark.html b/templates/l7-dark.html deleted file mode 100644 index 7b6e9e36..00000000 --- a/templates/l7-dark.html +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - {{ message }} - - - - - - -
      -
      -
      -
      - {{ code }} -
      -
      - {{ message }} -
      -
      - {{ if show_details }} -
      - - {{- if host }} - - - {{ end -}} - {{- if original_uri }} - - - {{ end -}} - {{- if forwarded_for }} - - - {{ end -}} - {{- if namespace }} - - - {{ end -}} - {{- if ingress_name }} - - - {{ end -}} - {{- if service_name }} - - - {{ end -}} - {{- if service_port }} - - - {{ end -}} - {{- if request_id }} - - - {{ end -}} - - - - -
      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{{ now.Unix }}
      -
      - {{ end }} -
      -
      - - - - diff --git a/templates/l7-light.html b/templates/l7-light.html deleted file mode 100644 index b151ac9d..00000000 --- a/templates/l7-light.html +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - {{ message }} - - - - - - -
      -
      -
      -
      - {{ code }} -
      -
      - {{ message }} -
      -
      - {{ if show_details }} -
      - - {{- if host }} - - - {{ end -}} - {{- if original_uri }} - - - {{ end -}} - {{- if forwarded_for }} - - - {{ end -}} - {{- if namespace }} - - - {{ end -}} - {{- if ingress_name }} - - - {{ end -}} - {{- if service_name }} - - - {{ end -}} - {{- if service_port }} - - - {{ end -}} - {{- if request_id }} - - - {{ end -}} - - - - -
      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{{ now.Unix }}
      -
      - {{ end }} -
      -
      - - - - diff --git a/templates/l7.html b/templates/l7.html new file mode 100644 index 00000000..c435f660 --- /dev/null +++ b/templates/l7.html @@ -0,0 +1,171 @@ + + + + + + {{ message }} + + + + + + + + + + + + + + +
      +
      +
      +

      {{ code }}

      + +
        + +
      • Host
      • + +
      • Original URI
      • + +
      • Forwarded for
      • + +
      • Namespace
      • + +
      • Ingress name
      • + +
      • Service name
      • + +
      • Service port
      • + +
      • Request ID
      • + +
      • Timestamp
      • +
      + +
      +
      +

      {{ message }}

      + +
        + +
      • {{ host }}
      • + +
      • {{ original_uri }}
      • + +
      • {{ forwarded_for }}
      • + +
      • {{ namespace }}
      • + +
      • {{ ingress_name }}
      • + +
      • {{ service_name }}
      • + +
      • {{ service_port }}
      • + +
      • {{ request_id }}
      • + +
      • {{ nowUnix }}
      • +
      + +
      +
      +
      + + + + + + diff --git a/templates/lost-in-space.html b/templates/lost-in-space.html index 30cc8cb4..bd70c799 100644 --- a/templates/lost-in-space.html +++ b/templates/lost-in-space.html @@ -1,399 +1,455 @@ - - - - - {{ message }} - - - - - + + + {{ message }} + + + + + + + + + + + + +
      -
      - +
      + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - -
      -
      -

      {{ code }}

      -

      UH OH! {{ message }}

      -

      {{ description }}

      - - {{ if show_details }} -
        - {{- if host }}
      • Host: {{ host }}
      • {{ end -}} - {{- if original_uri }}
      • Original URI: {{ original_uri }}
      • {{ end -}} - {{- if forwarded_for }}
      • Forwarded for: {{ forwarded_for }}
      • {{ end -}} - {{- if namespace }}
      • Namespace: {{ namespace }}
      • {{ end -}} - {{- if ingress_name }}
      • Ingress name: {{ ingress_name }}
      • {{ end -}} - {{- if service_name }}
      • Service name: {{ service_name }}
      • {{ end -}} - {{- if service_port }}
      • Service port: {{ service_port }}
      • {{ end -}} - {{- if request_id }}
      • Request ID: {{ request_id }}
      • {{ end -}} -
      • Timestamp: {{ now.Unix }}
      • -
      - {{ end }} -
      -
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      +

      {{code}}

      +

      UH OH! {{ message }}

      +

      {{ description }}

      - - - + +
        + +
      • 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 }}
      • +
      + +
      + + + + - diff --git a/templates/matrix.html b/templates/matrix.html deleted file mode 100644 index 63d8e9e0..00000000 --- a/templates/matrix.html +++ /dev/null @@ -1,282 +0,0 @@ - - - - - - - - - {{ message }} ({{ code }}) - - - - - - - -
      - - -
      -

      {{ code }}: {{ message }}

      -

      {{ description }}

      - - {{ if show_details }} -
      -
        - {{- if host }}
      • Host: {{ host }}
      • {{ end -}} - {{- if original_uri }}
      • Original URI: {{ original_uri }}
      • {{ end -}} - {{- if forwarded_for }}
      • Forwarded for: {{ forwarded_for }}
      • {{ end -}} - {{- if namespace }}
      • Namespace: {{ namespace }}
      • {{ end -}} - {{- if ingress_name }}
      • Ingress name: {{ ingress_name }}
      • {{ end -}} - {{- if service_name }}
      • Service name: {{ service_name }}
      • {{ end -}} - {{- if service_port }}
      • Service port: {{ service_port }}
      • {{ end -}} - {{- if request_id }}
      • Request ID: {{ request_id }}
      • {{ end -}} -
      • Timestamp: {{ now.Unix }}
      • -
      -
      - {{ end }} -
      -
      - - - - - - - diff --git a/templates/noise.html b/templates/noise.html index 9a6cc638..e540da43 100644 --- a/templates/noise.html +++ b/templates/noise.html @@ -1,7 +1,5 @@ - - - - - {{ code }}: {{ message }} - + + + + + + + + + + + + + {{ code }}: {{ message }} +
      -
      -

      {{ code }}

      -

      {{ description }}

      -
      +
      +

      {{code}}

      +

      {{ description }}

      +
      -
      -
      -
      +
      +
      +
      + + + + - diff --git a/templates/orient.html b/templates/orient.html index c0f5d4f3..2fd0b658 100644 --- a/templates/orient.html +++ b/templates/orient.html @@ -1,17 +1,19 @@ - - - - - - + {{ message }} + + + + + + + + + + + /* {{ if show_details }} */ + #details { + table-layout: fixed; + width: 100%; + opacity: .75; + margin-top: 1em; + } + + #details td { + white-space: nowrap; + font-size: 0.7em; + transition: opacity 1.4s, font-size .3s; + } + + #details.hidden td { + opacity: 0; + font-size: 0; + } + + #details .name, + #details .value { + width: 50%; + } + + #details .name::first-letter, + #details .value::first-letter { + font-weight: bold; + } + + #details .name { + text-align: right; + padding-right: .2em; + width: 50%; + } + + #details .value { + text-align: left; + padding-left: .4em; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + } + /* {{ end }} */ + -
      -
      -
      - {{ code }}: {{ message }} - -
      - {{ if show_details }} - - {{ end }} +
      +
      +
      +

      {{ code }}: {{ message }}

      +

      -
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/test/hurl/404.hurl b/test/hurl/404.hurl deleted file mode 100644 index 7d80230b..00000000 --- a/test/hurl/404.hurl +++ /dev/null @@ -1,7 +0,0 @@ -GET http://{{ host }}:{{ port }}/not-found - -HTTP 404 - -[Asserts] -header "Content-Type" contains "text/html" -body contains "The server can not find the requested page" diff --git a/test/hurl/code_502_default.hurl b/test/hurl/code_502_default.hurl deleted file mode 100644 index 0a62cd5b..00000000 --- a/test/hurl/code_502_default.hurl +++ /dev/null @@ -1,10 +0,0 @@ -GET http://{{ host }}:{{ port }}/502.html - -HTTP 200 - -[Asserts] -header "Content-Type" contains "text/html" -header "X-Robots-Tag" == "noindex" -body contains "502" -body contains "Bad Gateway" -body contains "The server received an invalid response from the upstream server" diff --git a/test/hurl/code_502_json.hurl b/test/hurl/code_502_json.hurl deleted file mode 100644 index 6e4b1b99..00000000 --- a/test/hurl/code_502_json.hurl +++ /dev/null @@ -1,43 +0,0 @@ -# The common request -GET http://{{ host }}:{{ port }}/502.html -Content-Type: application/json;charset=UTF-8 -X-Original-URI: foo -X-Namespace: bar -X-Ingress-Name: baz -X-Service-Name: aaa -X-Service-Port: bbb -X-Request-ID: ccc -X-Forwarded-For: ddd -Host: eee - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -header "X-Robots-Tag" == "noindex" -jsonpath "$.error" == true -jsonpath "$.code" == "502" -jsonpath "$.message" == "Bad Gateway" -jsonpath "$.description" == "The server received an invalid response from the upstream server" -jsonpath "$.details.original_uri" == "foo" -jsonpath "$.details.namespace" == "bar" -jsonpath "$.details.ingress_name" == "baz" -jsonpath "$.details.service_name" == "aaa" -jsonpath "$.details.service_port" == "bbb" -jsonpath "$.details.request_id" == "ccc" -jsonpath "$.details.forwarded_for" == "ddd" -jsonpath "$.details.host" == "eee" -jsonpath "$.details.timestamp" isInteger - -# X-Format in the action -GET http://{{ host }}:{{ port }}/502.html -X-Format: text/json - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/json" -header "X-Robots-Tag" == "noindex" -jsonpath "$.error" == true -jsonpath "$.code" == "502" -jsonpath "$.message" == "Bad Gateway" diff --git a/test/hurl/code_502_xml.hurl b/test/hurl/code_502_xml.hurl deleted file mode 100644 index 41442ba6..00000000 --- a/test/hurl/code_502_xml.hurl +++ /dev/null @@ -1,42 +0,0 @@ -# The common request -GET http://{{ host }}:{{ port }}/502.html -Content-Type: application/xml;charset=UTF-8 -X-Original-URI: foo -X-Namespace: bar -X-Ingress-Name: baz -X-Service-Name: aaa -X-Service-Port: bbb -X-Request-ID: ccc -X-Forwarded-For: ddd -Host: eee - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/xml" -header "X-Robots-Tag" == "noindex" -xpath "string(//error/code)" == "502" -xpath "string(//error/message)" == "Bad Gateway" -xpath "string(//error/description)" == "The server received an invalid response from the upstream server" -xpath "string(//error/details/originalURI)" == "foo" -xpath "string(//error/details/namespace)" == "bar" -xpath "string(//error/details/ingressName)" == "baz" -xpath "string(//error/details/serviceName)" == "aaa" -xpath "string(//error/details/servicePort)" == "bbb" -xpath "string(//error/details/requestID)" == "ccc" -xpath "string(//error/details/forwardedFor)" == "ddd" -xpath "string(//error/details/host)" == "eee" -xpath "string(//error/details/timestamp)" exists - -# X-Format in the action -GET http://{{ host }}:{{ port }}/502.html -X-Format: text/xml - -HTTP 200 - -[Asserts] -header "Content-Type" contains "application/xml" -header "X-Robots-Tag" == "noindex" -xpath "string(//error/code)" == "502" -xpath "string(//error/message)" == "Bad Gateway" -xpath "string(//error/description)" == "The server received an invalid response from the upstream server" diff --git a/test/hurl/healthz.hurl b/test/hurl/healthz.hurl deleted file mode 100644 index c5f73b87..00000000 --- a/test/hurl/healthz.hurl +++ /dev/null @@ -1,12 +0,0 @@ -GET http://{{ host }}:{{ port }}/healthz - -HTTP 200 - -`OK` - -# Next endpoint marked as deprecated -GET http://{{ host }}:{{ port }}/health/live - -HTTP 200 - -`OK` diff --git a/test/hurl/index.hurl b/test/hurl/index.hurl deleted file mode 100644 index e67328b1..00000000 --- a/test/hurl/index.hurl +++ /dev/null @@ -1,34 +0,0 @@ -# HTML content -GET http://{{ host }}:{{ port }}/ - -HTTP 404 - -[Asserts] -header "Content-Type" contains "text/html" -body contains "404" -body contains "Not Found" - -# JSON content -GET http://{{ host }}:{{ port }}/ -Content-Type: text/json - -HTTP 404 - -[Asserts] -header "Content-Type" contains "application/json" -header "X-Robots-Tag" == "noindex" -jsonpath "$.error" == true -jsonpath "$.code" == "404" -jsonpath "$.message" == "Not Found" - -# XML content -GET http://{{ host }}:{{ port }}/ -Content-Type: application/xml - -HTTP 404 - -[Asserts] -header "Content-Type" contains "application/xml" -header "X-Robots-Tag" == "noindex" -xpath "string(//error/code)" == "404" -xpath "string(//error/message)" == "Not Found" diff --git a/test/hurl/metrics.hurl b/test/hurl/metrics.hurl deleted file mode 100644 index a3a599b3..00000000 --- a/test/hurl/metrics.hurl +++ /dev/null @@ -1,10 +0,0 @@ -# disabled until https://github.com/Orange-OpenSource/hurl/issues/2540 is not fixed - -#GET http://{{ host }}:{{ port }}/metrics -# -#HTTP 200 -# -#[Asserts] -#header "Content-Type" contains "text/plain" -#body contains "http_requests_duration_millisecond" -#body contains "http_requests_total_count" diff --git a/test/hurl/proxy_headers.hurl b/test/hurl/proxy_headers.hurl deleted file mode 100644 index 34714aae..00000000 --- a/test/hurl/proxy_headers.hurl +++ /dev/null @@ -1,13 +0,0 @@ -GET http://{{ host }}:{{ port }}/502.html -X-Foo: foo -bar: BAR -Baz_blah: baz Baz -NonEx: skip - -HTTP 200 - -[Asserts] -header "X-Foo" == "foo" -header "Bar" == "BAR" -header "Baz_blah" == "baz Baz" -header "NonEx" not exists diff --git a/test/hurl/readme.md b/test/hurl/readme.md deleted file mode 100644 index 88383481..00000000 --- a/test/hurl/readme.md +++ /dev/null @@ -1,27 +0,0 @@ -# Hurl - -Hurl is a command line tool that runs **HTTP requests** defined in a simple **plain text format**. - -## How to use - -It can perform requests, capture values and evaluate queries on headers and body response. Hurl is very versatile: it can be used for both fetching data and testing HTTP sessions. - -```hurl -# Get home: -GET https://example.net - -HTTP 200 -[Captures] -csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" - -# Do login! -POST https://example.net/login?user=toto&password=1234 -X-CSRF-TOKEN: {{csrf_token}} - -HTTP 302 -``` - -### Links: - -- [Official website](https://hurl.dev/) -- [GitHub](https://github.com/Orange-OpenSource/hurl) diff --git a/test/hurl/version.hurl b/test/hurl/version.hurl deleted file mode 100644 index f50a9ee9..00000000 --- a/test/hurl/version.hurl +++ /dev/null @@ -1,8 +0,0 @@ -GET http://{{ host }}:{{ port }}/version - -HTTP 200 - -[Asserts] -header "Content-Type" == "application/json" -jsonpath "$.version" exists -jsonpath "$.version" isString diff --git a/test/hurl/x_code.hurl b/test/hurl/x_code.hurl deleted file mode 100644 index c8904e40..00000000 --- a/test/hurl/x_code.hurl +++ /dev/null @@ -1,37 +0,0 @@ -# Common request to the index page -GET http://{{ host }}:{{ port }}/ -X-Code: 410 - -HTTP 410 - -[Asserts] -header "Content-Type" contains "text/html" -header "X-Robots-Tag" == "noindex" -body contains "410" -body contains "Gone" - -# X-Code with X-Format -GET http://{{ host }}:{{ port }}/ -X-Code: 410 -X-Format: text/html;q=0.9,application/xhtml+xml;q=0.9,application/json,image/avif,image/webp,*/*;q=0.8 - -HTTP 410 - -[Asserts] -header "Content-Type" contains "application/json" -header "X-Robots-Tag" == "noindex" -jsonpath "$.error" == true -jsonpath "$.code" == "410" -jsonpath "$.message" == "Gone" - -# Error pages should ignore X-Code -GET http://{{ host }}:{{ port }}/502.html -X-Code: 410 - -HTTP 200 - -[Asserts] -header "Content-Type" contains "text/html" -header "X-Robots-Tag" == "noindex" -body contains "502" -body contains "Bad Gateway" diff --git a/test/wrk/request.lua b/test/wrk/request.lua deleted file mode 100644 index 96775b0c..00000000 --- a/test/wrk/request.lua +++ /dev/null @@ -1,9 +0,0 @@ -local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' } - -request = function() - 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