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

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 @@ + + -
- {{ .Message }} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+
+ {{ .Message }} +
+ + + + +
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?