huge refactoring

This commit is contained in:
Julien Neuhart
2019-07-23 13:57:18 +02:00
parent f6b357691c
commit 7bfbda4490
105 changed files with 4051 additions and 2667 deletions

View File

@@ -1,34 +0,0 @@
package api
import (
"github.com/labstack/echo/v4"
"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"
)
// New returns an API.
func New(config *config.Config) *echo.Echo {
api := echo.New()
api.HideBanner = true
api.HidePort = true
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(handler.ConvertGroupEndpoint)
if config.EnableChromeEndpoints() {
g.POST(handler.HTMLEndpoint, handler.HTML)
g.POST(handler.URLEndpoint, handler.URL)
g.POST(handler.MarkdownEndpoint, handler.Markdown)
}
if config.EnableUnoconvEndpoints() {
g.POST(handler.OfficeEndpoint, handler.Office)
}
return api
}

View File

@@ -1,388 +0,0 @@
package api
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/handler"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/middleware"
"github.com/thecodingmachine/gotenberg/internal/app/api/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/config"
"github.com/thecodingmachine/gotenberg/test"
)
func TestPing(t *testing.T) {
endpoint := handler.PingEndpoint
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
// should be OK.
req := httptest.NewRequest(http.MethodGet, endpoint, nil)
test.AssertStatusCode(t, http.StatusOK, srv, req)
}
func TestMerge(t *testing.T) {
os.Setenv(middleware.TestingTraceEnvVar, "1")
endpoint := handler.MergeEndpoint
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
// should be OK.
body, contentType := test.PDFTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// bad request.
body, contentType = test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// timeout.
body, contentType = test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
// should have no more resources.
test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix)
err = os.RemoveAll(middleware.TestsTracePrefix)
assert.Nil(t, err)
os.Unsetenv(middleware.TestingTraceEnvVar)
}
func TestHTML(t *testing.T) {
os.Setenv(middleware.TestingTraceEnvVar, "1")
endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.HTMLEndpoint)
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
// should be OK.
body, contentType := test.HTMLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// bad request.
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// timeout.
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
// should have no more resources.
test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix)
err = os.RemoveAll(middleware.TestsTracePrefix)
assert.Nil(t, err)
os.Unsetenv(middleware.TestingTraceEnvVar)
}
func TestMarkdown(t *testing.T) {
os.Setenv(middleware.TestingTraceEnvVar, "1")
endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.MarkdownEndpoint)
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
// should be OK.
body, contentType := test.MarkdownTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// bad request.
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// timeout.
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
// should have no more resources.
test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix)
err = os.RemoveAll(middleware.TestsTracePrefix)
assert.Nil(t, err)
os.Unsetenv(middleware.TestingTraceEnvVar)
}
func TestURL(t *testing.T) {
os.Setenv(middleware.TestingTraceEnvVar, "1")
endpoint := fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.URLEndpoint)
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
// should be OK.
body, contentType := test.URLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// bad request.
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitDelayFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.PaperWidthFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.PaperHeightFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginTopFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginBottomFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginLeftFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.MarginRightFormField: "not a float"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.LandscapeFormField: "not a bool"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// timeout.
body, contentType = test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "0"})
req = httptest.NewRequest(http.MethodPost, endpoint, body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
// should have no more resources.
test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix)
err = os.RemoveAll(middleware.TestsTracePrefix)
assert.Nil(t, err)
os.Unsetenv(middleware.TestingTraceEnvVar)
}
func TestConcurrent(t *testing.T) {
const concurrentRequests int = 4
os.Setenv(middleware.TestingTraceEnvVar, "1")
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
// Merge.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"})
req := httptest.NewRequest(http.MethodPost, handler.MergeEndpoint, 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
},
concurrentRequests,
)
// HTML.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.HTMLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"})
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.HTMLEndpoint), 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
},
concurrentRequests,
)
// Markdown.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.MarkdownTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"})
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.MarkdownEndpoint), 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
},
concurrentRequests,
)
// URL.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.URLTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"})
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.URLEndpoint), 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
},
concurrentRequests,
)
// Office.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.OfficeTestMultipartForm(t, map[string]string{resource.WaitTimeoutFormField: "120"})
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", handler.ConvertGroupEndpoint, handler.OfficeEndpoint), 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
},
concurrentRequests,
)
// should have no more resources.
test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix)
err = os.RemoveAll(middleware.TestsTracePrefix)
assert.Nil(t, err)
os.Unsetenv(middleware.TestingTraceEnvVar)
}
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")
}()
os.Setenv(middleware.TestingTraceEnvVar, "1")
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.WebhookURLFormField: "http://localhost:3001/foo"})
req := httptest.NewRequest(http.MethodPost, "/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
err = <-status
assert.NoError(t, err)
// should have no more resources.
test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix)
err = os.RemoveAll(middleware.TestsTracePrefix)
assert.Nil(t, err)
os.Unsetenv(middleware.TestingTraceEnvVar)
}
func TestResultFilename(t *testing.T) {
os.Setenv(middleware.TestingTraceEnvVar, "1")
config, err := config.FromEnv()
assert.Nil(t, err)
srv := New(config)
body, contentType := test.PDFTestMultipartForm(t, map[string]string{resource.ResultFilenameFormField: "foo.pdf"})
req := httptest.NewRequest(http.MethodPost, "/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"))
// should have no more resources.
test.AssertDirectoryEmpty(t, middleware.TestsTracePrefix)
err = os.RemoveAll(middleware.TestsTracePrefix)
assert.Nil(t, err)
os.Unsetenv(middleware.TestingTraceEnvVar)
}

View File

@@ -1,2 +0,0 @@
// Package api helps managing the HTTP server behind Gotenberg.
package api

View File

@@ -1,134 +0,0 @@
package context
import (
"fmt"
"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 {
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 {
const op string = "context.MustCastFromEchoContext"
ctx, ok := c.(*Context)
if !ok {
panic(fmt.Sprintf("%s: unable to cast an echo.Context to a custom context", op))
}
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 string = "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 {
const op string = "context.LogRequestResult"
req := ctx.Request()
resp := ctx.Response()
stopTime := time.Now()
fields := map[string]interface{}{
"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).ErrorfOp(op, "request failed")
return err
}
if isDebug {
ctx.logger.WithFields(fields).DebugfOp(op, "request handled")
return nil
}
ctx.logger.WithFields(fields).InfofOp(op, "request handled")
return nil
}
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

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

View File

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

View File

@@ -1,142 +0,0 @@
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 string = "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(op, "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(op, "'%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 = "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(
op,
"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(
op,
"'%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 = "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(
op,
"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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +0,0 @@
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"
"github.com/thecodingmachine/gotenberg/internal/pkg/standarderror"
)
// URL is the endpoint for converting
// a URL to PDF.
func URL(c echo.Context) error {
const op string = "handler.URL"
ctx := context.MustCastFromEchoContext(c)
ctx.StandardLogger().DebugfOp(op, "url request")
r := ctx.Resource()
opts, err := r.ChromePrinterOptions()
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
remoteURL, err := r.Get(resource.RemoteURLFormField)
if err != nil {
return &standarderror.Error{Op: op, Err: err}
}
p := printer.NewURL(remoteURL, opts)
if err := convert(ctx, p); err != nil {
return &standarderror.Error{Op: op, Err: err}
}
return nil
}

View File

@@ -1,38 +0,0 @@
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 string = "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

@@ -1,60 +0,0 @@
package middleware
import (
"fmt"
"os"
"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"
)
const (
// TestingTraceEnvVar is an environment
// variable used in some tests.
TestingTraceEnvVar string = "TESTING_TRACE"
// TestsTracePrefix helps
// creating all resources inside a prefix.
// Only used in some tests
// to check if the resources
// have been removed.
TestsTracePrefix string = "tmp"
)
// 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 {
var trace string
if os.Getenv(TestingTraceEnvVar) == "1" {
trace = fmt.Sprintf("%s/%s", TestsTracePrefix, random.Get())
} else {
// 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.
ctx.Error(err)
return ctx.LogRequestResult(err, false)
}
return next(ctx)
}
}
}

View File

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

View File

@@ -1,48 +0,0 @@
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
}
// if it's an error from echo
// like 404 not found and so on.
if echoHTTPErr, ok := err.(*echo.HTTPError); ok {
return echoHTTPErr
}
// we log the initial error before returning
// the HTTP error.
errOp := standarderror.Op(err)
logger := ctx.StandardLogger()
logger.ErrorOp(errOp, err)
// 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.
ctx.Error(httpErr)
return httpErr
}
}
}

View File

@@ -1,21 +0,0 @@
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

@@ -1,5 +0,0 @@
// 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

@@ -1,421 +0,0 @@
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 string = "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 op string = "resource.formValues"
v := make(map[string]string)
fetch := func(formField string) string {
value := c.FormValue(formField)
if value == "" {
logger.DebugfOp(op, "'%s' is empty", formField)
return value
}
logger.DebugfOp(op, "'%s' retrieved, got '%s'", formField, value)
return value
}
v[ResultFilenameFormField] = fetch(ResultFilenameFormField)
v[WaitTimeoutFormField] = fetch(WaitTimeoutFormField)
v[WebhookURLFormField] = fetch(WebhookURLFormField)
v[RemoteURLFormField] = fetch(RemoteURLFormField)
v[WaitDelayFormField] = fetch(WaitDelayFormField)
v[PaperWidthFormField] = fetch(PaperWidthFormField)
v[PaperHeightFormField] = fetch(PaperHeightFormField)
v[MarginTopFormField] = fetch(MarginTopFormField)
v[MarginBottomFormField] = fetch(MarginBottomFormField)
v[MarginLeftFormField] = fetch(MarginLeftFormField)
v[MarginRightFormField] = fetch(MarginRightFormField)
v[LandscapeFormField] = fetch(LandscapeFormField)
return v
}
func formFiles(c echo.Context, logger *logger.Logger, dirPath string) error {
const op string = "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(op, "'%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 string = "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 string = "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, "printer options: %+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 string = "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, "printer options: %+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 string = "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, "printer options: %+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 string = "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 string = "resource.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 string = "resource.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, got '%s'", formField, v),
Op: op,
}
}
return f, nil
}
func (r *Resource) bool(formField string, defaultValue bool) (bool, error) {
const op string = "resource.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, got '%s'", formField, v),
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 string = "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 string = "resource.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 string = "resource.Fpaths"
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 &standarderror.Error{Op: op, 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

@@ -0,0 +1,3 @@
// Package xhttp defines our own implementation
// of echo.Echo.
package xhttp

View File

@@ -0,0 +1,298 @@
package xhttp
import (
"fmt"
"net/http"
"os"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
)
const (
pingEndpoint string = "/ping"
mergeEndpoint string = "/merge"
convertGroupEndpoint string = "/convert"
htmlEndpoint string = "/html"
urlEndpoint string = "/url"
markdownEndpoint string = "/markdown"
officeEndpoint string = "/office"
)
// pingHandler is the handler for healthcheck.
func pingHandler(c echo.Context) error {
const op string = "xhttp.pingHandler"
ctx := context.MustCastFromEchoContext(c)
ctx.XLogger().DebugOp(op, "handling ping request...")
if err := ctx.ProcessesHealthcheck(); err != nil {
return xerror.New(op, err)
}
return nil
}
// mergeHandler is the handler for merging
// PDF files.
func mergeHandler(c echo.Context) error {
const op string = "xhttp.mergeHandler"
resolver := func() error {
ctx := context.MustCastFromEchoContext(c)
logger := ctx.XLogger()
logger.DebugOp(op, "handling merge request...")
r := ctx.MustResource()
opts, err := mergePrinterOptions(r, ctx.Config())
if err != nil {
return xerror.New(op, err)
}
fpaths, err := r.Fpaths(".pdf")
if err != nil {
return err
}
p := printer.NewMergePrinter(logger, fpaths, opts)
return convert(ctx, p)
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
// htmlHandler is the handler for converting
// HTML to PDF.
func htmlHandler(c echo.Context) error {
const op string = "xhttp.htmlHandler"
resolver := func() error {
ctx := context.MustCastFromEchoContext(c)
logger := ctx.XLogger()
logger.DebugOp(op, "handling HTML request...")
r := ctx.MustResource()
opts, err := chromePrinterOptions(r, ctx.Config())
if err != nil {
return err
}
fpath, err := r.Fpath("index.html")
if err != nil {
return err
}
p := printer.NewHTMLPrinter(logger, fpath, opts)
return convert(ctx, p)
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
// urlHandler is the handler for converting
// a URL to PDF.
func urlHandler(c echo.Context) error {
const op string = "xhttp.urlHandler"
resolver := func() error {
ctx := context.MustCastFromEchoContext(c)
logger := ctx.XLogger()
logger.DebugOp(op, "handling URL request...")
r := ctx.MustResource()
opts, err := chromePrinterOptions(r, ctx.Config())
if err != nil {
return err
}
if !r.HasArg(resource.RemoteURLArgKey) {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' not found or empty", resource.RemoteURLArgKey),
nil,
)
}
remoteURL, err := r.StringArg(resource.RemoteURLArgKey, "")
if err != nil {
return err
}
p := printer.NewURLPrinter(logger, remoteURL, opts)
return convert(ctx, p)
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
// markdownHandler is the handler for converting
// Markdown to PDF.
func markdownHandler(c echo.Context) error {
const op string = "xhttp.markdownHandler"
resolver := func() error {
ctx := context.MustCastFromEchoContext(c)
logger := ctx.XLogger()
logger.DebugOp(op, "handling Markdown request...")
r := ctx.MustResource()
opts, err := chromePrinterOptions(r, ctx.Config())
if err != nil {
return err
}
fpath, err := r.Fpath("index.html")
if err != nil {
return err
}
p, err := printer.NewMarkdownPrinter(logger, fpath, opts)
if err != nil {
return err
}
return convert(ctx, p)
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
// officeHandler is the handler for converting
// Office documents to PDF.
func officeHandler(c echo.Context) error {
const op string = "xhttp.officeHandler"
resolver := func() error {
ctx := context.MustCastFromEchoContext(c)
logger := ctx.XLogger()
logger.DebugOp(op, "handling Office request...")
r := ctx.MustResource()
opts, err := officePrinterOptions(r, ctx.Config())
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.NewOfficePrinter(logger, fpaths, opts)
return convert(ctx, p)
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
func convert(ctx context.Context, p printer.Printer) error {
const op string = "xhttp.convert"
resolver := func() error {
logger := ctx.XLogger()
r := ctx.MustResource()
baseFilename := xrand.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.HasArg(resource.WebhookURLArgKey) {
logger.DebugfOp(op, "no '%s' found, converting synchronously", resource.WebhookURLArgKey)
return convertSync(ctx, p, filename, fpath)
}
// as a webhook URL has been given, we
// run the following lines in a goroutine so that
// it doesn't block.
logger.DebugfOp(op, "'%s' found, converting asynchronously", resource.WebhookURLArgKey)
return convertAsync(ctx, p, filename, fpath)
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
func convertSync(ctx context.Context, p printer.Printer, filename, fpath string) error {
const op = "xhttp.convertSync"
resolver := func() error {
logger := ctx.XLogger()
r := ctx.MustResource()
if err := p.Print(fpath); err != nil {
return err
}
if !r.HasArg(resource.ResultFilenameArgKey) {
logger.DebugfOp(
op,
"no '%s' found, using generated filename '%s'",
resource.RemoteURLArgKey,
filename,
)
if err := ctx.Attachment(fpath, filename); err != nil {
return err
}
return nil
}
logger.DebugfOp(
op,
"'%s' found, so not using generated filename",
resource.ResultFilenameArgKey,
)
filename, err := r.StringArg(resource.ResultFilenameArgKey, filename)
if err != nil {
return err
}
if err := ctx.Attachment(fpath, filename); err != nil {
return err
}
return nil
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
func convertAsync(ctx context.Context, p printer.Printer, filename, fpath string) error {
const op = "xhttp.convertAsync"
logger := ctx.XLogger()
r := ctx.MustResource()
go func() {
defer r.Close() // nolint: errcheck
if err := p.Print(fpath); err != nil {
xerr := xerror.New(op, err)
logger.ErrorOp(xerror.Op(xerr), xerr)
return
}
f, err := os.Open(fpath)
if err != nil {
xerr := xerror.New(op, err)
logger.ErrorOp(xerror.Op(xerr), xerr)
return
}
defer f.Close() // nolint: errcheck
webhookURL, err := r.StringArg(resource.WebhookURLArgKey, "")
if err != nil {
xerr := xerror.New(op, err)
logger.ErrorOp(xerror.Op(xerr), xerr)
return
}
logger.DebugfOp(
op,
"sending result file '%s' to '%s'",
filename,
webhookURL,
)
// TODO timeout
resp, err := http.Post(webhookURL, "application/pdf", f) /* #nosec */
if err != nil {
xerr := xerror.New(op, err)
logger.ErrorOp(xerror.Op(xerr), xerr)
return
}
defer resp.Body.Close() // nolint: errcheck
}()
return nil
}

View File

@@ -0,0 +1,128 @@
package xhttp
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/context"
"github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/conf"
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
)
// contextMiddleware extends the default echo.Context with
// our custom context.Context.
func contextMiddleware(config conf.Config, processes ...pm2.Process) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// generate a unique identifier for the request.
trace := xrand.Get()
// create the logger for this request using
// the previous identifier as trace.
logger := xlog.New(config.LogLevel(), trace)
// extend the current echo context with our custom
// context.
ctx := context.New(c, logger, config, processes...)
// if its an healthcheck request, there
// is no need to create a Resource.
if ctx.Path() == pingEndpoint {
return next(ctx)
}
// if the endpoint is not for healthcheck, create a
// Resource.
if err := ctx.WithResource(trace); err != nil {
// required to have a correct status code.
ctx.Error(err)
return ctx.LogRequestResult(err, false)
}
return next(ctx)
}
}
}
// loggerMiddleware logs the result of a request.
func loggerMiddleware() 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() == pingEndpoint
return ctx.LogRequestResult(err, isDebug)
}
}
}
// cleanupMiddleware removes a resource.Resource
// at the end of a request.
func cleanupMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
const op string = "xhttp.cleanupMiddleware"
err := next(c)
ctx := context.MustCastFromEchoContext(c)
if !ctx.HasResource() {
// nothing to remove.
return err
}
r := ctx.MustResource()
// if a webhook URL has been given,
// do not remove the resource.Resource here because
// we don't know if the result file has been
// generated or sent.
if r.HasArg(resource.WebhookURLArgKey) {
return err
}
// a resource.Resource is associated with our custom context.
if resourceErr := r.Close(); resourceErr != nil {
xerr := xerror.New(op, resourceErr)
ctx.XLogger().ErrorOp(xerror.Op(xerr), xerr)
}
return err
}
}
}
// errorMiddleware handles errors (if any).
func errorMiddleware() 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
}
// if it's an error from echo
// like 404 not found and so on.
if echoHTTPErr, ok := err.(*echo.HTTPError); ok {
return echoHTTPErr
}
// we log the initial error before returning
// the HTTP error.
errOp := xerror.Op(err)
logger := ctx.XLogger()
logger.ErrorOp(errOp, err)
// handle our custom HTTP error.
var httpErr error
errCode := xerror.Code(err)
errMessage := xerror.Message(err)
switch errCode {
case xerror.InvalidCode:
httpErr = echo.NewHTTPError(http.StatusBadRequest, errMessage)
case xerror.TimeoutCode:
// TODO status
httpErr = echo.NewHTTPError(http.StatusBadGateway, errMessage)
default:
httpErr = echo.NewHTTPError(http.StatusInternalServerError, errMessage)
}
// required to have a correct status code.
ctx.Error(httpErr)
return httpErr
}
}
}

View File

@@ -0,0 +1,93 @@
package xhttp
import (
"github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/conf"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
func mergePrinterOptions(r resource.Resource, config conf.Config) (printer.MergePrinterOptions, error) {
const op string = "xhttp.mergePrinterOptions"
waitTimeout, err := resource.WaitTimeoutArg(r, config)
if err != nil {
return printer.MergePrinterOptions{}, xerror.New(op, err)
}
return printer.MergePrinterOptions{
WaitTimeout: waitTimeout,
}, nil
}
func chromePrinterOptions(r resource.Resource, config conf.Config) (printer.ChromePrinterOptions, error) {
const op string = "xhttp.chromePrinterOptions"
resolver := func() (printer.ChromePrinterOptions, error) {
waitTimeout, err := resource.WaitTimeoutArg(r, config)
if err != nil {
return printer.ChromePrinterOptions{}, err
}
waitDelay, err := resource.WaitDelayArg(r, config)
if err != nil {
return printer.ChromePrinterOptions{}, err
}
headerHTML, footerHTML,
err := resource.HeaderFooterContents(r)
if err != nil {
return printer.ChromePrinterOptions{}, err
}
paperWidth, paperHeight,
err := resource.PaperSizeArgs(r)
if err != nil {
return printer.ChromePrinterOptions{}, err
}
marginTop, marginBottom, marginLeft, marginRight,
err := resource.MarginArgs(r)
if err != nil {
return printer.ChromePrinterOptions{}, err
}
landscape, err := r.BoolArg(resource.LandscapeArgKey, false)
if err != nil {
return printer.ChromePrinterOptions{}, err
}
return printer.ChromePrinterOptions{
WaitTimeout: waitTimeout,
WaitDelay: waitDelay,
HeaderHTML: headerHTML,
FooterHTML: footerHTML,
PaperWidth: paperWidth,
PaperHeight: paperHeight,
MarginTop: marginTop,
MarginBottom: marginBottom,
MarginLeft: marginLeft,
MarginRight: marginRight,
Landscape: landscape,
}, nil
}
opts, err := resolver()
if err != nil {
return opts, xerror.New(op, err)
}
return opts, nil
}
func officePrinterOptions(r resource.Resource, config conf.Config) (printer.OfficePrinterOptions, error) {
const op string = "xhttp.officePrinterOptions"
resolver := func() (printer.OfficePrinterOptions, error) {
waitTimeout, err := resource.WaitTimeoutArg(r, config)
if err != nil {
return printer.OfficePrinterOptions{}, err
}
landscape, err := r.BoolArg(resource.LandscapeArgKey, false)
if err != nil {
return printer.OfficePrinterOptions{}, err
}
return printer.OfficePrinterOptions{
WaitTimeout: waitTimeout,
Landscape: landscape,
}, nil
}
opts, err := resolver()
if err != nil {
return opts, xerror.New(op, err)
}
return opts, nil
}

View File

@@ -0,0 +1,210 @@
package context
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/xhttp/pkg/resource"
"github.com/thecodingmachine/gotenberg/internal/pkg/conf"
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// Context extends the default echo.Context.
type Context struct {
echo.Context
logger xlog.Logger
config conf.Config
processes []pm2.Process
resource resource.Resource
startTime time.Time
}
// New creates a new Context.
func New(c echo.Context, logger xlog.Logger, config conf.Config, processess ...pm2.Process) Context {
return Context{
c,
logger,
config,
processess,
resource.Resource{},
time.Now(),
}
}
/*
MustCastFromEchoContext cast an echo.Context
to our custom Context.
It panics if casting goes wrong.
*/
func MustCastFromEchoContext(c echo.Context) Context {
const op string = "context.MustCastFromEchoContext"
ctx, ok := c.(Context)
if !ok {
panic(fmt.Sprintf("%s: unable to cast an echo.Context to our custom context.Context", op))
}
return ctx
}
/*
XLogger returns the xlog.Logger associated
with the Context.
This method should be used instead of the
default Logger() method coming from
the echo.Context.
*/
func (ctx Context) XLogger() xlog.Logger {
return ctx.logger
}
// Config returns the conf.Config associated
// with the Context.
func (ctx Context) Config() conf.Config {
return ctx.config
}
// ProcessesHealthcheck returns an error if
// one of the processes is not viable.
func (ctx Context) ProcessesHealthcheck() error {
const op string = "context.Context.ProcessesHealthcheck"
for _, process := range ctx.processes {
if !process.IsViable() {
return xerror.New(
op,
fmt.Errorf("'%s' is not viable", process.Fullname()),
)
}
}
return nil
}
// WithResource creates a resource.Resource and
// adds it to the Context.
func (ctx *Context) WithResource(directoryName string) error {
const op string = "context.Context.WithResource"
resolver := func() (resource.Resource, error) {
r, err := resource.New(ctx.logger, directoryName)
if err != nil {
return r, err
}
// retrieve form values from request.
for _, key := range resource.ArgKeys() {
r.WithArg(key, ctx.FormValue(string(key)))
}
// write form files from request.
form, err := ctx.MultipartForm()
if err != nil {
return r, err
}
for _, files := range form.File {
for _, fh := range files {
in, err := fh.Open()
if err != nil {
return r, err
}
defer in.Close() // nolint: errcheck
if err := r.WithFile(fh.Filename, in); err != nil {
return r, err
}
}
}
return r, nil
}
resource, err := resolver()
ctx.resource = resource
if err != nil {
return xerror.New(op, err)
}
return nil
}
/*
MustResource returns the resource.Resource
associated with the Context.
It panics if no resource.Resource.
*/
func (ctx Context) MustResource() resource.Resource {
const op string = "context.Context.MustResource"
if !ctx.HasResource() {
panic(fmt.Sprintf("%s: unable to retrieve the resource.Resource from our custom context.Context", op))
}
return ctx.resource
}
// HasResource returns true if the Context
// has a resource.Resource.
func (ctx Context) HasResource() bool {
return &ctx.resource != nil
}
/*
LogRequestResult logs the result of a request.
This method should only be used by a middleware!
If an error is given, returns the exact same error.
*/
func (ctx Context) LogRequestResult(err error, isDebug bool) error {
const op string = "context.Context.LogRequestResult"
req := ctx.Request()
resp := ctx.Response()
stopTime := time.Now()
fields := map[string]interface{}{
"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).ErrorfOp(op, "request failed")
return err
}
if isDebug {
ctx.logger.WithFields(fields).DebugfOp(op, "request handled")
return nil
}
ctx.logger.WithFields(fields).InfofOp(op, "request handled")
return nil
}
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,7 @@
/*
Package context extends the default echo.Context.
All functions return our standard xerror.Error
in case of error.
*/
package context

View File

@@ -0,0 +1,253 @@
package resource
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/conf"
"github.com/thecodingmachine/gotenberg/internal/pkg/xassert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// ArgKey is a type for
// arguments' keys.
type ArgKey string
const (
// ResultFilenameArgKey is the key
// of the argument "resultFilename".
ResultFilenameArgKey ArgKey = "resultFilename"
// WaitTimeoutArgKey is the key
// of the argument "waitTimeout".
WaitTimeoutArgKey ArgKey = "waitTimeout"
// WebhookURLArgKey is the key
// of the argument "webhookURL".
WebhookURLArgKey ArgKey = "webhookURL"
// WebhookURLTimeoutArgKey is the key
// of the argument "webhookURLTimeout".
WebhookURLTimeoutArgKey ArgKey = "webhookURLTimeout"
// RemoteURLArgKey is the key
// of the argument "remoteURL".
RemoteURLArgKey ArgKey = "remoteURL"
// WaitDelayArgKey is the key
// of the argument "waitDelay".
WaitDelayArgKey ArgKey = "waitDelay"
// PaperWidthArgKey is the key
// of the argument "paperWidth".
PaperWidthArgKey ArgKey = "paperWidth"
// PaperHeightArgKey is the key
// of the argument "paperHeight".
PaperHeightArgKey ArgKey = "paperHeight"
// MarginTopArgKey is the key
// of the argument "marginTop".
MarginTopArgKey ArgKey = "marginTop"
// MarginBottomArgKey is the key
// of the argument "marginBottom".
MarginBottomArgKey ArgKey = "marginBottom"
// MarginLeftArgKey is the key
// of the argument "marginLeft".
MarginLeftArgKey ArgKey = "marginLeft"
// MarginRightArgKey is the key
// of the argument "marginRight".
MarginRightArgKey ArgKey = "marginRight"
// LandscapeArgKey is the key
// of the argument "landscape".
LandscapeArgKey ArgKey = "landscape"
)
/*
ArgKeys returns a slice
containing all available
arguments' keys.
*/
func ArgKeys() []ArgKey {
return []ArgKey{
ResultFilenameArgKey,
WaitTimeoutArgKey,
WebhookURLArgKey,
WebhookURLTimeoutArgKey,
RemoteURLArgKey,
WaitDelayArgKey,
PaperWidthArgKey,
PaperHeightArgKey,
MarginTopArgKey,
MarginBottomArgKey,
MarginLeftArgKey,
MarginRightArgKey,
LandscapeArgKey,
}
}
/*
WaitTimeoutArg is a helper for retrieving
the "waitTimeout" argument as float64.
It also validates it against the application
configuration.
*/
func WaitTimeoutArg(r Resource, config conf.Config) (float64, error) {
const op string = "resource.WaitTimeoutArg"
result, err := r.Float64Arg(
WaitTimeoutArgKey,
config.DefaultWaitTimeout(),
xassert.Float64NotInferiorTo(0),
xassert.Float64NotSuperiorTo(config.MaximumWaitTimeout()),
)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
WaitDelayArg is a helper for retrieving
the "waitDelay" argument as float64.
It also validates it against the application
configuration.
*/
func WaitDelayArg(r Resource, config conf.Config) (float64, error) {
const (
op string = "resource.WaitDelayArg"
defaultWaitDelay float64 = 0.0
)
result, err := r.Float64Arg(
WaitDelayArgKey,
defaultWaitDelay,
xassert.Float64NotInferiorTo(0.0),
xassert.Float64NotSuperiorTo(config.MaximumWaitDelay()),
)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
PaperSizeArgs is a helper for retrieving
the "paperWidth" and "paperHeight" arguments
as float64.
*/
func PaperSizeArgs(r Resource) (float64, float64, error) {
const (
op string = "resource.PaperSizeArgs"
defaultPaperWidth float64 = 8.27
defaultPaperHeight float64 = 11.7
)
resolver := func() (float64, float64, error) {
paperWidth, err := r.Float64Arg(
PaperWidthArgKey,
defaultPaperWidth,
xassert.Float64NotInferiorTo(0.0),
)
if err != nil {
return defaultPaperWidth,
defaultPaperHeight,
err
}
paperHeight, err := r.Float64Arg(
PaperHeightArgKey,
defaultPaperHeight,
xassert.Float64NotInferiorTo(0.0),
)
if err != nil {
return defaultPaperWidth,
defaultPaperHeight,
err
}
return paperWidth,
paperHeight,
nil
}
paperWidth, paperHeight,
err := resolver()
if err != nil {
return paperWidth,
paperHeight,
xerror.New(op, err)
}
return paperWidth,
paperHeight,
nil
}
/*
MarginArgs is a helper for retrieving
the "marginTop", "marginBottom", "marginLeft"
and "marginRight" arguments as float64.
*/
func MarginArgs(r Resource) (float64, float64, float64, float64, error) {
const (
op string = "resource.MarginArgs"
defaultMarginTop float64 = 1.0
defaultMarginBottom float64 = 1.0
defaultMarginLeft float64 = 1.0
defaultMarginRight float64 = 1.0
)
resolver := func() (float64, float64, float64, float64, error) {
marginTop, err := r.Float64Arg(
MarginTopArgKey,
defaultMarginTop,
xassert.Float64NotInferiorTo(0.0),
)
if err != nil {
return defaultMarginTop,
defaultMarginBottom,
defaultMarginLeft,
defaultMarginRight,
err
}
marginBottom, err := r.Float64Arg(
MarginBottomArgKey,
defaultMarginBottom,
xassert.Float64NotInferiorTo(0.0),
)
if err != nil {
return defaultMarginTop,
defaultMarginBottom,
defaultMarginLeft,
defaultMarginRight,
err
}
marginLeft, err := r.Float64Arg(
MarginLeftArgKey,
defaultMarginLeft,
xassert.Float64NotInferiorTo(0.0),
)
if err != nil {
return defaultMarginTop,
defaultMarginBottom,
defaultMarginLeft,
defaultMarginRight,
err
}
marginRight, err := r.Float64Arg(
MarginRightArgKey,
defaultMarginRight,
xassert.Float64NotInferiorTo(0.0),
)
if err != nil {
return defaultMarginTop,
defaultMarginBottom,
defaultMarginLeft,
defaultMarginRight,
err
}
return marginTop,
marginBottom,
marginLeft,
marginRight,
nil
}
marginTop, marginBottom, marginLeft, marginRight,
err := resolver()
if err != nil {
return marginTop,
marginBottom,
marginLeft,
marginRight,
xerror.New(op, err)
}
return marginTop,
marginBottom,
marginLeft,
marginRight,
nil
}

View File

@@ -0,0 +1,8 @@
/*
Package resource helps managing
arguments and files for a conversion.
All functions return our standard xerror.Error
in case of error.
*/
package resource

View File

@@ -0,0 +1,91 @@
package resource
import (
"io"
"io/ioutil"
"os"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// file represents a file within the resource.
type file struct {
fpath string
}
// write writes given content to the
// resourceFile location.
func (f file) write(in io.Reader) error {
const op string = "resource.file.write"
resolver := func() error {
out, err := os.Create(f.fpath)
if err != nil {
return err
}
defer out.Close() // nolint: errcheck
if err := out.Chmod(0644); err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
return err
}
if _, err := out.Seek(0, 0); err != nil {
return err
}
return nil
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
// content returns the string content of
// the file.
func (f file) content() (string, error) {
const op string = "resource.file.content"
b, err := ioutil.ReadFile(f.fpath)
if err != nil {
return "", xerror.New(op, err)
}
return string(b), nil
}
/*
HeaderFooterContents is a helper for retrieving
the content of the files "header.html"
and "footer.html".
*/
func HeaderFooterContents(r Resource) (string, string, error) {
const (
op string = "resource.HeaderFooterContents"
defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
)
resolver := func() (string, string, error) {
headerHTML, err := r.Fcontent("header.html", defaultHeaderFooterHTML)
if err != nil {
return defaultHeaderFooterHTML,
defaultHeaderFooterHTML,
err
}
footerHTML, err := r.Fcontent("footer.html", defaultHeaderFooterHTML)
if err != nil {
return defaultHeaderFooterHTML,
defaultHeaderFooterHTML,
err
}
return headerHTML,
footerHTML,
nil
}
headerHTML, footerHTML,
err := resolver()
if err != nil {
return headerHTML,
footerHTML,
xerror.New(op, err)
}
return headerHTML,
footerHTML,
nil
}

View File

@@ -0,0 +1,227 @@
package resource
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/thecodingmachine/gotenberg/internal/pkg/xassert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
/*
TemporaryDirectory is the directory
where all the resources directory
are located.
*/
const TemporaryDirectory string = "tmp"
// Resource helps managing
// arguments and files for a conversion.
type Resource struct {
logger xlog.Logger
dirPath string
args map[ArgKey]string
files map[string]file
}
// New creates a Resource where its files will
// be located in the given directory name.
func New(logger xlog.Logger, directoryName string) (Resource, error) {
const op string = "resource.New"
resolver := func() (string, error) {
dirPath := fmt.Sprintf("%s/%s", TemporaryDirectory, directoryName)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return "", err
}
absDirPath, err := filepath.Abs(dirPath)
if err != nil {
return "", err
}
return absDirPath, nil
}
dirPath, err := resolver()
if err != nil {
return Resource{}, xerror.New(op, err)
}
logger.DebugfOp(op, "resource directory '%s' created", directoryName)
return Resource{
logger: logger,
dirPath: dirPath,
args: make(map[ArgKey]string),
files: make(map[string]file),
}, nil
}
// Close removes the working directory of the
// Resource if it exists.
func (r Resource) Close() error {
const op string = "resource.Resource.Close"
if _, err := os.Stat(r.dirPath); os.IsNotExist(err) {
r.logger.DebugfOp(op, "resource directory '%s' does not exist, nothing to remove", r.dirPath)
return nil
}
if err := os.RemoveAll(r.dirPath); err != nil {
return xerror.New(op, err)
}
r.logger.DebugfOp(op, "resource directory '%s' removed", r.dirPath)
return nil
}
// WithArg add a new argument to the Resource.
func (r *Resource) WithArg(key ArgKey, value string) {
const op string = "resource.Resource.WithArg"
r.args[key] = value
r.logger.DebugfOp(op, "added '%s' with value '%s' to resource args", key, value)
}
// WithFile add a new file to the Resource.
func (r *Resource) WithFile(filename string, in io.Reader) error {
const op string = "resource.Resource.WithFile"
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
file := file{fpath: fpath}
if err := file.write(in); err != nil {
return xerror.New(op, err)
}
r.files[filename] = file
r.logger.DebugfOp(op, "resource file '%s' created", filename)
return nil
}
// DirPath returns the directory path
// of the Resource.
func (r Resource) DirPath() string {
return r.dirPath
}
// HasArg returns true if given key exists
// among the Resource and its value is not empty.
func (r Resource) HasArg(key ArgKey) bool {
if v, ok := r.args[key]; ok {
return v != ""
}
return false
}
/*
StringArg returns the value of the
argument identified by given key.
It works in the same manner as xassert.String.
*/
func (r Resource) StringArg(key ArgKey, defaultValue string, rules ...xassert.RuleString) (string, error) {
const op string = "resource.Resource.StringArg"
result, err := xassert.String(string(key), r.args[key], defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Int64Arg returns the int64 representation of the
argument identified by given key.
It works in the same manner as xassert.Int64.
*/
func (r Resource) Int64Arg(key ArgKey, defaultValue int64, rules ...xassert.RuleInt64) (int64, error) {
const op string = "resource.Resource.Int64Arg"
result, err := xassert.Int64(string(key), r.args[key], defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Float64Arg returns the float64 representation of the
argument identified by given key.
It works in the same manner as xassert.Float64.
*/
func (r Resource) Float64Arg(key ArgKey, defaultValue float64, rules ...xassert.RuleFloat64) (float64, error) {
const op string = "resource.Resource.Float64Arg"
result, err := xassert.Float64(string(key), r.args[key], defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
BoolArg returns the boolean representation of the
argument identified by given key.
It works in the same manner as xassert.Bool.
*/
func (r Resource) BoolArg(key ArgKey, defaultValue bool) (bool, error) {
const op string = "resource.Resource.BoolArg"
result, err := xassert.Bool(string(key), r.args[key], defaultValue)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
// Fpath returns the path of the given filename.
// This filename should exist whithin the Resource.
func (r Resource) Fpath(filename string) (string, error) {
const op string = "resource.Resource.Fpath"
file, ok := r.files[filename]
if !ok {
return "", xerror.Invalid(
op,
fmt.Sprintf("resource file '%s' does not exist", filename),
nil,
)
}
return file.fpath, nil
}
/*
Fpaths returns the paths of the files
having one of the given file extensions.
It should found at least one path.
*/
func (r Resource) Fpaths(exts ...string) ([]string, error) {
const op string = "resource.Resource.Fpaths"
var fpaths []string
for filename, file := range r.files {
for _, ext := range exts {
if filepath.Ext(filename) == ext {
fpaths = append(fpaths, file.fpath)
}
}
}
if len(fpaths) == 0 {
return nil, xerror.Invalid(
op,
fmt.Sprintf("no resource file found for extensions '%v'", exts),
nil,
)
}
return fpaths, nil
}
/*
Fcontent returns the string content of the
given filename.
If filename does not exist within the Resource,
returns the default value.
*/
func (r Resource) Fcontent(filename, defaultValue string) (string, error) {
const op string = "resource.Resource.Fcontent"
file, ok := r.files[filename]
if !ok {
return defaultValue, nil
}
content, err := file.content()
if err != nil {
return "", xerror.New(op, err)
}
return content, nil
}

View File

@@ -0,0 +1,33 @@
package xhttp
import (
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/pkg/conf"
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
)
// New returns a custom echo.Echo.
func New(config conf.Config, processes ...pm2.Process) *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
srv.Use(contextMiddleware(config, processes...))
srv.Use(loggerMiddleware())
srv.Use(cleanupMiddleware())
srv.Use(errorMiddleware())
srv.GET(pingEndpoint, pingHandler)
srv.POST(mergeEndpoint, mergeHandler)
if config.DisableGoogleChrome() && config.DisableUnoconv() {
return srv
}
g := srv.Group(convertGroupEndpoint)
if !config.DisableGoogleChrome() {
g.POST(htmlEndpoint, htmlHandler)
g.POST(urlEndpoint, urlHandler)
g.POST(markdownEndpoint, markdownHandler)
}
if !config.DisableUnoconv() {
g.POST(officeEndpoint, officeHandler)
}
return srv
}