diff --git a/go.mod b/go.mod index 85ced362..82700001 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 32ff4bc2..ececb92e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/api/api.go b/internal/app/api/api.go index 23db1d6f..3a711782 100644 --- a/internal/app/api/api.go +++ b/internal/app/api/api.go @@ -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 } diff --git a/internal/app/api/api_test.go b/internal/app/api/api_test.go deleted file mode 100644 index 1f29b33c..00000000 --- a/internal/app/api/api_test.go +++ /dev/null @@ -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) -} diff --git a/internal/app/api/handler.go b/internal/app/api/handler.go deleted file mode 100644 index 69e97763..00000000 --- a/internal/app/api/handler.go +++ /dev/null @@ -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 -} diff --git a/internal/app/api/handler_test.go b/internal/app/api/handler_test.go deleted file mode 100644 index 32cfd159..00000000 --- a/internal/app/api/handler_test.go +++ /dev/null @@ -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")) -} diff --git a/internal/app/api/middleware.go b/internal/app/api/middleware.go deleted file mode 100644 index 1cb35b97..00000000 --- a/internal/app/api/middleware.go +++ /dev/null @@ -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()) - } - } -} diff --git a/internal/app/api/pkg/context/context.go b/internal/app/api/pkg/context/context.go new file mode 100644 index 00000000..3b298ffc --- /dev/null +++ b/internal/app/api/pkg/context/context.go @@ -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) +} diff --git a/internal/app/api/pkg/context/doc.go b/internal/app/api/pkg/context/doc.go new file mode 100644 index 00000000..4c8b2439 --- /dev/null +++ b/internal/app/api/pkg/context/doc.go @@ -0,0 +1,3 @@ +// Package context helps extending +// the default echo.Context. +package context diff --git a/internal/app/api/pkg/handler/doc.go b/internal/app/api/pkg/handler/doc.go new file mode 100644 index 00000000..c7fdafe7 --- /dev/null +++ b/internal/app/api/pkg/handler/doc.go @@ -0,0 +1,3 @@ +// Package handler contains all +// the endpoint methods of the API. +package handler diff --git a/internal/app/api/pkg/handler/handler.go b/internal/app/api/pkg/handler/handler.go new file mode 100644 index 00000000..c15b29ea --- /dev/null +++ b/internal/app/api/pkg/handler/handler.go @@ -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 +} diff --git a/internal/app/api/pkg/handler/html.go b/internal/app/api/pkg/handler/html.go new file mode 100644 index 00000000..d5850685 --- /dev/null +++ b/internal/app/api/pkg/handler/html.go @@ -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) +} diff --git a/internal/app/api/pkg/handler/markdown.go b/internal/app/api/pkg/handler/markdown.go new file mode 100644 index 00000000..a4255fd2 --- /dev/null +++ b/internal/app/api/pkg/handler/markdown.go @@ -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) +} diff --git a/internal/app/api/pkg/handler/merge.go b/internal/app/api/pkg/handler/merge.go new file mode 100644 index 00000000..f9d1e5d1 --- /dev/null +++ b/internal/app/api/pkg/handler/merge.go @@ -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) +} diff --git a/internal/app/api/pkg/handler/office.go b/internal/app/api/pkg/handler/office.go new file mode 100644 index 00000000..3cf5c315 --- /dev/null +++ b/internal/app/api/pkg/handler/office.go @@ -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) +} diff --git a/internal/app/api/pkg/handler/ping.go b/internal/app/api/pkg/handler/ping.go new file mode 100644 index 00000000..c2fa275d --- /dev/null +++ b/internal/app/api/pkg/handler/ping.go @@ -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 +} diff --git a/internal/app/api/pkg/handler/url.go b/internal/app/api/pkg/handler/url.go new file mode 100644 index 00000000..cf1bc75d --- /dev/null +++ b/internal/app/api/pkg/handler/url.go @@ -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) +} diff --git a/internal/app/api/pkg/middleware/cleanup.go b/internal/app/api/pkg/middleware/cleanup.go new file mode 100644 index 00000000..ee21c9fb --- /dev/null +++ b/internal/app/api/pkg/middleware/cleanup.go @@ -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 + } + } +} diff --git a/internal/app/api/pkg/middleware/context.go b/internal/app/api/pkg/middleware/context.go new file mode 100644 index 00000000..29dc26c0 --- /dev/null +++ b/internal/app/api/pkg/middleware/context.go @@ -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) + } + } +} diff --git a/internal/app/api/pkg/middleware/doc.go b/internal/app/api/pkg/middleware/doc.go new file mode 100644 index 00000000..91719d08 --- /dev/null +++ b/internal/app/api/pkg/middleware/doc.go @@ -0,0 +1,3 @@ +// Package middleware contains the +// middleware of the API. +package middleware diff --git a/internal/app/api/pkg/middleware/error.go b/internal/app/api/pkg/middleware/error.go new file mode 100644 index 00000000..7cdb6199 --- /dev/null +++ b/internal/app/api/pkg/middleware/error.go @@ -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 + } + } +} diff --git a/internal/app/api/pkg/middleware/logger.go b/internal/app/api/pkg/middleware/logger.go new file mode 100644 index 00000000..5fb2579e --- /dev/null +++ b/internal/app/api/pkg/middleware/logger.go @@ -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) + } + } +} diff --git a/internal/app/api/pkg/resource/doc.go b/internal/app/api/pkg/resource/doc.go new file mode 100644 index 00000000..56c149b0 --- /dev/null +++ b/internal/app/api/pkg/resource/doc.go @@ -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 diff --git a/internal/app/api/pkg/resource/resource.go b/internal/app/api/pkg/resource/resource.go new file mode 100644 index 00000000..985bd30d --- /dev/null +++ b/internal/app/api/pkg/resource/resource.go @@ -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 = "" + +// 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 +} diff --git a/internal/app/api/resource.go b/internal/app/api/resource.go deleted file mode 100644 index 5a359cee..00000000 --- a/internal/app/api/resource.go +++ /dev/null @@ -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 = "" - -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 -} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go new file mode 100644 index 00000000..56dca33d --- /dev/null +++ b/internal/pkg/config/config.go @@ -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 +} diff --git a/internal/pkg/config/doc.go b/internal/pkg/config/doc.go new file mode 100644 index 00000000..d912156b --- /dev/null +++ b/internal/pkg/config/doc.go @@ -0,0 +1 @@ +package config diff --git a/internal/pkg/logger/doc.go b/internal/pkg/logger/doc.go new file mode 100644 index 00000000..90c66f62 --- /dev/null +++ b/internal/pkg/logger/doc.go @@ -0,0 +1 @@ +package logger diff --git a/internal/pkg/logger/logger.go b/internal/pkg/logger/logger.go new file mode 100644 index 00000000..cd290564 --- /dev/null +++ b/internal/pkg/logger/logger.go @@ -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()) +} diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index aa04d4f1..df87a4e5 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -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) diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index 2850c9ac..c53709fa 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -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 { diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 9350ab26..2bd53745 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -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}, } diff --git a/internal/pkg/random/doc.go b/internal/pkg/random/doc.go new file mode 100644 index 00000000..d79456c1 --- /dev/null +++ b/internal/pkg/random/doc.go @@ -0,0 +1,3 @@ +// Package random helps generating +// a random string. +package random diff --git a/internal/pkg/random/random.go b/internal/pkg/random/random.go new file mode 100644 index 00000000..07556b5b --- /dev/null +++ b/internal/pkg/random/random.go @@ -0,0 +1,10 @@ +package random + +import ( + "github.com/labstack/gommon/random" +) + +// Get returns a random string. +func Get() string { + return random.String(32) +} diff --git a/internal/pkg/standarderror/doc.go b/internal/pkg/standarderror/doc.go new file mode 100644 index 00000000..b834229b --- /dev/null +++ b/internal/pkg/standarderror/doc.go @@ -0,0 +1,5 @@ +// Package standarderror helps standardizing +// the errors in the application. +// +// Credits: https://middlemost.com/failure-is-your-domain/ +package standarderror diff --git a/internal/pkg/standarderror/standarderror.go b/internal/pkg/standarderror/standarderror.go new file mode 100644 index 00000000..f77bbe58 --- /dev/null +++ b/internal/pkg/standarderror/standarderror.go @@ -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." +}