diff --git a/README.md b/README.md
index 8b09ce8b..e3850fe2 100644
--- a/README.md
+++ b/README.md
@@ -87,8 +87,8 @@ The following templates are built-in and available for use without any additiona
[app-down-light]:https://github.com/user-attachments/assets/c5f42b53-51cd-47c4-a22f-553d44d2a288
[app-down-dark]:https://github.com/user-attachments/assets/135bac8c-983f-461c-97ba-e653e9b9adfe
[cats-link]:https://tarampampam.github.io/error-pages/cats/404.html
-[cats-light]:https://github.com/tarampampam/error-pages/assets/7326800/056cd00e-bc9a-4120-8325-310d7b0ebd1b
-[cats-dark]:https://github.com/tarampampam/error-pages/assets/7326800/5689880b-f770-406c-81dd-2d28629e6f2e
+[cats-light]:https://github.com/user-attachments/assets/7bea967e-a427-4ba2-a3a3-d71b9986ecfc
+[cats-dark]:https://github.com/user-attachments/assets/f9b945b2-3e19-44d5-842b-0c43c55d9b70
[connection-link]:https://tarampampam.github.io/error-pages/connection/404.html
[connection-light]:https://github.com/tarampampam/error-pages/assets/7326800/099ecc2d-e724-4d9c-b5ed-66ddabd71139
[connection-dark]:https://github.com/tarampampam/error-pages/assets/7326800/3f03dc1b-c1ee-4a91-b3d7-e3b93c79020e
diff --git a/cmd/builder/app/app.go b/cmd/builder/app/app.go
index c65a593c..9134730f 100644
--- a/cmd/builder/app/app.go
+++ b/cmd/builder/app/app.go
@@ -38,6 +38,7 @@ type App struct {
customTemplate string
l10nDisabled bool
homepageURL string
+ links []tpl.Link
}
}
@@ -60,6 +61,7 @@ func NewApp(name string) *App {
templateFlag = newTemplateFlag()
disableL10nFlag = shared.NewDisableL10nFlag()
homepageURLFlag = shared.NewHomepageURLFlag(app.opt.homepageURL)
+ addLinksFlag = shared.NewAddLinksFlag()
)
app.cmd.Flags = []cli.Flagger{
@@ -70,6 +72,7 @@ func NewApp(name string) *App {
&templateFlag,
&disableL10nFlag,
&homepageURLFlag,
+ &addLinksFlag,
}
app.cmd.Action = func(ctx context.Context, _ *cli.Command, _ []string) error {
@@ -89,6 +92,12 @@ func NewApp(name string) *App {
setIfFlagIsSet(&app.opt.customTemplate, templateFlag)
setIfFlagIsSet(&app.opt.homepageURL, homepageURLFlag)
+ if addLinksFlag.Value != nil && addLinksFlag.IsSet() {
+ if parsed, err := shared.ParseLinks(*addLinksFlag.Value); err == nil {
+ app.opt.links = parsed
+ }
+ }
+
// load custom template content if a source is provided (either URL, file path, or raw template string)
if src := app.opt.customTemplate; src != "" {
t, err := tploader.LoadTemplateContent(ctx, src)
@@ -194,6 +203,7 @@ func (a *App) renderCustomTemplate(httpCodes codes.Codes, history map[string][]h
Message: desc.Short,
Description: desc.Full,
HomepageURL: a.opt.homepageURL,
+ Links: a.opt.links,
Config: tpl.Config{L10nDisabled: a.opt.l10nDisabled},
})
if renderErr != nil {
@@ -254,6 +264,7 @@ func (a *App) renderBuiltInTemplates(httpCodes codes.Codes, history map[string][
Message: desc.Short,
Description: desc.Full,
HomepageURL: a.opt.homepageURL,
+ Links: a.opt.links,
Config: tpl.Config{L10nDisabled: a.opt.l10nDisabled},
})
if renderErr != nil {
diff --git a/cmd/error-pages/app/app.go b/cmd/error-pages/app/app.go
index 95cd02f0..ff63d839 100644
--- a/cmd/error-pages/app/app.go
+++ b/cmd/error-pages/app/app.go
@@ -45,6 +45,7 @@ type App struct {
templateName string
rotationMode tpl.RotationMode
homepageURL string
+ links []tpl.Link
customTemplates struct {
html, json, xml, text string
}
@@ -88,6 +89,7 @@ func NewApp(name string) *App { //nolint:funlen
templateNameFlag = newTemplateNameFlag(allTemplateNames, app.opt.errorPages.templateName)
rotationModeFlag = newRotationModeFlag(app.opt.errorPages.rotationMode)
homepageURLFlag = shared.NewHomepageURLFlag(app.opt.errorPages.homepageURL)
+ addLinksFlag = shared.NewAddLinksFlag()
htmlTemplateFlag = newHTMLTemplateFlag()
jsonTemplateFlag = newJSONTemplateFlag()
xmlTemplateFlag = newXMLTemplateFlag()
@@ -109,6 +111,7 @@ func NewApp(name string) *App { //nolint:funlen
&templateNameFlag,
&rotationModeFlag,
&homepageURLFlag,
+ &addLinksFlag,
&htmlTemplateFlag,
&jsonTemplateFlag,
&xmlTemplateFlag,
@@ -155,6 +158,13 @@ func NewApp(name string) *App { //nolint:funlen
}
setIfFlagIsSet(&app.opt.errorPages.homepageURL, homepageURLFlag)
+
+ if addLinksFlag.Value != nil && addLinksFlag.IsSet() {
+ if parsed, err := shared.ParseLinks(*addLinksFlag.Value); err == nil {
+ app.opt.errorPages.links = parsed
+ }
+ }
+
setIfFlagIsSet(&app.opt.errorPages.customTemplates.html, htmlTemplateFlag)
setIfFlagIsSet(&app.opt.errorPages.customTemplates.json, jsonTemplateFlag)
setIfFlagIsSet(&app.opt.errorPages.customTemplates.xml, xmlTemplateFlag)
@@ -303,6 +313,7 @@ func (a *App) run(ctx context.Context, log *logger.Logger) error {
a.opt.errorPages.showDetails,
a.opt.errorPages.l10nDisabled,
a.opt.errorPages.homepageURL,
+ a.opt.errorPages.links,
),
httpserver.WithErrorLog(logger.NewStdLog(log, logger.ErrorLevel)),
)
@@ -320,6 +331,7 @@ func (a *App) run(ctx context.Context, log *logger.Logger) error {
logger.Bool("show_details", a.opt.errorPages.showDetails),
logger.Strings("proxy_headers", a.opt.errorPages.proxyHeaders...),
logger.String("homepage_url", a.opt.errorPages.homepageURL),
+ logger.Int("links_count", len(a.opt.errorPages.links)),
logger.Bool("l10n_disabled", a.opt.errorPages.l10nDisabled),
)
diff --git a/deploy/helm/README.tpl.md b/deploy/helm/README.tpl.md
index 62e59085..dc3b42b9 100644
--- a/deploy/helm/README.tpl.md
+++ b/deploy/helm/README.tpl.md
@@ -107,9 +107,44 @@ Override descriptions or add non-standard codes (e.g. `499`, `4**` wildcard):
```yaml
config:
- addCode: |
- 499=Client Closed Request|The client closed the connection before the server finished responding.
- 4**=Client Error|Something went wrong on the client side.
+ addCode:
+ - {code: "4**", message: "Client Error", description: "Something went wrong on the client side"}
+ - code: "499"
+ message: "Client Closed Request"
+ description: "The client closed the connection before the server finished responding"
+```
+
+Via `--set` (`--set-string` is required for numeric-looking codes like `499`):
+
+```shell
+helm install error-pages oci://ghcr.io/tarampampam/error-pages/charts/error-pages \
+ --set-string 'config.addCode[0].code=4**' \
+ --set 'config.addCode[0].message=Client Error' \
+ --set-string 'config.addCode[1].code=499' \
+ --set 'config.addCode[1].message=Client Closed Request' \
+ --set 'config.addCode[1].description=The client closed the connection before the server finished responding'
+```
+
+### Adding extra links
+
+Display additional links (status page, contact, privacy policy, etc.) on all error pages:
+
+```yaml
+config:
+ addLink:
+ - {label: "Status Page", url: "https://status.example.com"}
+ - {label: "Contact Support", url: "https://example.com/contact"}
+ - {label: "Privacy Policy", url: "https://example.com/privacy"}
+```
+
+Via `--set`:
+
+```shell
+helm install error-pages oci://ghcr.io/tarampampam/error-pages/charts/error-pages \
+ --set 'config.addLink[0].label=Status Page' \
+ --set 'config.addLink[0].url=https://status.example.com' \
+ --set 'config.addLink[1].label=Contact Support' \
+ --set 'config.addLink[1].url=https://example.com/contact'
```
## 💊 Support
diff --git a/deploy/helm/templates/deployment.yaml b/deploy/helm/templates/deployment.yaml
index c26d7655..9c110172 100644
--- a/deploy/helm/templates/deployment.yaml
+++ b/deploy/helm/templates/deployment.yaml
@@ -75,7 +75,15 @@ spec:
- {name: DISABLE_BUILT_IN_CODES, value: "true"}
{{- end }}
{{- if .addCode }}
- - {name: ADD_CODE, value: {{ .addCode | toJson }}}
+ {{- $parts := list -}}
+ {{- range .addCode -}}
+ {{- $e := printf "%s=%s" .code .message -}}
+ {{- if .description -}}{{- $e = printf "%s|%s" $e .description -}}{{- end -}}
+ {{- $parts = append $parts $e -}}
+ {{- end }}
+ - name: ADD_CODE
+ value: |
+{{ join "\n" $parts | indent 16 }}
{{- end }}
{{- if .htmlTemplate.name }}
- {name: TEMPLATE_NAME, value: "{{ .htmlTemplate.name }}"}
@@ -101,6 +109,15 @@ spec:
{{- end }}
{{- if .homepageUrl }}
- {name: HOMEPAGE_URL, value: "{{ .homepageUrl }}"}
+ {{- end }}
+ {{- if .addLink }}
+ {{- $parts := list -}}
+ {{- range .addLink -}}
+ {{- $parts = append $parts (printf "%s=%s" .label .url) -}}
+ {{- end }}
+ - name: ADD_LINK
+ value: |
+{{ join "\n" $parts | indent 16 }}
{{- end }}
{{- if .disableL10n }}
- {name: DISABLE_L10N, value: "true"}
diff --git a/deploy/helm/values.schema.json b/deploy/helm/values.schema.json
index 8dd9f60b..526a52ef 100644
--- a/deploy/helm/values.schema.json
+++ b/deploy/helm/values.schema.json
@@ -277,7 +277,19 @@
},
"addCode": {
"oneOf": [
- {"type": "string", "minLength": 1, "examples": ["599=Custom Error|Something went wrong"]},
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "code": {"type": "string", "minLength": 1},
+ "message": {"type": "string", "minLength": 1},
+ "description": {"type": "string", "minLength": 1}
+ },
+ "required": ["code", "message"],
+ "additionalProperties": false
+ }
+ },
{"type": "null"}
]
},
@@ -355,6 +367,23 @@
{"type": "null"}
]
},
+ "addLink": {
+ "oneOf": [
+ {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {"type": "string", "minLength": 1},
+ "url": {"type": "string", "minLength": 1}
+ },
+ "required": ["label", "url"],
+ "additionalProperties": false
+ }
+ },
+ {"type": "null"}
+ ]
+ },
"disableL10n": {
"oneOf": [
{"type": "boolean"},
diff --git a/deploy/helm/values.yaml b/deploy/helm/values.yaml
index fc45b024..3d376f58 100644
--- a/deploy/helm/values.yaml
+++ b/deploy/helm/values.yaml
@@ -127,10 +127,12 @@ config:
proxyHeaders: null
# -- (bool/null) Disable built-in HTTP status code descriptions
disableBuiltInCodes: null
- # -- (string/null) Add or override HTTP status codes. Format: `CODE=MESSAGE[\|DESCRIPTION]` (`CODE` supports wildcards like `4**`).
- # Separate multiple entries with newlines
+ # -- ([]object/null) Add or override HTTP status codes. Each entry must have `code` and `message` (required), and
+ # optionally `description`. `code` supports wildcards like `4**`.
# @default -- *all built-in codes*
- addCode: null
+ addCode: null # Array<{code: string, message: string, description?: string}>
+ # -- ([]object/null) Extra links to display on error pages. Each entry must have `label` and `url` (both required).
+ addLink: null # Array<{label: string, url: string}>
htmlTemplate:
# -- (string/null) Built-in HTML template name (**ignored when `custom` is set**).
diff --git a/docs/CLI.md b/docs/CLI.md
index b47ae0a3..d557c512 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -27,6 +27,7 @@ Options:
--template-name="…" Name of the built-in HTML template to use (app-down/cats/connection/ghost/hacker-terminal/l7/lost-in-space/noise/orient/shuffle/win98; ignored if a custom HTML template is set) (default: app-down) [$TEMPLATE_NAME, $HTML_TEMPLATE_NAME]
--rotation-mode="…" Mode for rotating built-in HTML templates (disabled/random-on-startup/random-on-each-request/random-hourly/random-daily; ignored if a custom HTML template is set) (default: disabled) [$ROTATION_MODE]
--homepage-url="…" Homepage URL to show as a link in error pages (e.g. https://app.example.com/home) (default: /) [$HOMEPAGE_URL]
+ --add-link="…" Add extra links to error pages (format: 'LABEL=URL[||LABEL=URL...]'; separate multiple entries with '||', a newline, or a tab) [$ADD_LINK]
--html-template="…" Custom HTML template for error page responses (template text/URL/file path) [$HTML_TEMPLATE, $TEMPLATE]
--json-template="…" Custom JSON template for error page responses (template text/URL/file path) [$JSON_TEMPLATE]
--xml-template="…" Custom XML template for error page responses (template text/URL/file path) [$XML_TEMPLATE]
@@ -109,6 +110,17 @@ ADD_CODE="418=I'm a teapot|Short and stout
499=Client Closed Request|The client closed the connection"
```
+### Adding extra links
+
+Add custom, labeled links (e.g. status page, contact, policy) to be displayed on every error page. Format: `LABEL=URL`.
+
+```bash
+# multiple links separated by || or newlines
+error-pages --add-link "Status Page=https://status.example.com||Contact=https://example.com/contact"
+```
+
+URLs may contain `=` signs - only the first `=` in each entry is used as the separator.
+
## Templates builder
@@ -130,6 +142,7 @@ Options:
--template="…" Custom template for error pages [$TEMPLATE]
--disable-l10n Disable localization of error pages (if the template supports localization) [$DISABLE_L10N]
--homepage-url="…" Homepage URL to show as a link in error pages (e.g. https://app.example.com/home) [$HOMEPAGE_URL]
+ --add-link="…" Add extra links to error pages (format: 'LABEL=URL[||LABEL=URL...]'; separate multiple entries with '||', a newline, or a tab) [$ADD_LINK]
--help, -h Show help
--version, -v Print the version
```
@@ -181,3 +194,11 @@ builder --out ./error-pages --index
├── 404.html
└── ...
```
+
+### Adding extra links
+
+The `--add-link` flag works the same way as in the HTTP server - see [Adding extra links](#adding-extra-links) above.
+
+```bash
+builder --add-link "Status Page=https://status.example.com||Contact=https://example.com/contact" --out ./error-pages
+```
diff --git a/docs/templating.md b/docs/templating.md
index f7b5fca7..36932e2c 100644
--- a/docs/templating.md
+++ b/docs/templating.md
@@ -48,12 +48,30 @@ All templates receive a data object with the following fields:
| `.RequestID` | `string` | Unique request ID * |
| `.ForwardedFor` | `string` | Original client IP(s) from `X-Forwarded-For` * |
| `.Host` | `string` | Request `Host` header * |
-| `.HomepageURL` | `string` | Homepage URL set via `--homepage-url` (empty if not configured) |
-| `.Config.ShowRequestDetails` | `bool` | Whether `--show-details` is enabled |
-| `.Config.L10nDisabled` | `bool` | Whether `--disable-l10n` is set |
+| `.HomepageURL` | `string` | Homepage URL set via `--homepage-url` (empty if not configured) |
+| `.Links` | `[]Link` | Extra links set via `--add-link` (empty slice if not configured) |
+| `.Config.ShowRequestDetails` | `bool` | Whether `--show-details` is enabled |
+| `.Config.L10nDisabled` | `bool` | Whether `--disable-l10n` is set |
> `*` - Requires `--show-details`
+Each element of `.Links` has the following sub-fields:
+
+| Sub-field | Type | Description |
+|---------------|----------|-----------------------|
+| `.Label` | `string` | Link text |
+| `.URL` | `string` | Target URL |
+
+Example usage in a custom template:
+
+```html
+{{ if .Links }}
+
+{{ end }}
+```
+
In addition to the fields above, templates also have access to a set of built-in functions (see below), which are
pipeline-friendly (needle before haystack): `{{ .Message | default "Unknown" | upper }}`.
diff --git a/internal/cli/shared/flags.go b/internal/cli/shared/flags.go
index 923ac2d3..bbe67494 100644
--- a/internal/cli/shared/flags.go
+++ b/internal/cli/shared/flags.go
@@ -6,6 +6,7 @@ import (
"gh.tarampamp.am/error-pages/v4/internal/cli"
"gh.tarampamp.am/error-pages/v4/internal/codes"
+ tpl "gh.tarampamp.am/error-pages/v4/internal/template"
)
// NewDisableBuiltInCodesFlag returns a flag that disables the built-in HTTP status code descriptions.
@@ -100,6 +101,58 @@ func NewHomepageURLFlag(def string) cli.Flag[string] {
}
}
+// NewAddLinksFlag returns a flag for adding extra labeled links to error pages.
+func NewAddLinksFlag() cli.Flag[string] {
+ return cli.Flag[string]{
+ Names: []string{"add-link"},
+ Usage: "Add extra links to error pages " +
+ "(format: 'LABEL=URL[||LABEL=URL...]'; separate multiple entries with '||', a newline, or a tab)",
+ EnvVars: []string{"ADD_LINK"},
+ Validator: func(_ *cli.Command, s string) error {
+ _, err := ParseLinks(s)
+
+ return err
+ },
+ }
+}
+
+// ParseLinks parses the --add-link flag value into a slice of Link pairs.
+// Entries are separated by '||', newline, or tab; each entry has the format 'LABEL=URL' where only
+// the first '=' is used as the split point so that URLs containing '=' are handled correctly.
+// Returns an error if any entry is malformed.
+func ParseLinks(s string) ([]tpl.Link, error) {
+ s = strings.ReplaceAll(s, "\n", "||")
+ s = strings.ReplaceAll(s, "\t", "||")
+
+ parts := strings.Split(s, "||")
+ result := make([]tpl.Link, 0, len(parts))
+
+ for _, entry := range parts {
+ if entry = strings.TrimSpace(entry); entry == "" {
+ continue
+ }
+
+ label, url, ok := strings.Cut(entry, "=")
+ if !ok {
+ return nil, fmt.Errorf("wrong link entry %q: missing '='", entry)
+ }
+
+ label = strings.TrimSpace(label)
+ if label == "" {
+ return nil, fmt.Errorf("missing label in link entry %q", entry)
+ }
+
+ url = strings.TrimSpace(url)
+ if url == "" {
+ return nil, fmt.Errorf("missing URL in link entry %q", entry)
+ }
+
+ result = append(result, tpl.Link{Label: label, URL: url})
+ }
+
+ return result, nil
+}
+
// NewDisableL10nFlag returns a flag that disables client-side localization for templates that support it.
func NewDisableL10nFlag() cli.Flag[bool] {
return cli.Flag[bool]{
diff --git a/internal/cli/shared/flags_test.go b/internal/cli/shared/flags_test.go
index 42250870..eda5e100 100644
--- a/internal/cli/shared/flags_test.go
+++ b/internal/cli/shared/flags_test.go
@@ -5,6 +5,7 @@ import (
"gh.tarampamp.am/error-pages/v4/internal/cli/shared"
"gh.tarampamp.am/error-pages/v4/internal/codes"
+ tpl "gh.tarampamp.am/error-pages/v4/internal/template"
"gh.tarampamp.am/error-pages/v4/internal/testutil/assert"
)
@@ -190,3 +191,106 @@ func TestParseAddHTTPCodes(t *testing.T) {
})
}
}
+
+func TestNewAddLinksFlag(t *testing.T) {
+ t.Parallel()
+
+ f := shared.NewAddLinksFlag()
+
+ assert.Equal(t, 1, len(f.Names))
+ assert.Equal(t, "add-link", f.Names[0])
+ assert.Equal(t, 1, len(f.EnvVars))
+ assert.Equal(t, "ADD_LINK", f.EnvVars[0])
+ assert.True(t, f.Validator != nil)
+
+ assert.NoError(t, f.Validator(nil, "Status Page=https://status.example.com"))
+ assert.Error(t, f.Validator(nil, "bad-entry"))
+}
+
+func TestParseLinks(t *testing.T) {
+ t.Parallel()
+
+ for name, tt := range map[string]struct {
+ give string
+ want []tpl.Link
+ checkErr func(*testing.T, error)
+ }{
+ "empty string": {
+ give: "",
+ want: []tpl.Link{},
+ },
+ "single entry": {
+ give: "Status Page=https://status.example.com",
+ want: []tpl.Link{{Label: "Status Page", URL: "https://status.example.com"}},
+ },
+ "url with equals sign": {
+ give: "Search=https://example.com/search?q=foo&page=1",
+ want: []tpl.Link{{Label: "Search", URL: "https://example.com/search?q=foo&page=1"}},
+ },
+ "multiple entries/double pipe separator": {
+ give: "Status=https://status.example.com||Contact=https://example.com/contact",
+ want: []tpl.Link{
+ {Label: "Status", URL: "https://status.example.com"},
+ {Label: "Contact", URL: "https://example.com/contact"},
+ },
+ },
+ "multiple entries/newline separator": {
+ give: "Status=https://status.example.com\nContact=https://example.com/contact",
+ want: []tpl.Link{
+ {Label: "Status", URL: "https://status.example.com"},
+ {Label: "Contact", URL: "https://example.com/contact"},
+ },
+ },
+ "multiple entries/tab separator": {
+ give: "Status=https://status.example.com\tContact=https://example.com/contact",
+ want: []tpl.Link{
+ {Label: "Status", URL: "https://status.example.com"},
+ {Label: "Contact", URL: "https://example.com/contact"},
+ },
+ },
+ "empty entries in the middle are skipped": {
+ give: "Status=https://status.example.com||||Contact=https://example.com/contact",
+ want: []tpl.Link{
+ {Label: "Status", URL: "https://status.example.com"},
+ {Label: "Contact", URL: "https://example.com/contact"},
+ },
+ },
+ "whitespace trimmed around label and url": {
+ give: " Status Page = https://status.example.com ",
+ want: []tpl.Link{{Label: "Status Page", URL: "https://status.example.com"}},
+ },
+ "missing equals sign": {
+ give: "Status Page",
+ checkErr: func(t *testing.T, err error) { assert.ErrorContains(t, err, "missing '='") },
+ },
+ "empty label": {
+ give: "=https://status.example.com",
+ checkErr: func(t *testing.T, err error) { assert.ErrorContains(t, err, "missing label") },
+ },
+ "whitespace-only label": {
+ give: " =https://status.example.com",
+ checkErr: func(t *testing.T, err error) { assert.ErrorContains(t, err, "missing label") },
+ },
+ "empty url": {
+ give: "Status=",
+ checkErr: func(t *testing.T, err error) { assert.ErrorContains(t, err, "missing URL") },
+ },
+ "whitespace-only url": {
+ give: "Status= ",
+ checkErr: func(t *testing.T, err error) { assert.ErrorContains(t, err, "missing URL") },
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := shared.ParseLinks(tt.give)
+
+ if tt.checkErr != nil {
+ tt.checkErr(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.DeepEqual(t, tt.want, got)
+ }
+ })
+ }
+}
diff --git a/internal/httpserver/handler.go b/internal/httpserver/handler.go
index dc6ffcea..2a444df5 100644
--- a/internal/httpserver/handler.go
+++ b/internal/httpserver/handler.go
@@ -10,6 +10,7 @@ import (
"gh.tarampamp.am/error-pages/v4/internal/httpserver/handlers/version"
"gh.tarampamp.am/error-pages/v4/internal/httpserver/middleware"
"gh.tarampamp.am/error-pages/v4/internal/logger"
+ tpl "gh.tarampamp.am/error-pages/v4/internal/template"
)
// NewHandler creates a new HTTP handler that serves all server endpoints. It does not use MUX because the
@@ -24,6 +25,7 @@ func NewHandler(
showDetails bool,
l10nDisabled bool,
homepageURL string,
+ links []tpl.Link,
) http.Handler {
const (
healthzEndpoint = "/healthz"
@@ -48,6 +50,7 @@ func NewHandler(
showDetails,
l10nDisabled,
homepageURL,
+ links,
)
return middleware.Apply(
diff --git a/internal/httpserver/handlers/error_page/handler.go b/internal/httpserver/handlers/error_page/handler.go
index 7b8a76d6..919f56c9 100644
--- a/internal/httpserver/handlers/error_page/handler.go
+++ b/internal/httpserver/handlers/error_page/handler.go
@@ -36,6 +36,7 @@ func New( //nolint:funlen
showDetails bool,
l10nDisabled bool,
homepageURL string,
+ links []tpl.Link,
) http.Handler {
// bufPool reuses the render buffer across requests to avoid per-request heap allocation for the response body
bufPool := sync.Pool{New: func() any { return new(bytes.Buffer) }}
@@ -95,6 +96,7 @@ func New( //nolint:funlen
Message: codeDesc.Short,
Description: codeDesc.Full,
HomepageURL: homepageURL,
+ Links: links,
Config: tpl.Config{
ShowRequestDetails: showDetails,
L10nDisabled: l10nDisabled,
diff --git a/internal/httpserver/handlers/error_page/handler_test.go b/internal/httpserver/handlers/error_page/handler_test.go
index 75e7b791..f4d924dd 100644
--- a/internal/httpserver/handlers/error_page/handler_test.go
+++ b/internal/httpserver/handlers/error_page/handler_test.go
@@ -47,6 +47,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
for name, tc := range map[string]struct {
@@ -94,6 +95,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
}
@@ -181,6 +183,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
for name, tc := range map[string]struct {
@@ -381,6 +384,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
req := httptest.NewRequest(http.MethodGet, tc.givePath, nil)
@@ -407,6 +411,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
for name, tc := range map[string]struct {
@@ -485,6 +490,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
req := httptest.NewRequest(http.MethodGet, "/404", nil)
@@ -549,6 +555,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
req := httptest.NewRequest(http.MethodGet, tc.givePath, nil)
@@ -575,6 +582,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
for name, tc := range map[string]struct {
@@ -614,6 +622,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
for name, tc := range map[string]struct {
@@ -654,6 +663,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
for name, tc := range map[string]struct {
@@ -700,6 +710,7 @@ func TestNew(t *testing.T) {
true,
false,
"",
+ nil,
)
req := httptest.NewRequest(http.MethodGet, "/500", nil)
@@ -735,6 +746,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
req := httptest.NewRequest(http.MethodGet, "/500", nil)
@@ -765,6 +777,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
wantLen := strconv.Itoa(len(tplBody))
@@ -818,6 +831,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
for name, tc := range map[string]struct {
@@ -902,6 +916,7 @@ func TestNew(t *testing.T) {
false,
false,
"",
+ nil,
)
req := httptest.NewRequest(http.MethodGet, "/404", nil)
diff --git a/internal/template/data.go b/internal/template/data.go
index aca2803f..de3e0782 100644
--- a/internal/template/data.go
+++ b/internal/template/data.go
@@ -20,15 +20,23 @@ type Data struct {
ForwardedFor string // (ingress-nginx, Envoy Gateway) the value of the `X-Forwarded-For` header
Host string // the value of the `Host` header
HomepageURL string // homepage URL (optional, set via --homepage-url)
+ Links []Link // additional links to display on the error page (optional, set via --add-link)
Config Config // configuration values
// TODO: add incoming request headers as a map[string]string field, so they can be used in the templates?
}
+// Link represents a labeled hyperlink that can be displayed in error page templates.
+//
+// DO NOT MODIFY EXISTING FIELDS OR THEIR TYPES.
+type Link struct {
+ Label string // link text shown to the user
+ URL string // target URL
+}
+
// Config holds configuration values that can be used in the templates.
//
-// DO NOT MODIFY EXISTING FIELDS OR THEIR TYPES, as they are used in the templates and may be referenced in
-// the template files.
+// DO NOT MODIFY EXISTING FIELDS OR THEIR TYPES.
type Config struct {
ShowRequestDetails bool // show request details?
L10nDisabled bool // disable localization feature?
diff --git a/internal/template/template_test.go b/internal/template/template_test.go
index c7d6c077..375c58da 100644
--- a/internal/template/template_test.go
+++ b/internal/template/template_test.go
@@ -24,6 +24,10 @@ func TestTemplate_Render(t *testing.T) {
ForwardedFor: "123.123.123.123:321",
Host: "test-host",
HomepageURL: "https://app.example.com/home",
+ Links: []tpl.Link{
+ {Label: "Status Page", URL: "https://status.example.com"},
+ {Label: "Contact", URL: "https://example.com/contact"},
+ },
Config: tpl.Config{
ShowRequestDetails: true,
L10nDisabled: true,
@@ -46,7 +50,9 @@ ForwardedFor={{ .ForwardedFor }}
Host={{ .Host }}
HomepageURL={{ .HomepageURL }}
Config.ShowRequestDetails={{ .Config.ShowRequestDetails }}
-Config.L10nDisabled={{ .Config.L10nDisabled }}`
+Config.L10nDisabled={{ .Config.L10nDisabled }}
+{{ range .Links }}Link={{ .Label }}={{ .URL }}
+{{ end }}`
tmpl, err := tpl.New(src)
assert.NoError(t, err)
@@ -67,7 +73,10 @@ ForwardedFor=123.123.123.123:321
Host=test-host
HomepageURL=https://app.example.com/home
Config.ShowRequestDetails=true
-Config.L10nDisabled=true`, string(got))
+Config.L10nDisabled=true
+Link=Status Page=https://status.example.com
+Link=Contact=https://example.com/contact
+`, string(got))
})
t.Run("render built-in templates", func(t *testing.T) {
@@ -96,6 +105,22 @@ Config.L10nDisabled=true`, string(got))
_, err = template.Render(tpl.Data{})
assert.NoError(t, err)
})
+
+ t.Run("links rendered", func(t *testing.T) {
+ t.Parallel()
+
+ template, err := tpl.New(content)
+ assert.NoError(t, err)
+
+ rendered, renderErr := template.Render(tpl.Data{
+ Links: []tpl.Link{
+ {Label: "My Status Page", URL: "https://status.example.com"},
+ },
+ })
+ assert.NoError(t, renderErr)
+ assert.Contains(t, string(rendered), "My Status Page")
+ assert.Contains(t, string(rendered), "https://status.example.com")
+ })
})
}
diff --git a/templates/html/app-down.tpl.html b/templates/html/app-down.tpl.html
index 574e4171..d8a23fe2 100644
--- a/templates/html/app-down.tpl.html
+++ b/templates/html/app-down.tpl.html
@@ -174,6 +174,15 @@
font-size: clamp(0.9rem, 2.3vmin, 1rem);
color: var(--color-text-secondary);
+ /* {{ if .Links }} */
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-size: clamp(0.9rem, 2.3vmin, 1rem);
+ }
+ /* {{ end }} */
+
a {
color: inherit;
text-decoration: underline;
@@ -399,6 +408,16 @@
Suggestions
+
+
+
+
Request details
@@ -436,6 +455,7 @@ Request details
+
+
diff --git a/templates/html/cats.tpl.html b/templates/html/cats.tpl.html
index 9beae11a..c1f5041c 100644
--- a/templates/html/cats.tpl.html
+++ b/templates/html/cats.tpl.html
@@ -3,8 +3,8 @@
- {{ .Message }}
-
+ {{ .Message }}
+
@@ -12,150 +12,228 @@
+
+
-
-
-
-
-
-
-
-
-
- | Host |
- {{ .Host }} |
-
-
-
- | Original URI |
- {{ .OriginalURI }} |
-
-
-
- | Forwarded for |
- {{ .ForwardedFor }} |
-
-
-
- | Namespace |
- {{ .Namespace }} |
-
-
-
- | Ingress name |
- {{ .IngressName }} |
-
-
-
- | Service name |
- {{ .ServiceName }} |
-
-
-
- | Service port |
- {{ .ServicePort }} |
-
-
-
- | Request ID |
- {{ .RequestID }} |
-
+
+
+
+
+
+
+
+
+
+
+
+
+ | Host |
+ {{ .Host }} |
+
+
+
+ | Original URI |
+ {{ .OriginalURI }} |
+
+
+
+ | Forwarded for |
+ {{ .ForwardedFor }} |
+
+
+
+ | Namespace |
+ {{ .Namespace }} |
+
+
+
+ | Ingress name |
+ {{ .IngressName }} |
+
+
+
+ | Service name |
+ {{ .ServiceName }} |
+
+
+
+ | Service port |
+ {{ .ServicePort }} |
+
+
+
+ | Request ID |
+ {{ .RequestID }} |
+
+
+
+ | Timestamp |
+ {{ now.Unix }} |
+
+
+
-
- | Timestamp |
- {{ now.Unix }} |
-
-
-
-
+
diff --git a/templates/html/connection.tpl.html b/templates/html/connection.tpl.html
index 8618377f..71eed930 100644
--- a/templates/html/connection.tpl.html
+++ b/templates/html/connection.tpl.html
@@ -193,6 +193,23 @@
color: var(--color-text-secondary);
}
+ footer .links {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 1em;
+ font-size: clamp(0.9rem, 2.3vmin, 1rem);
+ margin: clamp(2rem, 5vmin, 3.5rem) 0;
+ }
+
+ footer .links a {
+ color: var(--color-text-secondary);
+ text-decoration: underline;
+ font-size: .85em;
+ }
+
/* {{ if .Config.ShowRequestDetails }} */
footer .details {
margin-top: 20px;
@@ -308,6 +325,16 @@ What can I do?