wip refactoring: better logging and error systems

This commit is contained in:
Julien Neuhart
2019-07-07 17:45:07 +02:00
parent a4aa8dafac
commit c8f7ea934c
36 changed files with 1346 additions and 1096 deletions

24
go.mod
View File

@@ -3,21 +3,19 @@ module github.com/thecodingmachine/gotenberg
go 1.12
require (
github.com/google/go-cmp v0.2.0 // indirect
github.com/google/go-cmp v0.3.0 // indirect
github.com/gorilla/websocket v1.4.0 // indirect
github.com/labstack/echo/v4 v4.0.0
github.com/labstack/gommon v0.2.8
github.com/mafredri/cdp v0.22.0
github.com/mattn/go-colorable v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.7 // indirect
github.com/microcosm-cc/bluemonday v1.0.1
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/labstack/echo/v4 v4.1.6
github.com/labstack/gommon v0.2.9
github.com/mafredri/cdp v0.23.4
github.com/microcosm-cc/bluemonday v1.0.2
github.com/russross/blackfriday/v2 v2.0.1
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.4.2
github.com/stretchr/testify v1.3.0
github.com/valyala/fasttemplate v1.0.1 // indirect
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c // indirect
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc // indirect
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f
golang.org/x/sys v0.0.0-20190621062556-bf70e4678053 // indirect
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 // indirect
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect
golang.org/x/sync v0.0.0-20190423024810-112230192c58
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb // indirect
)

74
go.sum
View File

@@ -3,58 +3,66 @@ 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/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/labstack/echo/v4 v4.0.0 h1:q1GH+caIXPP7H2StPIdzy/ez9CO0EepqYeUg6vi9SWM=
github.com/labstack/echo/v4 v4.0.0/go.mod h1:tZv7nai5buKSg5h/8E6zz4LsD/Dqh9/91Mvs7Z5Zyno=
github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/mafredri/cdp v0.22.0 h1:BV17j8hXLDWczo2SZIAFuOjMpQMIOq5DOcd9sgB2hv0=
github.com/mafredri/cdp v0.22.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/labstack/echo/v4 v4.1.6 h1:WOvLa4T1KzWCRpANwz0HGgWDelXSSGwIKtKBbFdHTv4=
github.com/labstack/echo/v4 v4.1.6/go.mod h1:kU/7PwzgNxZH4das4XNsSpBSOD09XIF5YEPzjpkGnGE=
github.com/labstack/gommon v0.2.9 h1:heVeuAYtevIQVYkGj6A41dtfT91LrvFG220lavpWhrU=
github.com/labstack/gommon v0.2.9/go.mod h1:E8ZTmW9vw5az5/ZyHWCp0Lw4OH2ecsaBP1C/NKavGG4=
github.com/mafredri/cdp v0.23.4 h1:ffp4qq6slfCL4rFWBDeRHapkLE776gER4tX5Z3LS8CY=
github.com/mafredri/cdp v0.23.4/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
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/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY=
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8=
github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw=
github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
golang.org/x/crypto v0.0.0-20190130090550-b01c7a725664/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c h1:Vj5n4GlwjmQteupaxJ9+0FNOmBrHfq7vN4btdGoDZgI=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190607181551-461777fb6f67/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8j4DQRpdYMnGn/bJUEU=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190621062556-bf70e4678053 h1:T0MJjz97TtCXa3ZNW2Oenb3KQWB91K965zMEbIJ4ThA=
golang.org/x/sys v0.0.0-20190621062556-bf70e4678053/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190609082536-301114b31cce/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb h1:fgwFCsaw9buMuxNd6+DQfAuSFqbNiQZpcgJQAgJsK6k=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190608022120-eacb66d2a7c3/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=

View File

@@ -2,32 +2,33 @@ package api
import (
"github.com/labstack/echo/v4"
conf "github.com/thecodingmachine/gotenberg/internal/pkg/config"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/middleware"
"github.com/thecodingmachine/gotenberg/internal/pkg/config"
)
const pingEndpoint = "/ping"
// New returns an API.
func New(config *conf.Config) *echo.Echo {
func New(config *config.Config) *echo.Echo {
api := echo.New()
api.HideBanner = true
api.HidePort = true
api.Use(contextMiddleware(config))
api.Use(loggingMiddleware())
api.Use(finalizeMiddleware())
api.GET(pingEndpoint, func(c echo.Context) error { return nil })
api.POST("/merge", merge)
api.Use(middleware.Context(config))
api.Use(middleware.Logger())
api.Use(middleware.Cleanup())
api.Use(middleware.Error())
api.GET(handler.PingEndpoint, handler.Ping)
api.POST(handler.MergeEndpoint, handler.Merge)
if !config.EnableChromeEndpoints() && !config.EnableUnoconvEndpoints() {
return api
}
g := api.Group("/convert")
g := api.Group(handler.ConvertGroupEndpoint)
if config.EnableChromeEndpoints() {
g.POST("/html", convertHTML)
g.POST("/url", convertURL)
g.POST("/markdown", convertMarkdown)
g.POST(handler.HTMLEndpoint, handler.HTML)
g.POST(handler.URLEndpoint, handler.URL)
g.POST(handler.MarkdownEndpoint, handler.Markdown)
}
if config.EnableUnoconvEndpoints() {
g.POST("/office", convertOffice)
g.POST(handler.OfficeEndpoint, handler.Office)
}
return api
}

View File

@@ -1,128 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/test"
)
func TestDefaultWaitTimeout(t *testing.T) {
opts := DefaultOptions()
opts.DefaultWaitTimeout = 0
srv := New(opts)
// testing if timeout.
body, contentType := test.URLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
// testing if no timeout.
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "10"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
}
func TestDisableChromeEndpoints(t *testing.T) {
opts := DefaultOptions()
opts.EnableChromeEndpoints = false
srv := New(opts)
// Ping.
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Merge.
body, contentType := test.PDFTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// HTML.
body, contentType = test.HTMLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Markdown.
body, contentType = test.MarkdownTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// URL.
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Office.
body, contentType = test.OfficeTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
}
func TestDisableUnoconvEndpoints(t *testing.T) {
opts := DefaultOptions()
opts.EnableUnoconvEndpoints = false
srv := New(opts)
// Ping.
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Merge.
body, contentType := test.PDFTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// HTML.
body, contentType = test.HTMLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Markdown.
body, contentType = test.MarkdownTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// URL.
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Office.
body, contentType = test.OfficeTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
}
func TestDisableChromeAndUnoconvEndpoints(t *testing.T) {
opts := DefaultOptions()
opts.EnableChromeEndpoints = false
opts.EnableUnoconvEndpoints = false
srv := New(opts)
// Ping.
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Merge.
body, contentType := test.PDFTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// HTML.
body, contentType = test.HTMLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Markdown.
body, contentType = test.MarkdownTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// URL.
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Office.
body, contentType = test.OfficeTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
}

View File

@@ -1,155 +0,0 @@
package api
import (
"fmt"
"net/http"
"os"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/random"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
type errBadRequest struct {
err error
}
func (e *errBadRequest) Error() string {
return e.err.Error()
}
func merge(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.mergePrinterOptions(ctx.config.DefaultWaitTimeout())
if err != nil {
return &errBadRequest{err}
}
fpaths, err := ctx.resource.fpaths(".pdf")
if err != nil {
return &errBadRequest{err}
}
p := printer.NewMerge(fpaths, opts)
return convert(ctx, p)
}
func convertHTML(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout())
if err != nil {
return &errBadRequest{err}
}
fpath, err := ctx.resource.fpath("index.html")
if err != nil {
return &errBadRequest{err}
}
p := printer.NewHTML(fpath, opts)
return convert(ctx, p)
}
func convertMarkdown(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout())
if err != nil {
return &errBadRequest{err}
}
fpath, err := ctx.resource.fpath("index.html")
if err != nil {
return &errBadRequest{err}
}
p, err := printer.NewMarkdown(fpath, opts)
if err != nil {
return err
}
return convert(ctx, p)
}
func convertURL(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.chromePrinterOptions(ctx.config.DefaultWaitTimeout())
if err != nil {
return &errBadRequest{err}
}
remote, err := ctx.resource.get(remoteURL)
if err != nil {
return &errBadRequest{err}
}
p := printer.NewURL(remote, opts)
return convert(ctx, p)
}
func convertOffice(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.officePrinterOptions(ctx.config.DefaultWaitTimeout())
if err != nil {
return &errBadRequest{err}
}
fpaths, err := ctx.resource.fpaths(
".txt",
".rtf",
".fodt",
".doc",
".docx",
".odt",
".xls",
".xlsx",
".ods",
".ppt",
".pptx",
".odp",
)
if err != nil {
return &errBadRequest{err}
}
p := printer.NewOffice(fpaths, opts)
return convert(ctx, p)
}
func convert(ctx *resourceContext, p printer.Printer) error {
baseFilename := random.String(32)
filename := fmt.Sprintf("%s.pdf", baseFilename)
fpath := fmt.Sprintf("%s/%s", ctx.resource.formFilesDirPath, filename)
// if no webhook URL given, run conversion
// and directly return the resulting PDF file
// or an error.
if !ctx.resource.has(webhookURL) {
if err := p.Print(fpath); err != nil {
return err
}
if !ctx.resource.has(resultFilename) {
return ctx.Attachment(fpath, filename)
}
filename, err := ctx.resource.get(resultFilename)
if err != nil {
return &errBadRequest{err}
}
return ctx.Attachment(fpath, filename)
}
// as a webhook URL has been given, we
// run the following lines in a goroutine so that
// it doesn't block.
go func() {
defer ctx.resource.close() // nolint: errcheck
if err := p.Print(fpath); err != nil {
ctx.logger.Error(err)
return
}
f, err := os.Open(fpath)
if err != nil {
ctx.logger.Error(err)
return
}
defer f.Close() // nolint: errcheck
webhook, err := ctx.resource.get(webhookURL)
if err != nil {
ctx.logger.Error(err)
return
}
resp, err := http.Post(webhook, "application/pdf", f) /* #nosec */
if err != nil {
ctx.logger.Error(err)
return
}
defer resp.Body.Close() // nolint: errcheck
}()
return nil
}

View File

@@ -1,360 +0,0 @@
package api
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMerge(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.PDFTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestHTML(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.HTMLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitDelay: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperWidth: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperHeight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginTop: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginBottom: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginLeft: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginRight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestMarkdown(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.MarkdownTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitDelay: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperWidth: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperHeight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginTop: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginBottom: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginLeft: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginRight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestURL(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.URLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitDelay: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{paperWidth: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{paperHeight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginTop: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginBottom: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginLeft: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginRight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestOffice(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.OfficeTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.OfficeTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestConcurrent(t *testing.T) {
opts := DefaultOptions()
opts.DefaultWaitTimeout = 30
srv := New(opts)
// Merge.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.MarkdownTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// HTML.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.HTMLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// Markdown.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.MarkdownTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// URL.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.URLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// Office.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.OfficeTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
}
func TestWebhook(t *testing.T) {
status := make(chan error, 2)
rcv := echo.New()
rcv.POST("/foo", func(c echo.Context) error {
if c.Request().Header.Get("Content-type") != "application/pdf" {
status <- fmt.Errorf("wrong Content-type: got %s want %s", c.Request().Header.Get("Content-type"), "application/pdf")
return nil
}
body, err := ioutil.ReadAll(c.Request().Body)
if err != nil {
status <- err
return nil
}
if body == nil || len(body) == 0 {
status <- errors.New("empty body")
return nil
}
status <- nil
return nil
})
go func() {
rcv.Start(":3001")
}()
opts := DefaultOptions()
srv := New(opts)
body, contentType := test.PDFTestMultipartForm(t, map[string]string{webhookURL: "http://localhost:3001/foo"})
req := httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
err := <-status
assert.NoError(t, err)
}
func TestResultFilename(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
body, contentType := test.PDFTestMultipartForm(t, map[string]string{resultFilename: "foo.pdf"})
req := httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition"))
}

View File

@@ -1,89 +0,0 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/random"
conf "github.com/thecodingmachine/gotenberg/internal/pkg/config"
log "github.com/thecodingmachine/gotenberg/internal/pkg/logger"
)
func contextMiddleware(config *conf.Config) echo.MiddlewareFunc {
// middleware for extending the default context
// with one of our own context.
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// generate a unique identifier for our request.
trace := random.String(32)
// create the logger for this request using
// the previous identifier as trace.
logger := log.New(config.LogLevel(), trace)
// extend the current echo context with our standard
// context.
ctx := newStandardContext(c, logger, config)
// if the endpoint is not for liveness, make a
// context with resource.
if ctx.Path() != pingEndpoint {
ctx, err := ctx.withResource(trace)
if err != nil {
ctx.Error(err)
return ctx.logEndOfRequest(err)
}
}
return next(ctx)
}
}
}
func loggingMiddleware() echo.MiddlewareFunc {
// middleware for enabling logging.
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.(*standardContext)
err := next(ctx)
if err != nil {
ctx.Error(err)
}
return ctx.logEndOfRequest(err)
}
}
}
func finalizeMiddleware() echo.MiddlewareFunc {
// middleware for removing resources at the end of a request
// and for improving response in case of error.
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
ctx, ok := c.(*resourceContext)
// a resource is associated with the context.
if ok {
// if a webhookURL has been given,
// do not remove the resources here because
// we don't know if the result file has been
// generated or sent.
if !ctx.resource.has(webhookURL) {
if resourceErr := ctx.resource.close(); resourceErr != nil {
ctx.logger.Error(err)
}
}
}
if err == nil {
return nil
}
if _, ok := err.(*echo.HTTPError); ok {
return err
}
if _, ok := err.(*errBadRequest); ok {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
return echo.NewHTTPError(http.StatusRequestTimeout, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
}
}

View File

@@ -0,0 +1,137 @@
package context
import (
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/config"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
// Context extends the default echo.Context.
type Context struct {
echo.Context
logger *logger.Logger
config *config.Config
resource *resource.Resource
startTime time.Time
}
// New creates a new context.
func New(c echo.Context, logger *logger.Logger, config *config.Config) *Context {
// TODO timeout context?
return &Context{
c,
logger,
config,
nil,
time.Now(),
}
}
// MustCastFromEchoContext cast an echo.Context to our custom
// context. If something goes wrong, panic.
func MustCastFromEchoContext(c echo.Context) *Context {
ctx, ok := c.(*Context)
if !ok {
panic("unable to cast an echo.Context to a custom context")
}
return ctx
}
// StandardLogger returns the custom logger.
// This method should be used instead of the
// default Logger() method coming from
// the echo.Context!
func (ctx *Context) StandardLogger() *logger.Logger {
return ctx.logger
}
// Resource returns the associated resource
// to the context.
func (ctx *Context) Resource() *resource.Resource {
return ctx.resource
}
// WithResource adds a resource to the context.
func (ctx *Context) WithResource(resourceDirPath string) error {
const op = "context.WithResource"
r, err := resource.New(ctx, ctx.logger, ctx.config, resourceDirPath)
ctx.resource = r
if err != nil {
return &standarderror.Error{
Op: op,
Err: err,
}
}
return nil
}
// LogRequestResult logs the result of a request.
// This method should only be used by a middleware!
func (ctx *Context) LogRequestResult(err error, isDebug bool) error {
req := ctx.Request()
resp := ctx.Response()
stopTime := time.Now()
fields := map[string]interface{}{
"time_rfc3339": timeRFC3339(), // FIXME required?
"remote_ip": ctx.RealIP(),
"host": req.Host,
"uri": req.RequestURI,
"method": req.Method,
"path": path(req),
"referer": req.Referer(),
"user_agent": req.UserAgent(),
"status": resp.Status,
"latency": lantency(ctx.startTime, stopTime),
"latency_human": latencyHuman(ctx.startTime, stopTime),
"bytes_in": bytesIn(req),
"bytes_out": bytesOut(resp),
}
if err != nil {
ctx.logger.WithFields(fields).Error("request failed")
return err
}
if isDebug {
ctx.logger.WithFields(fields).Debug("request handled")
return nil
}
ctx.logger.WithFields(fields).Info("request handled")
return nil
}
func timeRFC3339() string {
return time.Now().Format(time.RFC3339)
}
func path(r *http.Request) string {
path := r.URL.Path
if path == "" {
path = "/"
}
return path
}
func lantency(startTime time.Time, stopTime time.Time) string {
return strconv.FormatInt(int64(stopTime.Sub(startTime)), 10)
}
func latencyHuman(startTime time.Time, stopTime time.Time) string {
return stopTime.Sub(startTime).String()
}
func bytesIn(r *http.Request) string {
bytesIn := r.Header.Get(echo.HeaderContentLength)
if bytesIn == "" {
bytesIn = "0"
}
return bytesIn
}
func bytesOut(r *echo.Response) string {
return strconv.FormatInt(r.Size, 10)
}

View File

@@ -0,0 +1,3 @@
// Package context helps extending
// the default echo.Context.
package context

View File

@@ -0,0 +1,3 @@
// Package handler contains all
// the endpoint methods of the API.
package handler

View File

@@ -0,0 +1,151 @@
package handler
import (
"fmt"
"net/http"
"os"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/random"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
const (
// PingEndpoint is the route for healthcheck.
PingEndpoint = "/ping"
// MergeEndpoint is the route for merging PDF files.
MergeEndpoint = "/merge"
// ConvertGroupEndpoint is the route of the group
// in charge of converting files to PDF.
ConvertGroupEndpoint = "/convert"
// HTMLEndpoint is the route for converting
// HTML to PDF.
HTMLEndpoint = "/html"
// URLEndpoint is the route for converting
// a URL to PDF.
URLEndpoint = "/url"
// MarkdownEndpoint is the route for converting
// Markdown to PDF.
MarkdownEndpoint = "/markdown"
// OfficeEndpoint is the route for converting
// Office files to PDF.
OfficeEndpoint = "/office"
)
func convert(ctx *context.Context, p printer.Printer) error {
const (
op = "convert"
debugOp = "handler.convert"
)
r := ctx.Resource()
logger := ctx.StandardLogger()
baseFilename := random.Get()
filename := fmt.Sprintf("%s.pdf", baseFilename)
fpath := fmt.Sprintf("%s/%s", r.DirPath(), filename)
// if no webhook URL given, run conversion
// and directly return the resulting PDF file
// or an error.
if !r.Has(resource.WebhookURLFormField) {
logger.DebugfOp(debugOp, "no '%s' found, converting synchronously", resource.WebhookURLFormField)
if err := convertSync(filename, fpath, ctx, p); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
return nil
}
// as a webhook URL has been given, we
// run the following lines in a goroutine so that
// it doesn't block.
logger.DebugfOp(debugOp, "'%s' found, converting asynchronously", resource.WebhookURLFormField)
return convertAsync(filename, fpath, ctx, p)
}
func convertSync(filename, fpath string, ctx *context.Context, p printer.Printer) error {
const (
op = "convertSync"
debugOp = "handler.convertSync"
)
r := ctx.Resource()
logger := ctx.StandardLogger()
if err := p.Print(fpath); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
if !r.Has(resource.ResultFilenameFormField) {
logger.DebugfOp(
debugOp,
"no '%s' found, using generated filename '%s'",
resource.ResultFilenameFormField,
filename,
)
if err := ctx.Attachment(fpath, filename); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
return nil
}
logger.DebugfOp(
debugOp,
"'%s' found, so not using generated filename",
resource.ResultFilenameFormField,
)
filename, err := r.Get(resource.ResultFilenameFormField)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
if err := ctx.Attachment(fpath, filename); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
return nil
}
func convertAsync(filename, fpath string, ctx *context.Context, p printer.Printer) error {
const (
op = "convertAsync"
debugOp = "handler.convertAsync"
)
r := ctx.Resource()
logger := ctx.StandardLogger()
go func() {
defer r.Close() // nolint: errcheck
if err := p.Print(fpath); err != nil {
logger.ErrorOp(
op,
&standarderror.Error{Op: op, Err: err},
)
return
}
f, err := os.Open(fpath)
if err != nil {
logger.ErrorOp(
op,
&standarderror.Error{Op: op, Err: err},
)
return
}
defer f.Close() // nolint: errcheck
webhookURL, err := r.Get(resource.WebhookURLFormField)
if err != nil {
logger.ErrorOp(
op,
&standarderror.Error{Op: op, Err: err},
)
return
}
logger.DebugfOp(
debugOp,
"sending result file '%s' to '%s'",
filename,
webhookURL,
)
resp, err := http.Post(webhookURL, "application/pdf", f) /* #nosec */
if err != nil {
logger.ErrorOp(
op,
&standarderror.Error{Op: op, Err: err},
)
return
}
defer resp.Body.Close() // nolint: errcheck
}()
return nil
}

View File

@@ -0,0 +1,24 @@
package handler
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
// HTML is the endpoint for converting
// HTML to PDF.
func HTML(c echo.Context) error {
ctx := context.MustCastFromEchoContext(c)
r := ctx.Resource()
opts, err := r.ChromePrinterOptions()
if err != nil {
return err
}
fpath, err := r.Fpath("index.html")
if err != nil {
return err
}
p := printer.NewHTML(fpath, opts)
return convert(ctx, p)
}

View File

@@ -0,0 +1,27 @@
package handler
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
// Markdown is the endpoint for converting
// Markdown to PDF.
func Markdown(c echo.Context) error {
ctx := context.MustCastFromEchoContext(c)
r := ctx.Resource()
opts, err := r.ChromePrinterOptions()
if err != nil {
return err
}
fpath, err := r.Fpath("index.html")
if err != nil {
return err
}
p, err := printer.NewMarkdown(fpath, opts)
if err != nil {
return err
}
return convert(ctx, p)
}

View File

@@ -0,0 +1,24 @@
package handler
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
// Merge is the endpoint for
// merging PDF files.
func Merge(c echo.Context) error {
ctx := context.MustCastFromEchoContext(c)
r := ctx.Resource()
opts, err := r.MergePrinterOptions()
if err != nil {
return err
}
fpaths, err := r.Fpaths(".pdf")
if err != nil {
return err
}
p := printer.NewMerge(fpaths, opts)
return convert(ctx, p)
}

View File

@@ -0,0 +1,37 @@
package handler
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
// Office is the endpoint for converting
// Office files to PDF.
func Office(c echo.Context) error {
ctx := context.MustCastFromEchoContext(c)
r := ctx.Resource()
opts, err := r.OfficePrinterOptions()
if err != nil {
return err
}
fpaths, err := r.Fpaths(
".txt",
".rtf",
".fodt",
".doc",
".docx",
".odt",
".xls",
".xlsx",
".ods",
".ppt",
".pptx",
".odp",
)
if err != nil {
return err
}
p := printer.NewOffice(fpaths, opts)
return convert(ctx, p)
}

View File

@@ -0,0 +1,10 @@
package handler
import (
"github.com/labstack/echo/v4"
)
// Ping is the endpoint for healthcheck.
func Ping(c echo.Context) error {
return nil
}

View File

@@ -0,0 +1,25 @@
package handler
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
// URL is the endpoint for converting
// a URL to PDF.
func URL(c echo.Context) error {
ctx := context.MustCastFromEchoContext(c)
r := ctx.Resource()
opts, err := r.ChromePrinterOptions()
if err != nil {
return err
}
remoteURL, err := r.Get(resource.RemoteURLFormField)
if err != nil {
return err
}
p := printer.NewURL(remoteURL, opts)
return convert(ctx, p)
}

View File

@@ -0,0 +1,38 @@
package middleware
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
// Cleanup helps removing a resource at the end of a request.
func Cleanup() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
const op = "middleware.Cleanup"
err := next(c)
ctx := context.MustCastFromEchoContext(c)
r := ctx.Resource()
if r == nil {
return err
}
// if a webhook URL has been given,
// do not remove the resource here because
// we don't know if the result file has been
// generated or sent.
if r.Has(resource.WebhookURLFormField) {
return err
}
// a resource is associated with our custom context.
if resourceErr := r.Close(); resourceErr != nil {
ctx.StandardLogger().ErrorOp(op, &standarderror.Error{
Op: op,
Err: resourceErr,
})
}
return err
}
}
}

View File

@@ -0,0 +1,41 @@
package middleware
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler"
"github.com/thecodingmachine/gotenberg/internal/pkg/config"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/random"
)
// Context helps extending the default echo.Context with
// our custom context.
func Context(config *config.Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// generate a unique identifier for the request.
trace := random.Get()
// create the logger for this request using
// the previous identifier as trace.
logger := logger.New(config.LogLevel(), trace)
// extend the current echo context with our custom
// context.
ctx := context.New(c, logger, config)
// if its an healthcheck request, there
// is no resource associated to it.
if ctx.Path() == handler.PingEndpoint {
return next(ctx)
}
// if the endpoint is not for healthcheck, associate a
// resource to our custom context.
if err := ctx.WithResource(trace); err != nil {
// required to have a correct status code
// in the logs.
ctx.Error(err)
return ctx.LogRequestResult(err, false)
}
return next(ctx)
}
}
}

View File

@@ -0,0 +1,3 @@
// Package middleware contains the
// middleware of the API.
package middleware

View File

@@ -0,0 +1,43 @@
package middleware
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
// Error helps handling errors (if any).
func Error() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := context.MustCastFromEchoContext(c)
err := next(ctx)
if err == nil {
// so far so good!
return nil
}
// we log the initial error before returning
// the HTTP error.
logger := ctx.StandardLogger()
logger.Error(err.Error())
// handle our custom HTTP error.
var httpErr error
errCode := standarderror.Code(err)
errMessage := standarderror.Message(err)
switch errCode {
case standarderror.Invalid:
httpErr = echo.NewHTTPError(http.StatusBadRequest, errMessage)
case standarderror.Timeout:
httpErr = echo.NewHTTPError(http.StatusRequestTimeout, errMessage)
default:
httpErr = echo.NewHTTPError(http.StatusInternalServerError, errMessage)
}
// required to have a correct status code
// in the logs.
ctx.Error(httpErr)
return httpErr
}
}
}

View File

@@ -0,0 +1,21 @@
package middleware
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler"
)
// Logger helps logging the result of a request.
func Logger() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := context.MustCastFromEchoContext(c)
err := next(ctx)
// we do not want to log healthcheck requests if
// log level is not set to DEBUG.
isDebug := ctx.Path() == handler.PingEndpoint
return ctx.LogRequestResult(err, isDebug)
}
}
}

View File

@@ -0,0 +1,5 @@
// Package resource helps creating a folder
// containing all uploaded files and the resulting
// PDF file. It also helps centralizing all
// the form values.
package resource

View File

@@ -0,0 +1,417 @@
package resource
import (
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/pkg/config"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
const (
// ResultFilenameFormField contains the name
// of a form field.
ResultFilenameFormField string = "resultFilename"
// WaitTimeoutFormField contains the name
// of a form field.
WaitTimeoutFormField string = "waitTimeout"
// WebhookURLFormField contains the name
// of a form field.
WebhookURLFormField string = "webhookURL"
// RemoteURLFormField contains the name
// of a form field.
RemoteURLFormField string = "remoteURL"
// WaitDelayFormField contains the name
// of a form field.
WaitDelayFormField string = "waitDelay"
// PaperWidthFormField contains the name
// of a form field.
PaperWidthFormField string = "paperWidth"
// PaperHeightFormField contains the name
// of a form field.
PaperHeightFormField string = "paperHeight"
// MarginTopFormField contains the name
// of a form field.
MarginTopFormField string = "marginTop"
// MarginBottomFormField contains the name
// of a form field.
MarginBottomFormField string = "marginBottom"
// MarginLeftFormField contains the name
// of a form field.
MarginLeftFormField string = "marginLeft"
// MarginRightFormField contains the name
// of a form field.
MarginRightFormField string = "marginRight"
// LandscapeFormField contains the name
// of a form field.
LandscapeFormField string = "landscape"
)
// Resource helps retrieving form values
// and form files from a request.
type Resource struct {
logger *logger.Logger
config *config.Config
formValues map[string]string
formFilesDirPath string
}
// New creates a new resource.
func New(c echo.Context, logger *logger.Logger, config *config.Config, dirPath string) (*Resource, error) {
const op = "resource.New"
r := &Resource{
logger: logger,
config: config,
formValues: formValues(c, logger),
formFilesDirPath: dirPath,
}
if err := os.MkdirAll(dirPath, 0755); err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
r.logger.DebugfOp(op, "directory '%s' created", dirPath)
if err := formFiles(c, logger, dirPath); err != nil {
return r, &standarderror.Error{Op: op, Err: err}
}
return r, nil
}
func formValues(c echo.Context, logger *logger.Logger) map[string]string {
const debugOp = "resource.formValues"
v := make(map[string]string)
v[ResultFilenameFormField] = c.FormValue(ResultFilenameFormField)
v[WaitTimeoutFormField] = c.FormValue(WaitTimeoutFormField)
v[WebhookURLFormField] = c.FormValue(WebhookURLFormField)
v[RemoteURLFormField] = c.FormValue(RemoteURLFormField)
v[WaitDelayFormField] = c.FormValue(WaitDelayFormField)
v[PaperWidthFormField] = c.FormValue(PaperWidthFormField)
v[PaperHeightFormField] = c.FormValue(PaperHeightFormField)
v[MarginTopFormField] = c.FormValue(MarginTopFormField)
v[MarginBottomFormField] = c.FormValue(MarginBottomFormField)
v[MarginLeftFormField] = c.FormValue(MarginLeftFormField)
v[MarginRightFormField] = c.FormValue(MarginRightFormField)
v[LandscapeFormField] = c.FormValue(LandscapeFormField)
logger.DebugfOp(debugOp, "%v", v)
return v
}
func formFiles(c echo.Context, logger *logger.Logger, dirPath string) error {
const (
op = "formFiles"
debugOp = "resource.formFiles"
)
form, err := c.MultipartForm()
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
for _, files := range form.File {
for _, fh := range files {
in, err := fh.Open()
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
defer in.Close() // nolint: errcheck
fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename)
out, err := os.Create(fpath)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
defer out.Close() // nolint: errcheck
if err := out.Chmod(0644); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
if _, err := io.Copy(out, in); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
if _, err := out.Seek(0, 0); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
logger.DebugfOp(debugOp, "'%s' created", fh.Filename)
}
}
return nil
}
// DirPath returns the directory
// path where are stored the form
// files and the resulting PDF file.
func (r *Resource) DirPath() string {
return r.formFilesDirPath
}
// Close deletes the working directory of the
// resource if it exists.
func (r *Resource) Close() error {
const op = "resource.Close"
if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) {
r.logger.DebugfOp(op, "directory '%s' does not exist, nothing to remove", r.formFilesDirPath)
return nil
}
if err := os.RemoveAll(r.formFilesDirPath); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
r.logger.DebugfOp(op, "directory '%s' removed", r.formFilesDirPath)
return nil
}
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
// ChromePrinterOptions returns the Chrome printer options
// thanks to the form values and form files from the request
// plus the default values from the configuration.
func (r *Resource) ChromePrinterOptions() (*printer.ChromeOptions, error) {
const op = "resource.ChromePrinterOptions"
waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout())
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
waitDelay, err := r.float64(WaitDelayFormField, 0.0)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
headerHTML, err := r.content("header.html", defaultHeaderFooterHTML)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
footerHTML, err := r.content("footer.html", defaultHeaderFooterHTML)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
paperWidth, err := r.float64(PaperWidthFormField, 8.27)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
paperHeight, err := r.float64(PaperHeightFormField, 11.7)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
marginTop, err := r.float64(MarginTopFormField, 1)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
marginBottom, err := r.float64(MarginBottomFormField, 1)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
marginLeft, err := r.float64(MarginLeftFormField, 1)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
marginRight, err := r.float64(MarginRightFormField, 1)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
landscape, err := r.bool(LandscapeFormField, false)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
opts := &printer.ChromeOptions{
WaitTimeout: waitTimeout,
WaitDelay: waitDelay,
HeaderHTML: headerHTML,
FooterHTML: footerHTML,
PaperWidth: paperWidth,
PaperHeight: paperHeight,
MarginTop: marginTop,
MarginBottom: marginBottom,
MarginLeft: marginLeft,
MarginRight: marginRight,
Landscape: landscape,
}
r.logger.DebugfOp(op, "%v", opts)
return opts, nil
}
// OfficePrinterOptions returns the Office printer options
// thanks to the form values from the request
// plus the default values from the configuration.
func (r *Resource) OfficePrinterOptions() (*printer.OfficeOptions, error) {
const op = "resource.OfficePrinterOptions"
waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout())
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
landscape, err := r.bool(LandscapeFormField, false)
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
opts := &printer.OfficeOptions{
WaitTimeout: waitTimeout,
Landscape: landscape,
}
r.logger.DebugfOp(op, "%v", opts)
return opts, nil
}
// MergePrinterOptions returns the merge printer options
// thanks to the form values from the request
// plus the default values from the configuration.
func (r *Resource) MergePrinterOptions() (*printer.MergeOptions, error) {
const op = "resource.MergePrinterOptions"
waitTimeout, err := r.float64(WaitTimeoutFormField, r.config.DefaultWaitTimeout())
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
opts := &printer.MergeOptions{
WaitTimeout: waitTimeout,
}
r.logger.DebugfOp(op, "%v", opts)
return opts, nil
}
// Has returns true if the resource
// contains the given form field and
// its value is not empty.
func (r *Resource) Has(formField string) bool {
v, ok := r.formValues[formField]
if ok {
ok = v != ""
}
return ok
}
func (r *Resource) hasFile(filename string) bool {
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath)
return !os.IsNotExist(err)
}
// Get returns the form field value.
func (r *Resource) Get(formField string) (string, error) {
const op = "resource.Get"
v, err := r.value(formField)
if err != nil {
return "", &standarderror.Error{Op: op, Err: err}
}
return v, nil
}
func (r *Resource) value(formField string) (string, error) {
const op = "value"
v, ok := r.formValues[formField]
if !ok {
return "", &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' does not exist", formField),
Op: op,
}
}
return v, nil
}
func (r *Resource) float64(formField string, defaultValue float64) (float64, error) {
const op = "float64"
if !r.Has(formField) {
return defaultValue, nil
}
v, err := r.value(formField)
if err != nil {
return 0.0, &standarderror.Error{Op: op, Err: err}
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0.0, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' is not a float", formField),
Op: op,
}
}
return f, nil
}
func (r *Resource) bool(formField string, defaultValue bool) (bool, error) {
const op = "bool"
if !r.Has(formField) {
return defaultValue, nil
}
v, err := r.value(formField)
if err != nil {
return false, &standarderror.Error{Op: op, Err: err}
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("'%s' is not a boolean", formField),
Op: op,
}
}
return b, nil
}
// Fpath returns the path of the given filename.
// This filename should be the name of a form file.
func (r *Resource) Fpath(filename string) (string, error) {
const op = "resource.Fpath"
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath)
if os.IsNotExist(err) {
return "", &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("file '%s' does not exist", filename),
Op: op,
}
}
absPath, err := filepath.Abs(fpath)
if err != nil {
return "", &standarderror.Error{Op: op, Err: err}
}
return absPath, nil
}
func (r *Resource) content(filename string, defaultValue string) (string, error) {
const op = "content"
if !r.hasFile(filename) {
return defaultValue, nil
}
fpath, err := r.Fpath(filename)
if err != nil {
return "", &standarderror.Error{Op: op, Err: err}
}
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", &standarderror.Error{Op: op, Err: err}
}
return string(b), nil
}
// Fpaths returns the list of files of the resource
// according to given file extensions.
func (r *Resource) Fpaths(exts ...string) ([]string, error) {
const op = "resource.Fpaths"
var fpaths []string
err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error {
const walkOp = "filepath.Walk"
if info.IsDir() {
return nil
}
fpath, err := r.Fpath(info.Name())
if err != nil {
return &standarderror.Error{Op: walkOp, Err: err}
}
for _, ext := range exts {
if filepath.Ext(fpath) == ext {
fpaths = append(fpaths, fpath)
return nil
}
}
return nil
})
if err != nil {
return nil, &standarderror.Error{Op: op, Err: err}
}
if len(fpaths) == 0 {
return nil, &standarderror.Error{
Code: standarderror.Invalid,
Message: fmt.Sprintf("no file found for extentions %v", exts),
Op: op,
}
}
return fpaths, nil
}

View File

@@ -1,297 +0,0 @@
package api
import (
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
const (
resultFilename string = "resultFilename"
waitTimeout string = "waitTimeout"
webhookURL string = "webhookURL"
remoteURL string = "remoteURL"
waitDelay string = "waitDelay"
paperWidth string = "paperWidth"
paperHeight string = "paperHeight"
marginTop string = "marginTop"
marginBottom string = "marginBottom"
marginLeft string = "marginLeft"
marginRight string = "marginRight"
landscape string = "landscape"
)
type resource struct {
formValues map[string]string
formFilesDirPath string
}
func newResource(c echo.Context, dirPath string) (*resource, error) {
r := &resource{
formValues: formValues(c),
}
r.formFilesDirPath = dirPath
if err := os.MkdirAll(dirPath, 0755); err != nil {
return nil, fmt.Errorf("%s: making directory: %v", dirPath, err)
}
if err := formFiles(c, dirPath); err != nil {
return r, err
}
return r, nil
}
func formValues(c echo.Context) map[string]string {
v := make(map[string]string)
v[resultFilename] = c.FormValue(resultFilename)
v[waitTimeout] = c.FormValue(waitTimeout)
v[webhookURL] = c.FormValue(webhookURL)
v[remoteURL] = c.FormValue(remoteURL)
v[waitDelay] = c.FormValue(waitDelay)
v[paperWidth] = c.FormValue(paperWidth)
v[paperHeight] = c.FormValue(paperHeight)
v[marginTop] = c.FormValue(marginTop)
v[marginBottom] = c.FormValue(marginBottom)
v[marginLeft] = c.FormValue(marginLeft)
v[marginRight] = c.FormValue(marginRight)
v[landscape] = c.FormValue(landscape)
return v
}
func formFiles(c echo.Context, dirPath string) error {
form, err := c.MultipartForm()
if err != nil {
return fmt.Errorf("getting multipart form: %v", err)
}
for _, files := range form.File {
for _, fh := range files {
in, err := fh.Open()
if err != nil {
return fmt.Errorf("%s: opening file: %v", fh.Filename, err)
}
defer in.Close() // nolint: errcheck
fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename)
out, err := os.Create(fpath)
if err != nil {
return fmt.Errorf("%s: creating new file: %v", fpath, err)
}
defer out.Close() // nolint: errcheck
if err := out.Chmod(0644); err != nil {
return fmt.Errorf("%s: changing file mode: %v", fpath, err)
}
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("%s: writing file: %v", fpath, err)
}
if _, err := out.Seek(0, 0); err != nil {
return fmt.Errorf("%s: resetting read pointer: %v", fpath, err)
}
}
}
return nil
}
func (r *resource) close() error {
if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) {
return nil
}
return os.RemoveAll(r.formFilesDirPath)
}
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
func (r *resource) chromePrinterOptions(defaultWaitTimeout float64) (*printer.ChromeOptions, error) {
timeout, err := r.float64(waitTimeout, defaultWaitTimeout)
if err != nil {
return nil, err
}
delay, err := r.float64(waitDelay, 0.0)
if err != nil {
return nil, err
}
header, err := r.content("header.html", defaultHeaderFooterHTML)
if err != nil {
return nil, err
}
footer, err := r.content("footer.html", defaultHeaderFooterHTML)
if err != nil {
return nil, err
}
width, err := r.float64(paperWidth, 8.27)
if err != nil {
return nil, err
}
height, err := r.float64(paperHeight, 11.7)
if err != nil {
return nil, err
}
top, err := r.float64(marginTop, 1)
if err != nil {
return nil, err
}
bottom, err := r.float64(marginBottom, 1)
if err != nil {
return nil, err
}
left, err := r.float64(marginLeft, 1)
if err != nil {
return nil, err
}
right, err := r.float64(marginRight, 1)
if err != nil {
return nil, err
}
landscape, err := r.bool(landscape, false)
if err != nil {
return nil, err
}
return &printer.ChromeOptions{
WaitTimeout: timeout,
WaitDelay: delay,
HeaderHTML: header,
FooterHTML: footer,
PaperWidth: width,
PaperHeight: height,
MarginTop: top,
MarginBottom: bottom,
MarginLeft: left,
MarginRight: right,
Landscape: landscape,
}, nil
}
func (r *resource) officePrinterOptions(defaultWaitTimeout float64) (*printer.OfficeOptions, error) {
timeout, err := r.float64(waitTimeout, defaultWaitTimeout)
if err != nil {
return nil, err
}
landscape, err := r.bool(landscape, false)
if err != nil {
return nil, err
}
return &printer.OfficeOptions{
WaitTimeout: timeout,
Landscape: landscape,
}, nil
}
func (r *resource) mergePrinterOptions(defaultWaitTimeout float64) (*printer.MergeOptions, error) {
timeout, err := r.float64(waitTimeout, defaultWaitTimeout)
if err != nil {
return nil, err
}
return &printer.MergeOptions{
WaitTimeout: timeout,
}, nil
}
func (r *resource) has(key string) bool {
v, ok := r.formValues[key]
if ok {
ok = v != ""
}
return ok
}
func (r *resource) hasFile(filename string) bool {
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath)
return !os.IsNotExist(err)
}
func (r *resource) get(key string) (string, error) {
v, ok := r.formValues[key]
if !ok {
return "", fmt.Errorf("form value %s does not exist", key)
}
return v, nil
}
func (r *resource) float64(key string, defaultValue float64) (float64, error) {
if !r.has(key) {
return defaultValue, nil
}
v, err := r.get(key)
if err != nil {
return 0.0, err
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0.0, fmt.Errorf("form value %s: %v", key, err)
}
return f, nil
}
func (r *resource) bool(key string, defaultValue bool) (bool, error) {
if !r.has(key) {
return defaultValue, nil
}
v, err := r.get(key)
if err != nil {
return false, err
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("form value %s: %v", key, err)
}
return b, nil
}
func (r *resource) fpath(filename string) (string, error) {
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath)
if os.IsNotExist(err) {
return "", fmt.Errorf("%s: form file does not exist", filename)
}
absPath, err := filepath.Abs(fpath)
if err != nil {
return "", fmt.Errorf("%s: getting absolute path: %v", fpath, err)
}
return absPath, nil
}
func (r *resource) content(filename string, defaultValue string) (string, error) {
if !r.hasFile(filename) {
return defaultValue, nil
}
fpath, err := r.fpath(filename)
if err != nil {
return "", err
}
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading form file: %v", fpath, err)
}
return string(b), nil
}
func (r *resource) fpaths(exts ...string) ([]string, error) {
var fpaths []string
err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error {
if info.IsDir() {
return nil
}
fpath, err := r.fpath(info.Name())
if err != nil {
return err
}
for _, ext := range exts {
if filepath.Ext(fpath) == ext {
fpaths = append(fpaths, fpath)
return nil
}
}
return nil
})
if err != nil {
return nil, err
}
if len(fpaths) == 0 {
return nil, fmt.Errorf("no form files found for extensions: %v", exts)
}
return fpaths, nil
}

View File

@@ -0,0 +1,122 @@
package config
import (
"fmt"
"os"
"strconv"
log "github.com/sirupsen/logrus"
)
const (
defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT"
defaultListenPortEnvVar = "DEFAULT_LISTEN_PORT"
disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME"
disableUnoconvEnvVar = "DISABLE_UNOCONV"
logLevelEnvVar = "LOG_LEVEL"
)
type Config struct {
defaultWaitTimeout float64
defaultListenPort string
enableChromeEndpoints bool
enableUnoconvEndpoints bool
logLevel log.Level
}
func defaultConfig() *Config {
return &Config{
defaultWaitTimeout: 10,
defaultListenPort: "3000",
enableChromeEndpoints: true,
enableUnoconvEndpoints: true,
logLevel: log.InfoLevel,
}
}
func FromEnv() (*Config, error) {
c := defaultConfig()
defaultWaitTimeout, err := defaultWaitTimeoutFromEnv(defaultWaitTimeoutEnvVar, c.DefaultWaitTimeout())
c.defaultWaitTimeout = defaultWaitTimeout
if err != nil {
return c, err
}
defaultListenPort, err := defaultListenPortFromEnv(defaultListenPortEnvVar, c.DefaultListenPort())
c.defaultListenPort = defaultListenPort
if err != nil {
return c, err
}
disableChromeEndpoints, err := boolFromEnv(disableGoogleChromeEnvVar, c.EnableChromeEndpoints())
c.enableChromeEndpoints = !disableChromeEndpoints
if err != nil {
return c, err
}
disableUnoconvEndpoints, err := boolFromEnv(disableUnoconvEnvVar, c.EnableUnoconvEndpoints())
c.enableUnoconvEndpoints = !disableUnoconvEndpoints
if err != nil {
return c, err
}
logLevel, err := logLevelFromEnv(logLevelEnvVar, c.LogLevel())
c.logLevel = logLevel
if err != nil {
return c, err
}
return c, nil
}
func (c *Config) DefaultWaitTimeout() float64 { return c.defaultWaitTimeout }
func (c *Config) DefaultListenPort() string { return c.defaultListenPort }
func (c *Config) EnableChromeEndpoints() bool { return c.enableChromeEndpoints }
func (c *Config) EnableUnoconvEndpoints() bool { return c.enableUnoconvEndpoints }
func (c *Config) LogLevel() log.Level { return c.logLevel }
func defaultWaitTimeoutFromEnv(envVar string, defaultValue float64) (float64, error) {
if v, ok := os.LookupEnv(envVar); ok {
waitTimeout, err := strconv.ParseFloat(v, 64)
if err != nil {
return defaultValue, fmt.Errorf("%s: wrong value: want float got %v", envVar, err)
}
return waitTimeout, nil
}
return defaultValue, nil
}
func defaultListenPortFromEnv(envVar string, defaultValue string) (string, error) {
if v, ok := os.LookupEnv(envVar); ok {
portAsUint, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return defaultValue, fmt.Errorf("%s: wrong value: want uint got %v", envVar, err)
}
if portAsUint > 65535 {
return defaultValue, fmt.Errorf("%s: wrong value: want uint < 65535 got %d", envVar, portAsUint)
}
return v, nil
}
return defaultValue, nil
}
func boolFromEnv(envVar string, defaultValue bool) (bool, error) {
if v, ok := os.LookupEnv(envVar); ok {
if v != "1" && v != "0" {
return defaultValue, fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %s", envVar, v)
}
return v == "1", nil
}
return defaultValue, nil
}
func logLevelFromEnv(envVar string, defaultValue log.Level) (log.Level, error) {
if v, ok := os.LookupEnv(envVar); ok {
switch v {
case "DEBUG":
return log.DebugLevel, nil
case "INFO":
return log.InfoLevel, nil
case "ERROR":
return log.ErrorLevel, nil
default:
return defaultValue, fmt.Errorf("%s: wrong value: want \"DEBUG\",\"INFO\" or \"ERROR\" got %s", envVar, v)
}
}
return defaultValue, nil
}

View File

@@ -0,0 +1 @@
package config

View File

@@ -0,0 +1 @@
package logger

View File

@@ -0,0 +1,33 @@
package logger
import (
"github.com/sirupsen/logrus"
)
// Logger enforces specific log message formats.
type Logger struct {
*logrus.Entry
}
// New initializes the logger.
func New(level logrus.Level, trace string) *Logger {
l := logrus.New()
l.SetLevel(level)
// TODO no formatter if TTY.
l.SetFormatter(&logrus.JSONFormatter{})
return &Logger{
l.WithField("trace", trace),
}
}
// DebugfOp logs a debug message for given
// logical operation.
func (l *Logger) DebugfOp(op string, format string, args ...interface{}) {
l.WithField("op", op).Debugf(format, args...)
}
// ErrorOp logs an error message for given
// logical operation.
func (l *Logger) ErrorOp(op string, err error) {
l.WithField("op", op).Error(err.Error())
}

View File

@@ -5,7 +5,7 @@ import (
"time"
"github.com/mafredri/cdp/devtool"
log "github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
)
const warmupTime = 10 * time.Second
@@ -16,7 +16,7 @@ type chrome struct {
// NewChrome returns a Google Chrome
// headless process.
func NewChrome(logger *log.StandardLogger) Process {
func NewChrome(logger *logger.Logger) Process {
return &chrome{
manager: &processManager{logger: logger},
}
@@ -61,7 +61,10 @@ func (p *chrome) viable() bool {
// check if Google Chrome is correctly running.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.manager.logger.Debugf("%s: checking liveness via debug version endpoint http://localhost:9222/json/version", p.Fullname())
p.manager.logger.Debugf(
"%s: checking liveness via debug version endpoint http://localhost:9222/json/version",
p.Fullname(),
)
v, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
p.manager.logger.Debugf("%s: debug version endpoint returned error: %v", p.Fullname(), err)

View File

@@ -7,7 +7,7 @@ import (
"os/exec"
"strings"
log "github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
)
const (
@@ -30,7 +30,7 @@ type Process interface {
type processManager struct {
heuristicState int32
logger *log.StandardLogger
logger *logger.Logger
}
func (m *processManager) start(p Process) error {

View File

@@ -1,7 +1,7 @@
package pm2
import (
log "github.com/thecodingmachine/gotenberg/internal/pkg/logger"
"github.com/thecodingmachine/gotenberg/internal/pkg/logger"
)
type unoconv struct {
@@ -10,7 +10,7 @@ type unoconv struct {
// NewUnoconv returns a unoconv listener
// process.
func NewUnoconv(logger *log.StandardLogger) Process {
func NewUnoconv(logger *logger.Logger) Process {
return &unoconv{
manager: &processManager{logger: logger},
}

View File

@@ -0,0 +1,3 @@
// Package random helps generating
// a random string.
package random

View File

@@ -0,0 +1,10 @@
package random
import (
"github.com/labstack/gommon/random"
)
// Get returns a random string.
func Get() string {
return random.String(32)
}

View File

@@ -0,0 +1,5 @@
// Package standarderror helps standardizing
// the errors in the application.
//
// Credits: https://middlemost.com/failure-is-your-domain/
package standarderror

View File

@@ -0,0 +1,85 @@
package standarderror
import (
"bytes"
"fmt"
)
const (
// Internal is a code
// for internal errors.
Internal = "internal"
// Invalid is a code
// for validation errors.
Invalid = "invalid"
// Timeout is a code
// for timeout errors.
Timeout = "timeout"
)
// Error defines a standard application
// error.
type Error struct {
// Code is a machine-readable
// error code.
Code string
// Message is a human-readable
// message.
Message string
// Op is a logical operation.
Op string
// Err is a nested error.
Err error
}
// Error returns the string representation of the error message.
func (err *Error) Error() string {
var buf bytes.Buffer
// print the current operation in our stack, if any.
if err.Op != "" {
fmt.Fprintf(&buf, "%s: ", err.Op)
}
// if wrapping an error, print its Error() message.
// Otherwise print the error code & message.
if err.Err != nil {
buf.WriteString(err.Err.Error())
} else {
if err.Code != "" {
fmt.Fprintf(&buf, "<%s> ", err.Code)
}
buf.WriteString(err.Message)
}
return buf.String()
}
// Code returns the code of the root error, if available.
// Otherwise returns Internal.
func Code(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if ok && e.Code != "" {
return e.Code
}
if ok && e.Err != nil {
return Code(e.Err)
}
return Internal
}
// Message returns the human-readable message of the error, if available.
// Otherwise returns a generic error message.
func Message(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if ok && e.Message != "" {
return e.Message
}
if ok && e.Err != nil {
return Message(e.Err)
}
return "An internal error has occurred. Please contact technical support."
}