Compare commits

...

346 Commits

Author SHA1 Message Date
6b3be0d550 v3.0.0 (#287) 2024-07-03 18:12:13 +04:00
d4b2b5ef96 Merge pull request #290 from tarampampam/dependabot/go_modules/gomod-bdfb6ece4e
build(deps): bump github.com/valyala/fasthttp from 1.54.0 to 1.55.0 in the gomod group
2024-07-01 22:58:34 +00:00
f7bbaf97f0 build(deps): bump github.com/valyala/fasthttp in the gomod group
Bumps the gomod group with 1 update: [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp).


Updates `github.com/valyala/fasthttp` from 1.54.0 to 1.55.0
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/1.54.0...v1.55.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-01 22:58:20 +00:00
e6f49f622d fix(ci): Disable cache for setup-node github action 2024-06-20 14:16:57 +04:00
985fc18a48 chore(ci): Switch from gacts/setup-node-with-cache to actions/setup-node action 2024-06-20 14:07:49 +04:00
5512f2d8bb build(deps): bump the github-actions group with 2 updates (#283) 2024-06-02 09:07:46 +00:00
d585b531cb Merge pull request #282 from tarampampam/dependabot/go_modules/gomod-86828b0f1c
build(deps): bump the gomod group with 4 updates
2024-06-01 22:26:00 +00:00
31b64ff3e9 build(deps): bump the gomod group with 4 updates
Bumps the gomod group with 4 updates: [github.com/fasthttp/router](https://github.com/fasthttp/router), [github.com/fatih/color](https://github.com/fatih/color), [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) and [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp).


Updates `github.com/fasthttp/router` from 1.5.0 to 1.5.1
- [Release notes](https://github.com/fasthttp/router/releases)
- [Commits](https://github.com/fasthttp/router/compare/v1.5.0...v1.5.1)

Updates `github.com/fatih/color` from 1.16.0 to 1.17.0
- [Release notes](https://github.com/fatih/color/releases)
- [Commits](https://github.com/fatih/color/compare/v1.16.0...v1.17.0)

Updates `github.com/prometheus/client_golang` from 1.19.0 to 1.19.1
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.19.0...v1.19.1)

Updates `github.com/valyala/fasthttp` from 1.52.0 to 1.54.0
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.52.0...1.54.0)

---
updated-dependencies:
- dependency-name: github.com/fasthttp/router
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: github.com/fatih/color
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
- dependency-name: github.com/valyala/fasthttp
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-01 22:25:44 +00:00
98d1a5bf6e chore(deps): update golangci/golangci-lint docker tag to v1.59 (#281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-27 23:51:34 +04:00
09db299d37 chore(deps): update golangci/golangci-lint docker tag to v1.58 (#280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-05 10:09:54 +04:00
676c65e66a Merge pull request #279 from tarampampam/dependabot/go_modules/gomod-34457f1dff
build(deps): bump github.com/urfave/cli/v2 from 2.27.1 to 2.27.2 in the gomod group
2024-05-01 22:26:24 +00:00
f6fe108380 build(deps): bump github.com/urfave/cli/v2 in the gomod group
Bumps the gomod group with 1 update: [github.com/urfave/cli/v2](https://github.com/urfave/cli).


Updates `github.com/urfave/cli/v2` from 2.27.1 to 2.27.2
- [Release notes](https://github.com/urfave/cli/releases)
- [Changelog](https://github.com/urfave/cli/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/urfave/cli/compare/v2.27.1...v2.27.2)

---
updated-dependencies:
- dependency-name: github.com/urfave/cli/v2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-01 22:26:10 +00:00
506b4d6ab5 build(deps): bump the github-actions group with 2 updates (#278) 2024-04-27 10:54:51 +00:00
9f552a7bec chore(deps): update ghcr.io/orange-opensource/hurl docker tag to v4.3.0 (#276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-27 14:52:16 +04:00
b2eb35627b Merge pull request #277 from tarampampam/dependabot/go_modules/gomod-a0bc6120b0
build(deps): bump github.com/prometheus/client_model from 0.6.0 to 0.6.1 in the gomod group
2024-04-27 10:35:22 +00:00
4309bede7c build(deps): bump github.com/prometheus/client_model in the gomod group
Bumps the gomod group with 1 update: [github.com/prometheus/client_model](https://github.com/prometheus/client_model).


Updates `github.com/prometheus/client_model` from 0.6.0 to 0.6.1
- [Release notes](https://github.com/prometheus/client_model/releases)
- [Commits](https://github.com/prometheus/client_model/compare/v0.6.0...v0.6.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_model
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: gomod
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-27 10:35:10 +00:00
cf929abebd Update dependabot.yml 2024-04-27 14:34:46 +04:00
70847336ff build(deps): bump the any group with 2 updates (#273) 2024-04-02 05:49:17 +00:00
add7da3fe1 chore(deps): update golangci/golangci-lint docker tag to v1.57 (#272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-19 21:48:27 -07:00
abd647e975 Update dependabot.yml 2024-03-19 09:52:17 +04:00
c851aad4f2 hotfix for the release error 2024-03-17 11:39:51 +04:00
0c7e766f93 build(deps): bump golangci/golangci-lint-action from 3 to 4 (#268) 2024-03-16 16:46:45 +00:00
4d9db28c78 Merge pull request #270 from tarampampam/dependabot/go_modules/github.com/fasthttp/router-1.5.0
build(deps): bump github.com/fasthttp/router from 1.4.22 to 1.5.0
2024-03-16 15:11:55 +00:00
1c6c35c1db build(deps): bump github.com/fasthttp/router from 1.4.22 to 1.5.0
Bumps [github.com/fasthttp/router](https://github.com/fasthttp/router) from 1.4.22 to 1.5.0.
- [Release notes](https://github.com/fasthttp/router/releases)
- [Commits](https://github.com/fasthttp/router/compare/v1.4.22...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/fasthttp/router
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-16 15:11:42 +00:00
ed8303a1a6 Merge pull request #269 from tarampampam/dependabot/github_actions/aquasecurity/trivy-action-0.18.0
build(deps): bump aquasecurity/trivy-action from 0.16.1 to 0.18.0
2024-03-01 22:38:56 +00:00
db7969d4bc build(deps): bump aquasecurity/trivy-action from 0.16.1 to 0.18.0
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.16.1 to 0.18.0.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.16.1...0.18.0)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:38:41 +00:00
0b0b0c2bca Merge pull request #267 from tarampampam/dependabot/go_modules/github.com/valyala/fasthttp-1.52.0
build(deps): bump github.com/valyala/fasthttp from 1.51.0 to 1.52.0
2024-03-01 22:08:47 +00:00
5b23d767c7 build(deps): bump github.com/valyala/fasthttp from 1.51.0 to 1.52.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.51.0 to 1.52.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.51.0...v1.52.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:08:32 +00:00
8065125526 Merge pull request #265 from tarampampam/dependabot/go_modules/github.com/stretchr/testify-1.9.0
build(deps): bump github.com/stretchr/testify from 1.8.4 to 1.9.0
2024-03-01 22:08:16 +00:00
ba5faed23c build(deps): bump github.com/stretchr/testify from 1.8.4 to 1.9.0
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.8.4 to 1.9.0.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.8.4...v1.9.0)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:08:05 +00:00
5c9ffba5de Merge pull request #266 from tarampampam/dependabot/go_modules/github.com/prometheus/client_model-0.6.0
build(deps): bump github.com/prometheus/client_model from 0.5.0 to 0.6.0
2024-03-01 22:07:46 +00:00
5697f2c1a2 build(deps): bump github.com/prometheus/client_model from 0.5.0 to 0.6.0
Bumps [github.com/prometheus/client_model](https://github.com/prometheus/client_model) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/prometheus/client_model/releases)
- [Commits](https://github.com/prometheus/client_model/compare/v0.5.0...v0.6.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_model
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:07:33 +00:00
b477db7af0 Merge pull request #264 from tarampampam/dependabot/go_modules/github.com/prometheus/client_golang-1.19.0
build(deps): bump github.com/prometheus/client_golang from 1.18.0 to 1.19.0
2024-03-01 22:06:48 +00:00
cc0b862c5a Merge pull request #263 from tarampampam/dependabot/go_modules/go.uber.org/zap-1.27.0
build(deps): bump go.uber.org/zap from 1.26.0 to 1.27.0
2024-03-01 22:06:42 +00:00
46a8b0002e build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.18.0 to 1.19.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.19.0/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.18.0...v1.19.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:06:36 +00:00
544ae0c7ab build(deps): bump go.uber.org/zap from 1.26.0 to 1.27.0
Bumps [go.uber.org/zap](https://github.com/uber-go/zap) from 1.26.0 to 1.27.0.
- [Release notes](https://github.com/uber-go/zap/releases)
- [Changelog](https://github.com/uber-go/zap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/uber-go/zap/compare/v1.26.0...v1.27.0)

---
updated-dependencies:
- dependency-name: go.uber.org/zap
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:06:30 +00:00
8dfc68dd6f Merge pull request #262 from tarampampam/dependabot/docker/golang-1.22-bullseye
build(deps): bump golang from 1.21-bullseye to 1.22-bullseye
2024-03-01 22:02:06 +00:00
7beea7b5a8 build(deps): bump golang from 1.21-bullseye to 1.22-bullseye
Bumps golang from 1.21-bullseye to 1.22-bullseye.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 22:01:50 +00:00
b848ea7525 chore(deps): update golangci/golangci-lint docker tag to v1.56 (#261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-08 09:57:10 +04:00
e9b7884daf build(deps): bump codecov/codecov-action from 3 to 4 (#260) 2024-02-01 22:48:40 +00:00
b8b58fc129 build(deps): bump peter-evans/dockerhub-description from 3 to 4 (#258) 2024-02-01 22:48:26 +00:00
f49da87a1f Merge pull request #259 from tarampampam/dependabot/github_actions/aquasecurity/trivy-action-0.16.1
build(deps): bump aquasecurity/trivy-action from 0.16.0 to 0.16.1
2024-02-01 22:17:35 +00:00
bff018f66a build(deps): bump aquasecurity/trivy-action from 0.16.0 to 0.16.1
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.16.0 to 0.16.1.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.16.0...0.16.1)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 22:17:21 +00:00
b8f9608992 build(deps): bump actions/upload-artifact from 3 to 4 (#252) 2024-01-02 08:15:22 +00:00
f22855fee6 build(deps): bump github/codeql-action from 2 to 3 (#251) 2024-01-02 08:12:55 +00:00
b2231cb97c build(deps): bump actions/download-artifact from 3 to 4 (#253)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-02 12:07:20 +04:00
759f70d1f0 Merge pull request #255 from tarampampam/dependabot/go_modules/github.com/prometheus/client_golang-1.18.0
build(deps): bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0
2024-01-01 22:35:50 +00:00
5b74eaa3de build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.17.0 to 1.18.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.17.0...v1.18.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 22:35:37 +00:00
3eb8343ade Merge pull request #254 from tarampampam/dependabot/go_modules/github.com/urfave/cli/v2-2.27.1
build(deps): bump github.com/urfave/cli/v2 from 2.25.7 to 2.27.1
2024-01-01 22:34:59 +00:00
71b50da264 build(deps): bump github.com/urfave/cli/v2 from 2.25.7 to 2.27.1
Bumps [github.com/urfave/cli/v2](https://github.com/urfave/cli) from 2.25.7 to 2.27.1.
- [Release notes](https://github.com/urfave/cli/releases)
- [Changelog](https://github.com/urfave/cli/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/urfave/cli/compare/v2.25.7...v2.27.1)

---
updated-dependencies:
- dependency-name: github.com/urfave/cli/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 22:34:46 +00:00
1354854ba9 Merge pull request #250 from tarampampam/dependabot/github_actions/aquasecurity/trivy-action-0.16.0
build(deps): bump aquasecurity/trivy-action from 0.14.0 to 0.16.0
2024-01-01 22:25:11 +00:00
1c91c2b2fa build(deps): bump aquasecurity/trivy-action from 0.14.0 to 0.16.0
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.14.0 to 0.16.0.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.14.0...0.16.0)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 22:24:59 +00:00
c289f2cb97 Merge pull request #248 from tarampampam/dependabot/go_modules/github.com/fatih/color-1.16.0
build(deps): bump github.com/fatih/color from 1.15.0 to 1.16.0
2023-12-01 22:36:28 +00:00
29771b9188 build(deps): bump github.com/fatih/color from 1.15.0 to 1.16.0
Bumps [github.com/fatih/color](https://github.com/fatih/color) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/fatih/color/releases)
- [Commits](https://github.com/fatih/color/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: github.com/fatih/color
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 22:36:13 +00:00
5deada7d6b Merge pull request #249 from tarampampam/dependabot/go_modules/github.com/valyala/fasthttp-1.51.0
build(deps): bump github.com/valyala/fasthttp from 1.50.0 to 1.51.0
2023-12-01 22:35:26 +00:00
99694d43e1 Merge pull request #247 from tarampampam/dependabot/go_modules/github.com/fasthttp/router-1.4.22
build(deps): bump github.com/fasthttp/router from 1.4.21 to 1.4.22
2023-12-01 22:35:16 +00:00
0d2418f8aa build(deps): bump github.com/valyala/fasthttp from 1.50.0 to 1.51.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.50.0 to 1.51.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.50.0...v1.51.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 22:35:14 +00:00
afdf0543b7 build(deps): bump github.com/fasthttp/router from 1.4.21 to 1.4.22
Bumps [github.com/fasthttp/router](https://github.com/fasthttp/router) from 1.4.21 to 1.4.22.
- [Release notes](https://github.com/fasthttp/router/releases)
- [Commits](https://github.com/fasthttp/router/compare/v1.4.21...v1.4.22)

---
updated-dependencies:
- dependency-name: github.com/fasthttp/router
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 22:35:02 +00:00
605d019a3f Merge pull request #246 from tarampampam/dependabot/github_actions/aquasecurity/trivy-action-0.14.0
build(deps): bump aquasecurity/trivy-action from 0.13.1 to 0.14.0
2023-12-01 22:19:04 +00:00
3c1f5d9a99 build(deps): bump aquasecurity/trivy-action from 0.13.1 to 0.14.0
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.13.1 to 0.14.0.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.13.1...0.14.0)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-01 22:18:48 +00:00
8b18d02666 Update CHANGELOG.md 2023-11-20 12:28:22 +04:00
8e7570eee3 fix: "Too big request header" (#244)
* Added environment variable and CLI parameter for adjusting the read buffer size

* Changed readBufferSize to int and some bugfixing

* Small fixes

* ci: 👷 CI system updated

* ci: 👷 CI system updated

---------

Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2023-11-20 12:27:40 +04:00
2c8ba9c0f3 build(deps): bump github.com/prometheus/client_model (#241) 2023-11-02 05:14:07 +00:00
1ab0973011 build(deps): bump github.com/fasthttp/router from 1.4.20 to 1.4.21 (#242) 2023-11-02 05:13:41 +00:00
540139db3a chore(deps): update golangci/golangci-lint docker tag to v1.55 (#239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-02 09:13:02 +04:00
ff3d16d294 Merge pull request #243 from tarampampam/dependabot/github_actions/aquasecurity/trivy-action-0.13.1
build(deps): bump aquasecurity/trivy-action from 0.12.0 to 0.13.1
2023-11-01 22:55:13 +00:00
ebbded51bf build(deps): bump aquasecurity/trivy-action from 0.12.0 to 0.13.1
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.12.0 to 0.13.1.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.12.0...0.13.1)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 22:54:59 +00:00
384e45ce7f Merge pull request #240 from tarampampam/dependabot/go_modules/go.uber.org/goleak-1.3.0
build(deps): bump go.uber.org/goleak from 1.2.1 to 1.3.0
2023-11-01 22:19:14 +00:00
cdac8665de build(deps): bump go.uber.org/goleak from 1.2.1 to 1.3.0
Bumps [go.uber.org/goleak](https://github.com/uber-go/goleak) from 1.2.1 to 1.3.0.
- [Release notes](https://github.com/uber-go/goleak/releases)
- [Changelog](https://github.com/uber-go/goleak/blob/master/CHANGELOG.md)
- [Commits](https://github.com/uber-go/goleak/compare/v1.2.1...v1.3.0)

---
updated-dependencies:
- dependency-name: go.uber.org/goleak
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-01 22:18:58 +00:00
fac512bd74 Fix spanish translated tags (#237) 2023-10-10 15:10:11 +04:00
ea85191d9e build(deps): bump actions/checkout from 3 to 4 (#232) 2023-10-09 12:46:36 +00:00
38ce1f9cf3 build(deps): bump docker/build-push-action from 4 to 5 (#234) 2023-10-09 12:46:25 +00:00
9e4a1451f5 build(deps): bump docker/setup-qemu-action from 2 to 3 (#235) 2023-10-09 12:42:41 +00:00
9feca1f509 Update dependabot.yml 2023-10-05 11:15:01 +04:00
38bf4abc1e Update dependabot.yml 2023-10-02 12:30:56 +04:00
ffc2af1c27 Update dependabot.yml 2023-10-02 11:35:41 +04:00
763c4ad109 build(deps): bump github.com/valyala/fasthttp from 1.49.0 to 1.50.0 (#227) 2023-10-02 07:07:00 +00:00
dcfd8ab3a7 build(deps): bump go.uber.org/zap from 1.25.0 to 1.26.0 (#229) 2023-10-02 06:54:19 +00:00
62f309cefd build(deps): bump docker/login-action from 2 to 3 (#233) 2023-10-02 06:53:40 +00:00
b5504de6d8 build(deps): bump github.com/prometheus/client_golang (#228) 2023-10-02 06:49:50 +00:00
4d91e17273 build(deps): bump aquasecurity/trivy-action from 0.11.2 to 0.12.0 (#230) 2023-10-02 06:49:03 +00:00
86e182c25d build(deps): bump docker/setup-buildx-action from 2 to 3 (#231) 2023-10-02 06:48:39 +00:00
8006cce4b0 Polish translation added. (#226)
* Polish translation added.

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: Pаramtamtām <7326800+tarampampam@users.noreply.github.com>
2023-09-28 10:51:55 +04:00
caf4e33193 Update l10n.js (#225) 2023-09-24 21:11:49 -07:00
7ab0fa6f23 chore: The docker environment is refactored 2023-09-03 20:17:07 +04:00
81570b42c0 chore: The docker environment is refactored 2023-09-03 20:16:50 +04:00
4c3ebc055d Update CHANGELOG.md 2023-09-02 07:17:47 -07:00
61c1958717 build(deps): bump golang from 1.20-alpine to 1.21-alpine (#222)
* build(deps): bump golang from 1.20-alpine to 1.21-alpine

Bumps golang from 1.20-alpine to 1.21-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-type: direct:production
...

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

* wip: 🔕 temporary commit

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2023-09-02 07:17:15 -07:00
3ab1a23ac5 Create dependabot.yml 2023-09-02 06:46:02 -07:00
a81c780e1e build(deps): bump go.uber.org/zap from 1.24.0 to 1.25.0 (#221) 2023-09-02 12:59:52 +00:00
2baeb2eb5b build(deps): bump github.com/valyala/fasthttp from 1.48.0 to 1.49.0 (#220) 2023-09-02 12:54:58 +00:00
e6b011b41b Add Indonesian translation (#218)
* Add Indonesian

Add all strings in bahasa indonesia.

* Update README.md

Add Indonesian flag emoji

* Add Indonesian language credit

* Update CHANGELOG.md (add 🇮🇩  language)

* Update l10n/l10n.js

---------

Co-authored-by: Pаramtamtām <7326800+tarampampam@users.noreply.github.com>
2023-09-01 10:25:17 +04:00
308467006b Add CatchAll functionality (#217)
* Add CatchAll functionality

* Added link to PR to changelog
2023-09-01 10:17:11 +04:00
ecf1359336 chore(deps): update golangci/golangci-lint docker tag to v1.54 (#215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-09 21:09:41 +04:00
a12dc4882e build(deps): bump github.com/fasthttp/router from 1.4.19 to 1.4.20 (#213) 2023-08-02 06:39:59 +00:00
980d0a5810 build(deps): bump go.uber.org/automaxprocs from 1.5.2 to 1.5.3 (#214) 2023-08-02 06:38:58 +00:00
eb3d84ee9d Change Ukrainian translation (#211)
* Update l10n.js

* Update l10n.js
2023-07-24 17:16:52 +04:00
6b43057333 build(deps): bump github.com/valyala/fasthttp from 1.47.0 to 1.48.0 (#208) 2023-07-01 23:48:11 +00:00
071ded0eac build(deps): bump github.com/prometheus/client_golang (#210) 2023-07-01 23:47:44 +00:00
47c4338c9e build(deps): bump aquasecurity/trivy-action from 0.10.0 to 0.11.2 (#207) 2023-07-01 23:43:11 +00:00
dfdeea4b6c build(deps): bump github.com/urfave/cli/v2 from 2.25.5 to 2.25.7 (#209) 2023-07-01 23:42:31 +00:00
cbb7936149 chore(deps): update golangci/golangci-lint docker tag to v1.53 (#200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-06-04 01:32:57 -07:00
c08e1307e8 build(deps): bump github.com/urfave/cli/v2 from 2.25.3 to 2.25.5 (#201) 2023-06-04 08:29:11 +00:00
25b86a057f build(deps): bump github.com/prometheus/client_model from 0.3.0 to 0.4.0 (#203) 2023-06-04 08:24:31 +00:00
ef72fa405d build(deps): bump github.com/fasthttp/router from 1.4.18 to 1.4.19 (#202) 2023-06-04 08:24:25 +00:00
1eb773fb57 depguard disabled 2023-06-04 01:18:45 -07:00
439b6d0326 build(deps): bump github.com/prometheus/client_golang (#199) 2023-06-02 06:35:01 +00:00
ef0081f711 build(deps): bump github.com/stretchr/testify from 1.8.2 to 1.8.4 (#198) 2023-06-02 06:34:58 +00:00
8c7a24b3d7 build(deps): bump github.com/urfave/cli/v2 from 2.25.1 to 2.25.3 (#197) 2023-05-04 11:20:25 +00:00
c76026c9f1 build(deps): bump github.com/prometheus/client_golang (#196) 2023-05-02 03:46:37 +00:00
37e4ecbf47 build(deps): bump aquasecurity/trivy-action from 0.9.2 to 0.10.0 (#194) 2023-05-02 03:46:24 +00:00
58dc38f72e build(deps): bump github.com/valyala/fasthttp from 1.45.0 to 1.47.0 (#195) 2023-05-02 03:46:09 +00:00
36c5472987 feat: IPv6 support (#192)
* 🐛 fix(server.go): validate IP address before starting server
 feat(server.go): add support for IPv6 addresses

*  feat(cli): add support for IPv6 addresses in the `--listen` flag

* 🐛 fix(server.go): add nolint comment to ignore magic number warning in ipv6 check

* 🐛 fix(server.go): use fmt.Sprintf to format IP and port instead of strconv.Itoa and string concatenation
2023-04-21 16:33:33 +04:00
717542e045 Update README.md 2023-04-18 10:48:55 +04:00
940bd0405f New template orient added (#190)
* added orient theme

* added creation test for orient theme

* Added new Orient theme to CHANGELOG.md

* fix: Template fixed a bit

---------

Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2023-04-17 15:06:28 +04:00
d40e8879d1 feat: Non-existing pages now return styled 404 status page (with 404 status code) (#189)
* 🎨 style(CHANGELOG.md): add UNRELEASED section
🔧 chore(notfound/handler.go): refactor to use core.RespondWithErrorPage
🔧 chore(http/server.go): pass config, templatePicker, renderer and options to notfoundHandler
🔧 chore(hurl/404.hurl): update test to expect HTML response instead of plain text

* 📝 docs(CHANGELOG.md): fix typo in changed section

* 🔥 chore(CHANGELOG.md): release version 2.22.0
🔥 chore(flags.go): remove unused code for serve command
2023-04-07 14:42:00 +04:00
6a67510bdc build(deps): bump go.uber.org/automaxprocs from 1.5.1 to 1.5.2 (#183) 2023-04-02 02:27:23 +00:00
a79521a37d build(deps): bump github.com/urfave/cli/v2 from 2.24.4 to 2.25.1 (#185) 2023-04-02 02:24:01 +00:00
4667194271 build(deps): bump github.com/fasthttp/router from 1.4.17 to 1.4.18 (#184) 2023-04-02 02:23:55 +00:00
a20852b03a build(deps): bump aquasecurity/trivy-action from 0.9.1 to 0.9.2 (#182) 2023-04-02 02:22:48 +00:00
ec0f1cc0d3 build(deps): bump github.com/valyala/fasthttp from 1.44.0 to 1.45.0 (#186) 2023-04-02 02:19:54 +00:00
49d9650d35 build(deps): bump github.com/fatih/color from 1.14.1 to 1.15.0 (#187) 2023-04-02 02:19:27 +00:00
1b876c45cc chore(deps): update golangci/golangci-lint docker tag to v1.52 (#181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-18 17:17:13 +04:00
23f52f25e2 Bump aquasecurity/trivy-action from 0.8.0 to 0.9.1 (#177) 2023-03-02 03:53:22 +00:00
16d7d80183 Bump github.com/stretchr/testify from 1.8.1 to 1.8.2 (#178) 2023-03-02 03:49:08 +00:00
5389fe00dd Bump github.com/fasthttp/router from 1.4.16 to 1.4.17 (#179) 2023-03-02 03:48:51 +00:00
cd67674976 Bump go.uber.org/goleak from 1.2.0 to 1.2.1 (#180) 2023-03-02 03:48:33 +00:00
36673a49a4 ci: 👷 CI system updated 2023-02-23 22:03:32 +04:00
b84b3ba9f4 chore: 🔧 Changes that do not modify src or test files 2023-02-23 21:50:35 +04:00
5343d8c934 docs(changelog): 📚 Changelog file updated 2023-02-23 21:50:31 +04:00
80891b0b46 ci: 👷 CI system updated 2023-02-23 21:50:20 +04:00
48313685ec chore: Module name changed, deps updated 2023-02-23 21:49:45 +04:00
7dc47c00ac chore(deps): update golangci/golangci-lint docker tag to v1.51 (#174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-02 17:08:43 +04:00
830b5bb934 chore(deps): update golang docker tag to v1.20 (#173)
* chore(deps): update golang docker tag to v1.20

* updated

* update

* update

* Update .github/workflows/tests.yml

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2023-02-02 13:09:29 +04:00
8d43984644 Bump github.com/urfave/cli/v2 from 2.24.1 to 2.24.2 (#168) 2023-02-02 07:25:44 +00:00
1b152c1a80 Bump github.com/fasthttp/router from 1.4.14 to 1.4.16 (#169) 2023-02-02 03:26:30 +00:00
175b9f0cfb Bump go.uber.org/goleak from 1.1.11 to 1.2.0 (#170) 2023-02-02 03:25:57 +00:00
16180be7e2 Bump github.com/valyala/fasthttp from 1.43.0 to 1.44.0 (#171) 2023-02-02 03:22:36 +00:00
11b270ee68 Bump github.com/fatih/color from 1.13.0 to 1.14.1 (#172) 2023-02-02 03:22:15 +00:00
97ab8a4475 Bump docker/build-push-action from 3 to 4 (#167) 2023-02-02 03:20:00 +00:00
da3b864e02 fix: docker-compose server running command 2023-01-29 15:56:58 +04:00
0bd989e493 docs(changelog): file updated 2023-01-29 15:49:28 +04:00
59c4d2022c ci: workflow files have been updated 2023-01-29 15:39:49 +04:00
1ec17caa1d feat: Possibility to use custom env variables in templates (#165) 2023-01-29 15:25:38 +04:00
252618a975 chore: Better CLI (#163) 2023-01-29 14:54:56 +04:00
315c7660d1 Update README.md 2022-12-27 11:41:51 +04:00
6a6809b07f Bump go.uber.org/zap from 1.23.0 to 1.24.0 (#159) 2022-12-02 06:09:55 +00:00
a960b5928e Bump github.com/valyala/fasthttp from 1.41.0 to 1.43.0 (#157) 2022-12-02 06:06:16 +00:00
46d96d7bb4 Bump github.com/prometheus/client_golang from 1.13.0 to 1.14.0 (#160) 2022-12-02 06:02:10 +00:00
69063a9cf7 Bump github.com/fasthttp/router from 1.4.13 to 1.4.14 (#158) 2022-12-02 06:01:28 +00:00
7c8d2f54c7 Update orangeopensource/hurl Docker tag to v1.8.0 (#155)
* Update orangeopensource/hurl Docker tag to v1.8.0

* wip: temporary commit

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2022-11-18 23:46:46 +04:00
49aed23f8c Update README.md 2022-11-15 13:06:39 +04:00
3418f85292 Update CHANGELOG.md 2022-11-02 01:38:14 +04:00
361afd87aa Bump github.com/stretchr/testify from 1.8.0 to 1.8.1 (#152)
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.8.0 to 1.8.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.8.0...v1.8.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-02 01:33:17 +04:00
7f6815c274 Bump github.com/spf13/cobra from 1.5.0 to 1.6.1 (#151) 2022-11-01 21:31:51 +00:00
4ecd70330a Bump aquasecurity/trivy-action from 0.7.1 to 0.8.0 (#148) 2022-11-01 21:28:27 +00:00
ae13905512 Bump github.com/prometheus/client_model from 0.2.0 to 0.3.0 (#149) 2022-11-01 21:27:39 +00:00
e3377d0f28 Bump github.com/fasthttp/router from 1.4.12 to 1.4.13 (#153) 2022-11-01 21:27:23 +00:00
b15061a110 wip: Readme file updated 2022-11-02 01:25:19 +04:00
CDN
6d6945bf44 Add 🇨🇳 Chinese Translation (#147) 2022-11-02 01:23:29 +04:00
cf7c526d4f golangci-lint issues fixed 2022-11-02 01:13:03 +04:00
8e21be0340 Update golangci/golangci-lint Docker tag to v1.50 (#144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-04 15:41:42 +04:00
37265ccb4f Bump github.com/fasthttp/router from 1.4.11 to 1.4.12 (#142) 2022-10-02 05:28:04 +00:00
169fbe3b93 Bump github.com/valyala/fasthttp from 1.39.0 to 1.40.0 (#143) 2022-10-02 02:22:55 +00:00
617b378c36 Update orangeopensource/hurl Docker tag to v1.7.0 (#141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-09-24 10:31:48 +04:00
438e954dd6 Bump go.uber.org/zap from 1.21.0 to 1.23.0 (#135) 2022-09-02 02:57:20 +00:00
df1a0e20ee Bump aquasecurity/trivy-action from 0.6.1 to 0.7.1 (#137) 2022-09-02 02:53:52 +00:00
7b3c286790 Bump github.com/valyala/fasthttp from 1.38.0 to 1.39.0 (#136) 2022-09-02 02:53:34 +00:00
fb7d7c75cf Bump github.com/prometheus/client_golang from 1.12.2 to 1.13.0 (#138) 2022-09-02 02:52:41 +00:00
1eafe58d16 Update golang Docker tag to v1.19 (#127)
* Update golang Docker tag to v1.19

* wip: temporary commit

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2022-08-24 21:38:26 +04:00
e7a909dc4e Update golangci/golangci-lint Docker tag to v1.49 (#134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-08-24 20:38:41 +04:00
9deee9ddba replace google by bunny fonts (#131) 2022-08-19 23:19:10 +04:00
e769c2103f Update dependency golangci/golangci-lint to v1.48 (#128)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-08-05 06:05:19 +04:00
ca9cdf0379 Bump github.com/fasthttp/router from 1.4.10 to 1.4.11 (#125) 2022-08-02 04:56:43 +00:00
7ef471381c Bump aquasecurity/trivy-action from 0.5.1 to 0.6.1 (#126) 2022-08-02 04:56:24 +00:00
48e9b20836 go get -u golang.org/x/sys 2022-07-26 21:49:19 +04:00
7329d7697c Readme & changelog files are updated 2022-07-25 11:13:18 +04:00
83f38cdd16 Spanish Translation (#124) 2022-07-25 11:05:06 +04:00
057006366d Update dependency golangci/golangci-lint to v1.47 (#123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-07-18 19:32:55 +04:00
f08539962e Bump github.com/valyala/fasthttp from 1.37.0 to 1.38.0 (#122) 2022-07-02 11:06:00 +00:00
2cc8549cef Bump github.com/spf13/cobra from 1.4.0 to 1.5.0 (#121) 2022-07-02 11:02:45 +00:00
8f8e5abd3d Bump github.com/fasthttp/router from 1.4.9 to 1.4.10 (#120) 2022-07-02 10:59:19 +00:00
1889a57c05 Bump github.com/stretchr/testify from 1.7.1 to 1.8.0 (#119) 2022-07-02 10:58:56 +00:00
c42ff85dd6 Bump aquasecurity/trivy-action from 0.3.0 to 0.5.1 (#118) 2022-07-02 10:58:42 +00:00
00d3c10e5e Update tests.yml 2022-06-12 13:38:17 +05:00
7153c260d8 Update dependency orangeopensource/hurl to v1.6.1 (#117)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
2022-06-12 13:11:52 +05:00
48bd1a44e6 Configure Renovate (#116)
Co-authored-by: Renovate Bot <bot@renovateapp.com>
Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2022-06-12 12:55:49 +05:00
bde35e2c79 Bump golang from 1.18.1-alpine to 1.18.3-alpine (#114) 2022-06-05 09:22:08 +00:00
d649e371a5 Update CHANGELOG.md 2022-06-04 17:11:20 +05:00
01c2a37055 Add 🇩🇪 German translation (#115)
* german translation

* update readmes and changelog
2022-06-04 17:06:39 +05:00
a932f94ec0 Bump docker/setup-buildx-action from 1 to 2 (#111) 2022-06-02 05:14:32 +00:00
3ac2c74249 Bump github.com/valyala/fasthttp from 1.36.0 to 1.37.0 (#106) 2022-06-02 05:14:08 +00:00
18af96bada Bump github.com/prometheus/client_golang from 1.12.1 to 1.12.2 (#108) 2022-06-02 05:10:46 +00:00
445aad8b41 Bump docker/login-action from 1 to 2 (#109) 2022-06-02 05:10:27 +00:00
b61cc7460f Bump github.com/fasthttp/router from 1.4.8 to 1.4.9 (#110) 2022-06-02 05:10:14 +00:00
c9586fe79a Bump aquasecurity/trivy-action from 0.2.5 to 0.3.0 (#112) 2022-06-02 05:09:47 +00:00
405afec38a Bump docker/setup-qemu-action from 1 to 2 (#107) 2022-06-02 05:09:16 +00:00
5e0be010b7 Bump docker/build-push-action from 2 to 3 (#113) 2022-06-02 05:09:00 +00:00
9bc00fa4ca Update release.yml 2022-05-12 17:20:02 +05:00
6742381562 Update tests.yml 2022-05-12 17:18:45 +05:00
6d3ced480d Add 🇳🇱 Dutch translation (#104)
Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2022-05-07 15:09:23 +05:00
e8fa8896c9 Update CHANGELOG.md 2022-05-06 22:28:42 +05:00
c9bd47618d adding translation to Portuguese language (#103)
Co-authored-by: Fabio Correia <fabiocorreia@trixlog.com>
Co-authored-by: Paramtamtam <7326800+tarampampam@users.noreply.github.com>
2022-05-06 22:28:12 +05:00
3ffb952cdd Bump golang from 1.18.0-alpine to 1.18.1-alpine (#95) 2022-05-02 06:54:34 +00:00
b5892f44d9 Bump github.com/valyala/fasthttp from 1.34.0 to 1.36.0 (#96) 2022-05-02 04:24:25 +00:00
769b0cebb6 Bump github.com/fasthttp/router from 1.4.7 to 1.4.8 (#98) 2022-05-02 04:18:32 +00:00
8f49ff7204 Bump github/codeql-action from 1 to 2 (#99) 2022-05-02 04:18:12 +00:00
f89bdfbd51 Bump aquasecurity/trivy-action from 0.2.2 to 0.2.5 (#97) 2022-05-02 04:17:53 +00:00
1b2e899201 Bump actions/download-artifact from 2 to 3 (#100) 2022-05-02 04:17:31 +00:00
6c0c85544e Bump actions/upload-artifact from 2 to 3 (#101) 2022-05-02 04:17:11 +00:00
d3d1c62411 Bump codecov/codecov-action from 2 to 3 (#102) 2022-05-02 04:16:54 +00:00
8019d07cab Update CHANGELOG.md 2022-04-12 15:35:19 +05:00
d21a6f2797 Added possibility to disable error pages auto-localization (#94) 2022-04-12 15:34:35 +05:00
a3389aaafa Changing UID/GID to the numeric values (#93) 2022-04-09 20:39:52 +05:00
c6a7e30609 Bump github.com/fasthttp/router from 1.4.6 to 1.4.7 (#90) 2022-04-01 22:07:18 +00:00
01abc48a01 CI updated 2022-04-01 13:14:16 +05:00
d6374d7edf Bump actions/setup-go from 2 to 3 2022-04-01 13:01:22 +05:00
7ebfac9dc2 Bump actions/cache from 2 to 3.0.1 (#89) 2022-04-01 07:51:10 +00:00
64d4798156 Bump peter-evans/dockerhub-description from 2 to 3 (#88) 2022-04-01 07:48:54 +00:00
4adad3df10 CI updated (#87) 2022-03-30 21:23:42 +05:00
30a7b2793f Fix translation FR (#86) 2022-03-30 18:00:32 +05:00
2d9deb7370 CI now purges the CDN cache (#84) 2022-03-28 22:13:19 +05:00
873944f90f Changelog updated 2022-03-28 16:05:44 +05:00
cd5abe458b l10n file formatted, CI updated (#83) 2022-03-28 16:04:23 +05:00
481e11d527 Error pages now translated in 🇫🇷 (#82) 2022-03-28 15:11:44 +05:00
fac7394ae2 Update CHANGELOG.md 2022-03-27 20:38:04 +05:00
4a918b1899 Template matrix (#81) 2022-03-27 20:33:31 +05:00
05be3841d7 Update .gitattributes 2022-03-25 14:51:50 +05:00
02cadcd907 Create .gitattributes 2022-03-25 14:46:59 +05:00
94dff2421c Changelog updated 2022-03-24 13:54:50 +05:00
51f8824659 shuffle template fixed 2022-03-24 13:54:07 +05:00
e82c02c768 fix the translation mistakes 2022-03-24 12:28:03 +05:00
dc51e3192c Update CHANGELOG.md 2022-03-24 00:32:40 +05:00
45ca69432b Translated in 🇺🇦 and 🇷🇺 languages (#80) 2022-03-24 00:31:34 +05:00
f5f572a4d3 small template fixes 2022-03-23 12:30:33 +05:00
2d418ecffa Changelog updated 2022-03-22 23:46:01 +05:00
c6b3342361 Readme file updated 2022-03-22 23:44:31 +05:00
3614f0503f New template connection added (#79) 2022-03-22 23:31:33 +05:00
a2ee92acc4 v2.8.1 2022-03-21 13:23:21 +05:00
93dddd75d9 Update README.md 2022-03-20 23:31:45 +05:00
c17587ca6b Changelog updated 2022-03-20 12:44:27 +05:00
d7d5245d07 Template app-down added (#74) 2022-03-20 11:32:40 +05:00
6c0885a5d3 Bump golang from 1.17.7-alpine to 1.18.0-alpine (#76) 2022-03-19 11:33:30 +00:00
4b83ce7d09 Bump github.com/valyala/fasthttp from 1.33.0 to 1.34.0 (#77) 2022-03-19 11:33:19 +00:00
d6cebc27ab Bump github.com/spf13/cobra from 1.3.0 to 1.4.0 (#78) 2022-03-19 11:28:55 +00:00
2bcbd4ba41 Bump github.com/stretchr/testify from 1.7.0 to 1.7.1 (#75) 2022-03-19 11:25:36 +00:00
edc05ec6d2 an attempt to fix the ci 2022-03-04 10:49:40 +05:00
94b6af6d53 Bump golangci/golangci-lint-action from 2 to 3.1.0 (#72) 2022-03-04 05:43:59 +00:00
8d24125eee Bump golang from 1.17.6-alpine to 1.17.7-alpine (#69) 2022-03-04 05:25:18 +00:00
97fc3b8693 Bump actions/setup-node from 2 to 3 (#73) 2022-03-02 03:41:56 +00:00
ac1c19df28 Bump go.uber.org/zap from 1.20.0 to 1.21.0 (#70) 2022-03-02 03:39:14 +00:00
b7f82e4635 Bump actions/checkout from 2 to 3 (#71) 2022-03-02 03:38:28 +00:00
62493411b4 Update README.md 2022-02-24 09:47:53 +05:00
0e20e39cd2 Update README.md 2022-02-24 02:35:49 +05:00
4bdbb882b5 Update README.md 2022-02-24 02:34:31 +05:00
4b2a792148 Update README.md 2022-02-24 02:31:44 +05:00
1d41cf190b Update CHANGELOG.md 2022-02-23 11:14:57 +05:00
e857c0309b proxy headers (#67) 2022-02-23 11:09:54 +05:00
06aff4ecb3 Update README.md 2022-02-22 21:20:32 +05:00
3145bdfa00 fix the theme (auto-dark mode) 2022-02-22 20:58:26 +05:00
178e6b2d9b New template lost-in-space (#68) 2022-02-22 20:48:55 +05:00
7a3dc917a2 Readme file updated 2022-02-22 13:16:47 +05:00
8a14836bd1 migrate to the another docker scanning action (#66) 2022-02-21 16:48:35 +05:00
ae2bf27463 issue templates update 2022-02-21 16:08:07 +05:00
c53a87b816 Update README.md 2022-02-14 15:45:34 +05:00
8463ecf00d Update README.md 2022-02-08 11:03:23 +05:00
1d7596b3df Bump github.com/prometheus/client_golang from 1.12.0 to 1.12.1 (#63) 2022-02-02 04:09:32 +00:00
251e0a01cf Bump github.com/fasthttp/router from 1.4.5 to 1.4.6 (#64) 2022-02-02 04:08:52 +00:00
22d3e3485e Changelog updated 2022-02-01 20:12:11 +05:00
375272b561 Change themes in random order once a day/hour (#62) 2022-02-01 19:39:50 +05:00
7e7f956fae template docs added 2022-01-31 14:45:12 +05:00
d672112cc2 Changelog updated 2022-01-31 13:53:06 +05:00
32b92611a7 small fixes 2022-01-31 13:45:22 +05:00
cc6cbc7d47 Template rendering performance issue has been fixed (#60) 2022-01-31 13:43:40 +05:00
690a405994 fix the template 2022-01-31 10:53:51 +05:00
f72c2b85fd Changes after merging 2022-01-31 10:46:51 +05:00
42523ae9d9 Adds "Host" and "X-Forwarded-For" header options (#61) 2022-01-31 10:40:58 +05:00
da2dc5c63a Bump github.com/fasthttp/router from 1.4.4 to 1.4.5 (#59) 2022-01-29 07:50:34 +00:00
a0a1d3caca Bump go.uber.org/zap from 1.19.1 to 1.20.0 (#58) 2022-01-29 07:50:31 +00:00
915e810088 Bump golang from 1.17.5-alpine to 1.17.6-alpine (#57) 2022-01-29 07:50:17 +00:00
00c139b525 Bump github.com/valyala/fasthttp from 1.31.0 to 1.33.0 (#56) 2022-01-29 07:46:47 +00:00
eca99eb569 Readme file updated 2022-01-29 01:14:06 +05:00
dfaeea7483 Readme file updated 2022-01-29 01:12:36 +05:00
f71b07f647 fix typos 2022-01-29 01:11:44 +05:00
be0a3c4820 Update CHANGELOG.md 2022-01-29 00:40:01 +05:00
04bf2231bc Readme file updated 2022-01-28 23:58:04 +05:00
ba98272530 Readme file updated 2022-01-28 23:23:25 +05:00
fab38255eb chore: add ingress-nginx to docs (#53) 2022-01-28 20:51:52 +05:00
88278d37a7 Prometheus metrics implemented (#54) 2022-01-28 20:42:08 +05:00
32daf80b76 Issue templates added (#55) 2022-01-28 20:41:54 +05:00
13e7a72790 Fix for the X-Format header (#51) 2022-01-28 12:53:35 +05:00
0efbccbb18 Content-type added into the logs 2022-01-27 19:28:11 +05:00
bed576f26c Go templates support, XML, JSON, Ingress (#49) 2022-01-27 17:29:49 +05:00
f75bf15552 Readme file updated 2022-01-03 22:43:47 +05:00
9915e321f4 CI updated 2022-01-03 22:07:48 +05:00
83720999d8 Changelog updated 2022-01-03 21:52:47 +05:00
79bbf3d71e Flag --default-http-code for the serve subcommand added (#44) 2022-01-03 21:51:30 +05:00
1dec69d726 Bump golang from 1.17.3-alpine to 1.17.5-alpine (#42) 2022-01-03 15:40:05 +00:00
ef2db68430 Bump github.com/spf13/cobra from 1.2.1 to 1.3.0 (#43) 2022-01-02 07:25:37 +00:00
e6f3250286 Bump golang from 1.17.2-alpine to 1.17.3-alpine (#38) 2021-12-02 11:08:52 +00:00
ca56f1dd07 Bump github.com/a8m/envsubst from 1.2.0 to 1.3.0 (#39) 2021-12-02 04:51:26 +00:00
6bd973a803 Bump golang from 1.17.1-alpine to 1.17.2-alpine (#34) 2021-11-02 06:33:04 +00:00
49dd703e12 Bump github.com/fasthttp/router from 1.4.3 to 1.4.4 (#36) 2021-11-02 06:32:13 +00:00
0f27441225 Updated: images to the latest version (#32) 2021-10-20 18:32:56 +05:00
97d76ddca8 Update README.md 2021-10-15 14:48:52 +05:00
891d491cdb Index page codes now sorted 2021-10-15 11:06:10 +05:00
2a1fb0eddf Changelog updated 2021-10-15 10:36:48 +05:00
5c25fbe2c4 Cats template updated 2021-10-15 10:32:31 +05:00
e3e618d3cf Add cat template (#31) 2021-10-15 09:55:28 +05:00
e2489a2487 Changelog updated 2021-10-06 22:38:26 +05:00
bb17027cc9 Allow to set default error page (#30) 2021-10-06 22:38:00 +05:00
6b17d3eb7d Bump github.com/fatih/color from 1.7.0 to 1.13.0 (#28) 2021-10-01 12:10:23 +00:00
c5f11eff8b Bump github.com/pkg/errors from 0.8.1 to 0.9.1 (#27) 2021-10-01 11:58:48 +00:00
b36bc5e47d Dependabot config added 2021-10-01 16:46:35 +05:00
29f024ebcc v2: App rewritten in Go (#25) 2021-09-29 20:38:50 +05:00
ce98410e51 Nginx Healthcheck endpoint + Dockerfile healthcheck (#23)
Co-authored-by: modem7 <modem7@gmail.com>
2021-09-06 11:47:10 +05:00
501d141ce7 Update CHANGELOG.md 2021-07-20 18:32:37 +05:00
8c2155407a Update Dockerfile 2021-07-20 18:29:54 +05:00
a73173309c Update CI 2021-07-20 15:05:06 +05:00
2fa41ec4b8 Update README.md 2021-05-03 12:43:09 +05:00
0efccb0187 Update CHANGELOG.md 2021-05-02 16:06:42 +05:00
914d6572b7 Update 100-setup-error-pages.sh (#12)
Random template generator, also picked up `nginx-error-pages` template, which we don't want. Proposing small patch to exclude from `allowed_templates`
2021-05-02 16:03:54 +05:00
455bc21d51 Readme file updated 2021-04-28 13:09:16 +05:00
e4bba25dd2 Template hacker-terminal added (#13)
* Template hacker-terminal added

* Changelog updated

* Update README.md
2021-04-28 13:08:24 +05:00
2695a32834 Readme file updated 2021-04-22 10:54:40 +05:00
7b9051c63d Noise template (#10)
Co-authored-by: Ralph <RHITNL@users.noreply.github.com>
2021-04-22 01:53:59 +05:00
fbf13ebb9b Fix file permissions in docker file 2021-04-13 21:51:23 +05:00
80be5911a5 Readme file updated 2021-04-13 19:46:08 +05:00
294f76d56b Readme file updated, docker arch linux/386 removed, changelog file updated 2021-04-13 19:37:12 +05:00
3c07d04c71 Readme images updated 2021-04-13 18:57:36 +05:00
515bd44e13 Source code refactored (#7) 2021-04-13 18:55:03 +05:00
c6aa014458 Changelog updated 2021-03-04 11:42:55 +05:00
a55ec08eef Release action fixed 2021-03-04 11:37:03 +05:00
7957d16c0f New template "shuffle" was added (#5) 2021-03-04 11:28:48 +05:00
80b2544f36 Fix issue #3 2020-08-31 14:03:48 +05:00
090767ba6b Readme file updated 2020-07-16 15:20:29 +05:00
a040c913e7 Set server_tokens off; in nginx server configuration 2020-07-16 15:19:44 +05:00
5ab113ba1a Update README.md 2020-07-16 12:15:57 +05:00
ea46e9f738 Update README.md 2020-07-16 12:14:34 +05:00
aeb6018a57 Update README.md 2020-07-14 15:05:36 +05:00
158856bebd Add 418 error (#2)
* Update config.json

Add 418 error

* Update CHANGELOG.md
2020-07-10 19:38:26 +05:00
f140dd3ad8 v1.2.0 2020-07-10 12:43:48 +05:00
699cccbdec v1.1.0 2020-07-10 00:15:05 +05:00
abc317955f Update README.md 2020-07-09 22:55:46 +05:00
be0d3b9e1f Update README.md 2020-07-09 16:59:14 +05:00
29fdeef742 Update README.md 2020-07-08 23:32:08 +05:00
119 changed files with 11749 additions and 757 deletions

View File

@ -0,0 +1,18 @@
{
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json",
"name": "default",
"image": "golang:1.22-bookworm",
"features": {
"ghcr.io/guiyomh/features/golangci-lint:0": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/sshd:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"streetsidesoftware.code-spell-checker"
]
}
},
"postCreateCommand": "go mod download"
}

View File

@ -1,3 +1,9 @@
/out
/node_modules
*.log
## Ignore everything
*
## Except the following files and directories
!/cmd
!/internal
!/l10n
!/templates
!/go.*

View File

@ -1,3 +1,5 @@
# EditorConfig docs: <https://editorconfig.org/>
root = true
[*]
@ -5,11 +7,12 @@ charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
indent_size = 2
trim_trailing_whitespace = true
[*.{yml, yaml, sh, conf}]
indent_size = 2
[{*.yml,*.yaml}]
ij_any_spaces_within_braces = false
ij_any_spaces_within_brackets = false
[Makefile]
[{Makefile,go.mod,*.go}]
indent_style = tab

3
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,3 @@
# @link <https://help.github.com/en/articles/about-code-owners>
* @tarampampam

57
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@ -0,0 +1,57 @@
# 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
labels: ['type:bug']
assignees: [tarampampam]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered
options:
- label: I have searched the existing issues
required: true
- label: And it has nothing to do with Traefik
required: true
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Start the container using command ...
2. Send an HTTP request using this curl command ...
3. See error
- type: textarea
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.
render: yaml
placeholder: Traefik, docker-compose, helm, etc.
- type: textarea
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.
render: shell
- type: textarea
attributes:
label: Anything else?
description: Links? References? Anything that will give us more context about the issue you are encountering!

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

@ -0,0 +1,13 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
# docs: https://git.io/JP3tm
blank_issues_enabled: false
contact_links:
- name: 🗣 Ask a Question, Discuss
url: https://github.com/tarampampam/error-pages/discussions
about: Feel free to ask anything
- name: 🌀 I have a question about Traefik..
url: https://community.traefik.io/
about: In this case - ask in the Traefik community

View File

@ -0,0 +1,34 @@
# 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
labels: ['type:feature_request']
assignees: [tarampampam]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered
options:
- label: I have searched the existing issues
required: true
- type: textarea
attributes:
label: Describe the problem to be solved
description: Please present a concise description of the problem to be addressed by this feature request
validations:
required: true
- type: textarea
attributes:
label: Suggest a solution
description: A concise description of your preferred solution
placeholder: If there are multiple solutions, please present each one separately
- type: textarea
attributes:
label: Additional context
description: Add any other context about the feature request
placeholder: You can attach images or log files by clicking this area to highlight it and then dragging files in

23
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,23 @@
# 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
updates:
- package-ecosystem: gomod
directory: /
groups: {gomod: {patterns: ['*']}}
schedule: {interval: monthly}
assignees: [tarampampam]
- package-ecosystem: github-actions
directory: /
groups: {github-actions: {patterns: ['*']}}
schedule: {interval: monthly}
assignees: [tarampampam]
- package-ecosystem: docker
directory: /
groups: {docker: {patterns: ['*']}}
schedule: {interval: monthly}
assignees: [tarampampam]

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

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

7
.github/renovate.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>tarampampam/.github//renovate/default",
":rebaseStalePrs"
]
}

27
.github/workflows/dependabot.yml vendored Normal file
View File

@ -0,0 +1,27 @@
# 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: {}
permissions:
contents: write
pull-requests: write
jobs:
dependabot: # https://tinyurl.com/e69djmen
name: Enable auto-merge for Dependabot PRs
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- uses: dependabot/fetch-metadata@v2
id: metadata
with: {github-token: "${{ secrets.GITHUB_TOKEN }}"}
- if: ${{ contains(fromJSON('["version-update:semver-minor", "version-update:semver-patch"]'), steps.metadata.outputs.update-type) }}
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

22
.github/workflows/documentation.yml vendored Normal file
View File

@ -0,0 +1,22 @@
# 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:
branches: [master, main]
paths: ['README.md']
jobs:
docker-hub-description:
name: Docker Hub Description
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_USER_PASSWORD }}
repository: tarampampam/error-pages

View File

@ -1,90 +1,105 @@
name: release
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
name: 🚀 Release
on:
release: # Docs: <https://git.io/JeBz1#release-event-release>
types: [published]
jobs:
demo:
name: Update demonstration, hosted on github pages
build:
name: Build for ${{ matrix.os }} (${{ matrix.arch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [linux, darwin, windows] # freebsd
arch: [amd64, arm64] # 386
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Setup NodeJS
uses: actions/setup-node@v1 # Action page: <https://github.com/actions/setup-node>
- uses: actions/checkout@v4
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- {uses: gacts/github-slug@v1, id: slug}
- 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/
- uses: svenstaro/upload-release-action@v2
with:
node-version: 12
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.values.outputs.binary-name }}
asset_name: ${{ steps.values.outputs.binary-name }}
tag: ${{ github.ref }}
- name: Generate version value
run: echo "::set-env name=PACKAGE_VERSION::${GITHUB_REF##*/v}"
- uses: actions/cache@v2
- 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:
path: '**/node_modules'
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn install
- name: Generate pages
run: ./bin/generator.js -c ./config.json -o ./out
- name: Copy static files
run: cp ./static/* ./out
- name: Upload artifact
uses: actions/upload-artifact@v2
with:
name: content
name: error-pages-static
path: out/
- name: Switch to github pages branch
uses: actions/checkout@v2
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:
ref: gh-pages
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: templates.zip
asset_name: error-pages-static.zip
tag: ${{ github.ref }}
- name: Download artifact
uses: actions/download-artifact@v2
demo:
name: Update the demo (GitHub Pages)
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/download-artifact@v4
with:
name: content
- name: Setup git
run: |
git config --global user.name "$GITHUB_ACTOR"
git config --global user.email 'actions@github.com'
git remote add github "https://$GITHUB_ACTOR:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git"
- name: Stage changes
run: git add .
- name: Commit changes
run: git commit --allow-empty -m "Deploying ${GITHUB_SHA} to Github Pages"
- name: Push changes
run: git push github --force
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:
- name: Check out code
uses: actions/checkout@v2
- uses: actions/checkout@v4
- {uses: gacts/github-slug@v1, id: slug}
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
fetch-depth: 1
- name: Generate image tag value
run: echo "::set-env name=IMAGE_TAG::${GITHUB_REF##*/[vV]}" # `/refs/tags/v1.2.3` -> `1.2.3`
- name: Make docker login
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_LOGIN }}" --password-stdin &> /dev/null
- name: Build image
run: docker build --tag "tarampampam/error-pages:${IMAGE_TAG}" --tag "tarampampam/error-pages:latest" -f ./Dockerfile .
- name: Push version image
run: docker push "tarampampam/error-pages:${IMAGE_TAG}"
- name: Push latest image
run: docker push "tarampampam/error-pages:latest"
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
build-args: "APP_VERSION=${{ steps.slug.outputs.version }}"
tags: ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
# tags: | # TODO: uncomment after the stable release
# tarampampam/error-pages:latest
# tarampampam/error-pages:${{ steps.slug.outputs.version }}
# tarampampam/error-pages:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
# tarampampam/error-pages:${{ steps.slug.outputs.version-major }}
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:latest
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version }}
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}
# ghcr.io/${{ github.actor }}/${{ github.event.repository.name }}:${{ steps.slug.outputs.version-major }}

View File

@ -1,55 +1,92 @@
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:
branches:
- master
tags-ignore:
- '**'
branches: [master, main]
tags-ignore: ['**']
paths-ignore: ['**.md']
pull_request:
paths-ignore: ['**.md']
jobs: # Docs: <https://git.io/JvxXE>
generate:
name: Try to run generator
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
gitleaks:
name: Check for GitLeaks
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- {uses: actions/checkout@v4, with: {fetch-depth: 0}}
- uses: gacts/gitleaks@v1
- name: Setup NodeJS
uses: actions/setup-node@v1 # Action page: <https://github.com/actions/setup-node>
with:
node-version: 12
- uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn install
- name: Run generator
run: ./bin/generator.js -c ./config.json -o ./out
- name: Test file creation
run: test -f ./out/ghost/404.html
docker-build:
name: Build docker image
golangci-lint:
name: Run golangci-lint
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- uses: actions/checkout@v4
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- uses: golangci/golangci-lint-action@v6
- name: Build docker image
run: docker build -f ./Dockerfile --tag image:local .
go-test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- run: go test -race ./...
- name: Run docker image
run: docker run --rm -d -p "8080:8080" -e "TEMPLATE_NAME=ghost" image:local
build:
name: Build for ${{ matrix.os }} (${{ matrix.arch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [linux, darwin, windows] # freebsd
arch: [amd64, arm64] # 386
needs: [golangci-lint, go-test]
steps:
- uses: actions/checkout@v4
- {uses: actions/setup-go@v5, with: {go-version-file: go.mod}}
- {uses: gacts/github-slug@v1, id: slug}
- env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
CGO_ENABLED: 0
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/
- if: matrix.os == 'linux' && matrix.arch == 'amd64'
run: ./error-pages --version && ./error-pages -h
- 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/404.html
test -f ./out/shuffle/404.html
test -f ./out/noise/404.html
test -f ./out/hacker-terminal/404.html
test -f ./out/cats/404.html
test -f ./out/lost-in-space/404.html
test -f ./out/app-down/404.html
test -f ./out/connection/404.html
test -f ./out/orient/404.html
- name: Pause
run: sleep 2
- name: Send HTTP request
run: curl -sS --fail "http://127.0.0.1:8080/500.html"
docker-image:
name: Build the docker image
runs-on: ubuntu-latest
needs: [golangci-lint, go-test]
steps:
- uses: actions/checkout@v4
- {uses: gacts/github-slug@v1, id: slug}
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: false
build-args: "APP_VERSION=${{ steps.slug.outputs.commit-hash-short }}"
tags: app:ci

27
.gitignore vendored
View File

@ -1,18 +1,21 @@
## IDEs
/.vscode
/.idea
/.vscode
## Vendors
/node_modules
## Lock files (use yarn only)
/package-lock.json
## Dist
/out
## Binaries
/error-pages
## Temp dirs & trash
/npm-debug.log
/yarn-error.log
/temp
/tmp
/*-old
/cmd/test*
.DS_Store
.env*
/go.work*
*.cache
*.out
*.env
/out
/gen
/cover*.*
/report.xml

127
.golangci.yml Normal file
View File

@ -0,0 +1,127 @@
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
# docs: https://github.com/golangci/golangci-lint#config-file
run:
timeout: 2m
modules-download-mode: readonly
allow-parallel-runners: true
output:
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:
enable:
- shadow
gocyclo:
min-complexity: 15
godot:
scope: declarations
capital: false
dupl:
threshold: 100
goconst:
min-len: 2
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:
require-specific: true
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.
# Default: 30
max-func-lines: 100
linters: # All available linters list: <https://golangci-lint.run/usage/linters/>
disable-all: true
enable:
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
- bidichk # Checks for dangerous unicode character sequences
- dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f())
- dupl # Tool for code clone detection
- errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases
- errorlint # find code that will cause problems with the error wrapping scheme introduced in Go 1.13
- exhaustive # check exhaustiveness of enum switch statements
- exportloopref # checks for pointers to enclosing loop variables
- funlen # Tool for detection of long functions
- gci # Gci control golang package import order and make it always deterministic
- godot # Check if comments end in a period
- gochecknoglobals # Checks that no globals are present in Go code
- gochecknoinits # Checks that no init functions are present in Go code
- gocognit # Computes and checks the cognitive complexity of functions
- goconst # Finds repeated strings that could be replaced by a constant
- gocritic # The most opinionated Go source code linter
- gocyclo # Computes and checks the cyclomatic complexity of functions
- gofmt # Gofmt checks whether code was gofmt-ed. By default, this tool runs with -s option to check for code simplification
- goimports # Goimports does everything that gofmt does. Additionally, it checks unused imports
- mnd # An analyzer to detect magic numbers
- goprintffuncname # Checks that printf-like functions are named with `f` at the end
- gosec # Inspects source code for security problems
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string
- ineffassign # Detects when assignments to existing variables are not used
- lll # Reports long lines
- forbidigo # Forbids identifiers
- misspell # Finds commonly misspelled English words in comments
- nakedret # Finds naked returns in functions greater than a specified function length
- nestif # Reports deeply nested if statements
- nlreturn # checks for a new line before return and branch statements to increase code clarity
- nolintlint # Reports ill-formed or insufficient nolint directives
- prealloc # Finds slice declarations that could potentially be preallocated
- promlinter # Check Prometheus metrics naming via promlint.
- typecheck # Like the front-end of a Go compiler, parses and type-checks Go code
- unconvert # Remove unnecessary type conversions
- whitespace # Tool for detection of leading and trailing whitespace
- wsl # Whitespace Linter - Forces you to use empty lines!
- unused # Checks Go code for unused constants, variables, functions and types
- gosimple # Linter for Go source code that specializes in simplifying code
- staticcheck # It's a set of rules from staticcheck
- asasalint # Check for pass []any as any in variadic func(...any)
- bodyclose # Checks whether HTTP response body is closed successfully
- contextcheck # Check whether the function uses a non-inherited context
- decorder # Check declaration order and count of types, constants, variables and functions
- dupword # Checks for duplicate words in the source code
- durationcheck # Check for two durations multiplied together
- errchkjson # Checks types passed to the json encoding functions
- errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error
issues:
exclude-dirs:
- .github
- .git
- tmp
- temp
- testdata
exclude-rules:
- {path: flags\.go, linters: [gochecknoglobals, lll, mnd, dupl]}
- {path: env\.go, linters: [lll, gosec]}
- path: _test\.go
linters:
- dupl
- dupword
- lll
- nolintlint
- funlen
- gocognit
- noctx
- goconst
- nlreturn
- gochecknoglobals

View File

@ -1,33 +0,0 @@
# Changelog
All notable changes to this package will be documented in this file.
The format is based on [Keep a Changelog][keepachangelog] and this project adheres to [Semantic Versioning][semver].
## v1.0.1
### Changed
- Repository (not docker image) renamed from `error-pages-docker` to `error-pages`
- `configuration.json` renamed to `config.json`
- Makefile contains new targets (`install`, `gen`, `preview`)
- Generator logging messages
### Added
- `docker-compose` for development
### Fixed
- Readme file content [#1]
[#1]:https://github.com/tarampampam/error-pages/issues/1
## v1.0.0
### Changed
- First project release
[keepachangelog]:https://keepachangelog.com/en/1.0.0/
[semver]:https://semver.org/spec/v2.0.0.html

View File

@ -1,22 +1,99 @@
# Image page: <https://hub.docker.com/_/node>
FROM node:12.16.2-alpine as builder
# syntax=docker/dockerfile:1
# -✂- this stage is used to develop and build the application locally -------------------------------------------------
FROM docker.io/library/golang:1.22-bookworm AS develop
# use the /var/tmp as the GOPATH to reuse the modules cache
ENV GOPATH="/var/tmp/go"
RUN set -x \
# renovate: source=github-releases name=golangci/golangci-lint
&& GOLANGCI_LINT_VERSION="1.59.1" \
&& wget -O- -nv "https://cdn.jsdelivr.net/gh/golangci/golangci-lint@v${GOLANGCI_LINT_VERSION}/install.sh" \
| sh -s -- -b /bin "v${GOLANGCI_LINT_VERSION}"
RUN set -x \
# customize the shell prompt (for the bash)
&& echo "PS1='\[\033[1;36m\][go] \[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'" >> /etc/bash.bashrc
WORKDIR /src
COPY . .
# burn the modules cache
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 develop AS compile
# can be passed with any prefix (like `v1.2.3@GITHASH`), e.g.: `docker build --build-arg "APP_VERSION=v1.2.3" .`
ARG APP_VERSION="undefined@docker"
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
# -✂- 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 --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
# 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 use inside other Docker images, for example)
RUN set -x \
&& yarn install --frozen-lockfile \
&& ./bin/generator.js -c ./config.json -o ./out
&& mkdir ./html \
&& ./../bin/error-pages build --index --target-dir ./html \
&& ls -l ./html
# Image page: <https://hub.docker.com/_/nginx>
FROM nginx:1.18-alpine
# -✂- and this is the final stage (an empty filesystem is used) -------------------------------------------------------
FROM scratch AS runtime
COPY --from=builder --chown=nginx /src/docker/docker-entrypoint.sh /docker-entrypoint.sh
COPY --from=builder --chown=nginx /src/docker/nginx-server.conf /etc/nginx/conf.d/default.conf
COPY --from=builder --chown=nginx /src/static /opt/html
COPY --from=builder --chown=nginx /src/out /opt/html
ARG APP_VERSION="undefined@docker"
ENTRYPOINT ["/docker-entrypoint.sh"]
LABEL \
# 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" \
org.opencontainers.image.source="https://github.com/tarampampam/error-pages" \
org.opencontainers.image.vendor="tarampampam" \
org.opencontainers.version="$APP_VERSION" \
org.opencontainers.image.licenses="MIT"
CMD ["nginx", "-g", "daemon off;"]
# import from builder
COPY --from=rootfs /tmp/rootfs /
# use an unprivileged user
USER 10001:10001
WORKDIR /opt
# to find out which environment variables and CLI arguments are supported by the application, run the app
# with the `--help` flag or refer to the documentation at https://github.com/tarampampam/error-pages#readme
ENV LOG_LEVEL="warn"
# 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-format", "json", "serve"]

View File

@ -1,32 +1,38 @@
#!/usr/bin/make
# Makefile readme (ru): <http://linux.yaroslavl.ru/docs/prog/gnu_make_3-79_russian_manual.html>
# Makefile readme (en): <https://www.gnu.org/software/make/manual/html_node/index.html#SEC_Contents>
SHELL = /bin/sh
DOCKER_BIN = $(shell command -v docker 2> /dev/null)
DC_BIN = $(shell command -v docker-compose 2> /dev/null)
DC_RUN_ARGS = --rm --user "$(shell id -u):$(shell id -g)"
APP_NAME = $(notdir $(CURDIR))
.PHONY : help install gen preview
.DEFAULT_GOAL : help
help: ## Show this help
@printf "\033[33m%s:\033[0m\n" 'Available commands'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[32m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[32m%-11s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## Install all dependencies
$(DC_BIN) run $(DC_RUN_ARGS) app yarn install
.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 \
"
gen: ## Generate error pages
$(DC_BIN) run $(DC_RUN_ARGS) app nodejs ./bin/generator.js -c ./config.json -o ./out
.PHONY: down
down: ## Stop the application
docker compose down --remove-orphans
preview: ## Build docker image and start preview
$(DOCKER_BIN) build -f ./Dockerfile -t $(APP_NAME):local .
@printf "\n \e[30;42m %s \033[0m\n\n" 'Now open in your favorite browser <http://127.0.0.1:8081> and press CTRL+C for stopping'
$(DOCKER_BIN) run --rm -i -p 8081:8080 $(APP_NAME):local
.PHONY: shell
shell: ## Start shell into development environment
docker compose run -ti $(DC_RUN_ARGS) develop bash
shell: ## Start shell into container with node
$(DC_BIN) run $(DC_RUN_ARGS) app sh
.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 ./...

664
README.md
View File

@ -1,78 +1,191 @@
<p align="center">
<img src="https://hsto.org/webt/rm/9y/ww/rm9ywwx3gjv9agwkcmllhsuyo7k.png" width="94" alt="" />
<a href="https://github.com/tarampampam/error-pages#readme"><img src="https://socialify.git.ci/tarampampam/error-pages/image?description=1&font=Raleway&forks=1&issues=1&logo=https%3A%2F%2Fhsto.org%2Fwebt%2Frm%2F9y%2Fww%2Frm9ywwx3gjv9agwkcmllhsuyo7k.png&owner=1&pulls=1&pattern=Solid&stargazers=1&theme=Dark" alt="banner" width="100%" /></a>
</p>
# HTTP's error pages in Docker image
<p align="center">
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/tarampampam/error-pages?longCache=true&label=&logo=go&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/tests.yml?branch=master&maxAge=30&label=tests&logo=github&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/actions"><img src="https://img.shields.io/github/actions/workflow/status/tarampampam/error-pages/release.yml?maxAge=30&label=release&logo=github&style=flat-square" alt="" /></a>
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/pulls/tarampampam/error-pages.svg?maxAge=30&label=pulls&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://hub.docker.com/r/tarampampam/error-pages"><img src="https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30&label=size&logo=docker&logoColor=white&style=flat-square" alt="" /></a>
<a href="https://github.com/tarampampam/error-pages/blob/master/LICENSE"><img src="https://img.shields.io/github/license/tarampampam/error-pages.svg?maxAge=30&style=flat-square" alt="" /></a>
</p>
[![Build Status][badge_build_status]][link_build_status]
[![Image size][badge_size_latest]][link_docker_hub]
[![License][badge_license]][link_license]
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:
This repository 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])
- A very simple [generator](./bin/generator.js) _(`nodejs`)_ for HTTP error pages _(like `404: Not found`)_ with different templates supports
- Dockerfile for [docker image][link_docker_hub] with generated pages and `nginx` as web server
[preview-sources]:https://github.com/tarampampam/error-pages/tree/gh-pages
[preview-demo]:https://tarampampam.github.io/error-pages/
### Demo
## 🔥 Features List
Generated pages (from the latest release) always [accessible here][link_branch_gh_pages] _(sources)_ and on GitHub pages [here][link_gh_pages].
- 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], [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!
## Development
[fasthttp]:https://github.com/valyala/fasthttp
[traefik]:https://github.com/traefik/traefik
> For project development we use `docker-ce` + `docker-compose`. Make sure you have them installed.
## 🧩 Install
Install `nodejs` dependencies:
Download the latest binary file for your OS/architecture from the [releases page][latest-release] or use our Docker image:
| Registry | Image |
|-----------------------------------|-----------------------------------|
| [GitHub Container Registry][ghcr] | `ghcr.io/tarampampam/error-pages` |
| [Docker Hub][docker-hub] (mirror) | `tarampampam/error-pages` |
> [!IMPORTANT]
> Using the `latest` tag for the Docker image is highly discouraged due to potential backward-incompatible changes
> during **major** upgrades. Please use tags in the `X.Y.Z` format.
💣 **Or** you can also download the **already rendered** error pages pack as a [zip][pages-pack-zip] or
[tar.gz][pages-pack-tar-gz] archive.
[latest-release]:https://github.com/tarampampam/error-pages/releases/latest
[docker-hub]:https://hub.docker.com/r/tarampampam/error-pages
[ghcr]:https://github.com/tarampampam/error-pages/pkgs/container/error-pages
[pages-pack-zip]:https://github.com/tarampampam/error-pages/zipball/gh-pages/
[pages-pack-tar-gz]:https://github.com/tarampampam/error-pages/tarball/gh-pages/
## 🛠 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
$ make install
./error-pages serve
# or
docker run --rm -p '8080:8080/tcp' tarampampam/error-pages serve
```
If you want to generate error pages on your machine _(after that look into output directory)_:
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
$ make gen
curl -H 'X-Code: 500' http://127.0.0.1:8080/
```
If you want to preview the pages using the Docker image:
The server respects the `Content-Type` HTTP header (and `X-Format`), delivering responses in requested formats
such as HTML, XML, JSON, and PlainText. Customization of these formats is possible via CLI flags or environment
variables.
For integration with [ingress-nginx][ingress-nginx] or debugging purposes, start the server with `--show-details`
(or set the environment variable `SHOW_DETAILS=true`) to enrich error pages (including JSON and XML responses)
with upstream proxy information.
Switch themes using the `TEMPLATE_NAME` environment variable or the `--template-name` flag; available templates
are detailed in the readme file below.
> [!TIP]
> Use the `--rotation-mode` flag or the `TEMPLATES_ROTATION_MODE` environment variable to automate theme
> rotation. Available modes include `random-on-startup`, `random-on-each-request`, `random-hourly`,
> and `random-daily`.
To proxy HTTP headers from requests to responses, utilize the `--proxy-headers` flag or environment variable
(comma-separated list of headers).
<details>
<summary><strong>🚀 Generate a set of error pages using built-in or my own template</strong></summary>
Generating a set of error pages is straightforward. If you prefer to use your own template, start by crafting it.
Create a file like this:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ code }}</title>
</head>
<body>
<h1>{{ message }}: {{ description }}</h1>
</body>
</html>
```
Save it as `my-template.html` and use it as your custom template. Then, generate your error pages using the command:
```bash
$ make preview
mkdir -p /path/to/output
./error-pages build --add-template /path/to/your/my-template.html --target-dir /path/to/output
```
Can be used for [Traefik error pages customization](https://docs.traefik.io/middlewares/errorpages/).
## Templates
Name | Preview
:------: | :-----:
`ghost` | ![ghost](https://hsto.org/webt/zg/ul/cv/zgulcvxqzhazoebxhg8kpxla8lk.png)
## Usage
Generated error pages in our [docker image][link_docker_hub] permanently located in directory `/opt/html/%THEME_NAME%`.
#### Supported environment variables
Name | Description
--------------- | -----------
`TEMPLATE_NAME` | "default" pages template _(allows to use error pages without passing theme name in URL - `http://127.0.0.1/500.html` instead `http://127.0.0.1/ghost/500.html`)_
### HTTP server for error pages serving only
Execute in your shell:
This will create error pages based on your template in the specified output directory:
```bash
$ docker run --rm -p "8082:8080" tarampampam/error-pages
$ cd /path/to/output && tree .
├── my-template
│ ├── 400.html
│ ├── 401.html
│ ├── 403.html
│ ├── 404.html
│ ├── 405.html
│ ├── 407.html
│ ├── 408.html
│ ├── 409.html
│ ├── 410.html
│ ├── 411.html
│ ├── 412.html
│ ├── 413.html
│ ├── 416.html
│ ├── 418.html
│ ├── 429.html
│ ├── 500.html
│ ├── 502.html
│ ├── 503.html
│ ├── 504.html
│ └── 505.html
$ cat my-template/403.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>403</title>
</head>
<body>
<h1>Forbidden: Access is forbidden to the requested page</h1>
</body>
</html>
```
And open in your browser `http://127.0.0.1:8082/ghost/400.html`.
</details>
### Custom error pages for [nginx][link_nginx]
<details>
<summary><strong>🚀 Customize error pages within your own Nginx Docker image</strong></summary>
You can build your own docker image with `nginx` and our error pages:
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`
# File: nginx.conf
server {
listen 80;
@ -97,62 +210,439 @@ server {
}
```
```dockerfile
FROM nginx:1.18-alpine
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 \
./nginx.conf /etc/nginx/conf.d/default.conf
COPY --chown=nginx \
--from=tarampampam/error-pages:1.0.0 \
--from=ghcr.io/tarampampam/error-pages:3 \
/opt/html/ghost /usr/share/nginx/errorpages/_error-pages
```
> More info about `error_page` directive can be [found here](http://nginx.org/en/docs/http/ngx_http_core_module.html#error_page).
Now, we can build the image:
### Custom error pages for [Traefik][link_traefik]
Simple traefik service configuration for usage in [docker swarm][link_swarm] (**change with your needs**):
```yaml
# Work in progress
```bash
docker build --tag your-nginx:local -f ./Dockerfile .
```
## Changes log
And voilà! Let's start the image and test if everything is working as expected:
[![Release date][badge_release_date]][link_releases]
[![Commits since latest release][badge_commits_since_release]][link_commits]
```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
```
Changes log can be [found here][link_changes_log].
</details>
## Support
<details>
<summary><strong>🚀 Usage with Traefik and local Docker Compose</strong></summary>
[![Issues][badge_issues]][link_issues]
[![Issues][badge_pulls]][link_pulls]
Instead of thousands of words, let's take a look at one compose file:
If you will find any package errors, please, [make an issue][link_create_issue] in current repository.
```yaml
# file: compose.yml (or docker-compose.yml)
## License
services:
traefik:
image: docker.io/library/traefik:v3.1
command:
#- --log.level=DEBUG
- --api.dashboard=true # activate dashboard
- --api.insecure=true # enable the API in insecure mode
- --providers.docker=true # enable Docker backend with default settings
- --providers.docker.exposedbydefault=false # do not expose containers by default
- --entrypoints.web.address=:80 # --entrypoints.<name>.address for ports, 80 (i.e., name = web)
ports:
- "80:80/tcp" # HTTP (web)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
traefik.enable: true
# dashboard
traefik.http.routers.traefik.rule: Host(`traefik.localtest.me`)
traefik.http.routers.traefik.service: api@internal
traefik.http.routers.traefik.entrypoints: web
traefik.http.routers.traefik.middlewares: error-pages-middleware
depends_on:
error-pages: {condition: service_healthy}
This is open-sourced software licensed under the [MIT License][link_license].
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
[badge_build_status]:https://img.shields.io/github/workflow/status/tarampampam/error-pages/tests/master
[badge_release_date]:https://img.shields.io/github/release-date/tarampampam/error-pages.svg?style=flat-square&maxAge=180
[badge_commits_since_release]:https://img.shields.io/github/commits-since/tarampampam/error-pages/latest.svg?style=flat-square&maxAge=180
[badge_issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?style=flat-square&maxAge=180
[badge_pulls]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?style=flat-square&maxAge=180
[badge_license]:https://img.shields.io/github/license/tarampampam/error-pages.svg?longCache=true
[badge_size_latest]:https://img.shields.io/docker/image-size/tarampampam/error-pages/latest?maxAge=30
[link_releases]:https://github.com/tarampampam/error-pages/releases
[link_commits]:https://github.com/tarampampam/error-pages/commits
[link_changes_log]:https://github.com/tarampampam/error-pages/blob/master/CHANGELOG.md
[link_issues]:https://github.com/tarampampam/error-pages/issues
[link_pulls]:https://github.com/tarampampam/error-pages/pulls
[link_build_status]:https://travis-ci.org/tarampampam/error-pages
[link_create_issue]:https://github.com/tarampampam/error-pages/issues/new
[link_license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
[link_docker_hub]:https://hub.docker.com/r/tarampampam/error-pages/
[link_nginx]:http://nginx.org/
[link_traefik]:https://docs.traefik.io/
[link_swarm]:https://docs.docker.com/engine/swarm/
[link_branch_gh_pages]:https://github.com/tarampampam/error-pages/tree/gh-pages
[link_gh_pages]:https://tarampampam.github.io/error-pages/
nginx-or-any-another-service:
image: docker.io/library/nginx:1.27-alpine
labels:
traefik.enable: true
traefik.http.routers.test-service.rule: Host(`test.localtest.me`)
traefik.http.routers.test-service.entrypoints: web
traefik.http.routers.test-service.middlewares: error-pages-middleware
```
After executing `docker compose up` in the same directory as the `compose.yml` file, you can:
- Open the Traefik dashboard [at `traefik.localtest.me`](http://traefik.localtest.me/dashboard/#/)
- [View customized error pages on the Traefik dashboard](http://traefik.localtest.me/foobar404)
- Open the nginx index page [at `test.localtest.me`](http://test.localtest.me/)
- View customized error pages for non-existent [pages](http://test.localtest.me/404) and [domains](http://404.localtest.me/)
Isn't this kind of magic? 😀
</details>
<details>
<summary><strong>🚀 Kubernetes (K8s) & Ingress Nginx</strong></summary>
Error-pages can be configured to work with the [ingress-nginx][ingress-nginx] helm chart in Kubernetes.
- Set the `custom-http-errors` config value
- Enable default backend
- Set the default backend image
```yaml
controller:
config:
custom-http-errors: >-
401,403,404,500,501,502,503
defaultBackend:
enabled: true
image:
repository: ghcr.io/tarampampam/error-pages
tag: '3' # using the latest tag is highly discouraged
extraEnvs:
- name: TEMPLATE_NAME # Optional: change the default theme
value: l7
- name: SHOW_DETAILS # Optional: enables the output of additional information on error pages
value: 'true'
```
</details>
## 🦾 Performance
Hardware used:
- 12th Gen Intel® Core™ i7-1260P (16 cores)
- 32 GiB RAM
RPS: **~180k** 🔥 requests served without any errors, with peak memory usage ~60 MiB under the default configuration
<details>
<summary>Performance test details (click to expand)</summary>
```shell
$ ulimit -aH | grep file
core file size (blocks, -c) unlimited
file size (blocks, -f) unlimited
open files (-n) 1048576
file locks (-x) unlimited
$ go build ./cmd/error-pages/ && ./error-pages --log-level warn serve
$ ./error-pages perftest # in separate terminal
Starting the test to bomb ONE PAGE (code). Please, be patient...
Test completed successfully. Here is the output:
Running 15s test @ http://127.0.0.1:8080/
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 3.54ms 4.90ms 74.57ms 86.55%
Req/Sec 16.47k 2.89k 38.11k 69.46%
2967567 requests in 15.09s, 44.70GB read
Requests/sec: 196596.49
Transfer/sec: 2.96GB
Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...
Test completed successfully. Here is the output:
Running 15s test @ http://127.0.0.1:8080/
12 threads and 400 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 4.25ms 6.03ms 74.23ms 86.97%
Req/Sec 14.29k 2.75k 32.16k 69.63%
2563245 requests in 15.07s, 38.47GB read
Requests/sec: 170062.69
Transfer/sec: 2.55GB
```
</details>
<!--GENERATED:CLI_DOCS-->
<!-- Documentation inside this block generated by github.com/urfave/cli; DO NOT EDIT -->
## 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` |
<!--/GENERATED:CLI_DOCS-->
## 🪂 Templates (themes)
The following templates are built-in and available for use without any additional setup:
> [!NOTE]
> The `cats` template is the only one of those that fetches resources (the actual cat pictures) from external
> servers - all other templates are self-contained.
<table>
<thead>
<tr>
<th>Template</th>
<th>Preview</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center">
<code>app-down</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fapp-down.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/4e668a56-a4c4-47cd-ac4d-b6b45db54ab8">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/ad4b4fd7-7c7b-4bdc-a6b6-44f9ba7f77ca">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>cats</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fcats.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/5689880b-f770-406c-81dd-2d28629e6f2e">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/056cd00e-bc9a-4120-8325-310d7b0ebd1b">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>connection</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fconnection.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/3f03dc1b-c1ee-4a91-b3d7-e3b93c79020e">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/099ecc2d-e724-4d9c-b5ed-66ddabd71139">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>ghost</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fghost.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/714482ab-f8c1-4455-8ae8-b2ae78f7a2c6">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/f253dfe7-96a0-4e96-915b-d4c544d4a237">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>hacker-terminal</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fhacker-terminal.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/c197fc35-0844-43d0-9830-82440cee4559">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>l7</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fl7.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/18e43ea3-6389-4459-be41-0fc6566a073f">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/05f26669-94ec-40ce-8d67-a199cde54202">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>lost-in-space</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Flost-in-space.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/debf87c0-6f27-41a8-b141-ee3464cbd6cc">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/c347e63d-13a7-46d4-81b9-b25266819a1d">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>noise</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fnoise.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/4cc5c3bd-6ebb-4e96-bee8-02d4ad4e7266">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>orient</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Forient.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/bc2b0dad-c32c-4628-98f6-e3eab61dd1f2">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/8fc0a7ea-694d-49ce-bb50-3ea032d52d1e">
</picture>
</td>
</tr>
<tr>
<td align="center">
<code>shuffle</code><br/><br/>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Fshuffle.json&query=%24.count&label=used%20times" alt="used times">
</td>
<td>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/tarampampam/error-pages/assets/7326800/7504b7c3-b0cb-4991-9ac2-759cd6c50fc0">
<img align="center" src="https://github.com/tarampampam/error-pages/assets/7326800/d2a73fc8-cf5f-4f42-bff8-cce33d8ae47e">
</picture>
</td>
</tr>
</tbody>
</table>
> [!NOTE]
> The "used times" counter increments when someone start the server with the specified template. Stats service does
> not collect any information about location, IP addresses, and so on. Moreover, the stats are open and available for
> everyone at [error-pages.goatcounter.com](https://error-pages.goatcounter.com/). This is simply a counter to display
> how often a particular template is used, nothing more.
## 🦾 Contributors
I want to say a big thank you to everyone who contributed to this project:
[![contributors](https://contrib.rocks/image?repo=tarampampam/error-pages)][contributors]
[contributors]:https://github.com/tarampampam/error-pages/graphs/contributors
## 👾 Support
[![Issues][badge-issues]][issues]
[![Issues][badge-prs]][prs]
If you encounter any bugs in the project, please [create an issue][new-issue] in this repository.
[badge-issues]:https://img.shields.io/github/issues/tarampampam/error-pages.svg?maxAge=45
[badge-prs]:https://img.shields.io/github/issues-pr/tarampampam/error-pages.svg?maxAge=45
[issues]:https://github.com/tarampampam/error-pages/issues
[prs]:https://github.com/tarampampam/error-pages/pulls
[new-issue]:https://github.com/tarampampam/error-pages/issues/new/choose
## 📖 License
This is open-sourced software licensed under the [MIT License][license].
[license]:https://github.com/tarampampam/error-pages/blob/master/LICENSE
[ingress-nginx]:https://github.com/kubernetes/ingress-nginx/tree/main/charts/ingress-nginx

View File

@ -1,71 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const yargs = require('yargs');
const options = yargs
.usage('Usage: -c <config.json> -d <output-directory>')
.option("c", {alias: "config", describe: "config file path", type: "string", demandOption: true})
.option("o", {alias: "out", describe: "output directory path", type: "string", demandOption: true})
.argv;
const configFile = options.config;
const outDir = options.out;
try {
// Make sure that config file exists
if (! fs.existsSync(configFile)) {
throw new Error(`Config file "${configFile}" was not found`);
}
// Create output directory (if needed)
if (!fs.existsSync(outDir)){
fs.mkdirSync(outDir);
}
// Read JSON config file and parse into object
const configContent = JSON.parse(fs.readFileSync(configFile));
// Loop over all defined templates in configuration file
configContent.templates.forEach((templateConfig) => {
// Make sure that template layout file exists
if (! fs.existsSync(templateConfig.path)) {
throw new Error(`Template "${templateConfig.name}" was not found in "${templateConfig.path}"`);
}
// Read layout content into memory prepare output directory for template
const layoutContent = String(fs.readFileSync(templateConfig.path));
const templateOutDir = path.join(outDir, templateConfig.name);
if (!fs.existsSync(templateOutDir)){
fs.mkdirSync(templateOutDir);
}
console.info(`Use template "${templateConfig.name}" located in "${templateConfig.path}"`);
// Loop over all pages
configContent.pages.forEach((pageConfig) => {
let outPath = path.join(templateOutDir, `${pageConfig.code}.${configContent.output.file_extension}`);
console.info(` [${templateConfig.name}:${pageConfig.code}] Output: ${outPath}`);
// Make replaces
let result = layoutContent
.replace(/{{\s?code\s?}}/g, pageConfig.code)
.replace(/{{\s?message\s?}}/g, pageConfig.message)
.replace(/{{\s?description\s?}}/g, pageConfig.description);
// And write into result file
fs.writeFileSync(outPath, result, {
encoding: "utf8",
flag: "w+",
mode: 0o644
})
});
})
} catch (err) {
console.error(err);
process.exit(1);
}

35
cmd/error-pages/main.go Normal file
View File

@ -0,0 +1,35 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"go.uber.org/automaxprocs/maxprocs"
"gh.tarampamp.am/error-pages/internal/cli"
)
// main CLI application entrypoint.
func main() {
// automatically set GOMAXPROCS to match Linux container CPU quota
_, _ = maxprocs.Set(maxprocs.Min(1), maxprocs.Logger(func(_ string, _ ...any) {}))
if err := run(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
// run this CLI application.
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 (cli.NewApp(filepath.Base(os.Args[0]))).Run(ctx, os.Args)
}

19
compose.yml Normal file
View File

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

View File

@ -1,108 +0,0 @@
{
"templates": [
{
"name": "ghost",
"path": "./templates/ghost.html"
}
],
"output": {
"file_extension": "html"
},
"pages": [
{
"code": 400,
"message": "Bad Request",
"description": "The server did not understand the request"
},
{
"code": 401,
"message": "Unauthorized",
"description": "The requested page needs a username and a password"
},
{
"code": 403,
"message": "Forbidden",
"description": "Access is forbidden to the requested page"
},
{
"code": 404,
"message": "Not Found",
"description": "The server can not find the requested page"
},
{
"code": 405,
"message": "Method Not Allowed",
"description": "The method specified in the request is not allowed"
},
{
"code": 407,
"message": "Proxy Authentication Required",
"description": "You must authenticate with a proxy server before this request can be served"
},
{
"code": 408,
"message": "Request Timeout",
"description": "The request took longer than the server was prepared to wait"
},
{
"code": 409,
"message": "Conflict",
"description": "The request could not be completed because of a conflict"
},
{
"code": 410,
"message": "Gone",
"description": "The requested page is no longer available"
},
{
"code": 411,
"message": "Length Required",
"description": "The \"Content-Length\" is not defined. The server will not accept the request without it"
},
{
"code": 412,
"message": "Precondition Failed",
"description": "The pre condition given in the request evaluated to false by the server"
},
{
"code": 413,
"message": "Payload Too Large",
"description": "The server will not accept the request, because the request entity is too large"
},
{
"code": 416,
"message": "Requested Range Not Satisfiable",
"description": "The requested byte range is not available and is out of bounds"
},
{
"code": 429,
"message": "Too Many Requests",
"description": "Too many requests in a given amount of time"
},
{
"code": 500,
"message": "Internal Server Error",
"description": "The server met an unexpected condition"
},
{
"code": 502,
"message": "Bad Gateway",
"description": "The server received an invalid response from the upstream server"
},
{
"code": 503,
"message": "Service Unavailable",
"description": "The server is temporarily overloading or down"
},
{
"code": 504,
"message": "Gateway Timeout",
"description": "The gateway has timed out"
},
{
"code": 505,
"message": "HTTP Version Not Supported",
"description": "The server does not support the \"http protocol\" version"
}
]
}

View File

@ -1,17 +0,0 @@
version: '3.2'
volumes:
tmp-data:
services:
app:
image: node:12.16.2-alpine # Image page: <https://hub.docker.com/_/node>
working_dir: /src
environment:
HOME: /tmp
PS1: '\[\033[1;32m\]\[\033[1;36m\][\u@docker] \[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]'
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
- .:/src:cached
- tmp-data:/tmp:cached

View File

@ -1,16 +0,0 @@
#!/usr/bin/env sh
set -e
TEMPLATE_NAME=${TEMPLATE_NAME:-} # string|empty
if [ -n "$TEMPLATE_NAME" ]; then
echo "$0: set pages for template '$TEMPLATE_NAME' as default (make accessible in root directory)";
if [ ! -d "/opt/html/$TEMPLATE_NAME" ]; then
(>&2 echo "$0: template '$TEMPLATE_NAME' was not found!"); exit 1;
fi;
ln -f -s "/opt/html/$TEMPLATE_NAME/"* /opt/html;
fi;
exec "$@"

View File

@ -1,9 +0,0 @@
server {
listen 8080;
server_name _;
location / {
root /opt/html;
index index.html index.htm;
}
}

26
go.mod Normal file
View File

@ -0,0 +1,26 @@
module gh.tarampamp.am/error-pages
go 1.22
require (
github.com/stretchr/testify v1.9.0
github.com/urfave/cli-docs/v3 v3.0.0-alpha5
github.com/urfave/cli/v3 v3.0.0-alpha9
github.com/valyala/fasthttp v1.55.0
go.uber.org/automaxprocs v1.5.3
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

45
go.sum Normal file
View File

@ -0,0 +1,45 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/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/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/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-docs/v3 v3.0.0-alpha5 h1:H1oWnR2/GN0dNm2PVylws+GxSOD6YOwW/jI5l78YfPk=
github.com/urfave/cli-docs/v3 v3.0.0-alpha5/go.mod h1:AIqom6Q60U4tiqHp41i7+/AB2XHgi1WvQ7jOFlccmZ4=
github.com/urfave/cli/v3 v3.0.0-alpha9 h1:P0RMy5fQm1AslQS+XCmy9UknDXctOmG/q/FZkUFnJSo=
github.com/urfave/cli/v3 v3.0.0-alpha9/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

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

View File

@ -0,0 +1,17 @@
package appmeta
import "strings"
// version value will be set during compilation.
var version = "v0.0.0@undefined"
// Version returns version value (without `v` prefix).
func Version() string {
var v = strings.TrimSpace(version)
if len(v) > 1 && ((v[0] == 'v' || v[0] == 'V') && (v[1] >= '0' && v[1] <= '9')) {
return v[1:]
}
return v
}

View File

@ -0,0 +1,38 @@
package appmeta
import "testing"
func TestVersion(t *testing.T) {
t.Parallel()
for give, want := range map[string]string{
// without changes
"vvv": "vvv",
"victory": "victory",
"voodoo": "voodoo",
"foo": "foo",
"0.0.0": "0.0.0",
"v": "v",
"V": "V",
// "v" prefix removal
"v0.0.0": "0.0.0",
"V0.0.0": "0.0.0",
"v1": "1",
"V1": "1",
// with spaces
" 0.0.0": "0.0.0",
"v0.0.0 ": "0.0.0",
" V0.0.0": "0.0.0",
"v1 ": "1",
" V1": "1",
"v ": "v",
} {
version = give
if v := Version(); v != want {
t.Errorf("want: %s, got: %s", want, v)
}
}
}

91
internal/cli/app.go Normal file
View File

@ -0,0 +1,91 @@
package cli
import (
"context"
"fmt"
"runtime"
"strings"
_ "github.com/urfave/cli-docs/v3" // required for `go generate` to work
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/appmeta"
"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/logger"
)
//go:generate go run update_readme.go
// NewApp creates a new console application.
func NewApp(appName string) *cli.Command { //nolint:funlen
var (
logLevelFlag = cli.StringFlag{
Name: "log-level",
Value: logger.InfoLevel.String(),
Usage: "Logging level (" + strings.Join(logger.LevelStrings(), "/") + ")",
Sources: cli.EnvVars("LOG_LEVEL"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error {
if _, err := logger.ParseLevel(s); err != nil {
return err
}
return nil
},
}
logFormatFlag = cli.StringFlag{
Name: "log-format",
Value: logger.ConsoleFormat.String(),
Usage: "Logging format (" + strings.Join(logger.FormatStrings(), "/") + ")",
Sources: cli.EnvVars("LOG_FORMAT"),
OnlyOnce: true,
Config: cli.StringConfig{TrimSpace: true},
Validator: func(s string) error {
if _, err := logger.ParseFormat(s); err != nil {
return err
}
return nil
},
}
)
// create 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 // swap the "default" logger with customized
return nil
},
Commands: []*cli.Command{
serve.NewCommand(log),
build.NewCommand(log),
healthcheck.NewCommand(log, healthcheck.NewHTTPHealthChecker()),
perftest.NewCommand(),
},
Version: fmt.Sprintf("%s (%s)", appmeta.Version(), runtime.Version()),
Flags: []cli.Flag{ // global flags
&logLevelFlag,
&logFormatFlag,
},
}
}

18
internal/cli/app_test.go Normal file
View File

@ -0,0 +1,18 @@
package cli_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/cli"
)
func TestNewApp(t *testing.T) {
t.Parallel()
app := cli.NewApp("appName")
assert.NoError(t, app.Run(context.Background(), []string{""}))
}

View File

@ -0,0 +1,242 @@
package build
import (
"context"
_ "embed"
"errors"
"fmt"
"html/template"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"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/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 *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 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),
)
}
}
}
// 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))
}
}
}
// 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{
&addTplFlag,
&disableTplFlag,
&addCodeFlag,
&disableL10nFlag,
&createIndexFlag,
&targetDirFlag,
},
}
return cmd.c
}
func (cmd *command) Run( //nolint:funlen
ctx context.Context,
log *logger.Logger,
cfg *config.Config,
) error {
type historyItem struct{ Code, Message, RelativePath string }
var history = make(map[string][]historyItem, len(cfg.Codes)*len(cfg.Templates)) // map[template_name]codes
for templateName, templateContent := range cfg.Templates {
log.Debug("Processing template", logger.String("name", templateName))
for code, codeDescription := range cfg.Codes {
if err := createDirectory(filepath.Join(cmd.opt.targetDirAbsPath, templateName)); err != nil {
return fmt.Errorf("cannot create directory for template '%s': %w", templateName, err)
}
var codeAsUint, codeParsingErr = strconv.ParseUint(code, 10, 32)
if codeParsingErr != nil {
log.Warn("Cannot parse code", logger.String("code", code))
continue
}
var outFilePath = path.Join(cmd.opt.targetDirAbsPath, templateName, code+".html")
if content, renderErr := appTemplate.Render(templateContent, appTemplate.Props{
Code: uint16(codeAsUint),
Message: codeDescription.Message,
Description: codeDescription.Description,
L10nDisabled: cfg.L10n.Disable,
ShowRequestDetails: false,
}); renderErr == nil {
if err := os.WriteFile(outFilePath, []byte(content), os.FileMode(0664)); err != nil { //nolint:mnd
return err
}
} else {
return fmt.Errorf("cannot render template '%s': %w", templateName, renderErr)
}
log.Debug("Page built", logger.String("template", templateName), logger.String("code", code))
history[templateName] = append(history[templateName], historyItem{
Code: code,
Message: codeDescription.Message,
RelativePath: "." + strings.TrimPrefix(outFilePath, cmd.opt.targetDirAbsPath), // to make it relative
})
}
}
if 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
}
var buf strings.Builder
if err := indexTpl.Execute(&buf, history); err != nil {
return err
}
return os.WriteFile(
filepath.Join(cmd.opt.targetDirAbsPath, "index.html"),
[]byte(buf.String()),
os.FileMode(0664), //nolint:mnd
)
}
return nil
}
func createDirectory(path string) error {
var stat, err = os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return os.MkdirAll(path, os.FileMode(0775)) //nolint:mnd
}
return err
}
if !stat.IsDir() {
return errors.New("is not a directory")
}
return nil
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
package healthcheck
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/logger"
)
type checker interface {
Check(ctx context.Context, baseURL string) error
}
// NewCommand creates `healthcheck` 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. 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{
&portFlag,
},
}
}

View File

@ -0,0 +1,55 @@
package healthcheck_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/cli/healthcheck"
"gh.tarampamp.am/error-pages/internal/logger"
)
func TestNewCommand(t *testing.T) {
t.Parallel()
var cmd = healthcheck.NewCommand(logger.NewNop(), nil)
assert.Equal(t, "healthcheck", cmd.Name)
assert.Equal(t, []string{"chk", "health", "check"}, cmd.Aliases)
}
type fakeHealthChecker struct {
t *testing.T
wantAddress string
giveErr error
}
func (m *fakeHealthChecker) Check(_ context.Context, addr string) error {
assert.Equal(m.t, m.wantAddress, addr)
return m.giveErr
}
func TestCommand_RunSuccess(t *testing.T) {
var cmd = healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
t: t,
wantAddress: "http://127.0.0.1:1234",
})
require.NoError(t, cmd.Run(context.Background(), []string{"", "--port", "1234"}))
}
func TestCommand_RunFail(t *testing.T) {
cmd := healthcheck.NewCommand(logger.NewNop(), &fakeHealthChecker{
t: t,
wantAddress: "http://127.0.0.1:4321",
giveErr: assert.AnError,
})
assert.ErrorIs(t,
cmd.Run(context.Background(), []string{"", "--port", "4321"}),
assert.AnError,
)
}

View File

@ -0,0 +1,194 @@
package perftest
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
"os"
"os/exec"
"runtime"
"strconv"
"time"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
)
const wrkOneCodeTestLua = `
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
request = function()
wrk.headers["User-Agent"] = "wrk"
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
return wrk.format("GET", "/500.html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
end
`
//nolint:lll
const bombDifferentCodes = `
local formats = { 'application/json', 'application/xml', 'text/html', 'text/plain' }
request = function()
wrk.headers["User-Agent"] = "wrk"
wrk.headers["X-Namespace"] = "NAMESPACE_" .. tostring(math.random(0, 99999999))
wrk.headers["X-Request-ID"] = "REQ_ID_" .. tostring(math.random(0, 99999999))
wrk.headers["Content-Type"] = formats[ math.random( 0, #formats - 1 ) ]
return wrk.format("GET", "/" .. tostring(math.random(400, 599)) .. ".html?rnd=" .. tostring(math.random(0, 99999999)), nil, nil)
end
`
// NewCommand creates `perftest` command.
func NewCommand() *cli.Command { //nolint:funlen
var (
portFlag = shared.ListenPortFlag
durationFlag = cli.DurationFlag{
Name: "duration",
Aliases: []string{"d"},
Usage: "Duration of test",
Value: 15 * time.Second, //nolint:mnd
Validator: func(d time.Duration) error {
if d <= time.Second {
return errors.New("duration can't be less than 1 second")
}
return nil
},
}
threadsFlag = cli.UintFlag{
Name: "threads",
Aliases: []string{"t"},
Usage: "Number of threads to use",
Value: max(2, uint64(math.Round(float64(runtime.NumCPU())/1.3))), //nolint:mnd
Validator: func(u uint64) error {
if u == 0 {
return errors.New("threads number can't be zero")
} else if u > math.MaxUint16 {
return errors.New("threads number can't be greater than 65535")
}
return nil
},
}
connectionsFlag = cli.UintFlag{
Name: "connections",
Aliases: []string{"c"},
Usage: "Number of connections to keep open",
Value: max(16, uint64(runtime.NumCPU()*25)), //nolint:mnd
Validator: func(u uint64) error {
if u == 0 {
return errors.New("threads number can't be zero")
} else if u > math.MaxUint16 {
return errors.New("threads number can't be greater than 65535")
}
return nil
},
}
)
return &cli.Command{
Name: "perftest",
Aliases: []string{"perf", "benchmark", "bench"},
Hidden: true,
Usage: "Performance (load) test for the HTTP server (locally installed wrk is required)",
Action: func(ctx context.Context, c *cli.Command) error {
var wrkBinPath, lErr = exec.LookPath("wrk")
if lErr != nil {
return fmt.Errorf("seems like wrk (https://github.com/wg/wrk) is not installed: %w", lErr)
}
var runTest = func(scriptContent string) error {
if stdOut, stdErr, err := wrkRunTest(ctx,
wrkBinPath,
uint16(c.Uint(threadsFlag.Name)),
uint16(c.Uint(connectionsFlag.Name)),
c.Duration(durationFlag.Name),
uint16(c.Uint(portFlag.Name)),
scriptContent,
); err != nil {
var errData, _ = io.ReadAll(stdErr)
return fmt.Errorf("failed to execute the test: %w (%s)", err, string(errData))
} else {
var outData, _ = io.ReadAll(stdOut)
printf("Test completed successfully. Here is the output:\n\n%s\n", string(outData))
}
return nil
}
printf("Starting the test to bomb ONE PAGE (code). Please, be patient...\n")
if err := runTest(wrkOneCodeTestLua); err != nil {
return err
}
printf("Starting the test to bomb DIFFERENT PAGES (codes). Please, be patient...\n")
if err := runTest(bombDifferentCodes); err != nil {
return err
}
return nil
},
Flags: []cli.Flag{
&portFlag,
&durationFlag,
&threadsFlag,
&connectionsFlag,
},
}
}
func printf(format string, args ...any) { fmt.Printf(format, args...) } //nolint:forbidigo
func wrkRunTest(
ctx context.Context,
wrkBinPath string,
threadsCount, connectionsCount uint16,
duration time.Duration,
port uint16,
scriptContent string,
) (io.Reader, io.Reader, error) {
var tmpFile, tErr = os.CreateTemp("", "ep-perf-one-page")
if tErr != nil {
return nil, nil, fmt.Errorf("failed to create a temporary file: %w", tErr)
}
defer func() {
_ = tmpFile.Close()
_ = os.Remove(tmpFile.Name())
}()
if _, err := tmpFile.WriteString(scriptContent); err != nil {
return nil, nil, fmt.Errorf("failed to write to a temporary file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return nil, nil, err
}
var stdout, stderr bytes.Buffer
var cmd = exec.CommandContext(ctx, wrkBinPath, //nolint:gosec
"--timeout", "1s",
"--threads", strconv.FormatUint(uint64(threadsCount), 10),
"--connections", strconv.FormatUint(uint64(connectionsCount), 10),
"--duration", duration.String(),
"--script", tmpFile.Name(),
fmt.Sprintf("http://127.0.0.1:%d/", port),
)
cmd.Stdout, cmd.Stderr = &stdout, &stderr
return &stdout, &stderr, cmd.Run() // execute
}

View File

@ -0,0 +1,391 @@
package serve
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config"
appHttp "gh.tarampamp.am/error-pages/internal/http"
"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
}
}
}
// 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}
)
var (
addrFlag = shared.ListenAddrFlag
portFlag = shared.ListenPortFlag
addTplFlag = shared.AddTemplatesFlag
disableTplFlag = shared.DisableTemplateNamesFlag
addCodeFlag = shared.AddHTTPCodesFlag
disableL10nFlag = shared.DisableL10nFlag
jsonFormatFlag = cli.StringFlag{
Name: "json-format",
Usage: "Override the default error page response in JSON format (Go templates are supported; the error " +
"page will use this template if the client requests JSON content type)",
Sources: env("RESPONSE_JSON_FORMAT"),
Category: shared.CategoryFormats,
OnlyOnce: true,
Config: trim,
}
xmlFormatFlag = cli.StringFlag{
Name: "xml-format",
Usage: "Override the default error page response in XML format (Go templates are supported; the error " +
"page will use this template if the client requests XML content type)",
Sources: env("RESPONSE_XML_FORMAT"),
Category: shared.CategoryFormats,
OnlyOnce: true,
Config: trim,
}
plainTextFormatFlag = cli.StringFlag{
Name: "plaintext-format",
Usage: "Override the default error page response in plain text format (Go templates are supported; the " +
"error page will use this template if the client requests plain text content type or does not specify any)",
Sources: env("RESPONSE_PLAINTEXT_FORMAT"),
Category: shared.CategoryFormats,
OnlyOnce: true,
Config: trim,
}
templateNameFlag = cli.StringFlag{
Name: "template-name",
Aliases: []string{"t"},
Value: cfg.TemplateName,
Usage: "Name of the template to use for rendering error pages (built-in templates: " +
strings.Join(cfg.Templates.Names(), ", ") + ")",
Sources: env("TEMPLATE_NAME"),
Category: shared.CategoryTemplates,
OnlyOnce: true,
Config: trim,
}
defaultCodeToRenderFlag = cli.UintFlag{
Name: "default-error-page",
Usage: "The code of the default (index page, when a code is not specified) error page to render",
Value: uint64(cfg.DefaultCodeToRender),
Sources: env("DEFAULT_ERROR_PAGE"),
Category: shared.CategoryCodes,
Validator: func(code uint64) error {
if code > 999 { //nolint:mnd
return fmt.Errorf("wrong HTTP code [%d] for the default error page", code)
}
return nil
},
OnlyOnce: true,
}
sendSameHTTPCodeFlag = cli.BoolFlag{
Name: "send-same-http-code",
Usage: "The HTTP response should have the same status code as the requested error page (by default, " +
"every response with an error page will have a status code of 200)",
Value: cfg.RespondWithSameHTTPCode,
Sources: env("SEND_SAME_HTTP_CODE"),
Category: shared.CategoryOther,
OnlyOnce: true,
}
showDetailsFlag = cli.BoolFlag{
Name: "show-details",
Usage: "Show request details in the error page response (if supported by the template)",
Value: cfg.ShowDetails,
Sources: env("SHOW_DETAILS"),
Category: shared.CategoryOther,
OnlyOnce: true,
}
proxyHeadersListFlag = cli.StringFlag{
Name: "proxy-headers",
Usage: "HTTP headers listed here will be proxied from the original request to the error page response " +
"(comma-separated list)",
Value: strings.Join(cfg.ProxyHeaders, ","),
Sources: env("PROXY_HTTP_HEADERS"),
Validator: func(s string) error {
for _, raw := range strings.Split(s, ",") {
if clean := strings.TrimSpace(raw); strings.ContainsRune(clean, ' ') {
return fmt.Errorf("whitespaces in the HTTP headers are not allowed: %s", clean)
}
}
return nil
},
Category: shared.CategoryOther,
OnlyOnce: true,
Config: trim,
}
rotationModeFlag = cli.StringFlag{
Name: "rotation-mode",
Value: config.RotationModeDisabled.String(),
Usage: "Templates automatic rotation mode (" + strings.Join(config.RotationModeStrings(), "/") + ")",
Sources: env("TEMPLATES_ROTATION_MODE"),
Category: shared.CategoryTemplates,
OnlyOnce: true,
Config: trim,
Validator: func(s string) error {
if _, err := config.ParseRotationMode(s); err != nil {
return err
}
return nil
},
}
readBufferSizeFlag = cli.UintFlag{
Name: "read-buffer-size",
Usage: "Per-connection buffer size in bytes for reading requests, this also limits the maximum header size " +
"(increase this buffer if your clients send multi-KB Request URIs and/or multi-KB headers (e.g., " +
"large cookies), note that increasing this value will increase memory consumption)",
Value: 1024 * 5, //nolint:mnd // 5 KB
Sources: env("READ_BUFFER_SIZE"),
Category: shared.CategoryOther,
OnlyOnce: true,
}
)
// override some flag usage messages
addrFlag.Usage = "The HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1/::1 for localhost, " +
"0.0.0.0 to listen on all interfaces, or specify a custom IP)"
portFlag.Usage = "The TCP port number for the HTTP server to listen on (0-65535)"
disableL10nFlag.Value = cfg.L10n.Disable // set the default value depending on the configuration
cmd.c = &cli.Command{
Name: "serve",
Aliases: []string{"s", "server", "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))
}
if c.IsSet(xmlFormatFlag.Name) {
cfg.Formats.XML = strings.TrimSpace(c.String(xmlFormatFlag.Name))
}
if c.IsSet(plainTextFormatFlag.Name) {
cfg.Formats.PlainText = strings.TrimSpace(c.String(plainTextFormatFlag.Name))
}
}
// 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)
}
}
// add custom HTTP codes to the configuration
if add := c.StringMap(addCodeFlag.Name); len(add) > 0 {
for code, desc := range shared.ParseHTTPCodes(add) {
cfg.Codes[code] = desc
log.Info("HTTP code added",
logger.String("code", code),
logger.String("message", desc.Message),
logger.String("description", desc.Description),
)
}
}
// disable templates specified by the user
if disable := c.StringSlice(disableTplFlag.Name); len(disable) > 0 {
for _, templateName := range disable {
if ok := cfg.Templates.Remove(templateName); ok {
log.Info("Template disabled", logger.String("name", templateName))
}
}
}
// check if there are any templates available to render error pages
if len(cfg.Templates.Names()) == 0 {
return errors.New("no templates available to render error pages")
}
// if the rotation mode is set to random-on-startup, pick a random template (ignore the user-provided
// template name)
if cfg.RotationMode == config.RotationModeRandomOnStartup {
cfg.TemplateName = cfg.Templates.RandomName()
} else { // otherwise, use the user-provided template name
cfg.TemplateName = c.String(templateNameFlag.Name)
if !cfg.Templates.Has(cfg.TemplateName) {
return fmt.Errorf(
"template '%s' not found and cannot be used (available templates: %s)",
cfg.TemplateName,
cfg.Templates.Names(),
)
}
}
log.Debug("Configuration",
logger.Strings("loaded templates", cfg.Templates.Names()...),
logger.Strings("described HTTP codes", cfg.Codes.Codes()...),
logger.String("JSON format", cfg.Formats.JSON),
logger.String("XML format", cfg.Formats.XML),
logger.String("plain text format", cfg.Formats.PlainText),
logger.String("template name", cfg.TemplateName),
logger.Bool("disable localization", cfg.L10n.Disable),
logger.Uint16("default code to render", cfg.DefaultCodeToRender),
logger.Bool("respond with the same HTTP code", cfg.RespondWithSameHTTPCode),
logger.String("rotation mode", cfg.RotationMode.String()),
logger.Bool("show details", cfg.ShowDetails),
logger.Strings("proxy HTTP headers", cfg.ProxyHeaders...),
)
return cmd.Run(ctx, log, &cfg)
},
Flags: []cli.Flag{
&addrFlag,
&portFlag,
&addTplFlag,
&disableTplFlag,
&addCodeFlag,
&jsonFormatFlag,
&xmlFormatFlag,
&plainTextFormatFlag,
&templateNameFlag,
&disableL10nFlag,
&defaultCodeToRenderFlag,
&sendSameHTTPCodeFlag,
&showDetailsFlag,
&proxyHeadersListFlag,
&rotationModeFlag,
&readBufferSizeFlag,
},
}
return cmd.c
}
// Run current command.
func (cmd *command) Run(ctx context.Context, log *logger.Logger, cfg *config.Config) error { //nolint:funlen
var srv = appHttp.NewServer(log, cmd.opt.http.readBufferSize)
if err := srv.Register(cfg); err != nil {
return err
}
var startingErrCh = make(chan error, 1) // channel for server starting error
defer close(startingErrCh)
// to track the frequency of each template's use, we send a simple GET request to the GoatCounter API
// (https://goatcounter.com, https://github.com/arp242/goatcounter) to increment the counter. this service is
// free and does not require an API key. no private data is sent, as shown in the URL below. this is necessary
// to render a badge displaying the number of template usages on the error-pages repository README file :D
//
// badge code example:
// ![Used times](https://img.shields.io/badge/dynamic/json?
// url=https%3A%2F%2Ferror-pages.goatcounter.com%2Fcounter%2F%2Fuse-template%2Flost-in-space.json
// &query=%24.count&label=Used%20times)
//
// if you wish, you may view the collected statistics at any time here - https://error-pages.goatcounter.com/
go func() {
var tpl = url.QueryEscape(cfg.TemplateName)
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(
// https://www.goatcounter.com/help/pixel
"https://error-pages.goatcounter.com/count?p=/use-template/%s&t=%s", tpl, tpl,
), http.NoBody)
if reqErr != nil {
return
}
req.Header.Set("User-Agent", fmt.Sprintf("Mozilla/5.0 (error-pages, rnd:%d)", time.Now().UnixNano()))
resp, respErr := (&http.Client{Timeout: 10 * time.Second}).Do(req) //nolint:mnd // don't care about the response
if respErr != nil {
log.Debug("Cannot send a request to increment the template usage counter", logger.Error(respErr))
return
} else if resp != nil {
_ = resp.Body.Close()
}
}()
// start HTTP server in separate goroutine
go func(errCh chan<- error) {
var now = time.Now()
defer func() {
log.Info("HTTP server stopped", logger.Duration("uptime", time.Since(now).Round(time.Millisecond)))
}()
log.Info("HTTP server starting",
logger.String("addr", cmd.opt.http.addr),
logger.Uint16("port", cmd.opt.http.port),
)
if err := srv.Start(cmd.opt.http.addr, cmd.opt.http.port); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}(startingErrCh)
// and wait for...
select {
case err := <-startingErrCh: // ..server starting error
return err
case <-ctx.Done(): // ..or context cancellation
const shutdownTimeout = 5 * time.Second
log.Info("HTTP server stopping", logger.Duration("with timeout", shutdownTimeout))
if err := srv.Stop(shutdownTimeout); err != nil { //nolint:contextcheck
return err
}
}
return nil
}

View File

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

View File

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

View File

@ -0,0 +1,148 @@
package shared
import (
"fmt"
"net"
"os"
"strings"
"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:"
)
// 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
},
}
// ParseHTTPCodes converts a map of HTTP status codes and their messages/descriptions into a map of codes and
// descriptions. Should be used together with [AddHTTPCodesFlag].
func ParseHTTPCodes(codes map[string]string) map[string]config.CodeDescription {
var result = make(map[string]config.CodeDescription, len(codes))
for code, msgAndDesc := range codes {
var (
parts = strings.SplitN(msgAndDesc, "/", 2)
desc config.CodeDescription
)
desc.Message = strings.TrimSpace(parts[0])
if len(parts) > 1 {
desc.Description = strings.TrimSpace(parts[1])
}
result[code] = desc
}
return result
}
var DisableL10nFlag = cli.BoolFlag{
Name: "disable-l10n",
Usage: "Disable localization of error pages (if the template supports localization)",
Sources: cli.EnvVars("DISABLE_L10N"),
Category: CategoryOther,
OnlyOnce: true,
}

View File

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

View File

@ -0,0 +1,24 @@
//go:build ignore
// +build ignore
package main
import (
"os"
cliDocs "github.com/urfave/cli-docs/v3"
"gh.tarampamp.am/error-pages/internal/cli"
)
func main() {
const readmePath = "../../README.md"
if stat, err := os.Stat(readmePath); err == nil && stat.Mode().IsRegular() {
if err = cliDocs.ToTabularToFileBetweenTags(cli.NewApp(""), "error-pages", readmePath); err != nil {
panic(err)
}
} else if err != nil {
println("readme file not found, cli docs not updated:", err.Error())
}
}

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

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

View File

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

175
internal/config/config.go Normal file
View File

@ -0,0 +1,175 @@
package config
import (
"maps"
"net/http"
"slices"
builtinTemplates "gh.tarampamp.am/error-pages/templates"
)
type Config struct {
// Templates hold all templates, with the key being the template name and the value being the template content
// in HTML format (Go templates are supported here).
Templates templates
// Formats contain alternative response formats (e.g., if a client requests a response in one of these formats,
// we will render the response using the specified format instead of HTML; Go templates are supported).
Formats struct {
JSON string
XML string
PlainText string
}
// Codes hold descriptions for HTTP codes (e.g., 404: "Not Found / The server can not find the requested page").
Codes Codes
// TemplateName is the name of the template to use for rendering error pages. The template must be present in the
// Templates map.
TemplateName string
// ProxyHeaders contains a list of HTTP headers that will be proxied from the incoming request to the
// error page response.
ProxyHeaders []string
// L10n contains localization settings.
L10n struct {
// Disable the localization of error pages.
Disable bool
}
// DefaultCodeToRender is the code for the default error page to be displayed. It is used when the requested
// code is not defined in the incoming request (i.e., the code to render as the index page).
DefaultCodeToRender uint16
// RespondWithSameHTTPCode determines whether the response should have the same HTTP status code as the requested
// error page.
// In other words, if set to true and the requested error page has a code of 404, the HTTP response will also have
// a status code of 404. If set to false, the HTTP response will have a status code of 200 regardless of the
// requested error page's status code.
RespondWithSameHTTPCode bool
// RotationMode allows to set the rotation mode for templates to switch between them automatically on startup,
// on each request, daily, hourly and so on.
RotationMode RotationMode
// ShowDetails determines whether to show additional details in the error response, extracted from the
// incoming request (if supported by the template).
ShowDetails bool
}
const defaultJSONFormat string = `{
"error": true,
"code": {{ code | json }},
"message": {{ message | json }},
"description": {{ description | json }}{{ if show_details }},
"details": {
"host": {{ host | json }},
"original_uri": {{ original_uri | json }},
"forwarded_for": {{ forwarded_for | json }},
"namespace": {{ namespace | json }},
"ingress_name": {{ ingress_name | json }},
"service_name": {{ service_name | json }},
"service_port": {{ service_port | json }},
"request_id": {{ request_id | json }},
"timestamp": {{ nowUnix }}
}{{ end }}
}
` // an empty line at the end is important for better UX
const defaultXMLFormat string = `<?xml version="1.0" encoding="utf-8"?>
<error>
<code>{{ code }}</code>
<message>{{ message }}</message>
<description>{{ description }}</description>{{ if show_details }}
<details>
<host>{{ host }}</host>
<originalURI>{{ original_uri }}</originalURI>
<forwardedFor>{{ forwarded_for }}</forwardedFor>
<namespace>{{ namespace }}</namespace>
<ingressName>{{ ingress_name }}</ingressName>
<serviceName>{{ service_name }}</serviceName>
<servicePort>{{ service_port }}</servicePort>
<requestID>{{ request_id }}</requestID>
<timestamp>{{ nowUnix }}</timestamp>
</details>{{ end }}
</error>
` // an empty line at the end is important for better UX
const defaultPlainTextFormat string = `Error {{ code }}: {{ message }}{{ if description }}
{{ description }}{{ end }}{{ if show_details }}
Host: {{ host }}
Original URI: {{ original_uri }}
Forwarded For: {{ forwarded_for }}
Namespace: {{ namespace }}
Ingress Name: {{ ingress_name }}
Service Name: {{ service_name }}
Service Port: {{ service_port }}
Request ID: {{ request_id }}
Timestamp: {{ nowUnix }}{{ end }}
` // an empty line at the end is important for better UX
//nolint:lll
var defaultCodes = Codes{ //nolint:gochecknoglobals
"400": {"Bad Request", "The server did not understand the request"},
"401": {"Unauthorized", "The requested page needs a username and a password"},
"403": {"Forbidden", "Access is forbidden to the requested page"},
"404": {"Not Found", "The server can not find the requested page"},
"405": {"Method Not Allowed", "The method specified in the request is not allowed"},
"407": {"Proxy Authentication Required", "You must authenticate with a proxy server before this request can be served"},
"408": {"Request Timeout", "The request took longer than the server was prepared to wait"},
"409": {"Conflict", "The request could not be completed because of a conflict"},
"410": {"Gone", "The requested page is no longer available"},
"411": {"Length Required", "The \"Content-Length\" is not defined. The server will not accept the request without it"},
"412": {"Precondition Failed", "The pre condition given in the request evaluated to false by the server"},
"413": {"Payload Too Large", "The server will not accept the request, because the request entity is too large"},
"416": {"Requested Range Not Satisfiable", "The requested byte range is not available and is out of bounds"},
"418": {"I'm a teapot", "Attempt to brew coffee with a teapot is not supported"},
"429": {"Too Many Requests", "Too many requests in a given amount of time"},
"500": {"Internal Server Error", "The server met an unexpected condition"},
"502": {"Bad Gateway", "The server received an invalid response from the upstream server"},
"503": {"Service Unavailable", "The server is temporarily overloading or down"},
"504": {"Gateway Timeout", "The gateway has timed out"},
"505": {"HTTP Version Not Supported", "The server does not support the \"http protocol\" version"},
}
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
}
// 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
}
cfg.Formats.JSON = defaultJSONFormat
cfg.Formats.XML = defaultXMLFormat
cfg.Formats.PlainText = defaultPlainTextFormat
// add built-in templates
for name, content := range builtinTemplates.BuiltIn() {
cfg.Templates[name] = content
}
// set first template as default
for _, name := range cfg.Templates.Names() {
cfg.TemplateName = name
break
}
// set default HTTP headers to proxy
cfg.ProxyHeaders = slices.Clone(defaultProxyHeaders)
// set defaults
cfg.DefaultCodeToRender = http.StatusNotFound
return cfg
}

View File

@ -0,0 +1,57 @@
package config_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/template"
)
func TestNew(t *testing.T) {
t.Parallel()
t.Run("default config", func(t *testing.T) {
var cfg = config.New()
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)
})
t.Run("changing cfg1 should not affect cfg2", func(t *testing.T) {
var cfg1, cfg2 = config.New(), config.New()
cfg1.Codes["400"] = config.CodeDescription{Message: "foo", Description: "bar"}
assert.NotEqual(t, cfg1.Codes["400"], cfg2.Codes["400"])
cfg1.ProxyHeaders = append(cfg1.ProxyHeaders, "foo")
assert.NotEqual(t, cfg1.ProxyHeaders, cfg2.ProxyHeaders)
})
t.Run("render default format templates", func(t *testing.T) {
var cfg = config.New()
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",
})
assert.NotEmpty(t, result)
assert.NoError(t, err)
t.Log(result)
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

View File

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

View File

View File

@ -0,0 +1,111 @@
package error_page
import (
"bytes"
"crypto/md5" //nolint:gosec
"encoding/gob"
"sync"
"time"
"gh.tarampamp.am/error-pages/internal/template"
)
type (
// RenderedCache is a cache for rendered error pages. It's safe for concurrent use.
// It uses a hash of the template and props as a key.
//
// To remove expired items, call ClearExpired method periodically (a bit more often than the ttl).
RenderedCache struct {
ttl time.Duration
mu sync.RWMutex
items map[[32]byte]cacheItem // map[template_hash[0:15];props_hash[16:32]]cache_item
}
cacheItem struct {
content []byte
addedAtNano int64
}
)
// NewRenderedCache creates a new RenderedCache with the specified ttl.
func NewRenderedCache(ttl time.Duration) *RenderedCache {
return &RenderedCache{ttl: ttl, items: make(map[[32]byte]cacheItem)}
}
// genKey generates a key for the cache item by hashing the template and props.
func (rc *RenderedCache) genKey(template string, props template.Props) [32]byte {
var (
key [32]byte
th, ph = hash(template), hash(props) // template hash, props hash
)
copy(key[:16], th[:]) // first 16 bytes for the template hash
copy(key[16:], ph[:]) // last 16 bytes for the props hash
return key
}
// Has checks if the cache has an item with the specified template and props.
func (rc *RenderedCache) Has(template string, props template.Props) bool {
var key = rc.genKey(template, props)
rc.mu.RLock()
_, ok := rc.items[key]
rc.mu.RUnlock()
return ok
}
// Put adds a new item to the cache with the specified template, props, and content.
func (rc *RenderedCache) Put(template string, props template.Props, content []byte) {
var key = rc.genKey(template, props)
rc.mu.Lock()
rc.items[key] = cacheItem{content: content, addedAtNano: time.Now().UnixNano()}
rc.mu.Unlock()
}
// Get returns the content of the item with the specified template and props.
func (rc *RenderedCache) Get(template string, props template.Props) ([]byte, bool) {
var key = rc.genKey(template, props)
rc.mu.RLock()
item, ok := rc.items[key]
rc.mu.RUnlock()
return item.content, ok
}
// ClearExpired removes all expired items from the cache.
func (rc *RenderedCache) ClearExpired() {
rc.mu.Lock()
var now = time.Now().UnixNano()
for key, item := range rc.items {
if now-item.addedAtNano > rc.ttl.Nanoseconds() {
delete(rc.items, key)
}
}
rc.mu.Unlock()
}
// Clear removes all items from the cache.
func (rc *RenderedCache) Clear() {
rc.mu.Lock()
clear(rc.items)
rc.mu.Unlock()
}
// hash returns an MD5 hash of the provided value (it may be any built-in type).
func hash(in any) [16]byte {
var b bytes.Buffer
if err := gob.NewEncoder(&b).Encode(in); err != nil {
return [16]byte{} // never happens because we encode only built-in types
}
return md5.Sum(b.Bytes()) //nolint:gosec
}

View File

@ -0,0 +1,86 @@
package error_page_test
import (
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
"gh.tarampamp.am/error-pages/internal/template"
)
func TestRenderedCache_CRUD(t *testing.T) {
t.Parallel()
var cache = error_page.NewRenderedCache(time.Millisecond)
t.Run("has", func(t *testing.T) {
assert.False(t, cache.Has("template", template.Props{}))
cache.Put("template", template.Props{}, []byte("content"))
assert.True(t, cache.Has("template", template.Props{}))
assert.False(t, cache.Has("template", template.Props{Code: 1}))
assert.False(t, cache.Has("foo", template.Props{Code: 1}))
})
t.Run("exists", func(t *testing.T) {
var got, ok = cache.Get("template", template.Props{})
assert.True(t, ok)
assert.Equal(t, []byte("content"), got)
cache.Clear()
assert.False(t, cache.Has("template", template.Props{}))
})
t.Run("not exists", func(t *testing.T) {
var got, ok = cache.Get("template", template.Props{Code: 2})
assert.False(t, ok)
assert.Nil(t, got)
})
t.Run("race condition provocation", func(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func(i int) {
defer wg.Done()
cache.Get("template", template.Props{})
cache.Put("template"+strconv.Itoa(i), template.Props{}, []byte("content"))
cache.Has("template", template.Props{})
}(i)
go func() {
defer wg.Done()
cache.ClearExpired()
}()
}
wg.Wait()
})
}
func TestRenderedCache_Expiring(t *testing.T) {
t.Parallel()
var cache = error_page.NewRenderedCache(10 * time.Millisecond)
cache.Put("template", template.Props{}, []byte("content"))
cache.ClearExpired()
assert.True(t, cache.Has("template", template.Props{}))
<-time.After(10 * time.Millisecond)
assert.True(t, cache.Has("template", template.Props{})) // expired, but not cleared yet
cache.ClearExpired()
assert.False(t, cache.Has("template", template.Props{})) // cleared
}

View File

@ -0,0 +1,62 @@
package error_page
import (
"path/filepath"
"strconv"
"strings"
"github.com/valyala/fasthttp"
)
// extractCodeFromURL extracts the error code from the given URL.
func extractCodeFromURL(url string) (uint16, bool) {
var parts = strings.SplitN(strings.TrimLeft(url, "/"), "/", 1)
if len(parts) == 0 {
return 0, false
}
var (
fileName = strings.ToLower(parts[0])
ext = filepath.Ext(fileName) // ".html", ".htm", ".%something%" or an empty string
)
if ext != "" && ext != ".html" && ext != ".htm" {
return 0, false
} else if ext != "" {
fileName = strings.TrimSuffix(fileName, ext)
}
if code, err := strconv.ParseUint(fileName, 10, 16); err == nil && code > 0 && code < 999 {
return uint16(code), true
}
return 0, false
}
// URLContainsCode checks if the given URL contains an error code.
func URLContainsCode(url string) (ok bool) { _, ok = extractCodeFromURL(url); return } //nolint:nlreturn
// extractCodeFromHeaders extracts the error code from the given headers.
func extractCodeFromHeaders(headers *fasthttp.RequestHeader) (uint16, bool) {
if headers == nil {
return 0, false
}
// https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
// HTTP status code returned by the request
if value := headers.Peek("X-Code"); len(value) > 0 && len(value) <= 3 {
if code, err := strconv.ParseUint(string(value), 10, 16); err == nil && code > 0 && code < 999 {
return uint16(code), true
}
}
return 0, false
}
// HeadersContainCode checks if the given headers contain an error code.
func HeadersContainCode(headers *fasthttp.RequestHeader) (ok bool) {
_, ok = extractCodeFromHeaders(headers)
return
}

View File

@ -0,0 +1,66 @@
package error_page_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
)
func TestURLContainsCode(t *testing.T) {
t.Parallel()
for giveUrl, wantOk := range map[string]bool{
"/404": true,
"/404.htm": true,
"/404.HTM": true,
"/404.html": true,
"/404.HtmL": true,
"/404.css": false,
"/foo/404": false,
"/foo/404.html": false,
"/error": false,
"/": false,
"/////": false,
"///404//": false,
"": false,
} {
t.Run(giveUrl, func(t *testing.T) {
assert.Equal(t, wantOk, error_page.URLContainsCode(giveUrl))
})
}
}
func TestHeadersContainCode(t *testing.T) {
t.Parallel()
var mkHeaders = func(key, value string) *fasthttp.RequestHeader {
var out = new(fasthttp.RequestHeader)
out.Set(key, value)
return out
}
for name, _tt := range map[string]struct {
giveHeaders *fasthttp.RequestHeader
wantOk bool
}{
"with code": {giveHeaders: mkHeaders("X-Code", "404"), wantOk: true},
"empty": {giveHeaders: nil},
"no code": {giveHeaders: mkHeaders("X-Code", "")},
"wrong": {giveHeaders: mkHeaders("X-Code", "foo")},
"too big": {giveHeaders: mkHeaders("X-Code", "1000")},
"too small": {giveHeaders: mkHeaders("X-Code", "0")},
"negative": {giveHeaders: mkHeaders("X-Code", "-1")},
} {
tt := _tt
t.Run(name, func(t *testing.T) {
assert.Equal(t, tt.wantOk, error_page.HeadersContainCode(tt.giveHeaders))
})
}
}

View File

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

View File

@ -0,0 +1,114 @@
package error_page
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
)
func Test_detectPreferredFormatForClient(t *testing.T) {
t.Parallel()
for name, _tt := range map[string]struct {
giveHeaders map[string][]string
wantFormat preferredFormat
}{
"content type json": {
giveHeaders: map[string][]string{"Content-Type": {"application/jSoN"}},
wantFormat: jsonFormat,
},
"content type xml": {
giveHeaders: map[string][]string{"Content-Type": {"application/xml; charset=UTF-8"}},
wantFormat: xmlFormat,
},
"content type html": {
giveHeaders: map[string][]string{"Content-Type": {"text/hTmL; charset=utf-8"}},
wantFormat: htmlFormat,
},
"content type plain": {
giveHeaders: map[string][]string{"Content-Type": {"text/plaIN"}},
wantFormat: plainTextFormat,
},
"accept json": {
giveHeaders: map[string][]string{"Accept": {"application/jsoN,*/*;q=0.8"}},
wantFormat: jsonFormat,
},
"accept xml, depends on weight": {
giveHeaders: map[string][]string{"Accept": {"text/html;q=0.5,application/xhtml+xml;q=0.9,application/xml;q=1,*/*;q=0.8"}},
wantFormat: xmlFormat,
},
"accept json, depends on weight": {
giveHeaders: map[string][]string{"Accept": {"application/jsoN,*/*;q=0.8"}},
wantFormat: jsonFormat,
},
"accept xml": {
giveHeaders: map[string][]string{"Accept": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
wantFormat: xmlFormat,
},
"accept html": {
giveHeaders: map[string][]string{"Accept": {"text/html, application/xhtml+xml, application/xml;q=0.9, image/avif, image/webp, */*;q=0.8"}},
wantFormat: htmlFormat,
},
"accept plain": {
giveHeaders: map[string][]string{"Accept": {"text/plaiN,text/html,application/xml;q=0.9,,,*/*;q=0.8"}},
wantFormat: plainTextFormat,
},
"accept json, weighted values only": {
giveHeaders: map[string][]string{"Accept": {"application/jsoN;Q=0.1,text/html;q=1.1,application/xml;q=-1,*/*;q=0.8"}},
wantFormat: jsonFormat,
},
"x-format json, depends on weight": {
giveHeaders: map[string][]string{"X-Format": {"application/jsoN,*/*;q=0.8"}},
wantFormat: jsonFormat,
},
"x-format xml": {
giveHeaders: map[string][]string{"X-Format": {"application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}},
wantFormat: xmlFormat,
},
"content type has priority over accept": {
giveHeaders: map[string][]string{"Content-Type": {"text/plain"}, "Accept": {"application/xml"}},
wantFormat: plainTextFormat,
},
"accept has priority over x-format": {
giveHeaders: map[string][]string{"Accept": {"application/xml"}, "X-Format": {"text/plain"}},
wantFormat: plainTextFormat,
},
"empty headers": {
giveHeaders: nil,
},
"empty content type": {
giveHeaders: map[string][]string{"Content-Type": {" "}},
},
"wrong content type": {
giveHeaders: map[string][]string{"Content-Type": {"multipart/form-data; boundary=something"}},
},
"wrong accept": {
giveHeaders: map[string][]string{"Accept": {";q=foobar,bar/baz;;;;;application/xml"}},
},
"none on invalid input": {
giveHeaders: map[string][]string{"Content-Type": {"foo/bar; charset=utf-8"}, "Accept": {"foo/bar; charset=utf-8"}},
},
"completely unknown": {
giveHeaders: map[string][]string{"Content-Type": {"😀"}, "Accept": {"😄"}, "X-Format": {"😍"}},
},
} {
tt := _tt
t.Run(name, func(t *testing.T) {
var headers = new(fasthttp.RequestHeader)
for key, values := range tt.giveHeaders {
for _, value := range values {
headers.Add(key, value)
}
}
assert.Equal(t, tt.wantFormat, detectPreferredFormatForClient(headers))
})
}
}

View File

@ -0,0 +1,276 @@
package error_page
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/config"
"gh.tarampamp.am/error-pages/internal/logger"
"gh.tarampamp.am/error-pages/internal/template"
)
// New creates a new handler that returns an error page with the specified status code and format.
func New(cfg *config.Config, log *logger.Logger) (_ fasthttp.RequestHandler, closeCache func()) { //nolint:funlen,gocognit,gocyclo,lll
// if the ttl will be bigger than 1 second, the template functions like `nowUnix` will not work as expected
const cacheTtl = 900 * time.Millisecond // the cache TTL
var (
cache, stopCh = NewRenderedCache(cacheTtl), make(chan struct{})
stopOnce sync.Once
)
// run a goroutine that will clear the cache from expired items. to stop the goroutine - close the stop channel
// or call the closeCache
go func() {
var timer = time.NewTimer(cacheTtl)
defer func() { timer.Stop(); cache.Clear() }()
for {
select {
case <-timer.C:
cache.ClearExpired()
timer.Reset(cacheTtl)
case <-stopCh:
return
}
}
}()
return func(ctx *fasthttp.RequestCtx) {
var (
reqHeaders = &ctx.Request.Header
code uint16
)
if fromUrl, okUrl := extractCodeFromURL(string(ctx.Path())); okUrl {
code = fromUrl
} else if fromHeader, okHeaders := extractCodeFromHeaders(reqHeaders); okHeaders {
code = fromHeader
} else {
code = cfg.DefaultCodeToRender
}
var httpCode int
if cfg.RespondWithSameHTTPCode {
httpCode = int(code)
} else {
httpCode = http.StatusOK
}
var format = detectPreferredFormatForClient(reqHeaders)
{ // deal with the headers
switch format {
case jsonFormat:
ctx.SetContentType("application/json; charset=utf-8")
case xmlFormat:
ctx.SetContentType("application/xml; charset=utf-8")
case htmlFormat:
ctx.SetContentType("text/html; charset=utf-8")
default:
ctx.SetContentType("text/plain; charset=utf-8") // plainTextFormat as default
}
// https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag
// disallow indexing of the error pages
ctx.Response.Header.Set("X-Robots-Tag", "noindex")
switch code {
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests,
http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
// tell the client (search crawler) to retry the request after 120 seconds
ctx.Response.Header.Set("Retry-After", "120")
}
// proxy the headers from the incoming request to the error page response if they are defined in the config
for _, proxyHeader := range cfg.ProxyHeaders {
if value := reqHeaders.Peek(proxyHeader); len(value) > 0 {
ctx.Response.Header.SetBytesV(proxyHeader, value)
}
}
}
ctx.SetStatusCode(httpCode)
// prepare the template properties for rendering
var tplProps = template.Props{
Code: code, // http status code
ShowRequestDetails: cfg.ShowDetails, // status message
L10nDisabled: cfg.L10n.Disable, // status description
}
//nolint:lll
if cfg.ShowDetails { // https://kubernetes.github.io/ingress-nginx/user-guide/custom-errors/
tplProps.OriginalURI = string(reqHeaders.Peek("X-Original-URI")) // (ingress-nginx) URI that caused the error
tplProps.Namespace = string(reqHeaders.Peek("X-Namespace")) // (ingress-nginx) namespace where the backend Service is located
tplProps.IngressName = string(reqHeaders.Peek("X-Ingress-Name")) // (ingress-nginx) name of the Ingress where the backend is defined
tplProps.ServiceName = string(reqHeaders.Peek("X-Service-Name")) // (ingress-nginx) name of the Service backing the backend
tplProps.ServicePort = string(reqHeaders.Peek("X-Service-Port")) // (ingress-nginx) port number of the Service backing the backend
tplProps.RequestID = string(reqHeaders.Peek("X-Request-Id")) // (ingress-nginx) unique ID that identifies the request - same as for backend service
tplProps.ForwardedFor = string(reqHeaders.Peek("X-Forwarded-For")) // the value of the `X-Forwarded-For` header
tplProps.Host = string(reqHeaders.Peek("Host")) // the value of the `Host` header
}
// try to find the code message and description in the config and if not - use the standard status text or fallback
if desc, found := cfg.Codes.Find(code); found {
tplProps.Message = desc.Message
tplProps.Description = desc.Description
} else if stdlibStatusText := http.StatusText(int(code)); stdlibStatusText != "" {
tplProps.Message = stdlibStatusText
} else {
tplProps.Message = "Unknown Status Code" // fallback
}
switch {
case format == jsonFormat && cfg.Formats.JSON != "":
if cached, ok := cache.Get(cfg.Formats.JSON, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.JSON, tplProps); err != nil {
errAsJson, _ := json.Marshal(fmt.Sprintf("Failed to render the JSON template: %s", err.Error()))
write(ctx, log, errAsJson) // error during rendering
} else {
cache.Put(cfg.Formats.JSON, tplProps, []byte(content))
write(ctx, log, content) // rendered successfully
}
}
case format == xmlFormat && cfg.Formats.XML != "":
if cached, ok := cache.Get(cfg.Formats.XML, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.XML, tplProps); err != nil {
write(ctx, log, fmt.Sprintf(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<error>Failed to render the XML template: %s</error>\n", err.Error(),
))
} else {
cache.Put(cfg.Formats.XML, tplProps, []byte(content))
write(ctx, log, content)
}
}
case format == htmlFormat:
var templateName = templateToUse(cfg)
if tpl, found := cfg.Templates.Get(templateName); found { //nolint:nestif
if cached, ok := cache.Get(tpl, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(tpl, tplProps); err != nil {
// TODO: add GZIP compression for the HTML content support
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Failed to render the HTML template %s: %s</body></html>\n",
templateName,
err.Error(),
))
} else {
cache.Put(tpl, tplProps, []byte(content))
write(ctx, log, content)
}
}
} else {
write(ctx, log, fmt.Sprintf(
"<!DOCTYPE html>\n<html><body>Template %s not found and cannot be used</body></html>\n", templateName,
))
}
default: // plainTextFormat as default
if cfg.Formats.PlainText != "" { //nolint:nestif
if cached, ok := cache.Get(cfg.Formats.PlainText, tplProps); ok { // cache hit
write(ctx, log, cached)
} else { // cache miss
if content, err := template.Render(cfg.Formats.PlainText, tplProps); err != nil {
write(ctx, log, fmt.Sprintf("Failed to render the PlainText template: %s", err.Error()))
} else {
cache.Put(cfg.Formats.PlainText, tplProps, []byte(content))
write(ctx, log, content)
}
}
} else {
write(ctx, log, `The requested content format is not supported.
Please create an issue on the project's GitHub page to request support for this format.
Supported formats: JSON, XML, HTML, Plain Text
`)
}
}
}, func() { stopOnce.Do(func() { close(stopCh) }) }
}
var (
templateChangedAt atomic.Pointer[time.Time] //nolint:gochecknoglobals // the time when the theme was changed last time
pickedTemplate atomic.Pointer[string] //nolint:gochecknoglobals // the name of the randomly picked template
)
// templateToUse decides which template to use based on the rotation mode and the last time the template was changed.
func templateToUse(cfg *config.Config) string {
switch rotationMode := cfg.RotationMode; rotationMode {
case config.RotationModeDisabled:
return cfg.TemplateName // not needed to do anything
case config.RotationModeRandomOnStartup:
return cfg.TemplateName // do nothing, the scope of this rotation mode is not here
case config.RotationModeRandomOnEachRequest:
return cfg.Templates.RandomName() // pick a random template on each request
case config.RotationModeRandomHourly, config.RotationModeRandomDaily:
var now, rndTemplate = time.Now(), cfg.Templates.RandomName()
if changedAt := templateChangedAt.Load(); changedAt == nil {
// the template was not changed yet (first request)
templateChangedAt.Store(&now)
pickedTemplate.Store(&rndTemplate)
return rndTemplate
} else {
// is it time to change the template?
if (rotationMode == config.RotationModeRandomHourly && changedAt.Hour() != now.Hour()) ||
(rotationMode == config.RotationModeRandomDaily && changedAt.Day() != now.Day()) {
templateChangedAt.Store(&now)
pickedTemplate.Store(&rndTemplate)
return rndTemplate
} else if lastUsed := pickedTemplate.Load(); lastUsed != nil {
// time to change the template has not come yet, so use the last picked template
return *lastUsed
} else {
// in case if the last picked template is not set, pick a random one and store it
templateChangedAt.Store(&now)
pickedTemplate.Store(&rndTemplate)
return rndTemplate
}
}
}
return cfg.TemplateName // the fallback of the fallback :D
}
// write the content to the response writer and log the error if any.
func write[T string | []byte](ctx *fasthttp.RequestCtx, log *logger.Logger, content T) {
var data []byte
if s, ok := any(content).(string); ok {
data = []byte(s)
} else {
data = any(content).([]byte)
}
if _, err := ctx.Write(data); err != nil && log != nil {
log.Error("failed to write the response body",
logger.String("content", string(data)),
logger.Error(err),
)
}
}

View File

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

View File

@ -0,0 +1,30 @@
package live
import (
"net/http"
"github.com/valyala/fasthttp"
)
// New creates a new handler that returns "OK" for GET and HEAD requests.
func New() fasthttp.RequestHandler {
var (
body = []byte("OK\n")
notAllowed = http.StatusText(http.StatusMethodNotAllowed) + "\n"
)
return func(ctx *fasthttp.RequestCtx) {
switch string(ctx.Method()) {
case fasthttp.MethodGet:
ctx.SetContentType("text/plain; charset=utf-8")
ctx.SetStatusCode(http.StatusOK)
_, _ = ctx.Write(body)
case fasthttp.MethodHead:
ctx.SetStatusCode(http.StatusOK)
default:
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
}
}
}

View File

@ -0,0 +1,52 @@
package live_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/http/handlers/live"
"gh.tarampamp.am/error-pages/internal/http/httptest"
)
func TestServeHTTP(t *testing.T) {
t.Parallel()
var (
handler = live.New()
url = "http://testing"
body = http.NoBody
)
t.Run("get", func(t *testing.T) {
httptest.HandleFast(t, handler, http.MethodGet, url, body, func(status int, body string, headers http.Header) {
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
assert.Equal(t, "OK\n", body)
})
})
t.Run("head", func(t *testing.T) {
httptest.HandleFast(t, handler, http.MethodHead, url, body, func(status int, body string, headers http.Header) {
assert.Equal(t, http.StatusOK, status)
assert.Empty(t, headers.Get("Content-Type"))
assert.Empty(t, body)
})
})
t.Run("method not allowed", func(t *testing.T) {
for _, method := range []string{
http.MethodDelete,
http.MethodPatch,
http.MethodPost,
http.MethodPut,
} {
httptest.HandleFast(t, handler, method, url, body, func(status int, body string, headers http.Header) {
assert.Equal(t, http.StatusMethodNotAllowed, status)
assert.Equal(t, "text/plain; charset=utf-8", headers.Get("Content-Type"))
assert.Equal(t, "Method Not Allowed\n", body)
})
}
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@ -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))
},
)
}

View File

@ -0,0 +1,35 @@
package version
import (
"encoding/json"
"net/http"
"strings"
"github.com/valyala/fasthttp"
)
// New creates a handler that returns the version of the service in JSON format.
func New(ver string) fasthttp.RequestHandler {
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) {
switch string(ctx.Method()) {
case fasthttp.MethodGet:
ctx.SetContentType("application/json; charset=utf-8")
ctx.SetStatusCode(http.StatusOK)
_, _ = ctx.Write(body)
case fasthttp.MethodHead:
ctx.SetStatusCode(http.StatusOK)
default:
ctx.Error(notAllowed, http.StatusMethodNotAllowed)
}
}
}

View File

@ -0,0 +1,52 @@
package version_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"gh.tarampamp.am/error-pages/internal/http/handlers/version"
"gh.tarampamp.am/error-pages/internal/http/httptest"
)
func TestServeHTTP(t *testing.T) {
t.Parallel()
var (
handler = version.New("\t\n foo@bar ")
url = "http://testing"
body = http.NoBody
)
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)
})
}
})
}

View File

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

View File

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

View File

@ -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`)
}

147
internal/http/server.go Normal file
View File

@ -0,0 +1,147 @@
package http
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/valyala/fasthttp"
"gh.tarampamp.am/error-pages/internal/appmeta"
"gh.tarampamp.am/error-pages/internal/config"
ep "gh.tarampamp.am/error-pages/internal/http/handlers/error_page"
"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 *logger.Logger
server *fasthttp.Server
beforeStop func()
}
// 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{
log: log,
server: &fasthttp.Server{
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
ReadBufferSize: int(readBufferSize),
DisablePreParseMultipartForm: true,
NoDefaultServerHeader: true,
CloseOnShutdown: true,
Logger: logger.NewStdLog(log),
},
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 {
return errors.New("invalid IP address")
}
var ln net.Listener
if strings.Count(ip, ":") >= 2 { //nolint:mnd // ipv6
if ln, err = net.Listen("tcp6", fmt.Sprintf("[%s]:%d", ip, port)); err != nil {
return err
}
} else { // ipv4
if ln, err = net.Listen("tcp4", fmt.Sprintf("%s:%d", ip, port)); err != nil {
return err
}
}
return s.server.Serve(ln)
}
// Stop server gracefully.
func (s *Server) Stop(timeout time.Duration) error {
var ctx, cancel = context.WithTimeout(context.Background(), timeout)
defer cancel()
s.beforeStop()
return s.server.ShutdownWithContext(ctx)
}

View File

@ -0,0 +1,396 @@
package http_test
import (
"errors"
"fmt"
"io"
"net"
"net/http"
"testing"
"time"
"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)
}

45
internal/logger/attr.go Normal file
View File

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

View File

@ -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())
})
}
}

68
internal/logger/format.go Normal file
View File

@ -0,0 +1,68 @@
package logger
import (
"fmt"
"strings"
)
// A Format is a logging format.
type Format uint8
const (
ConsoleFormat Format = iota // useful for console output (for humans)
JSONFormat // useful for logging aggregation systems (for robots)
)
// String returns a lower-case ASCII representation of the log format.
func (f Format) String() string {
switch f {
case ConsoleFormat:
return "console"
case JSONFormat:
return "json"
}
return fmt.Sprintf("format(%d)", f)
}
// Formats returns a slice of all logging formats.
func Formats() []Format {
return []Format{ConsoleFormat, JSONFormat}
}
// FormatStrings returns a slice of all logging formats as strings.
func FormatStrings() []string {
var (
formats = Formats()
result = make([]string, len(formats))
)
for i := range formats {
result[i] = formats[i].String()
}
return result
}
// ParseFormat parses a format (case is ignored) based on the ASCII representation of the log format.
// If the provided ASCII representation is invalid an error is returned.
//
// This is particularly useful when dealing with text input to configure log formats.
func ParseFormat[T string | []byte](text T) (Format, error) {
var format string
if s, ok := any(text).(string); ok {
format = s
} else {
format = string(any(text).([]byte))
}
switch strings.ToLower(format) {
case "console", "": // make the zero value useful
return ConsoleFormat, nil
case "json":
return JSONFormat, nil
}
return Format(0), fmt.Errorf("unrecognized logging format: %q", text)
}

View File

@ -0,0 +1,70 @@
package logger_test
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/logger"
)
func TestFormat_String(t *testing.T) {
for name, tt := range map[string]struct {
giveFormat logger.Format
wantString string
}{
"json": {giveFormat: logger.JSONFormat, wantString: "json"},
"console": {giveFormat: logger.ConsoleFormat, wantString: "console"},
"<unknown>": {giveFormat: logger.Format(255), wantString: "format(255)"},
} {
t.Run(name, func(t *testing.T) {
require.Equal(t, tt.wantString, tt.giveFormat.String())
})
}
}
func TestParseFormat(t *testing.T) {
for name, tt := range map[string]struct {
giveBytes []byte
giveString string
wantFormat logger.Format
wantError error
}{
"<empty value>": {giveBytes: []byte(""), wantFormat: logger.ConsoleFormat},
"<empty value> (string)": {giveString: "", wantFormat: logger.ConsoleFormat},
"console": {giveBytes: []byte("console"), wantFormat: logger.ConsoleFormat},
"console (string)": {giveString: "console", wantFormat: logger.ConsoleFormat},
"json": {giveBytes: []byte("json"), wantFormat: logger.JSONFormat},
"json (string)": {giveString: "json", wantFormat: logger.JSONFormat},
"foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging format: \"foobar\"")}, //nolint:lll
} {
t.Run(name, func(t *testing.T) {
var (
f logger.Format
err error
)
if tt.giveString != "" {
f, err = logger.ParseFormat(tt.giveString)
} else {
f, err = logger.ParseFormat(tt.giveBytes)
}
if tt.wantError == nil {
require.NoError(t, err)
require.Equal(t, tt.wantFormat, f)
} else {
require.EqualError(t, err, tt.wantError.Error())
}
})
}
}
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())
}

78
internal/logger/level.go Normal file
View File

@ -0,0 +1,78 @@
package logger
import (
"fmt"
"strings"
)
// A Level is a logging level.
type Level int8
const (
DebugLevel Level = iota - 1
InfoLevel // default level (zero-value)
WarnLevel
ErrorLevel
)
// String returns a lower-case ASCII representation of the log level.
func (l Level) String() string {
switch l {
case DebugLevel:
return "debug"
case InfoLevel:
return "info"
case WarnLevel:
return "warn"
case ErrorLevel:
return "error"
}
return fmt.Sprintf("level(%d)", l)
}
// Levels returns a slice of all logging levels.
func Levels() []Level {
return []Level{DebugLevel, InfoLevel, WarnLevel, ErrorLevel}
}
// LevelStrings returns a slice of all logging levels as strings.
func LevelStrings() []string {
var (
levels = Levels()
result = make([]string, len(levels))
)
for i := range levels {
result[i] = levels[i].String()
}
return result
}
// ParseLevel parses a level (case is ignored) based on the ASCII representation of the log level.
// If the provided ASCII representation is invalid an error is returned.
//
// This is particularly useful when dealing with text input to configure log levels.
func ParseLevel[T string | []byte](text T) (Level, error) {
var lvl string
if s, ok := any(text).(string); ok {
lvl = s
} else {
lvl = string(any(text).([]byte))
}
switch strings.ToLower(lvl) {
case "debug", "verbose", "trace":
return DebugLevel, nil
case "info", "": // make the zero value useful
return InfoLevel, nil
case "warn":
return WarnLevel, nil
case "error":
return ErrorLevel, nil
}
return Level(0), fmt.Errorf("unrecognized logging level: %q", text)
}

View File

@ -0,0 +1,80 @@
package logger_test
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/logger"
)
func TestLevel_String(t *testing.T) {
for name, tt := range map[string]struct {
giveLevel logger.Level
wantString string
}{
"debug": {giveLevel: logger.DebugLevel, wantString: "debug"},
"info": {giveLevel: logger.InfoLevel, wantString: "info"},
"warn": {giveLevel: logger.WarnLevel, wantString: "warn"},
"error": {giveLevel: logger.ErrorLevel, wantString: "error"},
"<unknown>": {giveLevel: logger.Level(127), wantString: "level(127)"},
} {
t.Run(name, func(t *testing.T) {
require.Equal(t, tt.wantString, tt.giveLevel.String())
})
}
}
func TestParseLevel(t *testing.T) {
for name, tt := range map[string]struct {
giveBytes []byte
giveString string
wantLevel logger.Level
wantError error
}{
"<empty value>": {giveBytes: []byte(""), wantLevel: logger.InfoLevel},
"<empty value> (string)": {giveString: "", wantLevel: logger.InfoLevel},
"trace": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel},
"verbose": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel},
"debug": {giveBytes: []byte("debug"), wantLevel: logger.DebugLevel},
"debug (string)": {giveString: "debug", wantLevel: logger.DebugLevel},
"info": {giveBytes: []byte("info"), wantLevel: logger.InfoLevel},
"warn": {giveBytes: []byte("warn"), wantLevel: logger.WarnLevel},
"error": {giveBytes: []byte("error"), wantLevel: logger.ErrorLevel},
"foobar": {giveBytes: []byte("foobar"), wantError: errors.New("unrecognized logging level: \"foobar\"")}, //nolint:lll
} {
t.Run(name, func(t *testing.T) {
var (
l logger.Level
err error
)
if tt.giveString != "" {
l, err = logger.ParseLevel(tt.giveString)
} else {
l, err = logger.ParseLevel(tt.giveBytes)
}
if tt.wantError == nil {
require.NoError(t, err)
require.Equal(t, tt.wantLevel, l)
} else {
require.EqualError(t, err, tt.wantError.Error())
}
})
}
}
func TestLevels(t *testing.T) {
require.Equal(t, []logger.Level{
logger.DebugLevel,
logger.InfoLevel,
logger.WarnLevel,
logger.ErrorLevel,
}, logger.Levels())
}
func TestLevelStrings(t *testing.T) {
require.Equal(t, []string{"debug", "info", "warn", "error"}, logger.LevelStrings())
}

126
internal/logger/logger.go Normal file
View File

@ -0,0 +1,126 @@
package logger
import (
"context"
"errors"
"io"
"log/slog"
"os"
"strings"
"time"
)
// internalAttrKeyLoggerName is used to store the logger name in the logger context (attributes).
const internalAttrKeyLoggerName = "named_logger"
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"))
}
}
return a
}
// 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
}
// 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 {
case DebugLevel:
options.Level = slog.LevelDebug
case InfoLevel:
options.Level = slog.LevelInfo
case WarnLevel:
options.Level = slog.LevelWarn
case ErrorLevel:
options.Level = slog.LevelError
default:
return nil, errors.New("unsupported logging level")
}
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 }
// 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...) }

View File

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

View File

@ -0,0 +1,235 @@
package logger_test
import (
"bytes"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gh.tarampamp.am/error-pages/internal/logger"
)
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 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")
var output = buf.String()
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 TestLogger_Info(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.InfoLevel, logger.JSONFormat, &buf)
)
require.NoError(t, logErr)
assert.Equal(t, logger.InfoLevel, 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.Contains(t, output, "info message")
assert.Contains(t, output, "warn message")
assert.Contains(t, output, "error message")
}
func TestLogger_Warn(t *testing.T) {
var (
buf bytes.Buffer
log, logErr = logger.New(logger.WarnLevel, logger.JSONFormat, &buf)
)
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`)
}

14
internal/logger/std.go Normal file
View File

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

View File

@ -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")
}

View File

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

View File

@ -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,
})
}

View File

@ -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 "&lt;":
// `{{ escape "<test>" }}` // `&lt;test&gt;`
"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
}

View File

@ -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: "&lt;script&gt;alert(&#39;XSS&#39; + &#34;HERE&#34;)&lt;/script&gt;",
},
} {
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)
}
})
}
}

14
l10n/embed_test.go Normal file
View File

@ -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")
}

9
l10n/enbed.go Normal file
View File

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

962
l10n/l10n.js Normal file
View File

@ -0,0 +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 () {
const tokenSerializationRe = /[^a-z0-9]/g;
/**
* @param {string} token
* @return {string}
*/
const tkn = function (token) {
return token.toLowerCase().replaceAll(tokenSerializationRe, '');
};
/**
* 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 lURL'],
['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é lURL'],
['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 na 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 dutilisateur 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 nest 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 na pas pu être complétée à cause dun 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 nest 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" nest 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 lentité 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é nest 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 nest 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 dattente 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 dorigine'],
['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 sest-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 lURL'],
['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 dautorisation'],
['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 lURL 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();
// noinspection JSUnusedGlobalSymbols
/**
* @param {string} locale
* @return {void}
*/
this.setLocale = function (locale) {
activeLocale = locale.toLowerCase();
}
/**
* @param {string} token
* @param {string?} def
* @return {string|undefined}
*/
this.translate = function (token, def) {
const t = tkn(token);
if (activeLocale === 'en' && Object.prototype.hasOwnProperty.call(data, t)) {
return token;
}
if (data.has(t) && data.get(t).has(activeLocale)) {
return data.get(t).get(activeLocale);
}
return def;
};
/**
* 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
}
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
// 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
}
// 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
}
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) {
$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();

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