Merge branch '6.0.0' into noto-emoji-font

This commit is contained in:
Vladyslav Baidak
2019-07-24 13:09:13 +03:00
committed by GitHub
95 changed files with 4548 additions and 1806 deletions

1
.gitignore vendored
View File

@@ -0,0 +1 @@
.idea

View File

@@ -17,9 +17,7 @@ stages:
jobs:
include:
- stage: tests
script: make lint
- stage: tests
script: make tests
script: make lint tests
- stage: publish
if: tag IS present
script: make publish VERSION=$TRAVIS_TAG DOCKER_USER=$DOCKER_USER DOCKER_PASSWORD=$DOCKER_PASS

View File

@@ -2,11 +2,26 @@ GOLANG_VERSION=1.12
VERSION=snapshot
DOCKER_USER=
DOCKER_PASSWORD=
DOCKER_REPOSITORY=thecodingmachine
GOLANGCI_LINT_VERSION=1.17.1
MAXIMUM_WAIT_TIMEOUT=30.0
MAXIMUM_WAIT_DELAY=10.0
MAXIMUM_WEBHOOK_URL_TIMEOUT=30.0
DEFAULT_WAIT_TIMEOUT=10.0
DEFAULT_WEBHOOK_URL_TIMEOUT=10.0
DEFAULT_LISTEN_PORT=3000
DISABLE_GOOGLE_CHROME=0
DISABLE_UNOCONV=0
LOG_LEVEL=INFO
# generate documentation.
doc:
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:docs -f build/docs/Dockerfile .
docker run --rm -it -v "$(PWD):/docs" thecodingmachine/gotenberg:docs
# build the base Docker image.
base:
docker build -t $(DOCKER_REPOSITORY)/gotenberg:base -f build/base/Dockerfile .
# build the workspace Docker image.
workspace:
make base
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:workspace -f build/workspace/Dockerfile .
# gofmt and goimports all go files.
fmt:
@@ -15,24 +30,32 @@ fmt:
# run all linters.
lint:
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:lint -f build/lint/Dockerfile .
docker run --rm -it -v "$(PWD):/lint" thecodingmachine/gotenberg:lint
make workspace
docker build --build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:lint -f build/lint/Dockerfile .
docker run --rm -it $(DOCKER_REPOSITORY)/gotenberg:lint
# run all tests.
tests:
docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile .
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:tests -f build/tests/Dockerfile .
docker run --rm -it -v "$(PWD):/tests" thecodingmachine/gotenberg:tests
make workspace
docker build -t $(DOCKER_REPOSITORY)/gotenberg:tests -f build/tests/Dockerfile .
docker run --rm -it $(DOCKER_REPOSITORY)/gotenberg:tests
# build Docker image.
# generate documentation.
doc:
make workspace
docker build -t $(DOCKER_REPOSITORY)/gotenberg:docs -f build/docs/Dockerfile .
docker run --rm -it -v "$(PWD):/gotenberg/docs" $(DOCKER_REPOSITORY)/gotenberg:docs
# build Gotenberg Docker image.
image:
docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile .
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) --build-arg VERSION=$(VERSION) -t thecodingmachine/gotenberg:$(VERSION) -f build/package/Dockerfile .
make workspace
docker build --build-arg VERSION=$(VERSION) -t $(DOCKER_REPOSITORY)/gotenberg:$(VERSION) -f build/package/Dockerfile .
# start the API using previously built Docker image.
gotenberg:
docker run -it --rm -p "3000:3000" thecodingmachine/gotenberg:$(VERSION)
docker run -it --rm -e MAXIMUM_WAIT_TIMEOUT=$(MAXIMUM_WAIT_TIMEOUT) -e MAXIMUM_WAIT_DELAY=$(MAXIMUM_WAIT_DELAY) -e MAXIMUM_WEBHOOK_URL_TIMEOUT=$(MAXIMUM_WEBHOOK_URL_TIMEOUT) -e DEFAULT_WEBHOOK_URL_TIMEOUT=$(DEFAULT_WEBHOOK_URL_TIMEOUT) -e MAXIMUM_WEBHOOK_URL_TIMEOUT=$(MAXIMUM_WEBHOOK_URL_TIMEOUT) -e DEFAULT_LISTEN_PORT=$(DEFAULT_LISTEN_PORT) -e DISABLE_GOOGLE_CHROME=$(DISABLE_GOOGLE_CHROME) -e DISABLE_UNOCONV=$(DISABLE_UNOCONV) -e LOG_LEVEL=$(LOG_LEVEL) -p "3000:$(DEFAULT_LISTEN_PORT)" $(DOCKER_REPOSITORY)/gotenberg:$(VERSION)
# publish Gotenberg images according to version.
publish:
./scripts/publish.sh $(GOLANG_VERSION) $(VERSION) $(DOCKER_USER) $(DOCKER_PASSWORD)
make workspace
./scripts/publish.sh $(VERSION) $(DOCKER_USER) $(DOCKER_PASSWORD)

View File

@@ -1,4 +1,4 @@
FROM debian:9.5-slim
FROM debian:9-slim
# |--------------------------------------------------------------------------
# | Common libraries
@@ -16,7 +16,7 @@ RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free"
# |--------------------------------------------------------------------------
# |
# | Installs PM2 for launching programs in background and with failure
# | recovering. In our case: Chrome (headless) and Office (headless).
# | recovering. In our case: Google Chrome (headless) and unoconv.
# |
RUN curl -sL https://deb.nodesource.com/setup_9.x | bash - &&\
@@ -94,3 +94,17 @@ COPY build/base/* /usr/share/fonts/
COPY build/base/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# |--------------------------------------------------------------------------
# | Default user
# |--------------------------------------------------------------------------
# |
# | All processes in the Docker container will run as a dedicated
# | non-root user.
# |
RUN groupadd --gid 1001 gotenberg \
&& useradd --uid 1001 --gid gotenberg --shell /bin/bash --home /gotenberg --no-create-home gotenberg \
&& mkdir /gotenberg \
&& chown gotenberg: /gotenberg
ENV PM2_HOME=/gotenberg/.pm2

View File

@@ -1,6 +1,4 @@
ARG GOLANG_VERSION
FROM golang:${GOLANG_VERSION}-stretch
FROM thecodingmachine/gotenberg:workspace
# |--------------------------------------------------------------------------
# | static
@@ -19,6 +17,6 @@ RUN go get github.com/apex/static/cmd/static-docs
# | Last instructions of this build.
# |
WORKDIR /docs
WORKDIR /gotenberg/docs
CMD [ "static-docs", "--in", "build/docs/content", "--out", "docs", "--theme", "gotenberg", "--title", "Gotenberg", "--subtitle", "A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF." ]

View File

@@ -10,6 +10,8 @@ You may start it with:
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5
```
All processes in the Docker container runs as a dedicated non-root user called `gotenberg` with user id `1001` from the working dir `/gotenberg`.
> The API will be available at [http://localhost:3000](http://localhost:3000).
## Docker Compose
@@ -38,5 +40,12 @@ Otherwise the API will not be able to launch Google Chrome and LibreOffice (unoc
> The more resources are granted, the quicker will be the conversions.
Also, in the deployment spec of the pod, specify the uid `1001` of the user `gotenberg`:
```
securityContext:
privileged: false
runAsUser: 1001
```
In the following examples, we will assume your
Gotenberg API is available at [http://localhost:3000](http://localhost:3000).

View File

@@ -8,7 +8,7 @@ You may customize the API behaviour thanks to environment variables.
In order to save some resources, the Gotenberg image accepts the environment variable `DISABLE_GOOGLE_CHROME`.
It takes the strings `"0"` or `"1"` as value.
It takes the strings `"0"` or `"1"` as value where `1` means `true`
> If Google Chrome is disabled, the following conversions will **not** be available anymore:
> [HTML](#html), [URL](#url) and [Markdown](#markdown)
@@ -37,7 +37,7 @@ By default, the API will add a log entry when the [healthcheck endpoint](#ping)
You may turn off this logging so as to avoid unnecessary entries in your logs with the environment variable `DISABLE_HEALTHCHECK_LOGGING`.
This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values.
This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values, where `"1"` is enabled.
## Default listen port
@@ -45,4 +45,12 @@ By default, the API will listen on port `3000`. For most use cases this is perfe
You may customize this port location with the environment variable `DEFAULT_LISTEN_PORT`.
This environment variable accepts any string that can be turned into a port number (e.g., the string `"0"` up to the string `"65535"`).
This environment variable accepts any string that can be turned into a port number (e.g., the string `"0"` up to the string `"65535"`).
## Debug logging of process startup
By default, `stdout` and `stderr` messages from the started processes are disabled.
You may enable some debug logging from starting the process by setting the environment variable `DEBUG_PROCESS_STARTUP`.
This environment variable operates in the same manner as the `DISABLE_GOOGLE_CHROME` and `DISABLE_UNOCONV` variables operate in that it accepts the strings `"0"` or `"1"` as values, where `1` means `true`.

View File

@@ -296,7 +296,8 @@ $client->store($request, $dest);
## Wait delay
In some cases, you may want to wait a certain amount of time to make sure the
page you're trying to generate is fully rendered.
page you're trying to generate is fully rendered. For instance, if your page relies
a lot on JavaScript for rendering.
> The wait delay is a duration in **seconds** (e.g `2.5` for 2.5 seconds).
@@ -339,4 +340,4 @@ $request = new HTMLRequest($index);
$request->setWaitDelay(5.5);
$dest = "result.pdf";
$client->store($request, $dest);
```
```

View File

@@ -1,6 +1,4 @@
ARG GOLANG_VERSION
FROM golang:${GOLANG_VERSION}-stretch
FROM thecodingmachine/gotenberg:workspace
# |--------------------------------------------------------------------------
# | GolangCI-Lint
@@ -10,7 +8,7 @@ FROM golang:${GOLANG_VERSION}-stretch
# | than gometalinter.
# |
ENV GOLANGCI_LINT_VERSION 1.16.0
ARG GOLANGCI_LINT_VERSION
RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b /usr/local/bin v${GOLANGCI_LINT_VERSION} &&\
golangci-lint --version
@@ -22,14 +20,15 @@ RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.s
# | Last instructions of this build.
# |
# Define our workding outside of $GOPATH (we're using go modules).
WORKDIR /lint
# Define our working directory outside of $GOPATH (we're using go modules).
USER gotenberg
WORKDIR /gotenberg/lint
# Copy our module dependencies definitions.
COPY go.mod .
COPY go.sum .
# Copy our code source.
COPY --chown=gotenberg:gotenberg . .
# Install module dependencies.
RUN go mod download
RUN go mod download &&\
go mod verify
CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl" ]

View File

@@ -1,5 +1,3 @@
ARG GOLANG_VERSION
# |--------------------------------------------------------------------------
# | Binary
# |--------------------------------------------------------------------------
@@ -7,7 +5,7 @@ ARG GOLANG_VERSION
# | Buils Gotenberg binary.
# |
FROM golang:${GOLANG_VERSION}-stretch AS golang
FROM thecodingmachine/gotenberg:workspace AS workspace
ARG VERSION
@@ -16,13 +14,16 @@ ENV GOOS=linux \
CGO_ENABLED=0
# Define our workding outside of $GOPATH (we're using go modules).
WORKDIR /gotenberg
WORKDIR /gotenberg/package
# Copy our source code.
COPY . .
COPY internal ./internal
COPY cmd ./cmd
COPY go.sum go.sum
COPY go.mod go.mod
# Build our binary.
RUN go build -o /gotenberg/gotenberg -ldflags "-X main.version=${VERSION}" cmd/gotenberg/main.go
RUN go build -o gotenberg -ldflags "-X main.version=${VERSION}" cmd/gotenberg/main.go
# |--------------------------------------------------------------------------
# | Final touch
@@ -35,8 +36,9 @@ FROM thecodingmachine/gotenberg:base
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
COPY --from=golang /gotenberg/gotenberg /usr/local/bin/
COPY --from=workspace /gotenberg/package/gotenberg /usr/local/bin/
USER gotenberg
WORKDIR /gotenberg
EXPOSE 3000

View File

@@ -1,48 +1,14 @@
ARG GOLANG_VERSION
FROM golang:${GOLANG_VERSION}-stretch AS golang
FROM thecodingmachine/gotenberg:base
# |--------------------------------------------------------------------------
# | Common libraries
# |--------------------------------------------------------------------------
# |
# | Libraries used in the build process of this image.
# |
RUN apt-get install -y git gcc
# |--------------------------------------------------------------------------
# | Golang
# |--------------------------------------------------------------------------
# |
# | Installs Golang.
# |
COPY --from=golang /usr/local/go /usr/local/go
RUN export PATH="/usr/local/go/bin:$PATH" &&\
go version
ENV GOPATH /go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
# |--------------------------------------------------------------------------
# | Final touch
# |--------------------------------------------------------------------------
# |
# | Last instructions of this build.
# |
FROM thecodingmachine/gotenberg:workspace
# Define our workding outside of $GOPATH (we're using go modules).
WORKDIR /tests
USER gotenberg
WORKDIR /gotenberg/tests
# Copy our module dependencies definitions.
COPY go.mod .
COPY go.sum .
# Copy our code source.
COPY --chown=gotenberg:gotenberg . .
# Install module dependencies.
RUN go mod download
RUN go mod download &&\
go mod verify
ENTRYPOINT [ "build/tests/docker-entrypoint.sh" ]

View File

@@ -2,16 +2,33 @@
set -xe
# Make sure the user running the
# tests is the Gotenberg user.
CURRENT_USER=$(whoami)
if [ "$CURRENT_USER" != "gotenberg" ]; then
exit 1
fi
# Start the PM2 processes
# (Google Chrome headless & unoconv listener).
go run github.com/thecodingmachine/gotenberg/test/cmd/pm2
# Run our tests.
go test -race -cover ./...
# Testing PM2 processes launch separatly for avoiding
# spending to much time on each tests depending on
# them.
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeStart
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart
#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeStart
#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart
# Running others tests.
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand
#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/config
#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/random
#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/standarderror
#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/timeout
#go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api
# Finally testing processes shutdown.
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvShutdown
#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown
#go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvShutdown

View File

@@ -0,0 +1,52 @@
ARG GOLANG_VERSION
FROM golang:${GOLANG_VERSION}-stretch as golang
FROM thecodingmachine/gotenberg:base
# |--------------------------------------------------------------------------
# | Common libraries
# |--------------------------------------------------------------------------
# |
# | Libraries used in the build process of this image.
# |
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
g++ \
gcc \
libc6-dev \
make \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# |--------------------------------------------------------------------------
# | Golang
# |--------------------------------------------------------------------------
# |
# | Installs Golang.
# |
COPY --from=golang /usr/local/go /usr/local/go
ENV GOPATH /gotenberg/go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" &&\
chmod -R 777 "$GOPATH"
# |--------------------------------------------------------------------------
# | Final touch
# |--------------------------------------------------------------------------
# |
# | Last instructions of this build.
# |
# Make sure the Gotenber user is able to
# call the Go binary.
USER gotenberg
RUN go version &&\
go env
USER root

View File

@@ -1,137 +1,55 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
"github.com/thecodingmachine/gotenberg/internal/app/xhttp"
"github.com/thecodingmachine/gotenberg/internal/pkg/conf"
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// version will be set on build time.
// nolint: gochecknoglobals
var version = "snapshot"
const (
defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT"
defaultListenPortEnvVar = "DEFAULT_LISTEN_PORT"
disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME"
disableUnoconvEnvVar = "DISABLE_UNOCONV"
disableHealthcheckLoggingEnvVar = "DISABLE_HEALTHCHECK_LOGGING"
)
func mustParseEnvVar() *api.Options {
opts := api.DefaultOptions()
if os.Getenv(defaultWaitTimeoutEnvVar) != "" {
defaultWaitTimeout, err := strconv.ParseFloat(os.Getenv(defaultWaitTimeoutEnvVar), 64)
if err != nil {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want float got %v", defaultWaitTimeoutEnvVar, err))
os.Exit(1)
}
opts.DefaultWaitTimeout = defaultWaitTimeout
}
if v, ok := os.LookupEnv(defaultListenPortEnvVar); ok {
defaultListener, err := strconv.ParseUint(os.Getenv(defaultListenPortEnvVar), 10, 64)
if err != nil {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want uint got %v", defaultListenPortEnvVar, err))
os.Exit(1)
}
if defaultListener > 65535 {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want uint < 65535 got %v", defaultListenPortEnvVar, defaultListener))
os.Exit(1)
}
opts.DefaultListenPort = v
}
if v, ok := os.LookupEnv(disableGoogleChromeEnvVar); ok {
if v != "1" && v != "0" {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", disableGoogleChromeEnvVar, v))
os.Exit(1)
}
opts.EnableChromeEndpoints = v != "1"
}
if v, ok := os.LookupEnv(disableUnoconvEnvVar); ok {
if v != "1" && v != "0" {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", disableUnoconvEnvVar, v))
os.Exit(1)
}
opts.EnableUnoconvEndpoints = v != "1"
}
if v, ok := os.LookupEnv(disableHealthcheckLoggingEnvVar); ok {
if v != "1" && v != "0" {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", disableHealthcheckLoggingEnvVar, v))
os.Exit(1)
}
opts.EnableHealthcheckLogging = v != "1"
}
return opts
}
func mustStartProcesses(opts *api.Options) []pm2.Process {
var processes []pm2.Process
if opts.EnableChromeEndpoints {
processes = append(processes, pm2.NewChrome())
}
if opts.EnableUnoconvEndpoints {
processes = append(processes, pm2.NewUnoconv())
}
for _, p := range processes {
notify.Printf("starting %s with PM2...", p.Fullname())
if err := p.Start(); err != nil {
notify.ErrPrint(err)
os.Exit(1)
}
}
return processes
}
func mustStartAPI(srv *echo.Echo, port string) {
notify.Printf("http server started on port %v", port)
if err := srv.Start(fmt.Sprintf(":%v", port)); err != nil {
if err != http.ErrServerClosed {
notify.ErrPrint(err)
os.Exit(1)
}
}
}
func mustShutdownProcesses(processes []pm2.Process) {
for _, p := range processes {
notify.Printf("shutting down %s with PM2... (Ctrl+C to force)", p.Fullname())
if err := p.Shutdown(); err != nil {
notify.ErrPrint(err)
os.Exit(1)
}
}
}
func mustShutdownAPI(srv *echo.Echo) {
// create a deadline to wait for.
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// doesn't block if no connections, but will otherwise wait
// until the timeout deadline.
notify.Print("shutting down http server... (Ctrl+C to force)")
if err := srv.Shutdown(ctx); err != nil {
notify.ErrPrint(err)
os.Exit(1)
}
}
func main() {
notify.Printf("Gotenberg %s", version)
opts := mustParseEnvVar()
srv := api.New(opts)
processes := mustStartProcesses(opts)
// run our API in a goroutine so that it doesn't block.s
const op = "main"
config, err := conf.FromEnv()
systemLogger := xlog.New(config.LogLevel(), "system")
if err != nil {
systemLogger.FatalOp(op, err)
}
systemLogger.InfofOp(op, "Gotenberg %s", version)
systemLogger.DebugfOp(op, "configuration: %+v", config)
// start PM2 processes.
var processes []pm2.Process
if !config.DisableGoogleChrome() {
processes = append(processes, pm2.NewChromeProcess(systemLogger))
}
if !config.DisableUnoconv() {
processes = append(processes, pm2.NewUnoconvProcess(systemLogger))
}
for _, p := range processes {
systemLogger.InfofOp(op, "starting '%s' with PM2...", p.Fullname())
if err := p.Start(); err != nil {
systemLogger.FatalOp(op, err)
}
}
// create our API.
srv := xhttp.New(config, processes...)
// run our API in a goroutine so that it doesn't block.
go func() {
mustStartAPI(srv, opts.DefaultListenPort)
systemLogger.InfofOp(op, "http server started on port '%d'", config.DefaultListenPort())
if err := srv.Start(fmt.Sprintf(":%d", config.DefaultListenPort())); err != nil {
if err != http.ErrServerClosed {
systemLogger.FatalOp(op, err)
}
}
}()
quit := make(chan os.Signal, 1)
// we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
@@ -139,8 +57,22 @@ func main() {
signal.Notify(quit, os.Interrupt)
// block until we receive our signal.
<-quit
mustShutdownAPI(srv)
mustShutdownProcesses(processes)
notify.Print("bye!")
// create a deadline to wait for.
ctx, cancel := xcontext.WithTimeout(systemLogger, 120)
defer cancel()
// doesn't block if no connections, but will otherwise wait
// until the timeout deadline.
systemLogger.InfoOp(op, "shutting down http server...")
if err := srv.Shutdown(ctx); err != nil {
systemLogger.FatalOp(op, err)
}
// shutdown PM2 processes.
for _, p := range processes {
systemLogger.InfofOp(op, "shutting down '%s' with PM2...", p.Fullname())
if err := p.Stop(); err != nil {
systemLogger.FatalOp(op, err)
}
}
systemLogger.InfoOp(op, "bye!")
os.Exit(0)
}

View File

@@ -139,6 +139,8 @@
<pre class="chroma">$ docker run --rm -p <span class="m">3000</span>:3000 thecodingmachine/gotenberg:5
</pre>
<p>All processes in the Docker container runs as a dedicated non-root user called <code>gotenberg</code> with user id <code>1001</code> from the working dir <code>/gotenberg</code>.</p>
<blockquote>
<p>The API will be available at <a href="http://localhost:3000">http://localhost:3000</a>.</p>
</blockquote>
@@ -176,6 +178,13 @@ Otherwise the API will not be able to launch Google Chrome and LibreOffice (unoc
<p>The more resources are granted, the quicker will be the conversions.</p>
</blockquote>
<p>Also, in the deployment spec of the pod, specify the uid <code>1001</code> of the user <code>gotenberg</code>:</p>
<pre class="chroma">securityContext:
privileged: false
runAsUser: 1001
</pre>
<p>In the following examples, we will assume your
Gotenberg API is available at <a href="http://localhost:3000">http://localhost:3000</a>.</p>
@@ -222,7 +231,7 @@ Gotenberg API is available at <a href="http://localhost:3000">http://localhost:3
<p>In order to save some resources, the Gotenberg image accepts the environment variable <code>DISABLE_GOOGLE_CHROME</code>.</p>
<p>It takes the strings <code>&#34;0&#34;</code> or <code>&#34;1&#34;</code> as value.</p>
<p>It takes the strings <code>&#34;0&#34;</code> or <code>&#34;1&#34;</code> as value where <code>1</code> means <code>true</code></p>
<blockquote>
<p>If Google Chrome is disabled, the following conversions will <strong>not</strong> be available anymore:
@@ -263,7 +272,7 @@ See the <a href="#timeout">timeout section</a>.</p>
<p>You may turn off this logging so as to avoid unnecessary entries in your logs with the environment variable <code>DISABLE_HEALTHCHECK_LOGGING</code>.</p>
<p>This environment variable operates in the same manner as the <code>DISABLE_GOOGLE_CHROME</code> and <code>DISABLE_UNOCONV</code> variables operate in that it accepts the strings <code>&#34;0&#34;</code> or <code>&#34;1&#34;</code> as values.</p>
<p>This environment variable operates in the same manner as the <code>DISABLE_GOOGLE_CHROME</code> and <code>DISABLE_UNOCONV</code> variables operate in that it accepts the strings <code>&#34;0&#34;</code> or <code>&#34;1&#34;</code> as values, where <code>&#34;1&#34;</code> is enabled.</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="environment_variables.default_listen_port" href="#environment_variables.default_listen_port">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
@@ -275,6 +284,16 @@ See the <a href="#timeout">timeout section</a>.</p>
<p>This environment variable accepts any string that can be turned into a port number (e.g., the string <code>&#34;0&#34;</code> up to the string <code>&#34;65535&#34;</code>).</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="environment_variables.debug_logging_of_process_startup" href="#environment_variables.debug_logging_of_process_startup">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Debug logging of process startup</h2>
<p>By default, <code>stdout</code> and <code>stderr</code> messages from the started processes are disabled.</p>
<p>You may enable some debug logging from starting the process by setting the environment variable <code>DEBUG_PROCESS_STARTUP</code>.</p>
<p>This environment variable operates in the same manner as the <code>DISABLE_GOOGLE_CHROME</code> and <code>DISABLE_UNOCONV</code> variables operate in that it accepts the strings <code>&#34;0&#34;</code> or <code>&#34;1&#34;</code> as values, where <code>1</code> means <code>true</code>.</p>
</div>
<div class="Page" id="html">
@@ -601,7 +620,8 @@ $client-&gt;store($request, $dest);
</a>Wait delay</h2>
<p>In some cases, you may want to wait a certain amount of time to make sure the
page youre trying to generate is fully rendered.</p>
page youre trying to generate is fully rendered. For instance, if your page relies
a lot on JavaScript for rendering.</p>
<blockquote>
<p>The wait delay is a duration in <strong>seconds</strong> (e.g <code>2.5</code> for 2.5 seconds).</p>

27
go.mod
View File

@@ -3,21 +3,20 @@ module github.com/thecodingmachine/gotenberg
go 1.12
require (
github.com/davecgh/go-spew v1.1.1 // indirect
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/mattn/go-isatty v0.0.8
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-20190322080309-f49334f85ddc // 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
)

81
go.sum
View File

@@ -3,51 +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/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.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
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-20190322080309-f49334f85ddc h1:4gbWbmmPFp4ySWICouJl6emP0MyS31yy9SrTlAGFT+g=
golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190609082536-301114b31cce/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb h1:fgwFCsaw9buMuxNd6+DQfAuSFqbNiQZpcgJQAgJsK6k=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190608022120-eacb66d2a7c3/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=

View File

@@ -1,53 +0,0 @@
package api
import (
"github.com/labstack/echo/v4"
)
const pingEndpoint = "/ping"
// Options allows to customize the behaviour
// of the API.
type Options struct {
DefaultWaitTimeout float64
DefaultListenPort string
EnableChromeEndpoints bool
EnableUnoconvEndpoints bool
EnableHealthcheckLogging bool
}
// DefaultOptions returns default options.
func DefaultOptions() *Options {
return &Options{
DefaultWaitTimeout: 10,
DefaultListenPort: "3000",
EnableChromeEndpoints: true,
EnableUnoconvEndpoints: true,
EnableHealthcheckLogging: true,
}
}
// New returns an API.
func New(opts *Options) *echo.Echo {
api := echo.New()
api.HideBanner = true
api.HidePort = true
api.Use(handleLogging(opts.EnableHealthcheckLogging))
api.GET(pingEndpoint, func(c echo.Context) error { return nil })
g := api.Group("/convert")
g.Use(handleContext(opts))
g.Use(handleError())
g.POST("/merge", merge)
if !opts.EnableChromeEndpoints && !opts.EnableUnoconvEndpoints {
return api
}
if opts.EnableChromeEndpoints {
g.POST("/html", convertHTML)
g.POST("/url", convertURL)
g.POST("/markdown", convertMarkdown)
}
if opts.EnableUnoconvEndpoints {
g.POST("/office", convertOffice)
}
return api
}

View File

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

View File

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

View File

@@ -1,157 +0,0 @@
package api
import (
"fmt"
"net/http"
"os"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
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()
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()
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()
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()
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()
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, err := rand.Get()
if err != nil {
return err
}
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) {
filename, err = ctx.resource.get(resultFilename)
if err != nil {
return &errBadRequest{err}
}
}
return ctx.Attachment(fpath, filename)
}
// as a webhook URL has been given, we
// run the following lines in a goroutine so that
// it doesn't block.
go func() {
defer ctx.resource.close() // nolint: errcheck
if err := p.Print(fpath); err != nil {
ctx.Logger().Error(err)
return
}
f, err := os.Open(fpath)
if err != nil {
ctx.Logger().Error(err)
return
}
defer f.Close() // nolint: errcheck
webhook, err := ctx.resource.get(webhookURL)
if err != nil {
ctx.Logger().Error(err)
return
}
resp, err := http.Post(webhook, "application/pdf", f) /* #nosec */
if err != nil {
ctx.Logger().Error(err)
return
}
defer resp.Body.Close() // nolint: errcheck
}()
return nil
}

View File

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

View File

@@ -1,75 +0,0 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func handleLogging(enableHealthcheckLogging bool) echo.MiddlewareFunc {
if enableHealthcheckLogging {
// default logging middleware.
return middleware.Logger()
}
// middleware for skipping logging when the ping endpoint is called.
return middleware.LoggerWithConfig(middleware.LoggerConfig{
Skipper: func(c echo.Context) bool {
return c.Request().URL.Path == pingEndpoint
},
})
}
func handleContext(opts *Options) echo.MiddlewareFunc {
// middleware for extending default context with our
// custom constext.
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := &resourceContext{c, opts, nil}
r, err := newResource(ctx)
if err != nil {
if resourceErr := r.close(); resourceErr != nil {
c.Logger().Error(resourceErr)
}
return err
}
ctx.resource = r
return next(ctx)
}
}
}
func handleError() echo.MiddlewareFunc {
// middleware for handling errors and removing resources
// once the request has been handled.
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
ctx := c.(*resourceContext)
// 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 {
c.Logger().Error(resourceErr)
}
}
if err != 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)
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return nil
}
}
}

View File

@@ -1,310 +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"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
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
opts *Options
}
type resourceContext struct {
echo.Context
opts *Options
resource *resource
}
func newResource(ctx *resourceContext) (*resource, error) {
r := &resource{
formValues: formValues(ctx),
opts: ctx.opts,
}
dirPath, err := rand.Get()
if err != nil {
return r, err
}
r.formFilesDirPath = dirPath
if err := os.MkdirAll(dirPath, 0755); err != nil {
return nil, fmt.Errorf("%s: making directory: %v", dirPath, err)
}
if err := formFiles(ctx, dirPath); err != nil {
return r, err
}
return r, nil
}
func formValues(ctx *resourceContext) map[string]string {
v := make(map[string]string)
v[resultFilename] = ctx.FormValue(resultFilename)
v[waitTimeout] = ctx.FormValue(waitTimeout)
v[webhookURL] = ctx.FormValue(webhookURL)
v[remoteURL] = ctx.FormValue(remoteURL)
v[waitDelay] = ctx.FormValue(waitDelay)
v[paperWidth] = ctx.FormValue(paperWidth)
v[paperHeight] = ctx.FormValue(paperHeight)
v[marginTop] = ctx.FormValue(marginTop)
v[marginBottom] = ctx.FormValue(marginBottom)
v[marginLeft] = ctx.FormValue(marginLeft)
v[marginRight] = ctx.FormValue(marginRight)
v[landscape] = ctx.FormValue(landscape)
return v
}
func formFiles(ctx *resourceContext, dirPath string) error {
form, err := ctx.MultipartForm()
if err != nil {
return fmt.Errorf("getting multipart form: %v", err)
}
for _, files := range form.File {
for _, fh := range files {
in, err := fh.Open()
if err != nil {
return fmt.Errorf("%s: opening file: %v", fh.Filename, err)
}
defer in.Close() // nolint: errcheck
fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename)
out, err := os.Create(fpath)
if err != nil {
return fmt.Errorf("%s: creating new file: %v", fpath, err)
}
defer out.Close() // nolint: errcheck
if err := out.Chmod(0644); err != nil {
return fmt.Errorf("%s: changing file mode: %v", fpath, err)
}
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("%s: writing file: %v", fpath, err)
}
if _, err := out.Seek(0, 0); err != nil {
return fmt.Errorf("%s: resetting read pointer: %v", fpath, err)
}
}
}
return nil
}
func (r *resource) close() error {
if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) {
return nil
}
return os.RemoveAll(r.formFilesDirPath)
}
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
func (r *resource) chromePrinterOptions() (*printer.ChromeOptions, error) {
timeout, err := r.float64(waitTimeout, r.opts.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() (*printer.OfficeOptions, error) {
timeout, err := r.float64(waitTimeout, r.opts.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() (*printer.MergeOptions, error) {
timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout)
if err != nil {
return nil, err
}
return &printer.MergeOptions{
WaitTimeout: timeout,
}, nil
}
func (r *resource) has(key string) bool {
v, ok := r.formValues[key]
if ok {
ok = v != ""
}
return ok
}
func (r *resource) hasFile(filename string) bool {
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath)
return !os.IsNotExist(err)
}
func (r *resource) get(key string) (string, error) {
v, ok := r.formValues[key]
if !ok {
return "", fmt.Errorf("form value %s does not exist", key)
}
return v, nil
}
func (r *resource) float64(key string, defaultValue float64) (float64, error) {
if !r.has(key) {
return defaultValue, nil
}
v, err := r.get(key)
if err != nil {
return 0.0, err
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0.0, fmt.Errorf("form value %s: %v", key, err)
}
return f, nil
}
func (r *resource) bool(key string, defaultValue bool) (bool, error) {
if !r.has(key) {
return defaultValue, nil
}
v, err := r.get(key)
if err != nil {
return false, err
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("form value %s: %v", key, err)
}
return b, nil
}
func (r *resource) fpath(filename string) (string, error) {
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath)
if os.IsNotExist(err) {
return "", fmt.Errorf("%s: form file does not exist", filename)
}
absPath, err := filepath.Abs(fpath)
if err != nil {
return "", fmt.Errorf("%s: getting absolute path: %v", fpath, err)
}
return absPath, nil
}
func (r *resource) content(filename string, defaultValue string) (string, error) {
if !r.hasFile(filename) {
return defaultValue, nil
}
fpath, err := r.fpath(filename)
if err != nil {
return "", err
}
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading form file: %v", fpath, err)
}
return string(b), nil
}
func (r *resource) fpaths(exts ...string) ([]string, error) {
var fpaths []string
err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error {
if info.IsDir() {
return nil
}
fpath, err := r.fpath(info.Name())
if err != nil {
return err
}
for _, ext := range exts {
if filepath.Ext(fpath) == ext {
fpaths = append(fpaths, fpath)
return nil
}
}
return nil
})
if err != nil {
return nil, err
}
if len(fpaths) == 0 {
return nil, fmt.Errorf("no form files found for extensions: %v", exts)
}
return fpaths, nil
}

View File

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

View File

@@ -0,0 +1,299 @@
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)
}
// TODO return processes info
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,127 @@
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:
httpErr = echo.NewHTTPError(http.StatusGatewayTimeout, 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,211 @@
package context
import (
"fmt"
"net/http"
"reflect"
"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
}
// HasResource returns true if the Context
// has a resource.Resource.
func (ctx Context) HasResource() bool {
return !reflect.DeepEqual(ctx.resource, resource.Resource{})
}
/*
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
}
/*
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
}

206
internal/pkg/conf/conf.go Normal file
View File

@@ -0,0 +1,206 @@
package conf
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xassert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
const (
maximumWaitTimeoutEnvVar string = "MAXIMUM_WAIT_TIMEOUT"
maximumWaitDelayEnvVar string = "MAXIMUM_WAIT_DELAY"
maximumWebhookURLTimeoutEnvVar string = "MAXIMUM_WEBHOOK_URL_TIMEOUT"
defaultWaitTimeoutEnvVar string = "DEFAULT_WAIT_TIMEOUT"
defaultWebhookURLTimeoutEnvVar string = "DEFAULT_WEBHOOK_URL_TIMEOUT"
defaultListenPortEnvVar string = "DEFAULT_LISTEN_PORT"
disableGoogleChromeEnvVar string = "DISABLE_GOOGLE_CHROME"
disableUnoconvEnvVar string = "DISABLE_UNOCONV"
logLevelEnvVar string = "LOG_LEVEL"
)
// Config contains the application
// configuration.
type Config struct {
maximumWaitTimeout float64
maximumWaitDelay float64
maximumWebhookURLTimeout float64
defaultWaitTimeout float64
defaultWebhookURLTimeout float64
defaultListenPort int64
disableGoogleChrome bool
disableUnoconv bool
logLevel xlog.Level
}
func defaultConfig() Config {
return Config{
maximumWaitTimeout: 30.0,
maximumWaitDelay: 10.0,
maximumWebhookURLTimeout: 30.0,
defaultWaitTimeout: 10.0,
defaultWebhookURLTimeout: 10.0,
defaultListenPort: 3000,
disableGoogleChrome: false,
disableUnoconv: false,
logLevel: xlog.InfoLevel,
}
}
/*
FromEnv returns a Conf according
to environment variables.
*/
func FromEnv() (Config, error) {
const op string = "conf.FromEnv"
resolver := func() (Config, error) {
c := defaultConfig()
maximumWaitTimeout, err := xassert.Float64FromEnv(
maximumWaitTimeoutEnvVar,
c.maximumWaitTimeout,
xassert.Float64NotInferiorTo(0.0),
)
c.maximumWaitTimeout = maximumWaitTimeout
if err != nil {
return c, err
}
maximumWaitDelay, err := xassert.Float64FromEnv(
maximumWaitDelayEnvVar,
c.maximumWaitDelay,
xassert.Float64NotInferiorTo(0.0),
)
c.maximumWaitDelay = maximumWaitDelay
if err != nil {
return c, err
}
maximumWebhookURLTimeout, err := xassert.Float64FromEnv(
maximumWebhookURLTimeoutEnvVar,
c.maximumWebhookURLTimeout,
xassert.Float64NotInferiorTo(0.0),
)
c.maximumWebhookURLTimeout = maximumWebhookURLTimeout
if err != nil {
return c, err
}
defaultWaitTimeout, err := xassert.Float64FromEnv(
defaultWaitTimeoutEnvVar,
c.defaultWaitTimeout,
xassert.Float64NotInferiorTo(0.0),
xassert.Float64NotSuperiorTo(c.maximumWaitTimeout),
)
c.defaultWaitTimeout = defaultWaitTimeout
if err != nil {
return c, err
}
defaultWebhookURLTimeout, err := xassert.Float64FromEnv(
defaultWebhookURLTimeoutEnvVar,
c.defaultWebhookURLTimeout,
xassert.Float64NotInferiorTo(0.0),
xassert.Float64NotSuperiorTo(c.defaultWebhookURLTimeout),
)
c.defaultWebhookURLTimeout = defaultWebhookURLTimeout
if err != nil {
return c, err
}
defaultListenPort, err := xassert.Int64FromEnv(
defaultListenPortEnvVar,
c.defaultListenPort,
xassert.Int64NotInferiorTo(0),
xassert.Int64NotSuperiorTo(65535),
)
c.defaultListenPort = defaultListenPort
if err != nil {
return c, err
}
disableGoogleChrome, err := xassert.BoolFromEnv(
disableGoogleChromeEnvVar,
c.disableGoogleChrome,
)
c.disableGoogleChrome = disableGoogleChrome
if err != nil {
return c, err
}
disableUnoconv, err := xassert.BoolFromEnv(
disableUnoconvEnvVar,
c.disableUnoconv,
)
c.disableUnoconv = disableUnoconv
if err != nil {
return c, err
}
logLevel, err := xassert.StringFromEnv(
logLevelEnvVar,
string(c.logLevel),
xassert.StringOneOf(xlog.Levels()),
)
c.logLevel = xlog.MustParseLevel(logLevel)
if err != nil {
return c, err
}
return c, nil
}
result, err := resolver()
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
// MaximumWaitTimeout returns the maximum
// wait timeout from the configuration.
func (c Config) MaximumWaitTimeout() float64 {
return c.maximumWaitTimeout
}
// MaximumWaitDelay returns the maximum
// wait timeout from the configuration.
func (c Config) MaximumWaitDelay() float64 {
return c.maximumWaitDelay
}
// MaximumWebhookURLTimeout returns the maximum
// webhook URL wait timeout from the configuration.
func (c Config) MaximumWebhookURLTimeout() float64 {
return c.maximumWebhookURLTimeout
}
// DefaultWaitTimeout returns the default
// wait timeout from the configuration.
func (c Config) DefaultWaitTimeout() float64 {
return c.defaultWaitTimeout
}
// DefaultWebhookURLTimeout returns the default
// webhook URL wait timeout from the configuration.
func (c Config) DefaultWebhookURLTimeout() float64 {
return c.defaultWebhookURLTimeout
}
// DefaultListenPort returns the default
// listen port from the configuration.
func (c Config) DefaultListenPort() int64 {
return c.defaultListenPort
}
/*
DisableGoogleChrome returns true if
Google Chrome is disabled in the
configuration.
*/
func (c Config) DisableGoogleChrome() bool {
return c.disableGoogleChrome
}
/*
DisableUnoconv returns true if
Unoconv is disabled in the
configuration.
*/
func (c Config) DisableUnoconv() bool {
return c.disableUnoconv
}
// LogLevel returns the xlog.Level from
// the configuration.
func (c Config) LogLevel() xlog.Level {
return c.logLevel
}

View File

@@ -0,0 +1,334 @@
package conf
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestEmptyFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// no environment variables set,
// values should be equal to default config.
expected = defaultConfig()
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
}
func TestMaximumWaitTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// MAXIMUM_WAIT_TIMEOUT correctly set.
os.Setenv(maximumWaitTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.maximumWaitTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitTimeoutEnvVar)
// MAXIMUM_WAIT_TIMEOUT wrongly set.
os.Setenv(maximumWaitTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitTimeoutEnvVar)
// MAXIMUM_WAIT_TIMEOUT < 0.
os.Setenv(maximumWaitTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitTimeoutEnvVar)
}
func TestMaximumWaitDelayFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// MAXIMUM_WAIT_DELAY correctly set.
os.Setenv(maximumWaitDelayEnvVar, "10.0")
expected = defaultConfig()
expected.maximumWaitDelay = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitDelayEnvVar)
// MAXIMUM_WAIT_DELAY wrongly set.
os.Setenv(maximumWaitDelayEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitDelayEnvVar)
// MAXIMUM_WAIT_DELAY < 0.
os.Setenv(maximumWaitDelayEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWaitDelayEnvVar)
}
func TestMaximumWebhookURLTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// MAXIMUM_WEBHOOK_URL_TIMEOUT correctly set.
os.Setenv(maximumWebhookURLTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.maximumWebhookURLTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWebhookURLTimeoutEnvVar)
// MAXIMUM_WEBHOOK_URL_TIMEOUT wrongly set.
os.Setenv(maximumWebhookURLTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWebhookURLTimeoutEnvVar)
// MAXIMUM_WEBHOOK_URL_TIMEOUT < 0.
os.Setenv(maximumWebhookURLTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(maximumWebhookURLTimeoutEnvVar)
}
func TestDefaultWaitTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DEFAULT_WAIT_TIMEOUT correctly set.
os.Setenv(defaultWaitTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.defaultWaitTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
// DEFAULT_WAIT_TIMEOUT wrongly set.
os.Setenv(defaultWaitTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
// DEFAULT_WAIT_TIMEOUT < 0.
os.Setenv(defaultWaitTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
// DEFAULT_WAIT_TIMEOUT > MAXIMUM_WAIT_TIMEOUT.
os.Setenv(defaultWaitTimeoutEnvVar, "40.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWaitTimeoutEnvVar)
}
func TestDefaultWebhookURLTimeoutFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DEFAULT_WEBHOOK_URL_TIMEOUT correctly set.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "10.0")
expected = defaultConfig()
expected.defaultWebhookURLTimeout = 10.0
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
// DEFAULT_WEBHOOK_URL_TIMEOUT wrongly set.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
// DEFAULT_WEBHOOK_URL_TIMEOUT < 0.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
// DEFAULT_WEBHOOK_URL_TIMEOUT > MAXIMUM_WEBHOOK_URL_TIMEOUT.
os.Setenv(defaultWebhookURLTimeoutEnvVar, "40.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultWebhookURLTimeoutEnvVar)
}
func TestDefaultListenPortFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DEFAULT_LISTEN_PORT correctly set.
os.Setenv(defaultListenPortEnvVar, "80")
expected = defaultConfig()
expected.defaultListenPort = 80
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
// DEFAULT_LISTEN_PORT wrongly set.
os.Setenv(defaultListenPortEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
// DEFAULT_LISTEN_PORT < 0.
os.Setenv(defaultListenPortEnvVar, "-1.0")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
// DEFAULT_LISTEN_PORT > 65535.
os.Setenv(defaultListenPortEnvVar, "65536")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(defaultListenPortEnvVar)
}
func TestDisableGoogleChromeFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DISABLE_GOOGLE_CHROME correctly set.
os.Setenv(disableGoogleChromeEnvVar, "1")
expected = defaultConfig()
expected.disableGoogleChrome = true
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableGoogleChromeEnvVar)
os.Setenv(disableGoogleChromeEnvVar, "0")
expected = defaultConfig()
expected.disableGoogleChrome = false
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableGoogleChromeEnvVar)
// DISABLE_GOOGLE_CHROME wrongly set.
os.Setenv(disableGoogleChromeEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableGoogleChromeEnvVar)
}
func TestDisableUnoconvFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// DISABLE_UNOCONV correctly set.
os.Setenv(disableUnoconvEnvVar, "1")
expected = defaultConfig()
expected.disableUnoconv = true
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableUnoconvEnvVar)
os.Setenv(disableUnoconvEnvVar, "0")
expected = defaultConfig()
expected.disableUnoconv = false
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableUnoconvEnvVar)
// DISABLE_UNOCONV wrongly set.
os.Setenv(disableUnoconvEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(disableUnoconvEnvVar)
}
func TestLogLevelFromEnv(t *testing.T) {
var (
expected Config
result Config
err error
)
// LOG_LEVEL correctly set.
os.Setenv(logLevelEnvVar, "DEBUG")
expected = defaultConfig()
expected.logLevel = xlog.DebugLevel
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
os.Setenv(logLevelEnvVar, "INFO")
expected = defaultConfig()
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
os.Setenv(logLevelEnvVar, "ERROR")
expected = defaultConfig()
expected.logLevel = xlog.ErrorLevel
result, err = FromEnv()
assert.Nil(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
// LOG_LEVEL wrongly set.
os.Setenv(logLevelEnvVar, "foo")
expected = defaultConfig()
result, err = FromEnv()
xerrortest.AssertError(t, err)
assert.Equal(t, expected, result)
os.Unsetenv(logLevelEnvVar)
}
func TestGetters(t *testing.T) {
result := defaultConfig()
assert.Equal(t, result.maximumWaitTimeout, result.MaximumWaitTimeout())
assert.Equal(t, result.maximumWaitDelay, result.MaximumWaitDelay())
assert.Equal(t, result.maximumWebhookURLTimeout, result.MaximumWebhookURLTimeout())
assert.Equal(t, result.defaultWaitTimeout, result.DefaultWaitTimeout())
assert.Equal(t, result.defaultWebhookURLTimeout, result.DefaultWebhookURLTimeout())
assert.Equal(t, result.defaultListenPort, result.DefaultListenPort())
assert.Equal(t, result.disableGoogleChrome, result.DisableGoogleChrome())
assert.Equal(t, result.disableUnoconv, result.DisableUnoconv())
assert.Equal(t, result.logLevel, result.LogLevel())
}

3
internal/pkg/conf/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package conf gathers all
// configuration data.
package conf

View File

@@ -1,5 +0,0 @@
/*
Package notify helps displaying nice outputs
to the user.
*/
package notify

View File

@@ -1,35 +0,0 @@
package notify
import (
"fmt"
"os"
"github.com/labstack/gommon/color"
)
// Print prints a message to stdout.
func Print(message string) {
stdout := color.New()
stdout.SetOutput(os.Stdout)
stdout.Printf("⇨ %s\n", message)
}
// Printf prints a formatted message to stdout.
func Printf(format string, a ...interface{}) {
message := fmt.Sprintf(format, a...)
Print(message)
}
// WarnPrint prints a warning to stderr.
func WarnPrint(err error) {
stderr := color.New()
stderr.SetOutput(os.Stderr)
stderr.Printf("%s\n", color.Yellow(fmt.Sprintf("⇨ warn: %v", err)))
}
// ErrPrint prints an error to stderr.
func ErrPrint(err error) {
stderr := color.New()
stderr.SetOutput(os.Stderr)
stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err)))
}

View File

@@ -5,33 +5,72 @@ import (
"time"
"github.com/mafredri/cdp/devtool"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
type chrome struct {
manager *processManager
type chromeProcess struct {
logger xlog.Logger
}
// NewChrome retruns a Google Chrome
// NewChromeProcess returns a Google Chrome
// headless process.
func NewChrome() Process {
return &chrome{
manager: &processManager{},
func NewChromeProcess(logger xlog.Logger) Process {
return chromeProcess{
logger: logger,
}
}
func (p *chrome) Fullname() string {
func (p chromeProcess) Fullname() string {
return "Google Chrome headless"
}
func (p *chrome) Start() error {
return p.manager.start(p)
func (p chromeProcess) Start() error {
const op string = "pm2.chromeProcess.Start"
if err := start(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p *chrome) Shutdown() error {
return p.manager.shutdown(p)
func (p chromeProcess) IsViable() bool {
const op string = "pm2.chromeProcess.IsViable"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.logger.DebugfOp(
op,
"checking '%s' viability via endpoint '%s'",
p.Fullname(),
"http://localhost:9222/json/version",
)
v, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
p.logger.ErrorfOp(
op,
"'%s' is not viable as endpoint returned '%v'",
p.Fullname(),
err,
)
return false
}
p.logger.DebugfOp(
op,
"'%s' is viable as endpoint returned '%v'",
p.Fullname(),
v,
)
return true
}
func (p *chrome) args() []string {
func (p chromeProcess) Stop() error {
const op string = "pm2.chromeProcess.Stop"
if err := stop(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p chromeProcess) args() []string {
return []string{
"--no-sandbox",
"--headless",
@@ -50,23 +89,25 @@ func (p *chrome) args() []string {
}
}
func (p *chrome) name() string {
func (p chromeProcess) binary() string {
return "google-chrome-stable"
}
func (p *chrome) viable() bool {
// check if Google Chrome is correctly running.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err := devtool.New("http://localhost:9222").Version(ctx)
return err == nil
}
func (p *chrome) warmup() {
time.Sleep(5 * time.Second)
func (p chromeProcess) warmup() {
const (
op string = "pm2.chromeProcess.warmup"
warmupTime time.Duration = 10 * time.Second
)
p.logger.DebugfOp(
op,
"waiting '%v' for allowing '%s' to warmup",
warmupTime,
p.Fullname(),
)
time.Sleep(warmupTime)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(chrome))
_ = Process(new(chromeProcess))
)

View File

@@ -1,19 +0,0 @@
package pm2
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestChromeStart(t *testing.T) {
p := NewChrome()
err := p.Start()
require.Nil(t, err)
}
func TestChromeShutdown(t *testing.T) {
p := NewChrome()
err := p.Shutdown()
require.Nil(t, err)
}

View File

@@ -1,6 +1,6 @@
/*
Package pm2 facilitates starting external
processes on which our API depends.
processes on which our application depends.
For instance, it may start Google Chrome headless and
unoconv listener with PM2.

View File

@@ -2,82 +2,101 @@ package pm2
import (
"fmt"
"os/exec"
)
const (
stoppedState = iota
runningState
errorState
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// Process is a type that can start or
// shutdown a process with PM2.
// stop a process with PM2.
type Process interface {
Fullname() string
Start() error
Shutdown() error
IsViable() bool
Stop() error
args() []string
name() string
viable() bool
binary() string
warmup()
}
type processManager struct {
heuristicState int32
}
type pm2Command string
func (m *processManager) start(p Process) error {
if err := m.pm2(p, "start"); err != nil {
return err
}
p.warmup()
if !p.viable() {
attempts := 0
for attempts < 5 && !p.viable() {
if err := m.pm2(p, "restart"); err != nil {
m.heuristicState = errorState
return err
const (
startCommand pm2Command = "start"
restartCommand pm2Command = "restart"
stopCommand pm2Command = "stop"
logsCommand pm2Command = "logs"
)
func start(logger xlog.Logger, process Process) error {
const (
op string = "pm2.start"
maximumAttempts int = 3
)
resolver := func() error {
// first, we try to start the process.
if err := run(logger, startCommand, process); err != nil {
return err
}
// we wait the process to be ready.
process.warmup()
// if the process failed to start correctly,
// we have to restart it.
if !process.IsViable() {
attempts := 0
for attempts < maximumAttempts && !process.IsViable() {
if err := run(logger, restartCommand, process); err != nil {
return err
}
process.warmup()
attempts++
}
if !process.IsViable() {
return fmt.Errorf("failed to start '%s'", process.Fullname())
}
p.warmup()
attempts++
}
if !p.viable() {
m.heuristicState = errorState
return fmt.Errorf("failed to launch %s", p.Fullname())
// the process is viable, let's log its
// output.
if err := run(logger, logsCommand, process); err != nil {
return err
}
}
m.heuristicState = runningState
return nil
}
func (m *processManager) shutdown(p Process) error {
if m.heuristicState != runningState {
return nil
}
if err := m.pm2(p, "stop"); err != nil {
m.heuristicState = errorState
return err
if err := resolver(); err != nil {
return xerror.New(op, err)
}
m.heuristicState = stoppedState
return nil
}
func (m *processManager) pm2(p Process, cmdName string) error {
cmdArgs := []string{
cmdName,
p.name(),
}
if cmdName == "start" {
cmdArgs = append(cmdArgs, "--interpreter=none", "--")
cmdArgs = append(cmdArgs, p.args()...)
}
cmd := exec.Command(
"pm2",
cmdArgs...,
)
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s %s with PM2: %v", cmdName, p.Fullname(), err)
func stop(logger xlog.Logger, process Process) error {
const op string = "pm2.stop"
if err := run(logger, stopCommand, process); err != nil {
return xerror.New(op, err)
}
return nil
}
func run(logger xlog.Logger, pm2Cmd pm2Command, process Process) error {
const op string = "pm2.run"
resolver := func() error {
args := []string{
string(pm2Cmd),
process.binary(),
}
if pm2Cmd == startCommand {
args = append(args, "--interpreter=none", "--")
args = append(args, process.args()...)
}
cmd, err := xexec.Command(logger, "pm2", args...)
if err != nil {
return err
}
xexec.LogBeforeExecute(logger, cmd)
return cmd.Start()
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}

View File

@@ -1,52 +1,77 @@
package pm2
type unoconv struct {
manager *processManager
import (
"time"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
type unoconvProcess struct {
logger xlog.Logger
}
// NewUnoconv retruns a unoconv listener
// NewUnoconvProcess returns a unoconv listener
// process.
func NewUnoconv() Process {
return &unoconv{
manager: &processManager{},
func NewUnoconvProcess(logger xlog.Logger) Process {
return unoconvProcess{
logger: logger,
}
}
func (p *unoconv) Fullname() string {
func (p unoconvProcess) Fullname() string {
return "unoconv listener"
}
func (p *unoconv) Start() error {
return p.manager.start(p)
}
func (p *unoconv) Shutdown() error {
return p.manager.shutdown(p)
}
func (p *unoconv) args() []string {
return []string{
"--listener",
"--verbose",
func (p unoconvProcess) Start() error {
const op string = "pm2.unoconvProcess.Start"
if err := start(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p *unoconv) name() string {
return "unoconv"
}
func (p *unoconv) viable() bool {
func (p unoconvProcess) IsViable() bool {
// TODO find a way to check if
// the unoconv listener
// is correctly started?
return true
}
func (p *unoconv) warmup() {
// let's do nothing.
func (p unoconvProcess) Stop() error {
const op string = "pm2.unoconvProcess.Stop"
if err := stop(p.logger, p); err != nil {
return xerror.New(op, err)
}
return nil
}
func (p unoconvProcess) args() []string {
return []string{
"--listener",
"--verbose",
}
}
func (p unoconvProcess) binary() string {
return "unoconv"
}
func (p unoconvProcess) warmup() {
const (
op string = "pm2.unoconvProcess.warmup"
warmupTime time.Duration = 3 * time.Second
)
p.logger.DebugfOp(
op,
"waiting '%v' for allowing '%s' to warmup",
warmupTime,
p.Fullname(),
)
time.Sleep(warmupTime)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(unoconv))
_ = Process(new(unoconvProcess))
)

View File

@@ -1,19 +0,0 @@
package pm2
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUnoconvStart(t *testing.T) {
p := NewUnoconv()
err := p.Start()
require.Nil(t, err)
}
func TestUnoconvShutdown(t *testing.T) {
p := NewUnoconv()
err := p.Shutdown()
require.Nil(t, err)
}

View File

@@ -12,17 +12,22 @@ import (
"github.com/mafredri/cdp/protocol/page"
"github.com/mafredri/cdp/protocol/target"
"github.com/mafredri/cdp/rpcc"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
"golang.org/x/sync/errgroup"
)
type chrome struct {
url string
opts *ChromeOptions
type chromePrinter struct {
logger xlog.Logger
url string
opts ChromePrinterOptions
}
// ChromeOptions helps customizing the
// ChromePrinterOptions helps customizing the
// Google Chrome printer behaviour.
type ChromeOptions struct {
type ChromePrinterOptions struct {
WaitTimeout float64
WaitDelay float64
HeaderHTML string
@@ -36,120 +41,137 @@ type ChromeOptions struct {
Landscape bool
}
func (p *chrome) Print(destination string) error {
duration := time.Duration(p.opts.WaitTimeout+p.opts.WaitDelay) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), duration)
func (p chromePrinter) Print(destination string) error {
const op string = "printer.chromePrinter.Print"
logOptions(p.logger, p.opts)
ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout+p.opts.WaitDelay)
defer cancel()
devt, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
return err
resolver := func() error {
devt, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
return err
}
// connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol.
devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL)
if err != nil {
return err
}
defer devtConn.Close() // nolint: errcheck
// create a new CDP Client that uses conn.
devtClient := cdp.NewClient(devtConn)
newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx)
if err != nil {
return err
}
// create a new blank target with the new browser context.
createTargetArgs := target.
NewCreateTargetArgs("about:blank").
SetBrowserContextID(newContextTarget.BrowserContextID)
newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs)
if err != nil {
return err
}
// connect the client to the new target.
newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID)
newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL)
if err != nil {
return err
}
defer newContextConn.Close() // nolint: errcheck
// create a new CDP Client that uses newContextConn.
targetClient := cdp.NewClient(newContextConn)
closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID)
// close the target when done.
defer targetClient.Target.CloseTarget(ctx, closeTargetArgs) // nolint: errcheck
if err := runBatch(
// enable all the domain events that we're interested in.
func() error { return targetClient.DOM.Enable(ctx) },
func() error { return targetClient.Network.Enable(ctx, network.NewEnableArgs()) },
func() error { return targetClient.Page.Enable(ctx) },
func() error { return targetClient.Runtime.Enable(ctx) },
); err != nil {
return err
}
if err := p.navigate(ctx, targetClient); err != nil {
return err
}
print, err := targetClient.Page.PrintToPDF(
ctx,
page.NewPrintToPDFArgs().
SetPaperWidth(p.opts.PaperWidth).
SetPaperHeight(p.opts.PaperHeight).
SetMarginTop(p.opts.MarginTop).
SetMarginBottom(p.opts.MarginBottom).
SetMarginLeft(p.opts.MarginLeft).
SetMarginRight(p.opts.MarginRight).
SetLandscape(p.opts.Landscape).
SetDisplayHeaderFooter(true).
SetHeaderTemplate(p.opts.HeaderHTML).
SetFooterTemplate(p.opts.FooterHTML).
SetPrintBackground(true),
)
if err != nil {
return err
}
if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil {
return err
}
return nil
}
// connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol.
devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL)
if err != nil {
return err
}
defer devtConn.Close() // nolint: errcheck
// create a new CDP Client that uses conn.
devtClient := cdp.NewClient(devtConn)
newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx)
if err != nil {
return fmt.Errorf("creating new browser context: %v", err)
}
// create a new blank target with the new browser context.
createTargetArgs := target.
NewCreateTargetArgs("about:blank").
SetBrowserContextID(newContextTarget.BrowserContextID)
newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs)
if err != nil {
return fmt.Errorf("creating new blank target: %v", err)
}
// connect the client to the new target.
newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID)
newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL)
if err != nil {
return fmt.Errorf("connecting client to blank target: %v", err)
}
defer newContextConn.Close() // nolint: errcheck
// create a new CDP Client that uses newContextConn.
targetClient := cdp.NewClient(newContextConn)
closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID)
// close the target when done.
defer targetClient.Target.CloseTarget(ctx, closeTargetArgs) // nolint: errcheck
if err := runBatch(
// enable all the domain events that we're interested in.
func() error { return targetClient.DOM.Enable(ctx) },
func() error { return targetClient.Network.Enable(ctx, network.NewEnableArgs()) },
func() error { return targetClient.Page.Enable(ctx) },
func() error { return targetClient.Runtime.Enable(ctx) },
); err != nil {
return err
}
if err := p.navigate(ctx, targetClient); err != nil {
return err
}
print, err := targetClient.Page.PrintToPDF(
ctx,
page.NewPrintToPDFArgs().
SetPaperWidth(p.opts.PaperWidth).
SetPaperHeight(p.opts.PaperHeight).
SetMarginTop(p.opts.MarginTop).
SetMarginBottom(p.opts.MarginBottom).
SetMarginLeft(p.opts.MarginLeft).
SetMarginRight(p.opts.MarginRight).
SetLandscape(p.opts.Landscape).
SetDisplayHeaderFooter(true).
SetHeaderTemplate(p.opts.HeaderHTML).
SetFooterTemplate(p.opts.FooterHTML).
SetPrintBackground(true),
)
if err != nil {
return fmt.Errorf("printing page to PDF: %v", err)
}
if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil {
return fmt.Errorf("%s: writing file: %v", destination, err)
if err := resolver(); err != nil {
return xcontext.MustHandleError(
ctx,
xerror.New(op, err),
)
}
return nil
}
func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error {
// make sure Page events are enabled.
if err := client.Page.Enable(ctx); err != nil {
return err
func (p chromePrinter) navigate(ctx context.Context, client *cdp.Client) error {
const op string = "printer.chromePrinter.navigate"
resolver := func() error {
// make sure Page events are enabled.
if err := client.Page.Enable(ctx); err != nil {
return err
}
// make sure Network events are enabled.
if err := client.Network.Enable(ctx, nil); err != nil {
return err
}
// create all clients for events.
domContentEventFired, err := client.Page.DOMContentEventFired(ctx)
if err != nil {
return err
}
defer domContentEventFired.Close() // nolint: errcheck
loadEventFired, err := client.Page.LoadEventFired(ctx)
if err != nil {
return err
}
defer loadEventFired.Close() // nolint: errcheck
loadingFinished, err := client.Network.LoadingFinished(ctx)
if err != nil {
return err
}
defer loadingFinished.Close() // nolint: errcheck
if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil {
return err
}
if err := runBatch(
// wait for all events.
func() error { _, err := domContentEventFired.Recv(); return err },
func() error { _, err := loadEventFired.Recv(); return err },
func() error { _, err := loadingFinished.Recv(); return err },
); err != nil {
return err
}
// wait for a given amount of time (useful for javascript delay).
time.Sleep(xtime.Duration(p.opts.WaitDelay))
return nil
}
// make sure Network events are enabled.
if err := client.Network.Enable(ctx, nil); err != nil {
return err
if err := resolver(); err != nil {
return xerror.New(op, err)
}
// create all clients for events.
domContentEventFired, err := client.Page.DOMContentEventFired(ctx)
if err != nil {
return err
}
defer domContentEventFired.Close() // nolint: errcheck
loadEventFired, err := client.Page.LoadEventFired(ctx)
if err != nil {
return err
}
defer loadEventFired.Close() // nolint: errcheck
loadingFinished, err := client.Network.LoadingFinished(ctx)
if err != nil {
return err
}
defer loadingFinished.Close() // nolint: errcheck
if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil {
return err
}
if err := runBatch(
// wait for all events.
func() error { _, err := domContentEventFired.Recv(); return err },
func() error { _, err := loadEventFired.Recv(); return err },
func() error { _, err := loadingFinished.Recv(); return err },
); err != nil {
return err
}
// wait for a given amount of time (useful for javascript delay).
time.Sleep(time.Duration(p.opts.WaitDelay) * time.Second)
return nil
}
@@ -165,5 +187,5 @@ func runBatch(fn ...func() error) error {
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(chrome))
_ = Printer(new(chromePrinter))
)

View File

@@ -1,5 +1,3 @@
/*
Package printer contains structs which convert
a specific file type to PDF.
*/
// Package printer helps converting
// a specific file type to PDF.
package printer

View File

@@ -2,13 +2,17 @@ package printer
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// NewHTML returns an HTML printer.
func NewHTML(fpath string, opts *ChromeOptions) Printer {
// NewHTMLPrinter returns a Printer which
// is able to convert an HTML file to PDF.
func NewHTMLPrinter(logger xlog.Logger, fpath string, opts ChromePrinterOptions) Printer {
URL := fmt.Sprintf("file://%s", fpath)
return &chrome{
url: URL,
opts: opts,
return chromePrinter{
logger: logger,
url: URL,
opts: opts,
}
}

View File

@@ -9,36 +9,46 @@ import (
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
)
// NewMarkdown returns a Markdown printer.
func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) {
tmpl, err := template.
New(filepath.Base(fpath)).
Funcs(template.FuncMap{"toHTML": markdownToHTML}).
ParseFiles(fpath)
// NewMarkdownPrinter returns a Printer which
// is able to convert Markdown files to PDF.
func NewMarkdownPrinter(logger xlog.Logger, fpath string, opts ChromePrinterOptions) (Printer, error) {
const op string = "printer.NewMarkdownPrinter"
resolver := func() (string, error) {
tmpl, err := template.
New(filepath.Base(fpath)).
Funcs(template.FuncMap{"toHTML": markdownToHTML}).
ParseFiles(fpath)
if err != nil {
return "", err
}
dirPath := filepath.Dir(fpath)
data := &templateData{DirPath: dirPath}
logger.DebugOp(op, "converting Markdown files to HTML...")
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return "", err
}
baseFilename := xrand.Get()
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
logger.DebugOp(op, "writing the HTML from previous conversion into new file...")
if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil {
return "", err
}
return fmt.Sprintf("file://%s", dst), nil
}
URL, err := resolver()
if err != nil {
return nil, fmt.Errorf("%s: parsing template: %v", fpath, err)
return chromePrinter{}, xerror.New(op, err)
}
dirPath := filepath.Dir(fpath)
data := &templateData{DirPath: dirPath}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return nil, fmt.Errorf("%s: executing template: %v", fpath, err)
}
baseFilename, err := rand.Get()
if err != nil {
return nil, err
}
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil {
return nil, fmt.Errorf("%s: writing file: %v", dst, err)
}
URL := fmt.Sprintf("file://%s", dst)
return &chrome{
url: URL,
opts: opts,
return chromePrinter{
logger: logger,
url: URL,
opts: opts,
}, nil
}
@@ -47,10 +57,11 @@ type templateData struct {
}
func markdownToHTML(dirPath, filename string) (template.HTML, error) {
const op string = "printer.markdownToHTML"
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading file: %v", fpath, err)
return "", xerror.New(op, err)
}
unsafe := blackfriday.Run(b)
content := bluemonday.UGCPolicy().SanitizeBytes(unsafe)

View File

@@ -2,49 +2,71 @@ package printer
import (
"context"
"fmt"
"os/exec"
"time"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
type merge struct {
type mergePrinter struct {
ctx context.Context
logger xlog.Logger
fpaths []string
opts *MergeOptions
opts MergePrinterOptions
}
// MergeOptions helps customizing the
// merge printer behaviour.
type MergeOptions struct {
// MergePrinterOptions helps customizing the
// merge Printer behaviour.
type MergePrinterOptions struct {
WaitTimeout float64
}
// NewMerge returns a merge printer.
func NewMerge(fpaths []string, opts *MergeOptions) Printer {
return &merge{
// NewMergePrinter returns a Printer which
// is able to merge PDFs.
func NewMergePrinter(logger xlog.Logger, fpaths []string, opts MergePrinterOptions) Printer {
return mergePrinter{
logger: logger,
fpaths: fpaths,
opts: opts,
}
}
func (p *merge) Print(destination string) error {
func (p mergePrinter) Print(destination string) error {
const op string = "printer.mergePrinter.Print"
/*
context.Context may be providen from
an officePrinter which needs to merge
its result files.
*/
if p.ctx == nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second)
logOptions(p.logger, p.opts)
ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout)
defer cancel()
p.ctx = ctx
}
var cmdArgs []string
cmdArgs = append(cmdArgs, p.fpaths...)
cmdArgs = append(cmdArgs, "cat", "output", destination)
cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...)
_, err := cmd.Output()
if err != nil {
return fmt.Errorf("pdtk: %v", err)
p.logger.DebugfOp(op, "merging '%v'...", p.fpaths)
resolver := func() error {
var args []string
args = append(args, p.fpaths...)
args = append(args, "cat", "output", destination)
cmd, err := xexec.CommandContext(p.ctx, p.logger, "pdftk", args...)
if err != nil {
return err
}
xexec.LogBeforeExecute(p.logger, cmd)
return cmd.Run()
}
if err := resolver(); err != nil {
return xcontext.MustHandleError(
p.ctx,
xerror.New(op, err),
)
}
return nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(merge))
_ = Printer(new(mergePrinter))
)

View File

@@ -0,0 +1,46 @@
package printer
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/test/internalpkg/printertest"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest"
)
func TestMergePrinter(t *testing.T) {
var (
logger xlog.Logger = xlogtest.DebugLogger()
fpaths []string = printertest.MergeFpaths(t)
opts MergePrinterOptions
dest string
p Printer
err error
)
// default options.
opts = MergePrinterOptions{
WaitTimeout: 10.0,
}
p = NewMergePrinter(logger, fpaths, opts)
dest = printertest.GenerateDestination()
err = p.Print(dest)
assert.Nil(t, err)
err = os.RemoveAll(dest)
assert.Nil(t, err)
// should not be OK as context.Context
// should timeout.
opts = MergePrinterOptions{
WaitTimeout: 0.0,
}
p = NewMergePrinter(logger, fpaths, opts)
dest = printertest.GenerateDestination()
err = p.Print(dest)
xerrortest.AssertError(t, err)
assert.Equal(t, xerror.TimeoutCode, xerror.Code(err))
err = os.RemoveAll(dest)
assert.Nil(t, err)
}

View File

@@ -4,87 +4,125 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/internal/pkg/xcontext"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xexec"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
)
type office struct {
type officePrinter struct {
logger xlog.Logger
fpaths []string
opts *OfficeOptions
opts OfficePrinterOptions
}
// OfficeOptions helps customizing the
// Office printer behaviour.
type OfficeOptions struct {
// OfficePrinterOptions helps customizing the
// Office Printer behaviour.
type OfficePrinterOptions struct {
WaitTimeout float64
Landscape bool
}
// NewOffice returns an Office printer.
func NewOffice(fpaths []string, opts *OfficeOptions) Printer {
return &office{
// NewOfficePrinter returns a Printer which
// is able to convert Office documents to PDF.
func NewOfficePrinter(logger xlog.Logger, fpaths []string, opts OfficePrinterOptions) Printer {
return officePrinter{
logger: logger,
fpaths: fpaths,
opts: opts,
}
}
func (p *office) Print(destination string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second)
func (p officePrinter) Print(destination string) error {
const op string = "printer.officePrinter.Print"
logOptions(p.logger, p.opts)
ctx, cancel := xcontext.WithTimeout(p.logger, p.opts.WaitTimeout)
defer cancel()
fpaths := make([]string, len(p.fpaths))
dirPath := filepath.Dir(destination)
for i, fpath := range p.fpaths {
baseFilename, err := rand.Get()
if err != nil {
return err
resolver := func() error {
fpaths := make([]string, len(p.fpaths))
dirPath := filepath.Dir(destination)
for i, fpath := range p.fpaths {
baseFilename := xrand.Get()
tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename)
p.logger.DebugfOp(op, "converting '%s' to PDF...", fpath)
if err := unoconv(ctx, p.logger, fpath, tmpDest, p.opts); err != nil {
return err
}
p.logger.DebugfOp(op, "'%s.pdf' created", baseFilename)
fpaths[i] = tmpDest
}
tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename)
if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil {
return err
if len(fpaths) == 1 {
p.logger.DebugOp(op, "only one PDF created, nothing to merge")
if err := os.Rename(fpaths[0], destination); err != nil {
return err
}
return nil
}
fpaths[i] = tmpDest
m := mergePrinter{
logger: p.logger,
ctx: ctx,
fpaths: fpaths,
}
return m.Print(destination)
}
if len(fpaths) == 1 {
return os.Rename(fpaths[0], destination)
}
m := &merge{
ctx: ctx,
fpaths: fpaths,
}
return m.Print(destination)
}
// nolint: gochecknoglobals
var mu sync.Mutex
func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions) error {
mu.Lock()
defer mu.Unlock()
cmdArgs := []string{
"--format",
"pdf",
}
if opts.Landscape {
cmdArgs = append(cmdArgs, "--printer", "PaperOrientation=landscape")
}
cmdArgs = append(cmdArgs, "--output", destination, fpath)
cmd := exec.CommandContext(
ctx,
"unoconv",
cmdArgs...,
)
_, err := cmd.Output()
if err != nil {
return fmt.Errorf("unoconv: %v", err)
if err := resolver(); err != nil {
return xcontext.MustHandleError(
ctx,
xerror.New(op, err),
)
}
return nil
}
// nolint: gochecknoglobals
var lock = make(chan struct{}, 1)
func unoconv(ctx context.Context, logger xlog.Logger, fpath, destination string, opts OfficePrinterOptions) error {
const op string = "printer.unoconv"
resolver := func() error {
args := []string{
"--format",
"pdf",
}
if opts.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape")
}
args = append(args, "--output", destination, fpath)
cmd, err := xexec.CommandContext(
ctx,
logger,
"unoconv",
args...,
)
if err != nil {
return err
}
xexec.LogBeforeExecute(logger, cmd)
return cmd.Run()
}
logger.DebugOp(op, "waiting lock to be acquired...")
select {
case lock <- struct{}{}:
// lock acquired.
logger.DebugOp(op, "lock acquired")
if err := resolver(); err != nil {
<-lock // we release the lock.
return xerror.New(op, err)
}
<-lock // we release the lock.
return nil
case <-ctx.Done():
// failed to acquire lock before
// deadline.
logger.DebugOp(op, "failed to acquire lock before context.Context deadline")
return xerror.New(op, ctx.Err())
}
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(office))
_ = Printer(new(officePrinter))
)

View File

@@ -0,0 +1,70 @@
package printer
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/test/internalpkg/printertest"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest"
)
func TestOfficePrinter(t *testing.T) {
var (
logger xlog.Logger = xlogtest.DebugLogger()
fpaths []string = printertest.OfficeFpaths(t)
opts OfficePrinterOptions
dest string
p Printer
err error
)
// default options.
opts = OfficePrinterOptions{
WaitTimeout: 10.0,
Landscape: false,
}
p = NewOfficePrinter(logger, fpaths, opts)
dest = printertest.GenerateDestination()
err = p.Print(dest)
assert.Nil(t, err)
err = os.RemoveAll(dest)
assert.Nil(t, err)
// using one file.
opts = OfficePrinterOptions{
WaitTimeout: 10.0,
Landscape: false,
}
p = NewOfficePrinter(logger, []string{fpaths[0]}, opts)
dest = printertest.GenerateDestination()
err = p.Print(dest)
assert.Nil(t, err)
err = os.RemoveAll(dest)
assert.Nil(t, err)
// options with landscape.
opts = OfficePrinterOptions{
WaitTimeout: 10.0,
Landscape: true,
}
p = NewOfficePrinter(logger, fpaths, opts)
dest = printertest.GenerateDestination()
err = p.Print(dest)
assert.Nil(t, err)
err = os.RemoveAll(dest)
assert.Nil(t, err)
// should not be OK as context.Context
// should timeout.
opts = OfficePrinterOptions{
WaitTimeout: 0.0,
Landscape: true,
}
p = NewOfficePrinter(logger, fpaths, opts)
dest = printertest.GenerateDestination()
err = p.Print(dest)
xerrortest.AssertError(t, err)
assert.Equal(t, xerror.TimeoutCode, xerror.Code(err))
err = os.RemoveAll(dest)
assert.Nil(t, err)
}

View File

@@ -1,7 +1,16 @@
package printer
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// Printer is a type that can create a PDF file from a source.
// The source is defined in the underlying implementation.
type Printer interface {
Print(destination string) error
}
func logOptions(logger xlog.Logger, opts interface{}) {
const op string = "printer.logOptions"
logger.DebugfOp(op, "options: %+v", opts)
}

View File

@@ -1,9 +1,15 @@
package printer
// NewURL returns a URL printer.
func NewURL(url string, opts *ChromeOptions) Printer {
return &chrome{
url: url,
opts: opts,
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// NewURLPrinter returns a Printer which
// is able to convert a URL to PDF.
func NewURLPrinter(logger xlog.Logger, url string, opts ChromePrinterOptions) Printer {
return chromePrinter{
logger: logger,
url: url,
opts: opts,
}
}

View File

@@ -1,7 +0,0 @@
/*
Package rand helps generating a random string.
It should be used for creating directory and
file names in order to avoid collision.
*/
package rand

View File

@@ -1,17 +0,0 @@
package rand
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// Get returns a random string.
func Get() (string, error) {
randBytes := make([]byte, 16)
_, err := rand.Read(randBytes)
if err != nil {
return "", fmt.Errorf("creating random string: %v", err)
}
return hex.EncodeToString(randBytes), nil
}

View File

@@ -1,16 +0,0 @@
package rand
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGet(t *testing.T) {
rand1, err := Get()
require.Nil(t, err)
rand2, err := Get()
require.Nil(t, err)
assert.NotEqual(t, rand1, rand2)
}

View File

@@ -0,0 +1,8 @@
/*
Package xassert is a helper for converting
and/or validating strings.
All functions return our standard xerror.Error
in case of error.
*/
package xassert

View File

@@ -0,0 +1,88 @@
package xassert
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// RuleFloat64 is an interface for
// validating a float64.
type RuleFloat64 interface {
with(key string, value float64)
validate() error
}
type baseRuleFloat64 struct {
key string
value float64
}
func (r *baseRuleFloat64) with(key string, value float64) {
r.key = key
r.value = value
}
type ruleFloat64NotInferiorTo struct {
*baseRuleFloat64
lowerBound float64
}
func (r ruleFloat64NotInferiorTo) validate() error {
const op string = "xassert.ruleFloat64NotInferiorTo.validate"
if r.value < r.lowerBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be > '%f', got '%f'", r.key, r.lowerBound, r.value),
nil,
)
}
return nil
}
/*
Float64NotInferiorTo returns a RuleFloat64 for
validating that a float64 is not inferior to
given lower bound.
*/
func Float64NotInferiorTo(lowerBound float64) RuleFloat64 {
return ruleFloat64NotInferiorTo{
&baseRuleFloat64{},
lowerBound,
}
}
type ruleFloat64NotSuperiorTo struct {
*baseRuleFloat64
upperBound float64
}
func (r ruleFloat64NotSuperiorTo) validate() error {
const op string = "xassert.ruleFloat64NotSuperiorTo.validate"
if r.value > r.upperBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be < '%f', got '%f'", r.key, r.upperBound, r.value),
nil,
)
}
return nil
}
/*
Float64NotSuperiorTo returns a RuleFloat64 for
validating that a float64 is not superior to
given upper bound.
*/
func Float64NotSuperiorTo(upperBound float64) RuleFloat64 {
return ruleFloat64NotSuperiorTo{
&baseRuleFloat64{},
upperBound,
}
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = RuleFloat64(new(ruleFloat64NotInferiorTo))
_ = RuleFloat64(new(ruleFloat64NotSuperiorTo))
)

View File

@@ -0,0 +1,32 @@
package xassert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestFloat64NotInferiorTo(t *testing.T) {
rule := Float64NotInferiorTo(0.0)
// should be OK.
rule.with("FOO", 10.0)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", -10.0)
err = rule.validate()
xerrortest.AssertError(t, err)
}
func TestFloat64NotSuperiorTo(t *testing.T) {
rule := Float64NotSuperiorTo(0.0)
// should be OK.
rule.with("FOO", -10.0)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", 10.0)
err = rule.validate()
xerrortest.AssertError(t, err)
}

View File

@@ -0,0 +1,88 @@
package xassert
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// RuleInt64 is an interface for
// validating an int64.
type RuleInt64 interface {
with(key string, value int64)
validate() error
}
type baseRuleInt64 struct {
key string
value int64
}
func (r *baseRuleInt64) with(key string, value int64) {
r.key = key
r.value = value
}
type ruleInt64NotInferiorTo struct {
*baseRuleInt64
lowerBound int64
}
func (r ruleInt64NotInferiorTo) validate() error {
const op string = "xassert.ruleInt64NotInferiorTo.validate"
if r.value < r.lowerBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be > '%d', got '%d'", r.key, r.lowerBound, r.value),
nil,
)
}
return nil
}
/*
Int64NotInferiorTo returns a RuleInt64 for
validating that an int64 is not inferior to
given lower bound.
*/
func Int64NotInferiorTo(lowerBound int64) RuleInt64 {
return &ruleInt64NotInferiorTo{
&baseRuleInt64{},
lowerBound,
}
}
type ruleInt64NotSuperiorTo struct {
*baseRuleInt64
upperBound int64
}
func (r ruleInt64NotSuperiorTo) validate() error {
const op string = "xassert.ruleInt64NotSuperiorTo.validate"
if r.value > r.upperBound {
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be < '%d', got '%d'", r.key, r.upperBound, r.value),
nil,
)
}
return nil
}
/*
Int64NotSuperiorTo returns a RuleInt64 for
validating that an int64 is not superior to
given upper bound.
*/
func Int64NotSuperiorTo(upperBound int64) RuleInt64 {
return ruleInt64NotSuperiorTo{
&baseRuleInt64{},
upperBound,
}
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = RuleInt64(new(ruleInt64NotInferiorTo))
_ = RuleInt64(new(ruleInt64NotSuperiorTo))
)

View File

@@ -0,0 +1,32 @@
package xassert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestInt64NotInferiorTo(t *testing.T) {
rule := Int64NotInferiorTo(0)
// should be OK.
rule.with("FOO", 10)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", -10)
err = rule.validate()
xerrortest.AssertError(t, err)
}
func TestInt64NotSuperiorTo(t *testing.T) {
rule := Int64NotSuperiorTo(0)
// should be OK.
rule.with("FOO", -10)
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", 10)
err = rule.validate()
xerrortest.AssertError(t, err)
}

View File

@@ -0,0 +1,60 @@
package xassert
import (
"fmt"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// RuleString is an interface for
// validating a string.
type RuleString interface {
with(key, value string)
validate() error
}
type baseRuleString struct {
key string
value string
}
func (r *baseRuleString) with(key, value string) {
r.key = key
r.value = value
}
type ruleStringOneOf struct {
*baseRuleString
values []string
}
func (r ruleStringOneOf) validate() error {
const op string = "xassert.ruleStringOneOf.validate"
for _, v := range r.values {
if r.value == v {
return nil
}
}
return xerror.Invalid(
op,
fmt.Sprintf("'%s' should be one of '%v', got '%s'", r.key, r.values, r.value),
nil,
)
}
/*
StringOneOf returns a RuleString for
validating that a string is one of given
values.
*/
func StringOneOf(values []string) RuleString {
return ruleStringOneOf{
&baseRuleString{},
values,
}
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = RuleString(new(ruleStringOneOf))
)

View File

@@ -0,0 +1,20 @@
package xassert
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestStringOfOne(t *testing.T) {
rule := StringOneOf([]string{"foo", "bar", "baz"})
// should be OK.
rule.with("FOO", "foo")
err := rule.validate()
assert.Nil(t, err)
// should not be OK.
rule.with("FOO", "qux")
err = rule.validate()
xerrortest.AssertError(t, err)
}

View File

@@ -0,0 +1,185 @@
package xassert
import (
"fmt"
"os"
"strconv"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
/*
String applies validation on a string.
If string is empty or validation fails,
returns the default value.
The key is used to identify the value.
*/
func String(key, value, defaultValue string, rules ...RuleString) (string, error) {
const op string = "xassert.String"
result := defaultValue
if value != "" {
result = value
}
for _, rule := range rules {
rule.with(key, result)
if err := rule.validate(); err != nil {
return defaultValue, xerror.New(op, err)
}
}
return result, nil
}
/*
StringFromEnv returns the value of given environment
variable or the default value if not found or
validation fails.
*/
func StringFromEnv(envVar, defaultValue string, rules ...RuleString) (string, error) {
const op string = "xassert.StringFromEnv"
value := os.Getenv(envVar)
result, err := String(envVar, value, defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Int64 tries to convert a string to an int64.
If string is empty, conversion or validation fails,
returns the default value.
The key is used to identify the value.
*/
func Int64(key, value string, defaultValue int64, rules ...RuleInt64) (int64, error) {
const op string = "xassert.Int64"
result := defaultValue
if value != "" {
parsedValue, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return defaultValue, xerror.Invalid(
op,
fmt.Sprintf("'%s' is not an integer, got '%s'", key, value),
err,
)
}
result = parsedValue
}
for _, rule := range rules {
rule.with(key, result)
if err := rule.validate(); err != nil {
return defaultValue, xerror.New(op, err)
}
}
return result, nil
}
/*
Int64FromEnv returns the int64 representation of the
value of given environment variable.
If not found, empty, conversion or validation fails,
returns the default value.
*/
func Int64FromEnv(envVar string, defaultValue int64, rules ...RuleInt64) (int64, error) {
const op string = "xassert.Int64FromEnv"
value := os.Getenv(envVar)
result, err := Int64(envVar, value, defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Float64 tries to convert a string to a float64.
If string is empty, conversion or validation fails,
returns the default value.
The key is used to identify the value.
*/
func Float64(key, value string, defaultValue float64, rules ...RuleFloat64) (float64, error) {
const op string = "xassert.Float64"
result := defaultValue
if value != "" {
parsedValue, err := strconv.ParseFloat(value, 64)
if err != nil {
return defaultValue, xerror.Invalid(
op,
fmt.Sprintf("'%s' is not a float, got '%s'", key, value),
err,
)
}
result = parsedValue
}
for _, rule := range rules {
rule.with(key, result)
if err := rule.validate(); err != nil {
return defaultValue, xerror.New(op, err)
}
}
return result, nil
}
/*
Float64FromEnv returns the float64 representation of the
value of given environment variable.
If not found, empty, conversion or validation fails,
returns the default value.
*/
func Float64FromEnv(envVar string, defaultValue float64, rules ...RuleFloat64) (float64, error) {
const op string = "xassert.Float64FromEnv"
value := os.Getenv(envVar)
result, err := Float64(envVar, value, defaultValue, rules...)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}
/*
Bool tries to convert a string to a boolean.
If string is empty or conversion fails, returns the
default value.
The key is used to identify the value.
*/
func Bool(key, value string, defaultValue bool) (bool, error) {
const op string = "xassert.Bool"
result := defaultValue
if value != "" {
parsedValue, err := strconv.ParseBool(value)
if err != nil {
return defaultValue, xerror.Invalid(
op,
fmt.Sprintf("'%s' is not a boolean, got '%s'", key, value),
err,
)
}
result = parsedValue
}
return result, nil
}
/*
BoolFromEnv returns the boolean representation of the
value of given environment variable.
If not found, empty or conversion fails, returns the
default value.
*/
func BoolFromEnv(envVar string, defaultValue bool) (bool, error) {
const op string = "xassert.BoolFromEnv"
value := os.Getenv(envVar)
result, err := Bool(envVar, value, defaultValue)
if err != nil {
return result, xerror.New(op, err)
}
return result, nil
}

View File

@@ -0,0 +1,289 @@
package xassert
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
)
func TestString(t *testing.T) {
const (
defaultValue string = "FOO"
)
var expected string
rule := StringOneOf([]string{"FOO", "BAR"})
// empty value, result should be equal
// to the default value.
v, err := String("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// as it is one of "FOO" and "BAR".
expected = "FOO"
v, err = String("foo", expected, defaultValue, rule)
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// one of "FOO" and "BAR".
v, err = String("foo", "BAZ", defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestStringFromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue string = "FOO"
)
var expected string
rule := StringOneOf([]string{"FOO", "BAR"})
// no environment variable set,
// value should be equal to default value.
v, err := StringFromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value as it is one of "FOO" and "BAR".
expected = "BAR"
os.Setenv(envVar, expected)
v, err = StringFromEnv(envVar, defaultValue, rule)
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not one of "FOO" and "BAR".
os.Setenv(envVar, "BAZ")
v, err = StringFromEnv(envVar, defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}
func TestInt64(t *testing.T) {
const (
defaultValue int64 = 10
)
var expected int64
rule := Int64NotInferiorTo(6)
// empty value, result should be equal
// to the default value.
v, err := Int64("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// but as integer.
v, err = Int64("foo", "5", defaultValue)
expected = 5
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// a string representation of an integer.
v, err = Int64("foo", "foo", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
// should not be OK as given value does not
// validate the rule x >= 6.
v, err = Int64("foo", "5", defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestInt64FromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue int64 = 10
)
var expected int64
rule := Int64NotInferiorTo(6)
// no environment variable set,
// value should be equal to default value.
v, err := Int64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value but as integer.
os.Setenv(envVar, "5")
v, err = Int64FromEnv(envVar, defaultValue)
expected = 5
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not a string representation of an integer.
os.Setenv(envVar, "foo")
v, err = Int64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value does not validate the rule x >= 6.
os.Setenv(envVar, "5")
v, err = Int64FromEnv(envVar, defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}
func TestFloat64(t *testing.T) {
const defaultValue float64 = 10.0
var expected float64
rule := Float64NotInferiorTo(6.0)
// empty value, result should be equal
// to the default value.
v, err := Float64("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// but as float.
v, err = Float64("foo", "5.5", defaultValue)
expected = 5.5
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// a string representation of a float.
v, err = Float64("foo", "foo", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
// should not be OK as given value does not
// validate the rule x >= 6.
v, err = Float64("foo", "5", defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestFloat64FromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue float64 = 10.0
)
var expected float64
rule := Float64NotInferiorTo(6.0)
// no environment variable set,
// value should be equal to default value.
v, err := Float64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value but as float.
os.Setenv(envVar, "5.5")
v, err = Float64FromEnv(envVar, defaultValue)
expected = 5.5
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not a string representation of a float.
os.Setenv(envVar, "foo")
v, err = Float64FromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value does not validate the rule x >= 6.
os.Setenv(envVar, "5")
v, err = Float64FromEnv(envVar, defaultValue, rule)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}
func TestBool(t *testing.T) {
const defaultValue bool = true
var expected bool
// empty value, result should be equal
// to the default value.
v, err := Bool("foo", "", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to given value
// but as boolean.
v, err = Bool("foo", "1", defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
v, err = Bool("foo", "true", defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
v, err = Bool("foo", "0", defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
v, err = Bool("foo", "false", defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
// should not be OK as given value is not
// a string representation of a boolean.
v, err = Bool("foo", "foo", defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
}
func TestBoolFromEnv(t *testing.T) {
const (
envVar string = "FOO"
defaultValue bool = true
)
var expected bool
// no environment variable set,
// value should be equal to default value.
v, err := BoolFromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
assert.Nil(t, err)
// result should be equal to environment variable
// value but as boolean.
os.Setenv(envVar, "1")
v, err = BoolFromEnv(envVar, defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
os.Setenv(envVar, "true")
v, err = BoolFromEnv(envVar, defaultValue)
expected = true
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
os.Setenv(envVar, "0")
v, err = BoolFromEnv(envVar, defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
os.Setenv(envVar, "false")
v, err = BoolFromEnv(envVar, defaultValue)
expected = false
assert.Equal(t, expected, v)
assert.Nil(t, err)
os.Unsetenv(envVar)
// should not be OK as environment variable
// value is not a string representation of a boolean.
os.Setenv(envVar, "foo")
v, err = BoolFromEnv(envVar, defaultValue)
expected = defaultValue
assert.Equal(t, expected, v)
xerrortest.AssertError(t, err)
os.Unsetenv(envVar)
}

View File

@@ -0,0 +1,3 @@
// Package xcontext helps managing
// context.Context with timeout.
package xcontext

View File

@@ -0,0 +1,56 @@
package xcontext
import (
"context"
"fmt"
"strings"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
)
// WithTimeout creates a context.Context which
// times out after given seconds.
func WithTimeout(logger xlog.Logger, seconds float64) (context.Context, context.CancelFunc) {
const op string = "xcontext.WithTimeout"
logger.DebugfOp(op, "creating context with '%.2fs' of timeout...", seconds)
return context.WithTimeout(context.Background(), xtime.Duration(seconds))
}
/*
MustHandleError checks if there is an error
in the given Context.
If no error, returns the previous error.
If context.DeadlineExceeded, wraps the previous
error inside an xerror.Error with xerror.TimeoutCode.
Otherwise wraps the previous error inside an
xerror.Error.
It panics if no previous error.
*/
func MustHandleError(ctx context.Context, previousErr error) error {
const op string = "xcontext.MustHandleError"
if previousErr == nil {
panic(fmt.Sprintf("%s: previous error should not be nil", op))
}
err := ctx.Err()
if err == nil {
// we do not wrap the previous error
// as it should be wrapped by the caller.
return previousErr
}
// context has timed out
if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
return xerror.Timeout(op, "context has timed out", previousErr)
}
/*
context has another error: we do not
wrap the error from the Context as the previous
error should contain it.
*/
return xerror.New(op, previousErr)
}

View File

@@ -0,0 +1,43 @@
package xcontext
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xtime"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xerrortest"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest"
)
func TestMustHandleError(t *testing.T) {
previousErr := errors.New("previous error")
logger := xlogtest.DebugLogger()
// context should not have an error.
ctx, cancel := WithTimeout(logger, 5)
defer cancel()
err := MustHandleError(ctx, previousErr)
assert.Equal(t, previousErr, err)
// should panic.
ctx, cancel = WithTimeout(logger, 5)
defer cancel()
assert.Panics(t, func() {
MustHandleError(ctx, nil)
})
// context should timed out.
ctx, cancel = WithTimeout(logger, 0.5)
defer cancel()
time.Sleep(xtime.Duration(1))
err = MustHandleError(ctx, previousErr)
xerr := xerrortest.AssertError(t, err)
assert.Equal(t, xerror.TimeoutCode, xerror.Code(xerr))
// context should have an error different
// than context.DeadlineExceeded.
ctx, cancel = WithTimeout(logger, 5)
cancel()
err = MustHandleError(ctx, previousErr)
xerr = xerrortest.AssertError(t, err)
assert.Equal(t, xerror.InternalCode, xerror.Code(xerr))
}

View File

@@ -0,0 +1,7 @@
/*
Package xerror helps standardizing
the errors through the application.
Credits: https://middlemost.com/failure-is-your-domain/
*/
package xerror

View File

@@ -0,0 +1,152 @@
package xerror
import (
"bytes"
"fmt"
"strings"
)
// ErrorCode is machine-readable error code.
type ErrorCode string
const (
// InternalCode is an internal error.
InternalCode ErrorCode = "internal"
// InvalidCode occurs when a validation
// failed.
InvalidCode ErrorCode = "invalid"
// TimeoutCode occurs when something
// timed out.
TimeoutCode ErrorCode = "timeout"
)
// Error defines our standard application
// error.
type Error struct {
code ErrorCode
message string
op string
err error
}
// Error returns the string representation of the error message.
func (e Error) Error() string {
var buf bytes.Buffer
// if wrapping an error, print its Error() message.
// Otherwise print the error code & message.
if e.err != nil {
buf.WriteString(e.err.Error())
} else {
if e.code != "" {
fmt.Fprintf(&buf, "<%s> ", e.code)
}
buf.WriteString(e.message)
}
return buf.String()
}
/*
New returns a xerror.Error.
Should be used for wrapping an error
at the end of a function.
*/
func New(op string, previous error) error {
return &Error{
op: op,
err: previous,
}
}
/*
Invalid returns a xerror.Error.
Should be used when an input
is wrong.
*/
func Invalid(op, message string, previous error) error {
return &Error{
code: InvalidCode,
message: message,
op: op,
err: previous,
}
}
/*
Timeout returns a xerror.Error.
Should be used when a timeout occurs.
*/
func Timeout(op, message string, previous error) error {
return &Error{
code: TimeoutCode,
message: message,
op: op,
err: previous,
}
}
// Code returns the code of the root error, if available.
// Otherwise returns InternalCode.
func Code(err error) ErrorCode {
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 InternalCode
}
const defaultMessage string = "an internal error has occurred: please contact technical support"
// 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 defaultMessage
}
// Op returns the logical operation of the error, if available.
// Otherwise returns an empty string.
func Op(err error) string {
if err == nil {
return ""
}
e, ok := err.(*Error)
if !ok {
return ""
}
var buf bytes.Buffer
nestedOp := Op(e.err)
if nestedOp != "" {
// we want to avoid having the same op chained.
if e.op != "" && !strings.Contains(nestedOp, e.op) {
fmt.Fprintf(&buf, "%s: %s", e.op, nestedOp)
} else {
fmt.Fprintf(&buf, "%s", nestedOp)
}
} else if e.op != "" {
fmt.Fprintf(&buf, "%s", e.op)
}
return buf.String()
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = error(new(Error))
)

View File

@@ -0,0 +1,97 @@
package xerror
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
/*
Error 1.0: op = "foo"
Error 1.1: op = "bar"
Error 1.2: code = "invalid", op = "baz", message = "nested error"
Error 1.3: message = "root error"
*/
func scenario1() error {
rootErr := errors.New("root error")
nestedErr := Invalid("baz", "nested error", rootErr)
wrappingErr := New("bar", nestedErr)
return New("foo", wrappingErr)
}
/*
Error 2.0: op = "foo"
Error 2.1: op = "bar"
Error 2.2: code = "timeout", op = "bar", message = "nested error"
*/
func scenario2() error {
nestedErr := Timeout("bar", "nested error", nil)
wrappingErr := New("bar", nestedErr)
return New("foo", wrappingErr)
}
// Error 3.0: code = "", op = "foo"
func scenario3() error {
return New("foo", nil)
}
func TestError(t *testing.T) {
// should return the Error 1.3
// message.
err := scenario1()
assert.Equal(t, "root error", err.Error())
// should return the Error 2.2 message with
// its code.
err = scenario2()
assert.Equal(t, "<timeout> nested error", err.Error())
}
func TestCode(t *testing.T) {
// should be an empty code if no error.
assert.Equal(t, "", fmt.Sprintf("%s", Code(nil)))
// should be the code of Error 1.2.
err := scenario1()
assert.Equal(t, InvalidCode, Code(err))
// should be the code of Error 2.2.
err = scenario2()
assert.Equal(t, TimeoutCode, Code(err))
// should be the default code.
err = scenario3()
assert.Equal(t, InternalCode, Code(err))
err = errors.New("some error")
assert.Equal(t, InternalCode, Code(err))
}
func TestMessage(t *testing.T) {
// should be an empty message if no error.
assert.Equal(t, "", Message(nil))
// should be the message of Error 1.2.
err := scenario1()
assert.Equal(t, "nested error", Message(err))
// should be the default message.
err = errors.New("some error")
assert.Equal(t, defaultMessage, Message(err))
}
func TestOp(t *testing.T) {
// should be an empty op if no error.
assert.Equal(t, "", Op(nil))
// should be the chain of op in this order:
// Error 1.0 -> Error 1.1 -> Error 1.2.
err := scenario1()
assert.Equal(t, "foo: bar: baz", Op(err))
/*
should be the chain of op in this order:
Error 2.0 -> Error 2.1.
As Error 2.1 and Error 2.2 shares the same
op, Error 2.2 op is not displayed.
*/
err = scenario2()
assert.Equal(t, "foo: bar", Op(err))
// should be an empty op if not Error.
err = errors.New("some error")
assert.Equal(t, "", Op(err))
}

View File

@@ -0,0 +1,8 @@
/*
Package xexec helps creating exec.Cmd
with logging.
All functions return our standard xerror.Error
in case of error.
*/
package xexec

View File

@@ -0,0 +1,99 @@
package xexec
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os/exec"
"strings"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
/*
Command is a wrapper around exec.Command.
If given xlog.Logger has a xlog.DebugLevel,
also logs the output from the command.
*/
func Command(logger xlog.Logger, binary string, args ...string) (*exec.Cmd, error) {
const op string = "xexec.Command"
cmd := exec.Command(binary, args...)
if err := pipe(logger, cmd); err != nil {
return nil, xerror.New(op, err)
}
return cmd, nil
}
/*
CommandContext is a wrapper around exec.CommandContext.
If given xlog.Logger has a xlog.DebugLevel,
also logs the output from the command.
*/
func CommandContext(ctx context.Context, logger xlog.Logger, binary string, args ...string) (*exec.Cmd, error) {
const op string = "xexec.CommandContext"
cmd := exec.CommandContext(ctx, binary, args...)
if err := pipe(logger, cmd); err != nil {
return nil, xerror.New(op, err)
}
return cmd, nil
}
// LogBeforeExecute logs a command before its execution.
func LogBeforeExecute(logger xlog.Logger, cmd *exec.Cmd) {
const op string = "xexec.LogBeforeExecute"
logger.DebugfOp(op, "executing command: %s", strings.Join(cmd.Args, " "))
}
func pipe(logger xlog.Logger, cmd *exec.Cmd) error {
const op string = "xexec.pipe"
if logger.Level() != xlog.DebugLevel {
return nil
}
// if xlog.DebugLevel, log the output
// from the command.
resolver := func() error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
go logCommandOutput(logger, stdout, "stdout", cmd)
go logCommandOutput(logger, stderr, "stderr", cmd)
return nil
}
if err := resolver(); err != nil {
return xerror.New(op, err)
}
return nil
}
func logCommandOutput(logger xlog.Logger, reader io.ReadCloser, outputType string, cmd *exec.Cmd) {
var buf bytes.Buffer
buf.WriteString(outputType)
for _, arg := range cmd.Args {
buf.WriteString(fmt.Sprintf(".%s", arg))
}
op := buf.String()
r := bufio.NewReader(reader)
defer reader.Close() // nolint: errcheck
for {
line, _, err := r.ReadLine()
if err != nil {
if err != io.EOF {
logger.ErrorOp(op, err)
}
break
}
if len(line) != 0 {
logger.DebugOp(op, string(line))
}
}
}

View File

@@ -0,0 +1,39 @@
package xexec
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest"
)
func TestCommand(t *testing.T) {
logger := xlogtest.DebugLogger()
// should pipe command output as
// xlog.Logger has a xlog.DebugLevel.
cmd, err := Command(logger, "echo", "Hello", "World")
assert.Nil(t, err)
LogBeforeExecute(logger, cmd)
// should not pipe command output as
// xlog.Logger has a xlog.InfoLevel.
logger = xlogtest.InfoLogger()
cmd, err = Command(logger, "echo", "Hello", "World")
LogBeforeExecute(logger, cmd)
assert.Nil(t, err)
}
func TestCommandContext(t *testing.T) {
logger := xlogtest.DebugLogger()
// should pipe command output as
// xlog.Logger has a xlog.DebugLevel.
cmd, err := CommandContext(context.Background(), logger, "echo", "Hello", "World")
assert.Nil(t, err)
LogBeforeExecute(logger, cmd)
// should not pipe command output as
// xlog.Logger has a xlog.InfoLevel.
logger = xlogtest.InfoLogger()
cmd, err = CommandContext(context.Background(), logger, "echo", "Hello", "World")
LogBeforeExecute(logger, cmd)
assert.Nil(t, err)
}

17
internal/pkg/xlog/doc.go Normal file
View File

@@ -0,0 +1,17 @@
/*
Package xlog defines a standard logger
for the application.
It uses structured logging thanks to
https://github.com/sirupsen/logrus.
All messages have at least two fields:
A "trace" field which helps to identify
messages belonging to the same context.
An "op" field which helps to identify
the logical operation associated
with the message.
*/
package xlog

141
internal/pkg/xlog/xlog.go Normal file
View File

@@ -0,0 +1,141 @@
package xlog
import (
"fmt"
"os"
"github.com/mattn/go-isatty"
"github.com/sirupsen/logrus"
)
// Level helps setting the severity
// of the messages displayed.
type Level string
const (
// DebugLevel is the lowest level.
DebugLevel Level = "DEBUG"
// InfoLevel is the intermediate level.
InfoLevel Level = "INFO"
// ErrorLevel is the highest level.
ErrorLevel Level = "ERROR"
)
// Logger enforces specific log message formats.
type Logger struct {
entry *logrus.Entry
level Level
}
// New returns a xlog.Logger.
func New(level Level, trace string) Logger {
l := logrus.New()
l.SetLevel(mustLogrusLevel(level))
if !isatty.IsTerminal(os.Stdout.Fd()) {
l.SetFormatter(&logrus.JSONFormatter{})
}
return Logger{
entry: l.WithField("trace", trace),
level: level,
}
}
func mustLogrusLevel(level Level) logrus.Level {
const op string = "xlog.mustLogrusLevel"
switch level {
case DebugLevel:
return logrus.DebugLevel
case InfoLevel:
return logrus.InfoLevel
case ErrorLevel:
return logrus.ErrorLevel
default:
panic(fmt.Sprintf("%s: '%s' is not associated with any logrus.Level", op, level))
}
}
// Levels returns a slice of string
// with all severities.
func Levels() []string {
return []string{
string(DebugLevel),
string(InfoLevel),
string(ErrorLevel),
}
}
/*
MustParseLevel returns the Level corresponding
to given string.
It panics if no correspondence.
*/
func MustParseLevel(level string) Level {
const op string = "xlog.MustParseLevel"
switch level {
case string(DebugLevel):
return DebugLevel
case string(InfoLevel):
return InfoLevel
case string(ErrorLevel):
return ErrorLevel
default:
panic(fmt.Sprintf("%s: '%s' is not one of '%v'", op, level, Levels()))
}
}
// Level returns the current Level.
func (l Logger) Level() Level {
return l.level
}
// WithFields returns a new xlog.Logger with
// given fields.
func (l Logger) WithFields(fields map[string]interface{}) Logger {
return Logger{
entry: l.entry.WithFields(fields),
level: l.level,
}
}
// DebugOp logs a debug message for given
// logical operation.
func (l Logger) DebugOp(op, message string) {
l.entry.WithField("op", op).Debug(message)
}
// DebugfOp logs a debug message for given
// logical operation and format.
func (l Logger) DebugfOp(op, format string, args ...interface{}) {
l.entry.WithField("op", op).Debugf(format, args...)
}
// InfoOp logs an info message for given
// logical operation.
func (l Logger) InfoOp(op, message string) {
l.entry.WithField("op", op).Info(message)
}
// InfofOp logs an info message for given
// logical operation and format.
func (l Logger) InfofOp(op, format string, args ...interface{}) {
l.entry.WithField("op", op).Infof(format, args...)
}
// ErrorOp logs an error for given
// logical operation.
func (l Logger) ErrorOp(op string, err error) {
l.entry.WithField("op", op).Error(err.Error())
}
// ErrorfOp logs an error message for given
// logical operation and format.
func (l Logger) ErrorfOp(op, format string, args ...interface{}) {
l.entry.WithField("op", op).Errorf(format, args...)
}
// FatalOp logs an error for given
// logical operation and exit 1.
func (l Logger) FatalOp(op string, err error) {
l.entry.WithField("op", op).Fatal(err.Error())
}

View File

@@ -0,0 +1,3 @@
// Package xrand helps generating
// random strings.
package xrand

View File

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

View File

@@ -0,0 +1,28 @@
package xrand
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGet(t *testing.T) {
var rands []string
// use case: for 1 000 concurrent
// requests (which is a big Gotenberg instance),
// none should have the same identifier.
for i := 0; i < 1000; i++ {
rands = append(rands, Get())
}
unique := func() bool {
for i, rand := range rands {
for j, current := range rands {
if i != j && rand == current {
return false
}
}
}
return true
}
assert.Equal(t, true, unique())
}

View File

@@ -0,0 +1,6 @@
/*
Package xtime helps generating
time.Duration from seconds represented
as float64.
*/
package xtime

View File

@@ -0,0 +1,10 @@
package xtime
import (
"time"
)
// Duration creates a time.Duration from seconds.
func Duration(seconds float64) time.Duration {
return time.Duration(1000*seconds) * time.Millisecond
}

View File

@@ -0,0 +1,14 @@
package xtime
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestDuration(t *testing.T) {
expected := time.Duration(1500) * time.Millisecond
result := Duration(1.5)
assert.Equal(t, expected.String(), result.String())
}

View File

@@ -18,9 +18,7 @@ if [ $VERSION_LENGTH -ne 3 ]; then
exit 1
fi
docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile .
docker build \
--build-arg GOLANG_VERSION=${GOLANG_VERSION} \
--build-arg VERSION=${VERSION} \
-t thecodingmachine/gotenberg:latest \
-t thecodingmachine/gotenberg:${SEMVER[0]} \

18
test/cmd/pm2/pm2.go Normal file
View File

@@ -0,0 +1,18 @@
package main
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
"github.com/thecodingmachine/gotenberg/test/internalpkg/xlogtest"
)
func main() {
logger := xlogtest.DebugLogger()
process := pm2.NewChromeProcess(logger)
if err := process.Start(); err != nil {
panic(err)
}
process = pm2.NewUnoconvProcess(logger)
if err := process.Start(); err != nil {
panic(err)
}
}

3
test/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package test contains useful
// functions used across tests.
package test

View File

@@ -0,0 +1,6 @@
/*
Package printertest contains useful
functions for tests related
to printer package.
*/
package printertest

View File

@@ -0,0 +1,49 @@
package printertest
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/xrand"
)
/*
testdataDirectoryPath should be
the absolute of the testdata INSIDE
the Docker image.
*/
const testdataDirectoryPath string = "/gotenberg/tests/test/testdata"
// GenerateDestination simply generates
// a path for a resulting PDF file.
func GenerateDestination() string {
return fmt.Sprintf("/tmp/%s.pdf", xrand.Get())
}
// MergeFpaths return the paths
// of the PDF files used in tests.
func MergeFpaths(t *testing.T) []string {
return []string{
fpath(t, "pdf", "gotenberg.pdf"),
fpath(t, "pdf", "gotenberg_bis.pdf"),
}
}
// OfficeFpaths return the paths
// of the Office documents used in tests.
func OfficeFpaths(t *testing.T) []string {
return []string{
fpath(t, "office", "document.docx"),
fpath(t, "office", "document.rtf"),
fpath(t, "office", "document.txt"),
}
}
func fpath(t *testing.T, kind, filename string) string {
require.NotEmpty(t, kind)
require.NotEmpty(t, filename)
fpath := fmt.Sprintf("%s/%s/%s", testdataDirectoryPath, kind, filename)
require.FileExists(t, fpath)
return fpath
}

View File

@@ -0,0 +1,6 @@
/*
Package xerrortest contains useful
functions for tests related
to xerror package.
*/
package xerrortest

View File

@@ -0,0 +1,18 @@
package xerrortest
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
)
// AssertError validates that given error
// is of an instance of xerror.Error.
// If so, returns the instance of xerror.Error.
func AssertError(t *testing.T, err error) *xerror.Error {
assert.NotNil(t, err)
standardized, ok := err.(*xerror.Error)
assert.Equal(t, true, ok)
return standardized
}

View File

@@ -0,0 +1,6 @@
/*
Package xlogtest contains useful
functions for tests related
to xlog package.
*/
package xlogtest

View File

@@ -0,0 +1,23 @@
package xlogtest
import (
"github.com/thecodingmachine/gotenberg/internal/pkg/xlog"
)
// DebugLogger creates a xlog.Logger
// with xlog.DebugLevel for our tests.
func DebugLogger() xlog.Logger {
return xlog.New(xlog.DebugLevel, "tests")
}
// InfoLogger creates a xlog.Logger
// with xlog.InfoLevel for our tests.
func InfoLogger() xlog.Logger {
return xlog.New(xlog.DebugLevel, "tests")
}
// ErrorLogger creates a xlog.Logger
// with xlog.ErrorLevel for our tests.
func ErrorLogger() xlog.Logger {
return xlog.New(xlog.ErrorLevel, "tests")
}

View File

@@ -1,4 +1,3 @@
// Package test contains useful functions used across tests.
package test
import (
@@ -16,6 +15,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/xerror"
"golang.org/x/sync/errgroup"
)
@@ -27,6 +27,19 @@ func AssertStatusCode(t *testing.T, expectedStatusCode int, srv http.Handler, re
assert.Equal(t, expectedStatusCode, rec.Code)
}
// AssertDirectoryEmpty checks if given directory
// is empty.
func AssertDirectoryEmpty(t *testing.T, directory string) {
f, err := os.Open(directory)
assert.Nil(t, err)
defer f.Close() // nolint: errcheck
_, err = f.Readdir(1)
if err == nil {
return
}
assert.Equal(t, io.EOF, err)
}
// AssertConcurrent runs all functions simultaneously
// and wait until execution has completed
// or an error is encountered.
@@ -39,6 +52,16 @@ func AssertConcurrent(t *testing.T, fn func() error, amount int) {
assert.NoError(t, err)
}
// AssertStandardError validates that given error
// is of an instance of xerror.Error.
// If so, returns the instance of xerror.Error.
func AssertStandardError(t *testing.T, err error) *xerror.Error {
assert.NotNil(t, err)
standardized, ok := err.(*xerror.Error)
assert.Equal(t, true, ok)
return standardized
}
// HTMLTestMultipartForm returns the body
// for a multipate/form-data request with all
// files under "html" folder.