diff --git a/README.md b/README.md index 344cd160..efd6c62c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ At TheCodingMachine, we build a lot of web applications (intranets, extranets an * HTML and Markdown conversions using Google Chrome headless * Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv) -* Performance :zap:: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2 +* Performance :zap:: Google Chrome and LibreOffice (unoconv) started once in the background thanks to PM2 * Failure prevention :broken_heart:: PM2 automatically restarts previous processes if they fail * Assets :package:: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs! * Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg-go-client) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries @@ -23,7 +23,7 @@ At TheCodingMachine, we build a lot of web applications (intranets, extranets an Open a terminal and run the following command: ```bash -$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:4 +$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5 ``` The API is now available on your host at `http://localhost:3000`. @@ -33,7 +33,7 @@ to learn how to interact with it! ## Badges -[![Docker image layers](https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:4.svg)](https://microbadger.com/images/thecodingmachine/gotenberg:4) +[![Docker image layers](https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:5.svg)](https://microbadger.com/images/thecodingmachine/gotenberg:5) [![Travis CI](https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=master)](https://travis-ci.org/thecodingmachine/gotenberg) [![GoDoc](https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg)](https://godoc.org/github.com/thecodingmachine/gotenberg) [![Go Report Card](https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg)](https://goreportcard.com/report/thecodingmachine/gotenberg) diff --git a/build/base/Dockerfile b/build/base/Dockerfile index 202b1b3b..c8cb8e94 100644 --- a/build/base/Dockerfile +++ b/build/base/Dockerfile @@ -20,7 +20,7 @@ RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" # | recovering. In our case: Chrome (headless) and Office (headless). # | -# Yep, this is dirty. The following script does not work anymore (see https://github.com/Unitech/pm2/issues/4127). +# Yep, this is dirty. The following script does not work anymore (see https://github.com/Unitech/pm2/issues/4127): #RUN curl -sL https://raw.githubusercontent.com/Unitech/pm2/master/packager/setup.deb.sh | bash - # Installing PM2 with Node.js and npm breaks something which prevents Google Chrome to work as expected. @@ -38,19 +38,19 @@ COPY --from=hack /etc/systemd/system/pm2.service /etc/systemd/system/pm2.service # | Chrome # |-------------------------------------------------------------------------- # | -# | Installs Chrome for HTML and Markdown conversions. +# | Installs Chrome. # | RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\ echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\ apt-get update &&\ - apt-get -y install google-chrome-stable + apt-get -y --allow-unauthenticated install google-chrome-stable # |-------------------------------------------------------------------------- # | Unoconv # |-------------------------------------------------------------------------- # | -# | Installs unoconv and LibreOffice for Office documents conversions. +# | Installs unoconv and LibreOffice. # | RUN pip3 install unoconv &&\ diff --git a/build/docs/content/00-introduction.md b/build/docs/content/00-introduction.md index 64fa9931..ac1bf49a 100644 --- a/build/docs/content/00-introduction.md +++ b/build/docs/content/00-introduction.md @@ -6,7 +6,7 @@ title: Introduction * HTML and Markdown conversions using Google Chrome headless * Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv) -* Performance: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2 +* Performance: Google Chrome and LibreOffice (unoconv) started once in the background thanks to PM2 * Failure prevention: PM2 automatically restarts previous processes if they fail * Assets: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs! * Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg-go-client) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries \ No newline at end of file diff --git a/build/docs/content/01-install.md b/build/docs/content/01-install.md index 89c05589..03585a66 100644 --- a/build/docs/content/01-install.md +++ b/build/docs/content/01-install.md @@ -7,12 +7,14 @@ Gotenberg is shipped within a Docker image. You may start it with: ```bash -$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:4 +$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5 ``` > The API will be available at [http://localhost:3000](http://localhost:3000). -Or add it in your Docker Compose stack: +## Docker Compose + +You may also add it in your Docker Compose stack: ```yaml version: '3' @@ -22,37 +24,19 @@ services: # your others services gotenberg: - image: thecodingmachine/gotenberg:4 + image: thecodingmachine/gotenberg:5 ``` > The API will be available under `gotenberg:3000` in your Docker Compose network. +## Kubernetes + It may also be deployed with Kubernetes. -> In Kubernetes, make sure to provide enough memory and CPU requests (for instance `512Mi` and `0.2` CPU). -> Otherwise the API will not be able to launch Google Chrome and Libreoffice (unoconv). -> -> Also note the more resources are granted, the quicker will be the conversions. +Make sure to provide enough memory and CPU requests (for instance `512Mi` and `0.2` CPU). +Otherwise the API will not be able to launch Google Chrome and LibreOffice (unoconv). + +> The more resources are granted, the quicker will be the conversions. In the following examples, we will assume your -Gotenberg API is available at [http://localhost:3000](http://localhost:3000). - -## Go client - -```bash -$ go get -u github.com/thecodingmachine/gotenberg-go-client/v4 -``` - -## PHP client - -Unless your project already has a PSR7 `HttpClient`, install `php-http/guzzle6-adapter`: - -```bash -$ composer require php-http/guzzle6-adapter -``` - -Then the PHP client: - -```bash -$ composer require thecodingmachine/gotenberg-php-client -``` \ No newline at end of file +Gotenberg API is available at [http://localhost:3000](http://localhost:3000). \ No newline at end of file diff --git a/build/docs/content/02-clients.md b/build/docs/content/02-clients.md new file mode 100644 index 00000000..195fb85d --- /dev/null +++ b/build/docs/content/02-clients.md @@ -0,0 +1,25 @@ +--- +title: Clients +--- + +We provide clients in various languages for easing the interactions with the API. + +## Go client + +```bash +$ go get -u github.com/thecodingmachine/gotenberg-go-client/v5 +``` + +## PHP client + +Unless your project already has a PSR7 `HttpClient`, install `php-http/guzzle6-adapter`: + +```bash +$ composer require php-http/guzzle6-adapter +``` + +Then the PHP client: + +```bash +$ composer require thecodingmachine/gotenberg-php-client +``` \ No newline at end of file diff --git a/build/docs/content/03-environment-variables.md b/build/docs/content/03-environment-variables.md new file mode 100644 index 00000000..1a915e04 --- /dev/null +++ b/build/docs/content/03-environment-variables.md @@ -0,0 +1,33 @@ +--- +title: Environment variables +--- + +You may customize the API behaviour thanks to environment variables. + +## Disable Google Chrome + +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. + +> If Google Chrome is disabled, the following conversions will **not** be available anymore: +> [HTML](#html), [URL](#url) and [Markdown](#markdown) + + +## Disable LibreOffice (unoconv) + +You may also disable LibreOffice (unoconv) with `DISABLE_UNOCONV`. + +> If LibreOffice (unoconv) is disabled, the following conversion will **not** be available anymore: +> [Office](#office) + +## Default wait timeout + +By default, the API will wait 10 seconds before it considers the conversion to be unsuccessful. + +You may customize this timeout thanks to the environment variable `DEFAULT_WAIT_TIMEOUT`. + +It takes a string representation of a float as value (e.g `"2.5"` for 2.5 seconds). + +> The default timeout may also be overridden per request thanks to the form field `waitTimeout`. +> See the [timeout section](#timeout). \ No newline at end of file diff --git a/build/docs/content/02-html.md b/build/docs/content/04-html.md similarity index 79% rename from build/docs/content/02-html.md rename to build/docs/content/04-html.md index ad32f051..b2661e3c 100644 --- a/build/docs/content/02-html.md +++ b/build/docs/content/04-html.md @@ -39,7 +39,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -113,13 +113,13 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} req, _ := gotenberg.NewHTMLRequest("index.html") - req.SetHeader("header.html") - req.SetFooter("footer.html") + req.Header("header.html") + req.Footer("footer.html") dest := "result.pdf" c.Store(req, dest) } @@ -186,9 +186,6 @@ You may also use *remote* paths for Google fonts, images and so on. > If you want to install fonts directly in the Gotenberg Docker image, > see to the [fonts section](#fonts). -> -> For web fonts (Google fonts), there is a timeout of 500ms by default. You may update -> this value thanks to the form field `webFontsTimeout`. ### cURL @@ -206,12 +203,12 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} req, _ := gotenberg.NewHTMLRequest("index.html") - req.SetAssets("font.woff", "img.gif", "style.css") + req.Assets("font.woff", "img.gif", "style.css") dest := "result.pdf" c.Store(req, dest) } @@ -243,8 +240,7 @@ You may also customize the resulting PDF format. By default, it will be rendered with `A4` size, `1 inch` margins and `portrait` orientation. -> Paper size and margins have to be provided in `inches`. -> Also, you have to set both `paperWidth` and `paperHeight`. Same for margins. +> Paper size and margins have to be provided in `inches`. Same for margins. ### cURL @@ -266,14 +262,14 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} req, _ := gotenberg.NewHTMLRequest("index.html") - req.SetPaperSize(gotenberg.A4) - req.SetMargins(gotenberg.NoMargins) - req.SetLandscape(true) + req.PaperSize(gotenberg.A4) + req.Margins(gotenberg.NoMargins) + req.Landscape(true) dest := "result.pdf" c.Store(req, dest) } @@ -295,4 +291,52 @@ $request->setMargins(Request::NO_MARGINS); $request->setLandscape(true); $dest = "result.pdf"; $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. + +> The wait delay is a duration in **seconds** (e.g `2.5` for 2.5 seconds). + +### cURL + +```bash +$ curl --request POST \ + --url http://localhost:3000/convert/html \ + --header 'Content-Type: multipart/form-data' \ + --form files=@index.html \ + --form waitDelay=5.5 \ + -o result.pdf +``` + +### Go + +```golang +import "github.com/thecodingmachine/gotenberg-go-client/v5" + +func main() { + c := &gotenberg.Client{Hostname: "http://localhost:3000"} + req, _ := gotenberg.NewHTMLRequest("index.html") + req.WaitDelay(5.5) + dest := "result.pdf" + c.Store(req, dest) +} +``` + +### PHP + +```php +use TheCodingMachine\Gotenberg\Client; +use TheCodingMachine\Gotenberg\DocumentFactory; +use TheCodingMachine\Gotenberg\HTMLRequest; +use TheCodingMachine\Gotenberg\Request; + +$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client()); +$index = DocumentFactory::makeFromPath('index.html', 'index.html'); +$request = new HTMLRequest($index); +$request->setWaitDelay(5.5); +$dest = "result.pdf"; +$client->store($request, $dest); ``` \ No newline at end of file diff --git a/build/docs/content/03-url.md b/build/docs/content/05-url.md similarity index 93% rename from build/docs/content/03-url.md rename to build/docs/content/05-url.md index 5f11c399..a26db834 100644 --- a/build/docs/content/03-url.md +++ b/build/docs/content/05-url.md @@ -31,12 +31,12 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} req := gotenberg.NewURLRequest("https://google.com") - req.SetMargins(gotenberg.NoMargins) + req.Margins(gotenberg.NoMargins) dest := "result.pdf" c.Store(req, dest) } diff --git a/build/docs/content/04-markdown.md b/build/docs/content/06-markdown.md similarity index 96% rename from build/docs/content/04-markdown.md rename to build/docs/content/06-markdown.md index cef48003..1dadb7c5 100644 --- a/build/docs/content/04-markdown.md +++ b/build/docs/content/06-markdown.md @@ -42,7 +42,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} diff --git a/build/docs/content/05-office.md b/build/docs/content/07-office.md similarity index 94% rename from build/docs/content/05-office.md rename to build/docs/content/07-office.md index 9d2258b2..b17b8146 100644 --- a/build/docs/content/05-office.md +++ b/build/docs/content/07-office.md @@ -43,7 +43,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} @@ -90,12 +90,12 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} req, _ := gotenberg.NewOfficeRequest("document.docx") - req.SetLandscape(true) + req.Landscape(true) dest := "result.pdf" c.Store(req, dest) } diff --git a/build/docs/content/08-filename.md b/build/docs/content/08-filename.md deleted file mode 100644 index 22ee347c..00000000 --- a/build/docs/content/08-filename.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Filename ---- - -All endpoints accept a form field named `filename`. - -If provided, the API will return the resulting PDF file with the given filename. -Otherwise a random filename is used. - -> The Go and PHP libraries do not provide a way to set this form field. -> However, you may hijack the response from the API or store the resulting PDF -> using a custom filename. -> -> **Attention:** this feature does not work if the form field `webhookURL` is given. \ No newline at end of file diff --git a/build/docs/content/06-merge.md b/build/docs/content/08-merge.md similarity index 86% rename from build/docs/content/06-merge.md rename to build/docs/content/08-merge.md index 6099a85b..3b8b7dfc 100644 --- a/build/docs/content/06-merge.md +++ b/build/docs/content/08-merge.md @@ -2,7 +2,7 @@ title: Merge --- -Gotenberg provides the endpoint `/merge` for merging PDFs. +Gotenberg provides the endpoint `/convert/merge` for merging PDFs. It accepts `POST` requests with a `multipart/form-data` Content-Type. @@ -17,7 +17,7 @@ will merge them and return the resulting PDF file. ```bash $ curl --request POST \ - --url http://localhost:3000/merge \ + --url http://localhost:3000/convert/merge \ --header 'Content-Type: multipart/form-data' \ --form files=@file.pdf \ --form files=@file2.pdf \ @@ -27,7 +27,7 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} diff --git a/build/docs/content/09-timeout.md b/build/docs/content/09-timeout.md new file mode 100644 index 00000000..92c78b83 --- /dev/null +++ b/build/docs/content/09-timeout.md @@ -0,0 +1,52 @@ +--- +title: Timeout +--- + +All endpoints accept a form field named `waitTimeout`. + +The API will wait the given **seconds** before it considers the conversion to be unsucessful. + +It takes a float as value (e.g `2.5` for 2.5 seconds). + +> You may also define this value globally: see the [environment variables](#environment_variables.default_wait_timeout) section. + +## Examples + +### cURL + +```bash +$ curl --request POST \ + --url http://localhost:3000/convert/html \ + --header 'Content-Type: multipart/form-data' \ + --form files=@index.html \ + --form waitTimeout=2.5 +``` + +### Go + +```golang +import "github.com/thecodingmachine/gotenberg-go-client/v5" + +func main() { + c := &gotenberg.Client{Hostname: "http://localhost:3000"} + req, _ := gotenberg.NewHTMLRequest("index.html") + req.WaitTimeout(2.5) + resp, _ := c.Post(req) +} +``` + +### PHP + +```php +use TheCodingMachine\Gotenberg\Client; +use TheCodingMachine\Gotenberg\DocumentFactory; +use TheCodingMachine\Gotenberg\HTMLRequest; +use TheCodingMachine\Gotenberg\Request; + +$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client()); +$index = DocumentFactory::makeFromPath('index.html', 'index.html'); +$request = new HTMLRequest($index); +$request->setWaitTimeout(2.5); +$dest = "result.pdf"; +$client->store($request, $dest); +``` \ No newline at end of file diff --git a/build/docs/content/07-webhook.md b/build/docs/content/10-webhook.md similarity index 89% rename from build/docs/content/07-webhook.md rename to build/docs/content/10-webhook.md index ca00be45..497baaca 100644 --- a/build/docs/content/07-webhook.md +++ b/build/docs/content/10-webhook.md @@ -24,13 +24,12 @@ $ curl --request POST \ ### Go ```golang -import "github.com/thecodingmachine/gotenberg-go-client/v4" +import "github.com/thecodingmachine/gotenberg-go-client/v5" func main() { c := &gotenberg.Client{Hostname: "http://localhost:3000"} req, _ := gotenberg.NewHTMLRequest("index.html") - req.SetWebhookURL("http://myapp.com/webhook/") - dest := "result.pdf" + req.WebhookURL("http://myapp.com/webhook/") resp, _ := c.Post(req) } ``` diff --git a/build/docs/content/11-result-filename.md b/build/docs/content/11-result-filename.md new file mode 100644 index 00000000..22f2c184 --- /dev/null +++ b/build/docs/content/11-result-filename.md @@ -0,0 +1,50 @@ +--- +title: Result filename +--- + +All endpoints accept a form field named `resultFilename`. + +If provided, the API will return the resulting PDF file with the given filename. +Otherwise a random filename is used. + +> **Attention:** this feature does not work if the form field `webhookURL` is given. + +## Examples + +### cURL + +```bash +$ curl --request POST \ + --url http://localhost:3000/convert/html \ + --header 'Content-Type: multipart/form-data' \ + --form files=@index.html \ + --form resultFilename='foo.pdf' +``` + +### Go + +```golang +import "github.com/thecodingmachine/gotenberg-go-client/v5" + +func main() { + c := &gotenberg.Client{Hostname: "http://localhost:3000"} + req, _ := gotenberg.NewHTMLRequest("index.html") + req.ResultFilename("foo.pdf") + resp, _ := c.Post(req) +} +``` + +### PHP + +```php +use TheCodingMachine\Gotenberg\Client; +use TheCodingMachine\Gotenberg\DocumentFactory; +use TheCodingMachine\Gotenberg\HTMLRequest; +use TheCodingMachine\Gotenberg\Request; + +$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client()); +$index = DocumentFactory::makeFromPath('index.html', 'index.html'); +$request = new HTMLRequest($index); +$request->setResultFilename('foo.pdf'); +$resp = $client->post($request); +``` \ No newline at end of file diff --git a/build/docs/content/09-scalability.md b/build/docs/content/12-scalability.md similarity index 92% rename from build/docs/content/09-scalability.md rename to build/docs/content/12-scalability.md index 906cc6dd..e5d1225f 100644 --- a/build/docs/content/09-scalability.md +++ b/build/docs/content/12-scalability.md @@ -14,7 +14,7 @@ services: # your others services gotenberg: - image: thecodingmachine/gotenberg:4 + image: thecodingmachine/gotenberg:5 ``` You may now launch your services using: diff --git a/build/docs/content/10-liveness.md b/build/docs/content/13-ping.md similarity index 96% rename from build/docs/content/10-liveness.md rename to build/docs/content/13-ping.md index 5da45771..3222cf55 100644 --- a/build/docs/content/10-liveness.md +++ b/build/docs/content/13-ping.md @@ -1,5 +1,5 @@ --- -title: Liveness +title: Ping --- Gotenberg provides the endpoint `/ping` for checking the API availability with diff --git a/build/docs/content/11-fonts.md b/build/docs/content/14-fonts.md similarity index 87% rename from build/docs/content/11-fonts.md rename to build/docs/content/14-fonts.md index daa7e040..fa120864 100644 --- a/build/docs/content/11-fonts.md +++ b/build/docs/content/14-fonts.md @@ -7,7 +7,7 @@ By default, a handful of fonts are installed. Asian characters are also supporte If you wish to use more fonts, you will have to create your own image: ```Dockerfile -FROM thecodingmachine/gotenberg:4 +FROM thecodingmachine/gotenberg:5 RUN apt-get -y install yourfonts ``` \ No newline at end of file diff --git a/build/docs/content/12-links.md b/build/docs/content/15-links.md similarity index 100% rename from build/docs/content/12-links.md rename to build/docs/content/15-links.md diff --git a/build/lint/Dockerfile b/build/lint/Dockerfile index ce0fb562..869447e6 100644 --- a/build/lint/Dockerfile +++ b/build/lint/Dockerfile @@ -10,7 +10,7 @@ FROM golang:${GOLANG_VERSION}-stretch # | than gometalinter. # | -ENV GOLANGCI_LINT_VERSION 1.15.0 +ENV GOLANGCI_LINT_VERSION 1.16.0 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 @@ -32,4 +32,4 @@ COPY go.sum . # Install module dependencies. RUN go mod download -CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl", "--disable=lll", "--disable=errcheck", "--disable=gosec", "--disable=gochecknoglobals", "--disable=gochecknoinits" ] \ No newline at end of file +CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl" ] \ No newline at end of file diff --git a/build/tests/docker-entrypoint.sh b/build/tests/docker-entrypoint.sh index f76c5367..fa2f311a 100755 --- a/build/tests/docker-entrypoint.sh +++ b/build/tests/docker-entrypoint.sh @@ -5,12 +5,11 @@ set -xe # 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 TestChromeLaunch -go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvLaunch +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/printer go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand # Finally testing processes shutdown. diff --git a/cmd/gotenberg/main.go b/cmd/gotenberg/main.go index c978aa3c..f3a9cf9f 100644 --- a/cmd/gotenberg/main.go +++ b/cmd/gotenberg/main.go @@ -1,21 +1,125 @@ 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/pkg/pm2" ) // version will be set on build time. +// nolint: gochecknoglobals var version = "snapshot" -func main() { - notify.Println(fmt.Sprintf("Gotenberg %s", version)) - if err := api.Start(); err != nil { - notify.ErrPrintln(err) +const ( + defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT" + disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME" + disableUnoconvEnvVar = "DISABLE_UNOCONV" +) + +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(disableGoogleChromeEnvVar); ok { + if v != "1" && v != "0" { + notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", defaultWaitTimeoutEnvVar, 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" + } + 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) { + notify.Print("http server started on port 3000") + if err := srv.Start(":3000"); 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 + go func() { + mustStartAPI(srv) + }() + quit := make(chan os.Signal, 1) + // we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) + // SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught. + signal.Notify(quit, os.Interrupt) + // block until we receive our signal. + <-quit + mustShutdownAPI(srv) + mustShutdownProcesses(processes) + notify.Print("bye!") os.Exit(0) } diff --git a/docs/index.html b/docs/index.html index 1593a94a..114bbd93 100755 --- a/docs/index.html +++ b/docs/index.html @@ -50,6 +50,14 @@ Install +
+ Clients +
+ +
+ Environment variables +
+
HTML
@@ -70,12 +78,16 @@ Merge +
+ Timeout +
+
Webhook
- Filename + Result filename
@@ -83,7 +95,7 @@
- Liveness + Ping
@@ -108,7 +120,7 @@

Clients

+

We provide clients in various languages for easing the interactions with the API.

+ +

Go client

-
$ go get -u github.com/thecodingmachine/gotenberg-go-client/v4
+
$ go get -u github.com/thecodingmachine/gotenberg-go-client/v5
 
-

PHP client

@@ -182,6 +210,53 @@ Gotenberg API is available at http://localhost:3

+
+

Environment variables

+

You may customize the API behaviour thanks to environment variables.

+ +

Disable Google Chrome

+ +

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.

+ +
+

If Google Chrome is disabled, the following conversions will not be available anymore: +HTML, URL and Markdown

+
+ +

Disable LibreOffice (unoconv)

+ +

You may also disable LibreOffice (unoconv) with DISABLE_UNOCONV.

+ +
+

If LibreOffice (unoconv) is disabled, the following conversion will not be available anymore: +Office

+
+ +

Default wait timeout

+ +

By default, the API will wait 10 seconds before it considers the conversion to be unsuccessful.

+ +

You may customize this timeout thanks to the environment variable DEFAULT_WAIT_TIMEOUT.

+ +

It takes a string representation of a float as value (e.g "2.5" for 2.5 seconds).

+ +
+

The default timeout may also be overridden per request thanks to the form field waitTimeout. +See the timeout section.

+
+ +
+

Go

-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -308,13 +383,13 @@ Also, footer.html CSS properties override the ones from heade
 	
 Go
 
-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
     req, _ := gotenberg.NewHTMLRequest("index.html")
-    req.SetHeader("header.html")
-    req.SetFooter("footer.html")
+    req.Header("header.html")
+    req.Footer("footer.html")
     dest := "result.pdf"
     c.Store(req, dest)
 }
@@ -383,9 +458,6 @@ are on the same level as the index.html file.

If you want to install fonts directly in the Gotenberg Docker image, see to the fonts section.

- -

For web fonts (Google fonts), there is a timeout of 500ms by default. You may update -this value thanks to the form field webFontsTimeout.

Go

-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
     req, _ := gotenberg.NewHTMLRequest("index.html")
-    req.SetAssets("font.woff", "img.gif", "style.css")
+    req.Assets("font.woff", "img.gif", "style.css")
     dest := "result.pdf"
     c.Store(req, dest)
 }
@@ -447,8 +519,7 @@ $client->store($request, $dest);
 

By default, it will be rendered with A4 size, 1 inch margins and portrait orientation.

-

Paper size and margins have to be provided in inches. -Also, you have to set both paperWidth and paperHeight. Same for margins.

+

Paper size and margins have to be provided in inches. Same for margins.

Go

-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
     req, _ := gotenberg.NewHTMLRequest("index.html")
-    req.SetPaperSize(gotenberg.A4)
-    req.SetMargins(gotenberg.NoMargins)
-    req.SetLandscape(true)
+    req.PaperSize(gotenberg.A4)
+    req.Margins(gotenberg.NoMargins)
+    req.Landscape(true)
     dest := "result.pdf"
     c.Store(req, dest)
 }
@@ -503,6 +574,61 @@ $request->setMargins(Request::NO_MARGINS);
 $request->setLandscape(true);
 $dest = "result.pdf";
 $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.

+ +
+

The wait delay is a duration in seconds (e.g 2.5 for 2.5 seconds).

+
+ +

cURL

+ +
$ curl --request POST \
+    --url http://localhost:3000/convert/html \
+    --header 'Content-Type: multipart/form-data' \
+    --form files=@index.html \
+    --form waitDelay=5.5 \
+    -o result.pdf
+
+ +

Go

+ +
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
+func main() {
+    c := &gotenberg.Client{Hostname: "http://localhost:3000"}
+    req, _ := gotenberg.NewHTMLRequest("index.html")
+    req.WaitDelay(5.5)
+    dest := "result.pdf"
+    c.Store(req, dest)
+}
+
+ +

PHP

+ +
use TheCodingMachine\Gotenberg\Client;
+use TheCodingMachine\Gotenberg\DocumentFactory;
+use TheCodingMachine\Gotenberg\HTMLRequest;
+use TheCodingMachine\Gotenberg\Request;
+
+$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
+$index = DocumentFactory::makeFromPath('index.html', 'index.html');
+$request = new HTMLRequest($index);
+$request->setWaitDelay(5.5);
+$dest = "result.pdf";
+$client->store($request, $dest);
 
@@ -546,12 +672,12 @@ If not, some of the content of the page might be hidden.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
     req := gotenberg.NewURLRequest("https://google.com")
-    req.SetMargins(gotenberg.NoMargins)
+    req.Margins(gotenberg.NoMargins)
     dest := "result.pdf"
     c.Store(req, dest)
 }
@@ -620,7 +746,7 @@ in the file index.html. This function will convert a given markdown
 	
 Go
 
-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -703,7 +829,7 @@ See the scalability section to find how to mitigate t
 	
 Go
 
-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -755,12 +881,12 @@ $client->store($request, $dest);
 	
 Go
 
-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
     req, _ := gotenberg.NewOfficeRequest("document.docx")
-    req.SetLandscape(true)
+    req.Landscape(true)
     dest := "result.pdf"
     c.Store(req, dest)
 }
@@ -790,7 +916,7 @@ $client->store($request, $dest);
                 

Merge

-

Gotenberg provides the endpoint /merge for merging PDFs.

+

Gotenberg provides the endpoint /convert/merge for merging PDFs.

It accepts POST requests with a multipart/form-data Content-Type.

@@ -810,7 +936,7 @@ will merge them and return the resulting PDF file.

cURL
$ curl --request POST \
-    --url http://localhost:3000/merge \
+    --url http://localhost:3000/convert/merge \
     --header 'Content-Type: multipart/form-data' \
     --form files=@file.pdf \
     --form files=@file2.pdf \
@@ -821,7 +947,7 @@ will merge them and return the resulting PDF file.

Go -
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
@@ -851,6 +977,68 @@ $client->store($request, $dest);
 
               
             
+              
+

Timeout

+

All endpoints accept a form field named waitTimeout.

+ +

The API will wait the given seconds before it considers the conversion to be unsucessful.

+ +

It takes a float as value (e.g 2.5 for 2.5 seconds).

+ +
+

You may also define this value globally: see the environment variables section.

+
+ +

Examples

+ +

cURL

+ +
$ curl --request POST \
+    --url http://localhost:3000/convert/html \
+    --header 'Content-Type: multipart/form-data' \
+    --form files=@index.html \
+    --form waitTimeout=2.5
+
+ +

Go

+ +
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
+func main() {
+    c := &gotenberg.Client{Hostname: "http://localhost:3000"}
+    req, _ := gotenberg.NewHTMLRequest("index.html")
+    req.WaitTimeout(2.5)
+    resp, _ := c.Post(req)
+}
+
+ +

PHP

+ +
use TheCodingMachine\Gotenberg\Client;
+use TheCodingMachine\Gotenberg\DocumentFactory;
+use TheCodingMachine\Gotenberg\HTMLRequest;
+use TheCodingMachine\Gotenberg\Request;
+
+$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
+$index = DocumentFactory::makeFromPath('index.html', 'index.html');
+$request = new HTMLRequest($index);
+$request->setWaitTimeout(2.5);
+$dest = "result.pdf";
+$client->store($request, $dest);
+
+ +
+

Go

-
import "github.com/thecodingmachine/gotenberg-go-client/v4"
+
import "github.com/thecodingmachine/gotenberg-go-client/v5"
 
 func main() {
     c := &gotenberg.Client{Hostname: "http://localhost:3000"}
     req, _ := gotenberg.NewHTMLRequest("index.html")
-    req.SetWebhookURL("http://myapp.com/webhook/")
-    dest := "result.pdf"
+    req.WebhookURL("http://myapp.com/webhook/")
     resp, _ := c.Post(req)
 }
 
@@ -909,23 +1096,64 @@ $resp = $client->post($request);
-
-

Filename

-

All endpoints accept a form field named filename.

+Result filename

+

All endpoints accept a form field named resultFilename.

If provided, the API will return the resulting PDF file with the given filename. Otherwise a random filename is used.

-

The Go and PHP libraries do not provide a way to set this form field. -However, you may hijack the response from the API or store the resulting PDF -using a custom filename.

-

Attention: this feature does not work if the form field webhookURL is given.

+

Examples

+ +

cURL

+ +
$ curl --request POST \
+    --url http://localhost:3000/convert/html \
+    --header 'Content-Type: multipart/form-data' \
+    --form files=@index.html \
+    --form resultFilename='foo.pdf'
+
+ +

Go

+ +
import "github.com/thecodingmachine/gotenberg-go-client/v5"
+
+func main() {
+    c := &gotenberg.Client{Hostname: "http://localhost:3000"}
+    req, _ := gotenberg.NewHTMLRequest("index.html")
+    req.ResultFilename("foo.pdf")
+    resp, _ := c.Post(req)
+}
+
+ +

PHP

+ +
use TheCodingMachine\Gotenberg\Client;
+use TheCodingMachine\Gotenberg\DocumentFactory;
+use TheCodingMachine\Gotenberg\HTMLRequest;
+use TheCodingMachine\Gotenberg\Request;
+
+$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
+$index = DocumentFactory::makeFromPath('index.html', 'index.html');
+$request = new HTMLRequest($index);
+$request->setResultFilename('foo.pdf');
+$resp = $client->post($request);
+
+
@@ -943,7 +1171,7 @@ using a custom filename.

# your others services gotenberg: - image: thecodingmachine/gotenberg:4 + image: thecodingmachine/gotenberg:5

You may now launch your services using:

@@ -956,10 +1184,10 @@ redirect a request to a Gotenberg container according to the round-robin strateg -
-

Liveness

+Ping

Gotenberg provides the endpoint /ping for checking the API availability with a simple GET request.

@@ -980,7 +1208,7 @@ a simple GET request.

If you wish to use more fonts, you will have to create your own image:

-
FROM thecodingmachine/gotenberg:4
+
FROM thecodingmachine/gotenberg:5
 
 RUN apt-get -y install yourfonts
 
diff --git a/go.mod b/go.mod index 8c624c4e..d67246a6 100644 --- a/go.mod +++ b/go.mod @@ -6,20 +6,18 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-cmp v0.2.0 // indirect github.com/gorilla/websocket v1.4.0 // indirect - github.com/hhrutter/pdfcpu v0.1.19 github.com/labstack/echo/v4 v4.0.0 github.com/labstack/gommon v0.2.8 - github.com/mafredri/cdp v0.20.0 + 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/pkg/errors v0.8.0 // indirect github.com/russross/blackfriday/v2 v2.0.1 github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect 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 // indirect + golang.org/x/sync v0.0.0-20181108010431-42b317875d0f golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc // indirect ) diff --git a/go.sum b/go.sum index 466ece0f..2e6eae15 100644 --- a/go.sum +++ b/go.sum @@ -7,14 +7,12 @@ 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/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/hhrutter/pdfcpu v0.1.19 h1:J6GjNFDPjurQWallPW9gmz3CcNqBfvgB7Dad9T3e8Hc= -github.com/hhrutter/pdfcpu v0.1.19/go.mod h1:iaCmXGnOXPIvoGLELZZc4m/GpIopVsvLQ00thrbu8LU= 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.20.0 h1:dRq16FwXCAAbolR4W2iyRNsL14Bp6jFHLbRp2EkICVs= -github.com/mafredri/cdp v0.20.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE= +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= @@ -26,8 +24,6 @@ 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/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= diff --git a/internal/app/api/api.go b/internal/app/api/api.go index 150cb370..8015a071 100644 --- a/internal/app/api/api.go +++ b/internal/app/api/api.go @@ -1,139 +1,48 @@ package api import ( - "context" - "fmt" - "net/http" - "os" - "os/signal" - "time" - "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "github.com/thecodingmachine/gotenberg/internal/pkg/notify" - "github.com/thecodingmachine/gotenberg/internal/pkg/pm2" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" ) -// Start starts the API server on port 3000. -func Start() error { - e := setup() - // start Chrome headless and - // unoconv listener with PM2. - chrome := &pm2.Chrome{} - unoconv := &pm2.Unoconv{} - if err := chrome.Launch(); err != nil { - return err - } - if err := unoconv.Launch(); err != nil { - return err - } - // run our API in a goroutine so that it doesn't block. - go func() { - notify.Println("http server started on port 3000") - if err := e.Start(":3000"); err != nil { - e.Logger.Fatalf("%v", err) - os.Exit(1) - } - }() - quit := make(chan os.Signal, 1) - // we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) - // SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught. - signal.Notify(quit, os.Interrupt) - // block until we receive our signal. - <-quit - // 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.Println("shutting down http server... (Ctrl+C to force)") - return e.Shutdown(ctx) +// Options allows to customize the behaviour +// of the API. +type Options struct { + DefaultWaitTimeout float64 + EnableChromeEndpoints bool + EnableUnoconvEndpoints bool } -func setup() *echo.Echo { - e := echo.New() - e.HideBanner = true - e.HidePort = true - e.Use(middleware.Logger()) - e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - if err := next(c); err != nil { - // TODO should return a better HTTP status code - // than 500 for some cases. - return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%v", err)) - } - return nil - } - }) - e.GET("/ping", func(c echo.Context) error { return nil }) - e.POST("/merge", merge) - g := e.Group("/convert") - g.POST("/html", convertHTML) - g.POST("/url", convertURL) - g.POST("/markdown", convertMarkdown) - g.POST("/office", convertOffice) - return e +// DefaultOptions returns default options. +func DefaultOptions() *Options { + return &Options{ + DefaultWaitTimeout: 10, + EnableChromeEndpoints: true, + EnableUnoconvEndpoints: true, + } } -func newContext(r *resource) (context.Context, context.CancelFunc) { - webhookURL := r.webhookURL() - if webhookURL == "" { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - return ctx, cancel +// New returns an API. +func New(opts *Options) *echo.Echo { + api := echo.New() + api.HideBanner = true + api.HidePort = true + api.Use(middleware.Logger()) + api.GET("/ping", 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 } - return context.Background(), nil -} - -func print(c echo.Context, p printer.Printer, r *resource) error { - baseFilename, err := rand.Get() - if err != nil { - return hijackErr(fmt.Errorf("getting result file name: %v", err), r) - } - 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.webhookURL() == "" { - defer r.removeAll() - if err := p.Print(fpath); err != nil { - return err - } - if r.filename() != "" { - filename = r.filename() - } - return c.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 r.removeAll() - if err := p.Print(fpath); err != nil { - c.Logger().Errorf("%v", err) - return - } - f, err := os.Open(fpath) - if err != nil { - c.Logger().Errorf("%v", err) - return - } - defer f.Close() - resp, err := http.Post(r.webhookURL(), "application/pdf", f) - if err != nil { - c.Logger().Errorf("%v", err) - return - } - defer resp.Body.Close() - }() - return nil -} - -func hijackErr(err error, r *resource) error { - if r != nil { - defer r.removeAll() - } - return err + if opts.EnableChromeEndpoints { + g.POST("/html", convertHTML) + g.POST("/url", convertURL) + g.POST("/markdown", convertMarkdown) + } + if opts.EnableUnoconvEndpoints { + g.POST("/office", convertOffice) + } + return api } diff --git a/internal/app/api/api_test.go b/internal/app/api/api_test.go index ea852fe3..1f29b33c 100644 --- a/internal/app/api/api_test.go +++ b/internal/app/api/api_test.go @@ -6,13 +6,123 @@ import ( "testing" "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/test" ) -func TestPing(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/ping", nil) - rec := httptest.NewRecorder() - e := echo.New() - _ = e.NewContext(req, rec) - assert.Equal(t, http.StatusOK, rec.Code) +func TestDefaultWaitTimeout(t *testing.T) { + opts := DefaultOptions() + opts.DefaultWaitTimeout = 0 + srv := New(opts) + // testing if timeout. + body, contentType := test.URLTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) + // testing if no timeout. + body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "10"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) +} + +func TestDisableChromeEndpoints(t *testing.T) { + opts := DefaultOptions() + opts.EnableChromeEndpoints = false + srv := New(opts) + // Ping. + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Merge. + body, contentType := test.PDFTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // HTML. + body, contentType = test.HTMLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Markdown. + body, contentType = test.MarkdownTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // URL. + body, contentType = test.URLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Office. + body, contentType = test.OfficeTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) +} + +func TestDisableUnoconvEndpoints(t *testing.T) { + opts := DefaultOptions() + opts.EnableUnoconvEndpoints = false + srv := New(opts) + // Ping. + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Merge. + body, contentType := test.PDFTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // HTML. + body, contentType = test.HTMLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Markdown. + body, contentType = test.MarkdownTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // URL. + body, contentType = test.URLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Office. + body, contentType = test.OfficeTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) +} +func TestDisableChromeAndUnoconvEndpoints(t *testing.T) { + opts := DefaultOptions() + opts.EnableChromeEndpoints = false + opts.EnableUnoconvEndpoints = false + srv := New(opts) + // Ping. + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Merge. + body, contentType := test.PDFTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // HTML. + body, contentType = test.HTMLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Markdown. + body, contentType = test.MarkdownTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // URL. + body, contentType = test.URLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) + // Office. + body, contentType = test.OfficeTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusNotFound, srv, req) } diff --git a/internal/app/api/doc.go b/internal/app/api/doc.go index 83b28ef6..7c19cefb 100644 --- a/internal/app/api/doc.go +++ b/internal/app/api/doc.go @@ -1,9 +1,2 @@ -/* -Package api starts a HTTP server on port 3000. - -It accepts POST requests with a multipart/form-data Content-Type -for converting HTML, Markdown and Office documents to PDF. - -It is also able to merge a list of PDF files. -*/ +// Package api helps managing the HTTP server behind Gotenberg. package api diff --git a/internal/app/api/handler.go b/internal/app/api/handler.go new file mode 100644 index 00000000..f350e452 --- /dev/null +++ b/internal/app/api/handler.go @@ -0,0 +1,157 @@ +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 +} diff --git a/internal/app/api/handler_test.go b/internal/app/api/handler_test.go new file mode 100644 index 00000000..32cfd159 --- /dev/null +++ b/internal/app/api/handler_test.go @@ -0,0 +1,360 @@ +package api + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/thecodingmachine/gotenberg/test" +) + +func TestMerge(t *testing.T) { + opts := DefaultOptions() + srv := New(opts) + // OK. + body, contentType := test.PDFTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Bad request. + body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // Timeout. + body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "0"}) + req = httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) +} + +func TestHTML(t *testing.T) { + opts := DefaultOptions() + srv := New(opts) + // OK. + body, contentType := test.HTMLTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Bad request. + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginTop: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginRight: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{landscape: "not a bool"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // Timeout. + body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "0"}) + req = httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) +} + +func TestMarkdown(t *testing.T) { + opts := DefaultOptions() + srv := New(opts) + // OK. + body, contentType := test.MarkdownTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Bad request. + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginTop: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginRight: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{landscape: "not a bool"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // Timeout. + body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "0"}) + req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) +} + +func TestURL(t *testing.T) { + opts := DefaultOptions() + srv := New(opts) + // OK. + body, contentType := test.URLTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Bad request. + body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{waitDelay: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{paperWidth: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{paperHeight: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{marginTop: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{marginBottom: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{marginLeft: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{marginRight: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, map[string]string{landscape: "not a bool"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // Timeout. + body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "0"}) + req = httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) +} + +func TestOffice(t *testing.T) { + opts := DefaultOptions() + srv := New(opts) + // OK. + body, contentType := test.OfficeTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + // Bad request. + body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "not a float"}) + req = httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.OfficeTestMultipartForm(t, map[string]string{landscape: "not a bool"}) + req = httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + body, contentType = test.URLTestMultipartForm(t, nil) + req = httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusBadRequest, srv, req) + // Timeout. + body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "0"}) + req = httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req) +} + +func TestConcurrent(t *testing.T) { + opts := DefaultOptions() + opts.DefaultWaitTimeout = 30 + srv := New(opts) + // Merge. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.MarkdownTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) + } + return nil + }, + 10, + ) + // HTML. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.HTMLTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/html", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) + } + return nil + }, + 10, + ) + // Markdown. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.MarkdownTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) + } + return nil + }, + 10, + ) + // URL. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.URLTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/url", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) + } + return nil + }, + 10, + ) + // Office. + test.AssertConcurrent( + t, + func() error { + body, contentType := test.OfficeTestMultipartForm(t, nil) + req := httptest.NewRequest(http.MethodPost, "/convert/office", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code) + } + return nil + }, + 10, + ) +} + +func TestWebhook(t *testing.T) { + status := make(chan error, 2) + rcv := echo.New() + rcv.POST("/foo", func(c echo.Context) error { + if c.Request().Header.Get("Content-type") != "application/pdf" { + status <- fmt.Errorf("wrong Content-type: got %s want %s", c.Request().Header.Get("Content-type"), "application/pdf") + return nil + } + body, err := ioutil.ReadAll(c.Request().Body) + if err != nil { + status <- err + return nil + } + if body == nil || len(body) == 0 { + status <- errors.New("empty body") + return nil + } + status <- nil + return nil + }) + go func() { + rcv.Start(":3001") + }() + opts := DefaultOptions() + srv := New(opts) + body, contentType := test.PDFTestMultipartForm(t, map[string]string{webhookURL: "http://localhost:3001/foo"}) + req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + test.AssertStatusCode(t, http.StatusOK, srv, req) + err := <-status + assert.NoError(t, err) +} + +func TestResultFilename(t *testing.T) { + opts := DefaultOptions() + srv := New(opts) + body, contentType := test.PDFTestMultipartForm(t, map[string]string{resultFilename: "foo.pdf"}) + req := httptest.NewRequest(http.MethodPost, "/convert/merge", body) + req.Header.Set(echo.HeaderContentType, contentType) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition")) +} diff --git a/internal/app/api/html.go b/internal/app/api/html.go deleted file mode 100644 index d22e8e3f..00000000 --- a/internal/app/api/html.go +++ /dev/null @@ -1,56 +0,0 @@ -package api - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" -) - -func convertHTML(c echo.Context) error { - r, err := newResource(c) - if err != nil { - return hijackErr(err, r) - } - ctx, cancel := newContext(r) - if cancel != nil { - defer cancel() - } - p := &printer.HTML{Context: ctx} - indexPath, err := r.filePath("index.html") - if err != nil { - return hijackErr(err, r) - } - p.WithLocalURL(indexPath) - headerPath, _ := r.filePath("header.html") - if err := p.WithHeaderFile(headerPath); err != nil { - return hijackErr(err, r) - } - footerPath, _ := r.filePath("footer.html") - if err := p.WithFooterFile(footerPath); err != nil { - return hijackErr(err, r) - } - paperSize, err := r.paperSize() - if err != nil { - return hijackErr(err, r) - } - p.PaperWidth = paperSize[0] - p.PaperHeight = paperSize[1] - paperMargins, err := r.paperMargins() - if err != nil { - return hijackErr(err, r) - } - p.MarginTop = paperMargins[0] - p.MarginBottom = paperMargins[1] - p.MarginLeft = paperMargins[2] - p.MarginRight = paperMargins[3] - landscape, err := r.landscape() - if err != nil { - return hijackErr(err, r) - } - p.Landscape = landscape - chromeWebFontsTimeout, err := r.webFontsTimeout() - if err != nil { - return hijackErr(err, r) - } - p.WebFontsTimeout = chromeWebFontsTimeout - return print(c, p, r) -} diff --git a/internal/app/api/html_test.go b/internal/app/api/html_test.go deleted file mode 100644 index 62a89487..00000000 --- a/internal/app/api/html_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestHTML(t *testing.T) { - body, contentType := test.HTMLTestMultipartForm(t) - req := httptest.NewRequest(http.MethodPost, "/convert/html", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - e := echo.New() - c := e.NewContext(req, rec) - if assert.NoError(t, convertHTML(c)) { - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType)) - } -} diff --git a/internal/app/api/markdown.go b/internal/app/api/markdown.go deleted file mode 100644 index 17a2e87a..00000000 --- a/internal/app/api/markdown.go +++ /dev/null @@ -1,55 +0,0 @@ -package api - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" -) - -func convertMarkdown(c echo.Context) error { - r, err := newResource(c) - if err != nil { - return hijackErr(err, r) - } - ctx, cancel := newContext(r) - if cancel != nil { - defer cancel() - } - indexPath, err := r.filePath("index.html") - if err != nil { - return hijackErr(err, r) - } - p := &printer.Markdown{Context: ctx, TemplatePath: indexPath} - headerPath, _ := r.filePath("header.html") - if err := p.WithHeaderFile(headerPath); err != nil { - return hijackErr(err, r) - } - footerPath, _ := r.filePath("footer.html") - if err := p.WithFooterFile(footerPath); err != nil { - return hijackErr(err, r) - } - paperSize, err := r.paperSize() - if err != nil { - return hijackErr(err, r) - } - p.PaperWidth = paperSize[0] - p.PaperHeight = paperSize[1] - paperMargins, err := r.paperMargins() - if err != nil { - return hijackErr(err, r) - } - p.MarginTop = paperMargins[0] - p.MarginBottom = paperMargins[1] - p.MarginLeft = paperMargins[2] - p.MarginRight = paperMargins[3] - landscape, err := r.landscape() - if err != nil { - return hijackErr(err, r) - } - p.Landscape = landscape - chromeWebFontsTimeout, err := r.webFontsTimeout() - if err != nil { - return hijackErr(err, r) - } - p.WebFontsTimeout = chromeWebFontsTimeout - return print(c, p, r) -} diff --git a/internal/app/api/markdown_test.go b/internal/app/api/markdown_test.go deleted file mode 100644 index f9866978..00000000 --- a/internal/app/api/markdown_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestMarkdown(t *testing.T) { - body, contentType := test.MarkdownTestMultipartForm(t) - req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - e := echo.New() - c := e.NewContext(req, rec) - if assert.NoError(t, convertMarkdown(c)) { - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType)) - } -} diff --git a/internal/app/api/merge.go b/internal/app/api/merge.go deleted file mode 100644 index 1233f4dc..00000000 --- a/internal/app/api/merge.go +++ /dev/null @@ -1,68 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "net/http" - "os" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" -) - -func merge(c echo.Context) error { - r, err := newResource(c) - if err != nil { - return hijackErr(err, r) - } - fpaths, err := r.filePaths([]string{".pdf"}) - if err != nil { - return hijackErr(err, r) - } - if len(fpaths) == 0 { - return hijackErr(errors.New("no suitable PDF files to merge"), r) - } - baseFilename, err := rand.Get() - if err != nil { - return hijackErr(fmt.Errorf("getting result file name: %v", err), r) - } - filename := fmt.Sprintf("%s.pdf", baseFilename) - fpath := fmt.Sprintf("%s/%s", r.dirPath, filename) - // if no webhook URL given, run merge - // and directly return the resulting PDF file - // or an error. - if r.webhookURL() == "" { - defer r.removeAll() - if err := printer.Merge(fpaths, fpath); err != nil { - return err - } - if r.filename() != "" { - filename = r.filename() - } - return c.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 r.removeAll() - if err := printer.Merge(fpaths, fpath); err != nil { - c.Logger().Errorf("%v", err) - return - } - f, err := os.Open(fpath) - if err != nil { - c.Logger().Errorf("%v", err) - return - } - defer f.Close() - resp, err := http.Post(r.webhookURL(), "application/pdf", f) - if err != nil { - c.Logger().Errorf("%v", err) - return - } - defer resp.Body.Close() - }() - return nil -} diff --git a/internal/app/api/merge_test.go b/internal/app/api/merge_test.go deleted file mode 100644 index 37b2f378..00000000 --- a/internal/app/api/merge_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package api - -import ( - "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) { - body, contentType := test.PDFTestMultipartForm(t) - req := httptest.NewRequest(http.MethodPost, "/merge", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - e := echo.New() - c := e.NewContext(req, rec) - if assert.NoError(t, merge(c)) { - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType)) - } -} diff --git a/internal/app/api/middleware.go b/internal/app/api/middleware.go new file mode 100644 index 00000000..bdf92fcb --- /dev/null +++ b/internal/app/api/middleware.go @@ -0,0 +1,61 @@ +package api + +import ( + "context" + "net/http" + "strings" + + "github.com/labstack/echo/v4" +) + +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 + } + } +} diff --git a/internal/app/api/office.go b/internal/app/api/office.go deleted file mode 100644 index 43687e0b..00000000 --- a/internal/app/api/office.go +++ /dev/null @@ -1,48 +0,0 @@ -package api - -import ( - "errors" - - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" -) - -var officeExts = []string{ - ".txt", - ".rtf", - ".fodt", - ".doc", - ".docx", - ".odt", - ".xls", - ".xlsx", - ".ods", - ".ppt", - ".pptx", - ".odp", -} - -func convertOffice(c echo.Context) error { - r, err := newResource(c) - if err != nil { - return hijackErr(err, r) - } - ctx, cancel := newContext(r) - if cancel != nil { - defer cancel() - } - fpaths, err := r.filePaths(officeExts) - if err != nil { - return hijackErr(err, r) - } - if len(fpaths) == 0 { - return hijackErr(errors.New("no suitable office documents to convert"), r) - } - p := &printer.Office{Context: ctx, FilePaths: fpaths} - landscape, err := r.landscape() - if err != nil { - return hijackErr(err, r) - } - p.Landscape = landscape - return print(c, p, r) -} diff --git a/internal/app/api/office_test.go b/internal/app/api/office_test.go deleted file mode 100644 index 9eb2efab..00000000 --- a/internal/app/api/office_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestOffice(t *testing.T) { - body, contentType := test.OfficeTestMultipartForm(t) - req := httptest.NewRequest(http.MethodPost, "/convert/office", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - e := echo.New() - c := e.NewContext(req, rec) - if assert.NoError(t, convertOffice(c)) { - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType)) - } -} diff --git a/internal/app/api/resource.go b/internal/app/api/resource.go index 1dfffc66..6648466d 100644 --- a/internal/app/api/resource.go +++ b/internal/app/api/resource.go @@ -1,106 +1,264 @@ package api import ( - "errors" "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 ( - remoteURL string = "remoteURL" - webhookURL string = "webhookURL" - paperWidth string = "paperWidth" - paperHeight string = "paperHeight" - marginTop string = "marginTop" - marginBottom string = "marginBottom" - marginLeft string = "marginLeft" - marginRight string = "marginRight" - landscape string = "landscape" - webFontsTimeout string = "webFontsTimeout" - filename string = "filename" + 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" ) -// resource facilitates storing and accessing -// data from a multipart/form-data request. type resource struct { - values map[string]string - dirPath string + formValues map[string]string + formFilesDirPath string + opts *Options } -func newResource(c echo.Context) (*resource, error) { +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 nil, err + return r, err } + r.formFilesDirPath = dirPath if err := os.MkdirAll(dirPath, 0755); err != nil { return nil, fmt.Errorf("%s: making directory: %v", dirPath, err) } - r := &resource{values: values(c), dirPath: dirPath} - form, err := c.MultipartForm() + 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 r, fmt.Errorf("getting multipart form: %v", err) + 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 r, fmt.Errorf("%s: opening file: %v", fh.Filename, err) + return fmt.Errorf("%s: opening file: %v", fh.Filename, err) } - defer in.Close() - if err := r.writeFile(fh.Filename, in); err != nil { - return r, 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 r, nil -} - -func values(c echo.Context) map[string]string { - v := make(map[string]string) - v[remoteURL] = c.FormValue(remoteURL) - v[webhookURL] = c.FormValue(webhookURL) - v[paperWidth] = c.FormValue(paperWidth) - v[paperHeight] = c.FormValue(paperHeight) - v[marginTop] = c.FormValue(marginTop) - v[marginBottom] = c.FormValue(marginBottom) - v[marginLeft] = c.FormValue(marginLeft) - v[marginRight] = c.FormValue(marginRight) - v[landscape] = c.FormValue(landscape) - v[webFontsTimeout] = c.FormValue(webFontsTimeout) - v[filename] = c.FormValue(filename) - return v -} - -func (r *resource) writeFile(filename string, in io.Reader) error { - fpath := fmt.Sprintf("%s/%s", r.dirPath, filename) - out, err := os.Create(fpath) - if err != nil { - return fmt.Errorf("%s: creating new file: %v", fpath, err) - } - defer out.Close() - 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) filePath(filename string) (string, error) { - fpath := fmt.Sprintf("%s/%s", r.dirPath, filename) +func (r *resource) close() error { + if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) { + return nil + } + return os.RemoveAll(r.formFilesDirPath) +} + +const defaultHeaderFooterHTML string = "" + +func (r *resource) chromePrinterOptions() (*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: file does not exist", filename) + return "", fmt.Errorf("%s: form file does not exist", filename) } absPath, err := filepath.Abs(fpath) if err != nil { @@ -109,13 +267,28 @@ func (r *resource) filePath(filename string) (string, error) { return absPath, nil } -func (r *resource) filePaths(exts []string) ([]string, error) { +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.dirPath, func(path string, info os.FileInfo, _ error) error { + err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error { if info.IsDir() { return nil } - fpath, err := r.filePath(info.Name()) + fpath, err := r.fpath(info.Name()) if err != nil { return err } @@ -130,86 +303,8 @@ func (r *resource) filePaths(exts []string) ([]string, error) { 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 } - -func (r *resource) paperSize() ([2]float64, error) { - defaultSize := [2]float64{8.27, 11.7} - widthStr := r.values[paperWidth] - heightStr := r.values[paperHeight] - if widthStr == "" || heightStr == "" { - return defaultSize, nil - } - width, err := strconv.ParseFloat(widthStr, 64) - if err != nil { - return defaultSize, fmt.Errorf("paper width: %v", err) - } - height, err := strconv.ParseFloat(heightStr, 64) - if err != nil { - return defaultSize, fmt.Errorf("paper height: %v", err) - } - return [2]float64{width, height}, nil -} - -func (r *resource) paperMargins() ([4]float64, error) { - defaultMargins := [4]float64{1, 1, 1, 1} - topStr := r.values[marginTop] - bottomStr := r.values[marginBottom] - leftStr := r.values[marginLeft] - rightStr := r.values[marginRight] - if topStr == "" || bottomStr == "" || leftStr == "" || rightStr == "" { - return defaultMargins, nil - } - top, err := strconv.ParseFloat(topStr, 64) - if err != nil { - return defaultMargins, fmt.Errorf("margin top: %v", err) - } - bottom, err := strconv.ParseFloat(bottomStr, 64) - if err != nil { - return defaultMargins, fmt.Errorf("margin bottom: %v", err) - } - left, err := strconv.ParseFloat(leftStr, 64) - if err != nil { - return defaultMargins, fmt.Errorf("margin left: %v", err) - } - right, err := strconv.ParseFloat(rightStr, 64) - if err != nil { - return defaultMargins, fmt.Errorf("margin right: %v", err) - } - return [4]float64{top, bottom, left, right}, nil -} - -func (r *resource) landscape() (bool, error) { - landscapeStr := r.values[landscape] - if landscapeStr == "" { - return false, nil - } - landscape, err := strconv.ParseBool(landscapeStr) - if err != nil { - return false, fmt.Errorf("landscape: %v", err) - } - return landscape, nil -} - -func (r *resource) remoteURL() (string, error) { - if r.values[remoteURL] == "" { - return "", errors.New("no remote URL") - } - return r.values[remoteURL], nil -} - -func (r *resource) webFontsTimeout() (int64, error) { - webFontsTimeoutStr := r.values[webFontsTimeout] - if webFontsTimeoutStr == "" { - return 500, nil - } - timeout, err := strconv.ParseInt(webFontsTimeoutStr, 10, 64) - if err != nil { - return 500, fmt.Errorf("web fonts timeout: %v", err) - } - return timeout, nil -} - -func (r *resource) webhookURL() string { return r.values[webhookURL] } -func (r *resource) filename() string { return r.values[filename] } -func (r *resource) removeAll() error { return os.RemoveAll(r.dirPath) } diff --git a/internal/app/api/url.go b/internal/app/api/url.go deleted file mode 100644 index dbc6fb87..00000000 --- a/internal/app/api/url.go +++ /dev/null @@ -1,56 +0,0 @@ -package api - -import ( - "github.com/labstack/echo/v4" - "github.com/thecodingmachine/gotenberg/internal/pkg/printer" -) - -func convertURL(c echo.Context) error { - r, err := newResource(c) - if err != nil { - return hijackErr(err, r) - } - ctx, cancel := newContext(r) - if cancel != nil { - defer cancel() - } - p := &printer.HTML{Context: ctx} - URL, err := r.remoteURL() - if err != nil { - return hijackErr(err, r) - } - p.URL = URL - headerPath, _ := r.filePath("header.html") - if err := p.WithHeaderFile(headerPath); err != nil { - return hijackErr(err, r) - } - footerPath, _ := r.filePath("footer.html") - if err := p.WithFooterFile(footerPath); err != nil { - return hijackErr(err, r) - } - paperSize, err := r.paperSize() - if err != nil { - return hijackErr(err, r) - } - p.PaperWidth = paperSize[0] - p.PaperHeight = paperSize[1] - paperMargins, err := r.paperMargins() - if err != nil { - return hijackErr(err, r) - } - p.MarginTop = paperMargins[0] - p.MarginBottom = paperMargins[1] - p.MarginLeft = paperMargins[2] - p.MarginRight = paperMargins[3] - landscape, err := r.landscape() - if err != nil { - return hijackErr(err, r) - } - p.Landscape = landscape - chromeWebFontsTimeout, err := r.webFontsTimeout() - if err != nil { - return hijackErr(err, r) - } - p.WebFontsTimeout = chromeWebFontsTimeout - return print(c, p, r) -} diff --git a/internal/app/api/url_test.go b/internal/app/api/url_test.go deleted file mode 100644 index 0831b68d..00000000 --- a/internal/app/api/url_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestURL(t *testing.T) { - body, contentType := test.URLTestMultipartForm(t) - req := httptest.NewRequest(http.MethodPost, "/convert/url", body) - req.Header.Set(echo.HeaderContentType, contentType) - rec := httptest.NewRecorder() - e := echo.New() - c := e.NewContext(req, rec) - if assert.NoError(t, convertURL(c)) { - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType)) - } -} diff --git a/internal/pkg/notify/doc.go b/internal/pkg/notify/doc.go index e942075c..d60f45c6 100644 --- a/internal/pkg/notify/doc.go +++ b/internal/pkg/notify/doc.go @@ -1,5 +1,5 @@ /* -Package notify is used across the application -to display nice outputs to the user. +Package notify helps displaying nice outputs +to the user. */ package notify diff --git a/internal/pkg/notify/notify.go b/internal/pkg/notify/notify.go index 5c78d126..6690e2cc 100644 --- a/internal/pkg/notify/notify.go +++ b/internal/pkg/notify/notify.go @@ -7,24 +7,29 @@ import ( "github.com/labstack/gommon/color" ) -var ( - stdout *color.Color - stderr *color.Color -) - -func init() { - stdout = color.New() +// Print prints a message to stdout. +func Print(message string) { + stdout := color.New() stdout.SetOutput(os.Stdout) - stderr = color.New() - stderr.SetOutput(os.Stderr) -} - -// Println prints a message to stdout. -func Println(message string) { stdout.Printf("⇨ %s\n", message) } -// ErrPrintln prints an error to stderr. -func ErrPrintln(err error) { +// 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))) } diff --git a/internal/pkg/pm2/chrome.go b/internal/pkg/pm2/chrome.go index 12d11d1d..dfcdc1f9 100644 --- a/internal/pkg/pm2/chrome.go +++ b/internal/pkg/pm2/chrome.go @@ -2,30 +2,36 @@ package pm2 import ( "context" - "fmt" "time" "github.com/mafredri/cdp/devtool" - "github.com/thecodingmachine/gotenberg/internal/pkg/notify" ) -// Chrome facilitates starting or shutting down -// Chrome headless with PM2. -type Chrome struct{} - -// Launch starts Chrome headless with PM2. -func (c *Chrome) Launch() error { - return launch(c) +type chrome struct { + manager *processManager } -// Shutdown stops Chrome headless and -// removes it from the list of PM2 -// processes. -func (c *Chrome) Shutdown() error { - return shutdown(c) +// NewChrome retruns a Google Chrome +// headless process. +func NewChrome() Process { + return &chrome{ + manager: &processManager{}, + } } -func (c *Chrome) getArgs() []string { +func (p *chrome) Fullname() string { + return "Google Chrome headless" +} + +func (p *chrome) Start() error { + return p.manager.start(p) +} + +func (p *chrome) Shutdown() error { + return p.manager.shutdown(p) +} + +func (p *chrome) args() []string { return []string{ "--no-sandbox", "--headless", @@ -44,28 +50,23 @@ func (c *Chrome) getArgs() []string { } } -func (c *Chrome) getName() string { +func (p *chrome) name() string { return "google-chrome-stable" } -func (c *Chrome) getFullname() string { - return "Chrome headless" -} - -func (c *Chrome) isViable() bool { - // check if Chrome is correctly running. +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 (c *Chrome) warmup() { - notify.Println(fmt.Sprintf("warming-up %s", c.getFullname())) +func (p *chrome) warmup() { time.Sleep(5 * time.Second) } // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Process(new(Chrome)) + _ = Process(new(chrome)) ) diff --git a/internal/pkg/pm2/chrome_test.go b/internal/pkg/pm2/chrome_test.go index 2c7c8b35..fa30c443 100644 --- a/internal/pkg/pm2/chrome_test.go +++ b/internal/pkg/pm2/chrome_test.go @@ -6,14 +6,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestChromeLaunch(t *testing.T) { - p := &Chrome{} - err := p.Launch() +func TestChromeStart(t *testing.T) { + p := NewChrome() + err := p.Start() require.Nil(t, err) } func TestChromeShutdown(t *testing.T) { - p := &Chrome{} + p := NewChrome() err := p.Shutdown() require.Nil(t, err) } diff --git a/internal/pkg/pm2/doc.go b/internal/pkg/pm2/doc.go index 23c08108..879a203c 100644 --- a/internal/pkg/pm2/doc.go +++ b/internal/pkg/pm2/doc.go @@ -2,7 +2,7 @@ Package pm2 facilitates starting external processes on which our API depends. -For instance, it starts Chrome headless and +For instance, it may start Google Chrome headless and unoconv listener with PM2. The PM2 process manager launch those processes and keep diff --git a/internal/pkg/pm2/pm2.go b/internal/pkg/pm2/pm2.go index 0dc451d6..7acf48fc 100644 --- a/internal/pkg/pm2/pm2.go +++ b/internal/pkg/pm2/pm2.go @@ -3,69 +3,81 @@ package pm2 import ( "fmt" "os/exec" - - "github.com/thecodingmachine/gotenberg/internal/pkg/notify" ) -// Process is a type that can launch or +const ( + stoppedState = iota + runningState + errorState +) + +// Process is a type that can start or // shutdown a process with PM2. type Process interface { - Launch() error + Fullname() string + Start() error Shutdown() error - getArgs() []string - getName() string - getFullname() string - isViable() bool + args() []string + name() string + viable() bool warmup() } -const maxRestartAttempts int = 5 - -var humanNames = map[string]string{ - "start": "started", - "restart": "restarted", - "stop": "stopped", +type processManager struct { + heuristicState int32 } -func launch(p Process) error { - if err := run(p, "start"); err != nil { +func (m *processManager) start(p Process) error { + if err := m.pm2(p, "start"); err != nil { return err } p.warmup() - if !p.isViable() { + if !p.viable() { attempts := 0 - for attempts < maxRestartAttempts && !p.isViable() { - run(p, "restart") + for attempts < 5 && !p.viable() { + if err := m.pm2(p, "restart"); err != nil { + m.heuristicState = errorState + return err + } p.warmup() attempts++ } - if !p.isViable() { - return fmt.Errorf("failed to launch %s", p.getFullname()) + if !p.viable() { + m.heuristicState = errorState + return fmt.Errorf("failed to launch %s", p.Fullname()) } } + m.heuristicState = runningState return nil } -func shutdown(p Process) error { - return run(p, "stop") +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 + } + m.heuristicState = stoppedState + return nil } -func run(p Process, cmdName string) error { +func (m *processManager) pm2(p Process, cmdName string) error { cmdArgs := []string{ cmdName, - p.getName(), + p.name(), } if cmdName == "start" { cmdArgs = append(cmdArgs, "--interpreter none", "--") - cmdArgs = append(cmdArgs, p.getArgs()...) + 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.getFullname(), err) + return fmt.Errorf("%s %s with PM2: %v", cmdName, p.Fullname(), err) } - notify.Println(fmt.Sprintf("%s %s with PM2", p.getFullname(), humanNames[cmdName])) return nil } diff --git a/internal/pkg/pm2/unoconv.go b/internal/pkg/pm2/unoconv.go index 030ba7d9..f2881010 100644 --- a/internal/pkg/pm2/unoconv.go +++ b/internal/pkg/pm2/unoconv.go @@ -1,47 +1,52 @@ package pm2 -// Unoconv facilitates starting or shutting down -// unoconv listener with PM2. -type Unoconv struct{} - -// Launch starts unoconv listener with PM2. -func (u *Unoconv) Launch() error { - return launch(u) +type unoconv struct { + manager *processManager } -// Shutdown stops unoconv listener and -// removes it from the list of PM2 -// processes. -func (u *Unoconv) Shutdown() error { - return shutdown(u) +// NewUnoconv retruns a unoconv listener +// process. +func NewUnoconv() Process { + return &unoconv{ + manager: &processManager{}, + } } -func (u *Unoconv) getArgs() []string { +func (p *unoconv) 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 (u *Unoconv) getName() string { +func (p *unoconv) name() string { return "unoconv" } -func (u *Unoconv) getFullname() string { - return "unoconv listener" -} - -func (u *Unoconv) isViable() bool { +func (p *unoconv) viable() bool { // TODO find a way to check if - // unoconv is correctly started? + // the unoconv listener + // is correctly started? return true } -func (u *Unoconv) warmup() { +func (p *unoconv) warmup() { // let's do nothing. } // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Process(new(Unoconv)) + _ = Process(new(unoconv)) ) diff --git a/internal/pkg/pm2/unoconv_test.go b/internal/pkg/pm2/unoconv_test.go index f2cd9ce6..8c74b244 100644 --- a/internal/pkg/pm2/unoconv_test.go +++ b/internal/pkg/pm2/unoconv_test.go @@ -6,14 +6,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestUnoconvLaunch(t *testing.T) { - p := &Unoconv{} - err := p.Launch() +func TestUnoconvStart(t *testing.T) { + p := NewUnoconv() + err := p.Start() require.Nil(t, err) } func TestUnoconvShutdown(t *testing.T) { - p := &Unoconv{} + p := NewUnoconv() err := p.Shutdown() require.Nil(t, err) } diff --git a/internal/pkg/printer/chrome.go b/internal/pkg/printer/chrome.go new file mode 100644 index 00000000..82f05569 --- /dev/null +++ b/internal/pkg/printer/chrome.go @@ -0,0 +1,169 @@ +package printer + +import ( + "context" + "fmt" + "io/ioutil" + "time" + + "github.com/mafredri/cdp" + "github.com/mafredri/cdp/devtool" + "github.com/mafredri/cdp/protocol/network" + "github.com/mafredri/cdp/protocol/page" + "github.com/mafredri/cdp/protocol/target" + "github.com/mafredri/cdp/rpcc" + "golang.org/x/sync/errgroup" +) + +type chrome struct { + url string + opts *ChromeOptions +} + +// ChromeOptions helps customizing the +// Google Chrome printer behaviour. +type ChromeOptions struct { + WaitTimeout float64 + WaitDelay float64 + HeaderHTML string + FooterHTML string + PaperWidth float64 + PaperHeight float64 + MarginTop float64 + MarginBottom float64 + MarginLeft float64 + MarginRight float64 + 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) + defer cancel() + 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 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) + } + 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 + } + // 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(time.Duration(p.opts.WaitDelay) * time.Second) + return nil +} + +func runBatch(fn ...func() error) error { + // run all functions simultaneously and wait until + // execution has completed or an error is encountered. + eg := errgroup.Group{} + for _, f := range fn { + eg.Go(f) + } + return eg.Wait() +} + +// Compile-time checks to ensure type implements desired interfaces. +var ( + _ = Printer(new(chrome)) +) diff --git a/internal/pkg/printer/doc.go b/internal/pkg/printer/doc.go index 473019df..4b2ce259 100644 --- a/internal/pkg/printer/doc.go +++ b/internal/pkg/printer/doc.go @@ -1,7 +1,5 @@ /* Package printer contains structs which convert a specific file type to PDF. - -It is also able to merge a list of PDF files. */ package printer diff --git a/internal/pkg/printer/html.go b/internal/pkg/printer/html.go index 68dc814a..026c0f24 100644 --- a/internal/pkg/printer/html.go +++ b/internal/pkg/printer/html.go @@ -1,193 +1,14 @@ package printer import ( - "context" - "errors" "fmt" - "io/ioutil" - "strings" - - "github.com/mafredri/cdp" - "github.com/mafredri/cdp/devtool" - "github.com/mafredri/cdp/protocol/network" - "github.com/mafredri/cdp/protocol/page" - "github.com/mafredri/cdp/protocol/runtime" - "github.com/mafredri/cdp/protocol/target" - "github.com/mafredri/cdp/rpcc" ) -// HTML facilitates HTML to PDF conversion. -type HTML struct { - Context context.Context - URL string - HeaderHTML string - FooterHTML string - PaperWidth float64 - PaperHeight float64 - MarginTop float64 - MarginBottom float64 - MarginLeft float64 - MarginRight float64 - Landscape bool - WebFontsTimeout int64 +// NewHTML returns an HTML printer. +func NewHTML(fpath string, opts *ChromeOptions) Printer { + URL := fmt.Sprintf("file://%s", fpath) + return &chrome{ + url: URL, + opts: opts, + } } - -const defaultHeaderFooterHTML string = "" - -// Print converts HTML to PDF. -// Credits: https://medium.com/compass-true-north/go-service-to-convert-web-pages-to-pdf-using-headless-chrome-5fd9ffbae1af -func (html *HTML) Print(destination string) error { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - // use the DevTools HTTP/JSON API to manage targets (e.g. pages, webworkers). - devt, err := devtool.New("http://localhost:9222").Version(ctx) - if err != nil { - return fmt.Errorf("creating DevTools target: %v", err) - } - // open a new RPC connection to the Chrome Debugging Protocol target. - conn, err := rpcc.DialContext(html.Context, devt.WebSocketDebuggerURL) - if err != nil { - return fmt.Errorf("creating RPC connection: %v", err) - } - defer conn.Close() - // create new browser context. - baseBrowser := cdp.NewClient(conn) - newContextTarget, err := baseBrowser.Target.CreateBrowserContext(html.Context) - if err != nil { - return fmt.Errorf("creating new browser context: %v", err) - } - // create a new blank target with the new browser context. - newTargetArgs := target.NewCreateTargetArgs("about:blank"). - SetBrowserContextID(newContextTarget.BrowserContextID) - newTarget, err := baseBrowser.Target.CreateTarget(html.Context, newTargetArgs) - 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(html.Context, newTargetWsURL) - if err != nil { - return fmt.Errorf("connecting client to blank target: %v", err) - } - defer newContextConn.Close() - // close the target when done. - closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID) - defer baseBrowser.Target.CloseTarget(html.Context, closeTargetArgs) - c := cdp.NewClient(newContextConn) - // enable the runtime. - if err := c.Runtime.Enable(html.Context); err != nil { - return fmt.Errorf("enabling runtime: %v", err) - } - // enable the network. - if err := c.Network.Enable(html.Context, network.NewEnableArgs()); err != nil { - return fmt.Errorf("enabling network: %v", err) - } - // enable events on the page domain. - if err := c.Page.Enable(html.Context); err != nil { - return fmt.Errorf("enabling events on page domain: %v", err) - } - // create a client to listen for the load event to be fired. - loadEventFiredClient, err := c.Page.LoadEventFired(html.Context) - if err != nil { - return fmt.Errorf("creating client listening for load event: %v", err) - } - defer loadEventFiredClient.Close() - // tell the page to navigate to the URL. - navArgs := page.NewNavigateArgs(html.URL) - _, err = c.Page.Navigate(html.Context, navArgs) - if err != nil { - return fmt.Errorf("%s: navigating to URL: %v", html.URL, err) - } - // wait for the page to finish loading. - _, err = loadEventFiredClient.Recv() - if err != nil { - return fmt.Errorf("waiting for page loading: %v", err) - } - // inject a script to make sure web fonts are loaded. - script := fmt.Sprintf(`new Promise((resolve, reject) => { - document.fonts.ready.then(function () { - resolve('fonts loaded'); - }); - setTimeout(resolve.bind(resolve, 'timeout'), %d); - });`, html.WebFontsTimeout) - scriptArg := runtime.NewEvaluateArgs(script).SetAwaitPromise(true) - returnObj, _ := c.Runtime.Evaluate(html.Context, scriptArg) - if returnObj.ExceptionDetails != nil { - return fmt.Errorf("script evaluated with exception: %+v", returnObj.ExceptionDetails) - } - loadFontsResult := string(returnObj.Result.Value) - if strings.Contains(loadFontsResult, "timeout") { - return errors.New("timed out loading fonts") - } - // if no header or footer, use the default template - // for avoiding displaying default Chrome templates. - if html.HeaderHTML == "" { - html.HeaderHTML = defaultHeaderFooterHTML - } - if html.FooterHTML == "" { - html.FooterHTML = defaultHeaderFooterHTML - } - print, err := c.Page.PrintToPDF( - html.Context, - page.NewPrintToPDFArgs(). - SetPaperWidth(html.PaperWidth). - SetPaperHeight(html.PaperHeight). - SetMarginTop(html.MarginTop). - SetMarginBottom(html.MarginBottom). - SetMarginLeft(html.MarginLeft). - SetMarginRight(html.MarginRight). - SetLandscape(html.Landscape). - SetDisplayHeaderFooter(true). - SetHeaderTemplate(html.HeaderHTML). - SetFooterTemplate(html.FooterHTML). - SetPrintBackground(true), - ) - if err != nil { - return fmt.Errorf("printing page to PDF: %v", err) - } - return writeBytesToFile(destination, print.Data) -} - -// WithLocalURL sets a local URL from a file path. -func (html *HTML) WithLocalURL(fpath string) { - html.URL = fmt.Sprintf("file://%s", fpath) -} - -// WithHeaderFile sets header content from a file. -func (html *HTML) WithHeaderFile(fpath string) error { - if fpath == "" { - return nil - } - contentHTML, err := fileContentToString(fpath) - if err != nil { - return err - } - html.HeaderHTML = contentHTML - return nil -} - -// WithFooterFile sets footer content from a file. -func (html *HTML) WithFooterFile(fpath string) error { - if fpath == "" { - return nil - } - contentHTML, err := fileContentToString(fpath) - if err != nil { - return err - } - html.FooterHTML = contentHTML - return nil -} - -func fileContentToString(fpath string) (string, error) { - b, err := ioutil.ReadFile(fpath) - if err != nil { - return "", fmt.Errorf("%s: reading file: %v", fpath, err) - } - return string(b), nil -} - -// Compile-time checks to ensure type implements desired interfaces. -var ( - _ = Printer(new(HTML)) -) diff --git a/internal/pkg/printer/html_test.go b/internal/pkg/printer/html_test.go deleted file mode 100644 index ef1c052c..00000000 --- a/internal/pkg/printer/html_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package printer - -import ( - "context" - "fmt" - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestHTML(t *testing.T) { - dirPath := test.HTMLTestDirPath(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - html := &HTML{ - Context: ctx, - PaperWidth: 8.27, - PaperHeight: 11.7, - MarginTop: 1, - MarginBottom: 1, - MarginLeft: 1, - MarginRight: 1, - } - html.WithLocalURL(fmt.Sprintf("%s/%s", dirPath, "index.html")) - err := html.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html")) - require.Nil(t, err) - err = html.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html")) - require.Nil(t, err) - dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf") - err = html.Print(dst) - require.Nil(t, err) - require.FileExists(t, dst) - err = os.RemoveAll(dirPath) - assert.Nil(t, err) -} diff --git a/internal/pkg/printer/markdown.go b/internal/pkg/printer/markdown.go index 81bfe984..0bbaa518 100644 --- a/internal/pkg/printer/markdown.go +++ b/internal/pkg/printer/markdown.go @@ -2,7 +2,6 @@ package printer import ( "bytes" - "context" "fmt" "html/template" "io/ioutil" @@ -13,101 +12,48 @@ import ( "github.com/thecodingmachine/gotenberg/internal/pkg/rand" ) -// Markdown facilitates Markdown to PDF conversion. -type Markdown struct { - Context context.Context - TemplatePath string - HeaderHTML string - FooterHTML string - PaperWidth float64 - PaperHeight float64 - MarginTop float64 - MarginBottom float64 - MarginLeft float64 - MarginRight float64 - Landscape bool - WebFontsTimeout int64 - - html *HTML +// 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) + if err != nil { + return nil, fmt.Errorf("%s: parsing template: %v", fpath, 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, + }, nil } type templateData struct { DirPath string } -// Print converts markdown to PDF. -func (md *Markdown) Print(destination string) error { - if md.html == nil { - md.html = &HTML{Context: md.Context} - } - if md.HeaderHTML != "" { - md.html.HeaderHTML = md.HeaderHTML - } - if md.FooterHTML != "" { - md.html.FooterHTML = md.FooterHTML - } - md.html.PaperWidth = md.PaperWidth - md.html.PaperHeight = md.PaperHeight - md.html.MarginTop = md.MarginTop - md.html.MarginBottom = md.MarginBottom - md.html.MarginLeft = md.MarginLeft - md.html.MarginRight = md.MarginRight - md.html.Landscape = md.Landscape - md.html.WebFontsTimeout = md.WebFontsTimeout - tmpl, err := template. - New(filepath.Base(md.TemplatePath)). - Funcs(template.FuncMap{"toHTML": toHTML}). - ParseFiles(md.TemplatePath) - if err != nil { - return fmt.Errorf("%s: parsing template: %v", md.TemplatePath, err) - } - dirPath := filepath.Dir(md.TemplatePath) - data := &templateData{DirPath: dirPath} - var buffer bytes.Buffer - if err := tmpl.Execute(&buffer, data); err != nil { - return fmt.Errorf("%s: executing template: %v", md.TemplatePath, err) - } - baseFilename, err := rand.Get() - if err != nil { - return err - } - - dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename) - if err := writeBytesToFile(dst, buffer.Bytes()); err != nil { - return err - } - md.html.WithLocalURL(dst) - return md.html.Print(destination) -} - -func toHTML(dirPath, filename string) (template.HTML, error) { +func markdownToHTML(dirPath, filename string) (template.HTML, error) { fpath := fmt.Sprintf("%s/%s", dirPath, filename) b, err := ioutil.ReadFile(fpath) if err != nil { return "", fmt.Errorf("%s: reading file: %v", fpath, err) } unsafe := blackfriday.Run(b) - contentHTML := bluemonday.UGCPolicy().SanitizeBytes(unsafe) - return template.HTML(contentHTML), nil + content := bluemonday.UGCPolicy().SanitizeBytes(unsafe) + /* #nosec */ + return template.HTML(content), nil } - -// WithHeaderFile sets header content from a file. -func (md *Markdown) WithHeaderFile(fpath string) error { - if md.html == nil { - md.html = &HTML{Context: md.Context} - } - return md.html.WithHeaderFile(fpath) -} - -// WithFooterFile sets footer content from a file. -func (md *Markdown) WithFooterFile(fpath string) error { - if md.html == nil { - md.html = &HTML{Context: md.Context} - } - return md.html.WithFooterFile(fpath) -} - -// Compile-time checks to ensure type implements desired interfaces. -var ( - _ = Printer(new(Markdown)) -) diff --git a/internal/pkg/printer/markdown_test.go b/internal/pkg/printer/markdown_test.go deleted file mode 100644 index b90a8b80..00000000 --- a/internal/pkg/printer/markdown_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package printer - -import ( - "context" - "fmt" - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestMarkdown(t *testing.T) { - dirPath := test.MarkdownTestDirPath(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - markdown := &Markdown{ - Context: ctx, - TemplatePath: fmt.Sprintf("%s/%s", dirPath, "index.html"), - PaperWidth: 8.27, - PaperHeight: 11.7, - MarginTop: 1, - MarginBottom: 1, - MarginLeft: 1, - MarginRight: 1, - } - err := markdown.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html")) - require.Nil(t, err) - err = markdown.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html")) - require.Nil(t, err) - dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf") - err = markdown.Print(dst) - require.Nil(t, err) - require.FileExists(t, dst) - err = os.RemoveAll(dirPath) - assert.Nil(t, err) -} diff --git a/internal/pkg/printer/merge.go b/internal/pkg/printer/merge.go new file mode 100644 index 00000000..d21c2420 --- /dev/null +++ b/internal/pkg/printer/merge.go @@ -0,0 +1,50 @@ +package printer + +import ( + "context" + "fmt" + "os/exec" + "time" +) + +type merge struct { + ctx context.Context + fpaths []string + opts *MergeOptions +} + +// MergeOptions helps customizing the +// merge printer behaviour. +type MergeOptions struct { + WaitTimeout float64 +} + +// NewMerge returns a merge printer. +func NewMerge(fpaths []string, opts *MergeOptions) Printer { + return &merge{ + fpaths: fpaths, + opts: opts, + } +} + +func (p *merge) Print(destination string) error { + if p.ctx == nil { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) + 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) + } + return nil +} + +// Compile-time checks to ensure type implements desired interfaces. +var ( + _ = Printer(new(merge)) +) diff --git a/internal/pkg/printer/office.go b/internal/pkg/printer/office.go index 5a090378..6a104ccf 100644 --- a/internal/pkg/printer/office.go +++ b/internal/pkg/printer/office.go @@ -2,66 +2,89 @@ package printer import ( "context" - "errors" "fmt" "os" "os/exec" "path/filepath" "sync" + "time" "github.com/thecodingmachine/gotenberg/internal/pkg/rand" ) -var mu sync.Mutex - -// Office facilitates Office documents to PDF conversion. -type Office struct { - Context context.Context - FilePaths []string - Landscape bool +type office struct { + fpaths []string + opts *OfficeOptions } -// Print converts Office documents to PDF. -func (o *Office) Print(destination string) error { - mu.Lock() - defer mu.Unlock() - fpaths := make([]string, len(o.FilePaths)) +// OfficeOptions helps customizing the +// Office printer behaviour. +type OfficeOptions struct { + WaitTimeout float64 + Landscape bool +} + +// NewOffice returns an Office printer. +func NewOffice(fpaths []string, opts *OfficeOptions) Printer { + return &office{ + fpaths: fpaths, + opts: opts, + } +} + +func (p *office) Print(destination string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second) + defer cancel() + fpaths := make([]string, len(p.fpaths)) dirPath := filepath.Dir(destination) - for i, fpath := range o.FilePaths { + for i, fpath := range p.fpaths { baseFilename, err := rand.Get() if err != nil { return err } - tmpDest := fmt.Sprintf("%s/%s.pdf", dirPath, baseFilename) - cmdArgs := []string{ - "--format", - "pdf", - } - if o.Landscape { - cmdArgs = append(cmdArgs, "--printer", "PaperOrientation=landscape") - } - cmdArgs = append(cmdArgs, "--output", tmpDest, fpath) - cmd := exec.CommandContext( - o.Context, - "unoconv", - cmdArgs..., - ) - _, err = cmd.Output() - if o.Context.Err() == context.DeadlineExceeded { - return errors.New("unoconv: command timed out") - } - if err != nil { - return fmt.Errorf("unoconv: non-zero exit code: %v", err) + tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename) + if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil { + return err } fpaths[i] = tmpDest } if len(fpaths) == 1 { return os.Rename(fpaths[0], destination) } - return Merge(fpaths, 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) + } + return nil } // Compile-time checks to ensure type implements desired interfaces. var ( - _ = Printer(new(Office)) + _ = Printer(new(office)) ) diff --git a/internal/pkg/printer/office_test.go b/internal/pkg/printer/office_test.go deleted file mode 100644 index fedefc8a..00000000 --- a/internal/pkg/printer/office_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package printer - -import ( - "context" - "fmt" - "os" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestOffice(t *testing.T) { - dirPath := test.OfficeTestDirPath(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - office := &Office{ - Context: ctx, - FilePaths: []string{ - fmt.Sprintf("%s/%s", dirPath, "document.docx"), - fmt.Sprintf("%s/%s", dirPath, "document.txt"), - fmt.Sprintf("%s/%s", dirPath, "document.rtf"), - }, - } - dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf") - err := office.Print(dst) - require.Nil(t, err) - require.FileExists(t, dst) - err = os.RemoveAll(dirPath) - assert.Nil(t, err) -} diff --git a/internal/pkg/printer/printer.go b/internal/pkg/printer/printer.go index 77d20b5c..e1e72497 100644 --- a/internal/pkg/printer/printer.go +++ b/internal/pkg/printer/printer.go @@ -1,46 +1,7 @@ package printer -import ( - "fmt" - "io/ioutil" - "os/exec" - - pdfcpuAPI "github.com/hhrutter/pdfcpu/pkg/api" - pdfcpuLog "github.com/hhrutter/pdfcpu/pkg/log" - pdfcpuConfig "github.com/hhrutter/pdfcpu/pkg/pdfcpu" -) - -func init() { - // disable loggers when merging - // PDFs. - pdfcpuLog.DisableLoggers() -} - // 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 } - -// Merge merges PDF files. -func Merge(fpaths []string, destination string) error { - cmdcpu := pdfcpuAPI.MergeCommand(fpaths, destination, pdfcpuConfig.NewDefaultConfiguration()) - _, err := pdfcpuAPI.Merge(cmdcpu) - if err == nil { - return nil - } - // if pdfcpu failed to merge PDF files... - // https://github.com/thecodingmachine/gotenberg/issues/29 - var cmdArgs []string - cmdArgs = append(cmdArgs, fpaths...) - cmdArgs = append(cmdArgs, "cat", "output", destination) - cmd := exec.Command("pdftk", cmdArgs...) - return cmd.Run() -} - -func writeBytesToFile(dst string, b []byte) error { - if err := ioutil.WriteFile(dst, b, 0644); err != nil { - return fmt.Errorf("%s: writing file: %v", dst, err) - } - return nil -} diff --git a/internal/pkg/printer/printer_test.go b/internal/pkg/printer/printer_test.go deleted file mode 100644 index 30367eb2..00000000 --- a/internal/pkg/printer/printer_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package printer - -import ( - "fmt" - "os" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/test" -) - -func TestMerge(t *testing.T) { - dirPath := test.PDFTestDirPath(t) - dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf") - err := Merge( - []string{ - fmt.Sprintf("%s/%s", dirPath, "gotenberg.pdf"), - fmt.Sprintf("%s/%s", dirPath, "gotenberg_bis.pdf"), - }, - dst, - ) - require.Nil(t, err) - require.FileExists(t, dst) - err = os.RemoveAll(dirPath) - assert.Nil(t, err) -} diff --git a/internal/pkg/printer/url.go b/internal/pkg/printer/url.go new file mode 100644 index 00000000..c31a92c0 --- /dev/null +++ b/internal/pkg/printer/url.go @@ -0,0 +1,9 @@ +package printer + +// NewURL returns a URL printer. +func NewURL(url string, opts *ChromeOptions) Printer { + return &chrome{ + url: url, + opts: opts, + } +} diff --git a/test/testfunc.go b/test/testfunc.go index 20ab67cf..acdbafa0 100644 --- a/test/testfunc.go +++ b/test/testfunc.go @@ -6,64 +6,88 @@ import ( "fmt" "io" "mime/multipart" + "net/http" + "net/http/httptest" "os" "path" "path/filepath" "runtime" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/thecodingmachine/gotenberg/internal/pkg/rand" + "golang.org/x/sync/errgroup" ) +// AssertStatusCode checks if the given request +// returns the expected status code. +func AssertStatusCode(t *testing.T, expectedStatusCode int, srv http.Handler, req *http.Request) { + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + assert.Equal(t, expectedStatusCode, rec.Code) +} + +// AssertConcurrent runs all functions simultaneously +// and wait until execution has completed +// or an error is encountered. +func AssertConcurrent(t *testing.T, fn func() error, amount int) { + eg := errgroup.Group{} + for i := 0; i < amount; i++ { + eg.Go(fn) + } + err := eg.Wait() + assert.NoError(t, err) +} + // HTMLTestMultipartForm returns the body // for a multipate/form-data request with all // files under "html" folder. -func HTMLTestMultipartForm(t *testing.T) (*bytes.Buffer, string) { - return multipartForm(t, "html") +func HTMLTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + return multipartForm(t, "html", formValues) } // URLTestMultipartForm returns the body // for a multipate/form-data request with all // files under "url" folder. -func URLTestMultipartForm(t *testing.T) (*bytes.Buffer, string) { - return multipartForm(t, "url") +func URLTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + return multipartForm(t, "url", formValues) } // MarkdownTestMultipartForm returns the body // for a multipate/form-data request with all // files under "markdown" folder. -func MarkdownTestMultipartForm(t *testing.T) (*bytes.Buffer, string) { - return multipartForm(t, "markdown") +func MarkdownTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + return multipartForm(t, "markdown", formValues) } // OfficeTestMultipartForm returns the body // for a multipate/form-data request with all // files under "office" folder. -func OfficeTestMultipartForm(t *testing.T) (*bytes.Buffer, string) { - return multipartForm(t, "office") +func OfficeTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + return multipartForm(t, "office", formValues) } // PDFTestMultipartForm returns the body // for a multipate/form-data request with all // files under "pdf" folder. -func PDFTestMultipartForm(t *testing.T) (*bytes.Buffer, string) { - return multipartForm(t, "pdf") +func PDFTestMultipartForm(t *testing.T, formValues map[string]string) (*bytes.Buffer, string) { + return multipartForm(t, "pdf", formValues) } -func multipartForm(t *testing.T, kind string) (*bytes.Buffer, string) { +func multipartForm(t *testing.T, kind string, formValues map[string]string) (*bytes.Buffer, string) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer writer.Close() dirPath := abs(t, kind, "") fpaths := make(map[string]string) - filepath.Walk(dirPath, func(path string, info os.FileInfo, _ error) error { + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, _ error) error { if info.IsDir() { return nil } fpaths[info.Name()] = abs(t, kind, info.Name()) return nil }) + require.Nil(t, err) for filename, fpath := range fpaths { file, err := os.Open(fpath) require.Nil(t, err) @@ -76,68 +100,13 @@ func multipartForm(t *testing.T, kind string) (*bytes.Buffer, string) { err := writer.WriteField("remoteURL", "http://google.com") require.Nil(t, err) } + for k, v := range formValues { + err := writer.WriteField(k, v) + require.Nil(t, err) + } return body, writer.FormDataContentType() } -// HTMLTestDirPath creates a copy -// of "html" folder in test/testdata. -func HTMLTestDirPath(t *testing.T) string { - return copyDir(t, "html") -} - -// URLTestDirPath creates a copy -// of "url" folder in test/testdata. -func URLTestDirPath(t *testing.T) string { - return copyDir(t, "url") -} - -// MarkdownTestDirPath creates a copy -// of "markdown" folder in test/testdata. -func MarkdownTestDirPath(t *testing.T) string { - return copyDir(t, "markdown") -} - -// OfficeTestDirPath creates a copy -// of "office" folder in test/testdata. -func OfficeTestDirPath(t *testing.T) string { - return copyDir(t, "office") -} - -// PDFTestDirPath creates a copy -// of "pdf" folder in test/testdata. -func PDFTestDirPath(t *testing.T) string { - return copyDir(t, "pdf") -} - -func copyDir(t *testing.T, kind string) string { - tmpDirPath, err := rand.Get() - require.Nil(t, err) - err = os.MkdirAll(tmpDirPath, 0755) - require.Nil(t, err) - dirPath := abs(t, kind, "") - filepath.Walk(dirPath, func(path string, info os.FileInfo, _ error) error { - if info.IsDir() { - return nil - } - fpath := abs(t, kind, info.Name()) - in, err := os.Open(fpath) - require.Nil(t, err) - defer in.Close() - tmpFpath := fmt.Sprintf("%s/%s", tmpDirPath, info.Name()) - out, err := os.Create(tmpFpath) - require.Nil(t, err) - defer out.Close() - err = out.Chmod(0644) - require.Nil(t, err) - _, err = io.Copy(out, in) - require.Nil(t, err) - _, err = out.Seek(0, 0) - require.Nil(t, err) - return nil - }) - return tmpDirPath -} - func abs(t *testing.T, kind, filename string) string { _, gofilename, _, ok := runtime.Caller(0) require.Equal(t, ok, true, "got no caller information")