2024-06-21 23:32:10 +00:00
package serve
import (
"context"
2024-06-22 20:05:11 +00:00
"errors"
2024-06-21 23:32:10 +00:00
"fmt"
2024-06-22 08:05:01 +00:00
"net/http"
2024-06-28 12:25:43 +00:00
"net/url"
2024-06-21 23:32:10 +00:00
"strings"
2024-06-22 20:05:11 +00:00
"time"
2024-06-21 23:32:10 +00:00
"github.com/urfave/cli/v3"
"gh.tarampamp.am/error-pages/internal/cli/shared"
"gh.tarampamp.am/error-pages/internal/config"
2024-06-22 20:05:11 +00:00
appHttp "gh.tarampamp.am/error-pages/internal/http"
2024-06-24 15:28:03 +00:00
"gh.tarampamp.am/error-pages/internal/logger"
2024-06-21 23:32:10 +00:00
)
type command struct {
c * cli . Command
2024-06-22 08:05:01 +00:00
opt struct {
http struct { // our HTTP server
2024-06-22 20:05:11 +00:00
addr string
port uint16
// readBufferSize uint
2024-06-22 08:05:01 +00:00
}
}
2024-06-21 23:32:10 +00:00
}
// NewCommand creates `serve` command.
2024-06-24 15:28:03 +00:00
func NewCommand ( log * logger . Logger ) * cli . Command { //nolint:funlen,gocognit,gocyclo
2024-06-21 23:32:10 +00:00
var (
2024-06-22 20:05:11 +00:00
cmd command
cfg = config . New ( )
env , trim = cli . EnvVars , cli . StringConfig { TrimSpace : true }
2024-06-22 08:05:01 +00:00
)
2024-06-21 23:32:10 +00:00
2024-06-22 08:05:01 +00:00
var (
2024-06-29 12:34:03 +00:00
addrFlag = shared . ListenAddrFlag
portFlag = shared . ListenPortFlag
addTplFlag = shared . AddTemplatesFlag
disableTplFlag = shared . DisableTemplateNamesFlag
addCodeFlag = shared . AddHTTPCodesFlag
disableL10nFlag = shared . DisableL10nFlag
jsonFormatFlag = cli . StringFlag {
2024-06-21 23:32:10 +00:00
Name : "json-format" ,
2024-06-29 15:11:21 +00:00
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)" ,
2024-06-22 20:05:11 +00:00
Sources : env ( "RESPONSE_JSON_FORMAT" ) ,
2024-06-21 23:32:10 +00:00
OnlyOnce : true ,
2024-06-22 20:05:11 +00:00
Config : trim ,
2024-06-21 23:32:10 +00:00
}
xmlFormatFlag = cli . StringFlag {
Name : "xml-format" ,
2024-06-29 15:11:21 +00:00
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)" ,
2024-06-22 20:05:11 +00:00
Sources : env ( "RESPONSE_XML_FORMAT" ) ,
2024-06-21 23:32:10 +00:00
OnlyOnce : true ,
2024-06-22 20:05:11 +00:00
Config : trim ,
2024-06-21 23:32:10 +00:00
}
2024-06-25 18:26:34 +00:00
plainTextFormatFlag = cli . StringFlag {
Name : "plaintext-format" ,
2024-06-29 15:11:21 +00:00
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 PlainText content type or does not specify any)" ,
2024-06-25 18:26:34 +00:00
Sources : env ( "RESPONSE_PLAINTEXT_FORMAT" ) ,
OnlyOnce : true ,
Config : trim ,
}
2024-06-22 08:05:01 +00:00
templateNameFlag = cli . StringFlag {
Name : "template-name" ,
Aliases : [ ] string { "t" } ,
Value : cfg . TemplateName ,
2024-06-29 15:11:21 +00:00
Usage : "name of the template to use for rendering error pages (builtin templates: " + strings . Join ( cfg . Templates . Names ( ) , ", " ) + ")" ,
2024-06-22 20:05:11 +00:00
Sources : env ( "TEMPLATE_NAME" ) ,
2024-06-22 08:05:01 +00:00
OnlyOnce : true ,
2024-06-22 20:05:11 +00:00
Config : trim ,
2024-06-22 08:05:01 +00:00
}
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" ,
2024-06-23 19:48:51 +00:00
Value : uint64 ( cfg . DefaultCodeToRender ) ,
2024-06-22 20:05:11 +00:00
Sources : env ( "DEFAULT_ERROR_PAGE" ) ,
2024-06-22 08:05:01 +00:00
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 ,
}
2024-06-23 19:48:51 +00:00
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" ) ,
OnlyOnce : true ,
2024-06-22 08:05:01 +00:00
}
showDetailsFlag = cli . BoolFlag {
Name : "show-details" ,
Usage : "show request details in the error page response (if supported by the template)" ,
Value : cfg . ShowDetails ,
2024-06-22 20:05:11 +00:00
Sources : env ( "SHOW_DETAILS" ) ,
2024-06-22 08:05:01 +00:00
OnlyOnce : true ,
}
proxyHeadersListFlag = cli . StringFlag {
2024-06-29 10:54:47 +00:00
Name : "proxy-headers" ,
2024-06-22 08:05:01 +00:00
Usage : "listed here HTTP headers will be proxied from the original request to the error page response " +
"(comma-separated list)" ,
Value : strings . Join ( cfg . ProxyHeaders , "," ) ,
2024-06-22 20:05:11 +00:00
Sources : env ( "PROXY_HTTP_HEADERS" ) ,
2024-06-22 08:05:01 +00:00
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
} ,
OnlyOnce : true ,
2024-06-22 20:05:11 +00:00
Config : trim ,
2024-06-22 08:05:01 +00:00
}
2024-06-22 20:05:11 +00:00
rotationModeFlag = cli . StringFlag {
Name : "rotation-mode" ,
Value : config . RotationModeDisabled . String ( ) ,
Usage : "templates automatic rotation mode (" + strings . Join ( config . RotationModeStrings ( ) , "/" ) + ")" ,
Sources : env ( "TEMPLATES_ROTATION_MODE" ) ,
OnlyOnce : true ,
Config : trim ,
Validator : func ( s string ) error {
if _ , err := config . ParseRotationMode ( s ) ; err != nil {
return err
}
return nil
} ,
2024-06-22 08:05:01 +00:00
}
2024-06-21 23:32:10 +00:00
)
2024-06-29 15:11:21 +00:00
addrFlag . Usage = "the HTTP server will listen on this IP (v4 or v6) address (set 127.0.0.1 for localhost, 0.0.0.0 to listen on all interfaces, or specify a custom IP)"
portFlag . Usage = "the TPC port number for the HTTP server to listen on (0-65535)"
2024-06-29 12:34:03 +00:00
disableL10nFlag . Value = cfg . L10n . Disable // set the default value depending on the configuration
2024-06-21 23:32:10 +00:00
cmd . c = & cli . Command {
Name : "serve" ,
Aliases : [ ] string { "s" , "server" , "http" } ,
Usage : "Start HTTP server" ,
Suggest : true ,
Action : func ( ctx context . Context , c * cli . Command ) error {
2024-06-22 08:05:01 +00:00
cmd . opt . http . addr = c . String ( addrFlag . Name )
cmd . opt . http . port = uint16 ( c . Uint ( portFlag . Name ) )
cfg . L10n . Disable = c . Bool ( disableL10nFlag . Name )
2024-06-23 19:48:51 +00:00
cfg . DefaultCodeToRender = uint16 ( c . Uint ( defaultCodeToRenderFlag . Name ) )
cfg . RespondWithSameHTTPCode = c . Bool ( sendSameHTTPCodeFlag . Name )
2024-06-22 20:05:11 +00:00
cfg . RotationMode , _ = config . ParseRotationMode ( c . String ( rotationModeFlag . Name ) )
2024-06-22 08:05:01 +00:00
cfg . ShowDetails = c . Bool ( showDetailsFlag . Name )
2024-06-26 10:52:21 +00:00
{ // 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 {
2024-06-25 18:26:34 +00:00
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 ) ,
)
}
}
}
2024-06-26 10:52:21 +00:00
// set the list of HTTP headers we need to proxy from the incoming request to the error page response
2024-06-22 08:05:01 +00:00
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 )
}
}
2024-06-21 23:32:10 +00:00
2024-06-26 10:52:21 +00:00
// add custom HTTP codes to the configuration
if add := c . StringMap ( addCodeFlag . Name ) ; len ( add ) > 0 {
2024-06-29 12:34:03 +00:00
for code , desc := range shared . ParseHTTPCodes ( add ) {
2024-06-21 23:32:10 +00:00
cfg . Codes [ code ] = desc
log . Info ( "HTTP code added" ,
2024-06-24 15:28:03 +00:00
logger . String ( "code" , code ) ,
logger . String ( "message" , desc . Message ) ,
logger . String ( "description" , desc . Description ) ,
2024-06-21 23:32:10 +00:00
)
}
}
2024-06-26 10:52:21 +00:00
// 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 ) )
}
2024-06-21 23:32:10 +00:00
}
2024-06-26 10:52:21 +00:00
}
2024-06-21 23:32:10 +00:00
2024-06-28 12:25:43 +00:00
// 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" )
}
2024-06-26 10:52:21 +00:00
// 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 )
2024-06-25 18:26:34 +00:00
2024-06-26 10:52:21 +00:00
if ! cfg . Templates . Has ( cfg . TemplateName ) {
2024-06-27 15:29:54 +00:00
return fmt . Errorf (
"template '%s' not found and cannot be used (available templates: %s)" ,
cfg . TemplateName ,
cfg . Templates . Names ( ) ,
)
2024-06-21 23:32:10 +00:00
}
}
log . Debug ( "Configuration" ,
2024-06-24 15:28:03 +00:00
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 ) ,
2024-06-26 10:52:21 +00:00
logger . String ( "plain text format" , cfg . Formats . PlainText ) ,
2024-06-24 15:28:03 +00:00
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 ) ,
2024-06-26 10:52:21 +00:00
logger . String ( "rotation mode" , cfg . RotationMode . String ( ) ) ,
2024-06-24 15:28:03 +00:00
logger . Bool ( "show details" , cfg . ShowDetails ) ,
logger . Strings ( "proxy HTTP headers" , cfg . ProxyHeaders ... ) ,
2024-06-21 23:32:10 +00:00
)
return cmd . Run ( ctx , log , & cfg )
} ,
Flags : [ ] cli . Flag {
& addrFlag ,
2024-06-22 08:05:01 +00:00
& portFlag ,
2024-06-21 23:32:10 +00:00
& addTplFlag ,
2024-06-26 10:52:21 +00:00
& disableTplFlag ,
2024-06-21 23:32:10 +00:00
& addCodeFlag ,
& jsonFormatFlag ,
& xmlFormatFlag ,
2024-06-25 18:26:34 +00:00
& plainTextFormatFlag ,
2024-06-22 08:05:01 +00:00
& templateNameFlag ,
& disableL10nFlag ,
& defaultCodeToRenderFlag ,
2024-06-23 19:48:51 +00:00
& sendSameHTTPCodeFlag ,
2024-06-22 08:05:01 +00:00
& showDetailsFlag ,
& proxyHeadersListFlag ,
2024-06-22 20:05:11 +00:00
& rotationModeFlag ,
2024-06-21 23:32:10 +00:00
} ,
}
return cmd . c
}
// Run current command.
2024-06-28 12:25:43 +00:00
func ( cmd * command ) Run ( ctx context . Context , log * logger . Logger , cfg * config . Config ) error { //nolint:funlen
2024-06-22 20:05:11 +00:00
var srv = appHttp . NewServer ( ctx , log )
if err := srv . Register ( cfg ) ; err != nil {
return err
}
var startingErrCh = make ( chan error , 1 ) // channel for server starting error
defer close ( startingErrCh )
2024-06-28 12:25:43 +00:00
// 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)
2024-06-28 20:49:59 +00:00
//
// if you wish, you may view the collected statistics at any time here - https://error-pages.goatcounter.com/
2024-06-28 12:25:43 +00:00
go func ( ) {
2024-06-28 12:55:06 +00:00
var tpl = url . QueryEscape ( cfg . TemplateName )
2024-06-28 12:25:43 +00:00
req , reqErr := http . NewRequestWithContext ( ctx , http . MethodGet , fmt . Sprintf (
2024-06-28 12:55:06 +00:00
// https://www.goatcounter.com/help/pixel
2024-06-28 15:28:35 +00:00
"https://error-pages.goatcounter.com/count?p=/use-template/%s&t=%s" , tpl , tpl ,
2024-06-28 12:25:43 +00:00
) , http . NoBody )
if reqErr != nil {
return
}
2024-06-28 20:49:59 +00:00
req . Header . Set ( "User-Agent" , fmt . Sprintf ( "Mozilla/5.0 (error-pages, rnd:%d)" , time . Now ( ) . UnixNano ( ) ) )
2024-06-28 12:55:06 +00:00
2024-06-28 12:25:43 +00:00
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 ( )
}
} ( )
2024-06-22 20:05:11 +00:00
// start HTTP server in separate goroutine
go func ( errCh chan <- error ) {
var now = time . Now ( )
defer func ( ) {
2024-06-24 15:28:03 +00:00
log . Info ( "HTTP server stopped" , logger . Duration ( "uptime" , time . Since ( now ) . Round ( time . Millisecond ) ) )
2024-06-22 20:05:11 +00:00
} ( )
log . Info ( "HTTP server starting" ,
2024-06-24 15:28:03 +00:00
logger . String ( "addr" , cmd . opt . http . addr ) ,
logger . Uint16 ( "port" , cmd . opt . http . port ) ,
2024-06-22 20:05:11 +00:00
)
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
2024-06-24 15:28:03 +00:00
log . Info ( "HTTP server stopping" , logger . Duration ( "with timeout" , shutdownTimeout ) )
2024-06-22 20:05:11 +00:00
if err := srv . Stop ( shutdownTimeout ) ; err != nil { //nolint:contextcheck
return err
}
}
return nil
2024-06-21 23:32:10 +00:00
}