Compare commits

...

469 Commits

Author SHA1 Message Date
Julien Neuhart
d94e614a54 chore: update Go dependencies 2023-07-19 22:03:45 +02:00
Julien Neuhart
9f459867bf feat(logging): add a new flag for preprending a prefix to each field in the logs (close #659) 2023-07-19 22:03:09 +02:00
Julien Neuhart
f5ce319c24 feat: improve Dockerfiles for a better DX 2023-07-19 09:50:43 +02:00
Julien Neuhart
6b9c02d42a chore: add a security policy (close #661) 2023-07-18 14:46:23 +02:00
Kevin Lindsay
e2e06a8f83 feat: graceful shutdown on SIGINT and SIGTERM (#649)
A second SIGINT forces shutdown, as usual.
2023-05-26 14:18:40 +02:00
Julien Neuhart
8402f73d52 chore: update Go dependencies 2023-05-26 14:00:21 +02:00
Julien Neuhart
84334ace6a feat(chromium): add health check (closes #633) 2023-04-24 11:46:26 +02:00
Julien Neuhart
43e2518fbf fix(uno): add missing Start test 2023-04-24 11:46:26 +02:00
Julien Neuhart
13098713e9 chore: update Go dependencies 2023-04-24 11:46:26 +02:00
Julien Neuhart
77c6e541d6 fix: go version in lint job 2023-03-31 16:39:42 +02:00
Julien Neuhart
bbd49f9372 fix: golangci-lint workflow 2023-03-31 16:37:20 +02:00
Julien Neuhart
015be517bf fix: pdfcpu new config module path 2023-03-31 15:36:22 +02:00
Julien Neuhart
8faa936a68 Merge branch 'main' of github.com:gotenberg/gotenberg 2023-03-31 15:19:11 +02:00
Julien Neuhart
39922cca5e chore: reduce until stale duration 2023-03-31 15:16:32 +02:00
Julien Neuhart
63dd68081a chore: update Go dependencies 2023-03-31 15:15:18 +02:00
dependabot[bot]
3694b19a97 chore(deps): bump docker/setup-buildx-action from 1 to 2
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-24 15:56:59 +01:00
dependabot[bot]
c58b2a5e45 chore(deps): bump golangci/golangci-lint-action from 2 to 3
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 2 to 3.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-24 15:46:17 +01:00
dependabot[bot]
5b8562f5bd chore(deps): bump docker/setup-qemu-action from 1 to 2
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-24 15:45:58 +01:00
dependabot[bot]
67020426f6 chore(deps): bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-24 15:45:40 +01:00
dependabot[bot]
3840894201 chore(deps): bump docker/login-action from 1 to 2
Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-24 15:45:26 +01:00
Julien Neuhart
888f3feaff feat: add dependabot configuration file for GitHub actions 2023-03-17 14:20:19 +01:00
Olle Jonsson
2e339d7457 Add Dependabot configuration file
...in order to update the GitHub Actions package versions.

I noted that the versions of `actions/checkout` in use were out of date. Didn't want to leave it unautomated.
2023-03-17 13:23:52 +01:00
Pieter Oliver
fd0ff633e6 chore: add 503 to every endpoint as this is a known error
As detailed on the website documentation.
2023-03-08 15:06:05 +01:00
Julien Neuhart
02923fe9ce chore: update Golang to 1.20 & update Go dependencies & update Golangci-lint to 1.51.2 2023-02-22 15:35:30 +01:00
Julien Neuhart
875f97813e fix: tests 2023-01-13 10:21:36 +01:00
Julien Neuhart
6a29385762 chore: update Go dependencies 2023-01-12 15:53:08 +01:00
Julien Neuhart
fbc9f9dec9 feat(libreoffice): add .odg, .dotx, .xltx support - close #567, close #573 2023-01-12 15:48:53 +01:00
Julien Neuhart
4607ade8e9 chore: update LICENSE 2023-01-04 17:01:56 +01:00
Julien Neuhart
e4a553c022 fix(libreoffice): downgrade LibreOffice version (7.0.4 at the time of writing) - fix #442 & fix #516 & fix #529 & fix #537 & fix #568 2023-01-04 15:29:02 +01:00
Julien Neuhart
bc733bab0c chore: update Go dependencies 2023-01-02 17:41:59 +01:00
Julien Neuhart
1a522ddf48 Merge branch 'main' of github.com:gotenberg/gotenberg 2022-12-03 16:12:29 +01:00
Dorian de Koning
1de0f4c4fa fix(webhook/client): error on http status codes >= 400 2022-12-03 16:10:39 +01:00
Julien Neuhart
71eb5c25b5 chore: upgrade golangci-lint to version 1.50.1 2022-12-03 15:51:00 +01:00
Julien Neuhart
ea34979a29 chore: update Go dependencies 2022-12-03 15:49:07 +01:00
Julien Neuhart
1f784ad44c chore(chromium): add test without custom header/footer templates 2022-10-21 12:10:16 +02:00
Julien Neuhart
f89726752f feat(chromium): add new omitBackground form field (closes #226) 2022-10-21 11:51:06 +02:00
Julien Neuhart
921b6b7ba4 chore: upgrade golangci-lint from 1.49 to 1.50 2022-10-21 11:51:06 +02:00
Julien Neuhart
6c5cc7a0ce chore: update Go dependencies 2022-10-21 11:51:06 +02:00
Yohann Tilotti
f851e2f2cf fix(libreoffice): Typo error on mkdir 2022-10-17 22:16:44 +02:00
Julien Neuhart
b73207f29a fix: websocket timeout reached (fixes #524) 2022-10-17 10:50:55 +02:00
Julien Neuhart
8d8f0b55ea chore: update Go dependencies 2022-10-17 10:50:55 +02:00
Julien Neuhart
0e1e9b9cf3 fix: only linux/amd64 for Cloud Run 2022-10-03 10:25:32 +02:00
Julien Neuhart
ab4eed78af chore: update PDFtk from v3.3.2 to v3.3.3 2022-10-03 10:23:18 +02:00
Julien Neuhart
8a36c15d5c chore: update Noto emoji from v2.034 to v2.038 2022-10-03 10:22:31 +02:00
Julien Neuhart
bd895286bc chore: update Go dependencies 2022-10-03 10:21:49 +02:00
Julien Neuhart
b76990ec16 feat(chromium): new flags chromium-allow-insecure-localhost and chromium-host-resolver-rules 2022-09-01 18:04:00 +02:00
Julien Neuhart
9bc93c5cf5 chore: remove ioutil package 2022-09-01 18:04:00 +02:00
Julien Neuhart
1d8ae79f84 feat(chromium): add ReturnAsStream support 2022-09-01 18:04:00 +02:00
Julien Neuhart
4269aa2422 chore: update Go dependencies 2022-09-01 18:04:00 +02:00
Julien Neuhart
c887dc1ec8 chore: upgrade golangci-lint to version from version 1.48 to 1.49 2022-09-01 18:04:00 +02:00
Julien Neuhart
8bbacd691d chore: update Golang versionin go.mod file 2022-08-19 15:24:09 +02:00
Julien Neuhart
d8f3f85fb7 fix: replace depracted io/ioutil package with os package 2022-08-19 15:01:31 +02:00
Julien Neuhart
159c0b3350 fix: Golang Docker image for tests version 2022-08-19 14:52:36 +02:00
Julien Neuhart
45788f3235 chore: upgrade Golang dependencies 2022-08-19 14:11:36 +02:00
Julien Neuhart
4c85c6ccdf chore: upgrade from Golang 1.18 to 1.19, golangci-lint from 1.46 to 1.48 2022-08-19 14:03:32 +02:00
Julien Neuhart
08408ae005 chore: update Go dependencies 2022-06-07 15:08:04 +02:00
Julien Neuhart
d470279fb7 chore: update golangci-lint version 2022-06-07 14:43:33 +02:00
Julien Neuhart
8260458f8f Revert "fix(chromium): regex only accepts tmp folder by default (#429)"
This reverts commit 27018fa8e2.
2022-03-22 10:48:01 +01:00
Julien Neuhart
0512dd1e86 fix(tests): add -buildvcs=false as a temporary fix for go 1.18 bug (see https://github.com/golang/go/issues/51723) 2022-03-21 17:13:35 +01:00
Julien Neuhart
4972019cb9 chore: update golang-ci-lint 2022-03-21 16:46:46 +01:00
Julien Neuhart
5e615cab52 chore: upgrade go to 1.18 and update Go dependencies 2022-03-21 16:46:15 +01:00
Julien Neuhart
64ea2b6d3b fix(libreoffice): start the long-running listener on the first conversion (fixes #420) 2022-03-21 16:39:52 +01:00
Gabor Szabad
27018fa8e2 fix(chromium): regex only accepts tmp folder by default (#429) 2022-02-25 13:52:27 +01:00
Julien Neuhart
263d634801 chore: rename MockContext to ContextMock and move it to a dedicated go file 2022-02-09 16:09:48 +01:00
Julien Neuhart
c61c33f71c chore: update LICENSE 2022-02-09 10:41:50 +01:00
Julien Neuhart
bfb30b8fed chore: remove Go Report Card from README (not working) 2022-02-08 19:46:53 +01:00
Julien Neuhart
ca3e27937e chore(uno): update uno-listener-restart-threshold flag description 2022-02-08 19:46:31 +01:00
Julien Neuhart
50c34a4186 fix(uno): repare not working test 2022-02-08 19:45:39 +01:00
Julien Neuhart
84875aaf82 fix(chromium): remove local files from extra ling tags & extra script tags (closes #418) 2022-02-08 19:45:10 +01:00
Julien Neuhart
b7a8b95820 fix(uno): close tcp connexions to the LibreOffice listener, better health check that take the restart into account 2022-02-08 16:12:41 +01:00
Julien Neuhart
5cad5ca11a fix: PDFtk new binary URL 2022-02-08 11:33:55 +01:00
Julien Neuhart
7598de29dd chore: upgrade golangci-lint ot v1.44.0 2022-02-08 11:33:55 +01:00
Julien Neuhart
af6ffe67c8 chore: upgrade PDFtk to v3.3.2 2022-02-08 11:33:55 +01:00
Julien Neuhart
6944c4b3bf chore: upgrade Noto Color Emoji font to v2.034 2022-02-08 11:33:55 +01:00
Julien Neuhart
24817d5707 feat: add more PDF formats, drastically improve LibreOffice long-running instance management 2022-02-08 11:33:55 +01:00
Julien Neuhart
3d855712ee chore: update Go dependencies 2022-02-08 11:33:55 +01:00
Julien Neuhart
872263d253 feat: switch from top to htop 2022-01-05 15:46:20 +01:00
Julien Neuhart
1c491fb1c5 fix: use bullseye-backports distribution instead of sid for installing LibreOffice (fix #403) 2022-01-05 14:59:38 +01:00
Julien Neuhart
29374c579a chore: update Go dependencies 2021-12-30 17:45:16 +01:00
Julien Neuhart
206d7b6ca9 fix(chromium): typo in godoc <link> -> <script> 2021-12-27 17:49:06 +01:00
Julien Neuhart
ab6ca8c894 chore(README): improve Gotenberg description 2021-12-27 17:34:10 +01:00
Kaijia Feng
e7f5fde051 feat: add Noto CJK fonts 2021-12-27 17:21:20 +01:00
Julien Neuhart
030b358b0e fix: rework api and webhook module timeout properties (fixes #396) 2021-12-17 19:13:14 +01:00
Julien Neuhart
4b1791d35c fix(release): add noto-emoji arg 2021-12-16 17:49:48 +01:00
Julien Neuhart
b788cf823d chore: update Go dependencies 2021-12-08 17:22:24 +01:00
Julien Neuhart
11cbc0288f chore: update Go dependencies 2021-12-08 17:17:40 +01:00
Julien Neuhart
2269c80b02 feat: start a unoconv listener by default, but allow stateless mode as before with the --unoconv-disable-listener flag. Also improve the shutdown process 2021-12-08 17:17:40 +01:00
Julien Neuhart
c922cc042e chore: improve some tests 2021-12-08 17:17:40 +01:00
Julien Neuhart
9cb6f44f72 feat: improve prometheus startup message 2021-12-08 17:17:40 +01:00
Julien Neuhart
f376cd5ab1 feat: improve 'logger' field from logger 2021-12-08 15:41:08 +01:00
Julien Neuhart
8b8a02b68c fix(qpdf): typo in godoc 2021-12-08 14:39:20 +01:00
Rodrigo Delduca
5759b1336a feat: add qpdf engine (#380)
Co-authored-by: Andrei Regiani <andrei.cpp@gmail.com>
2021-12-07 19:48:41 +01:00
Julien Neuhart
76f17cb6d7 fix(pdfengines): selection of PDF engines and add a new module interface for modules that want to print system message on startup 2021-12-07 17:20:25 +01:00
Julien Neuhart
7149c421cf fix(pdfengines): improve --pdfengines-engines flag description 2021-11-28 17:05:25 +01:00
Julien Neuhart
353c599767 chore: align <p> tags from README 2021-11-28 15:49:49 +01:00
Julien Neuhart
285c43dab7 fix(chromium): form field 'failOnConsoleException' does not work if JavaScript is disabled 2021-11-24 17:33:55 +01:00
Julien Neuhart
bd5933f4c2 chore: upgrade golangci-lint to 1.43.0 2021-11-24 17:33:55 +01:00
Julien Neuhart
0faac08237 chore(unoconv): improve code coverage 2021-11-24 17:33:55 +01:00
Julien Neuhart
14f94b1566 feat(chromium): add validation of 'emulatedMediaType' form field at the handler level 2021-11-24 17:33:55 +01:00
Julien Neuhart
415e2ff265 feat(chromium): add 'extraLinkTags' and 'extraScriptTags' form fields, add auto-detection of files for URL endpoint, disable some functionalities if JavaScript is disabled 2021-11-24 17:33:55 +01:00
Julien Neuhart
26e97358d1 chore: update Go dependencies 2021-11-24 17:33:55 +01:00
Julien Neuhart
0ed6fb33c5 feat(libreoffice): add high verbosity if debug log level 2021-11-24 17:33:55 +01:00
Julien Neuhart
df98e7512b feat(chromium): add new form field 'failOnConsoleExceptions' (fixes #262) 2021-11-24 17:33:55 +01:00
Julien Neuhart
99ede6477f fix(api): typo an handler -> a handler 2021-11-24 17:33:55 +01:00
Julien Neuhart
ee0debe14a feat(chromium): add --chromium-disable-javascript property (fixes #175) 2021-11-24 17:33:55 +01:00
Julien Neuhart
dd1e11f102 feat(chromium): add emulated media type 2021-11-24 17:33:55 +01:00
Julien Neuhart
ed47493459 feat(chromium): add 'waitForExpression' form field, deprecate 'waitWindowStatus' form field 2021-11-24 17:33:55 +01:00
Julien Neuhart
5b8391067d chore: reorganize useAgent form field 2021-11-24 17:33:55 +01:00
Julien Neuhart
4f41a59ceb chore: update Go dependencies 2021-11-24 17:33:55 +01:00
Julien Neuhart
4525cb38e4 feat(chromium): deprecate --chromium-user-agent property, add userAgent form field 2021-11-24 17:33:55 +01:00
Julien Neuhart
704dfc25e6 feat(chromium): add --chromium-disable-web-security property 2021-11-24 17:33:55 +01:00
Julien Neuhart
7022ebe4ca feat: add module property --chromium-proxy-server 2021-11-12 17:36:15 +01:00
Julien Neuhart
094863f0ea chore: update Go dependencies 2021-11-12 17:36:15 +01:00
Julien Neuhart
a6282125cf chore: update Go dependencies 2021-11-02 17:38:26 +01:00
Julien Neuhart
63387a09af chore: update Go dependencies 2021-10-22 15:48:37 +02:00
Julien Neuhart
d4c47cecf2 feat: add metrics system, move webhook feature to dedicated module (#372) 2021-10-19 18:35:24 +02:00
Oliver Grof
b839069af1 feat: add OpenAPI specification for API v7 (#362) 2021-10-15 11:30:32 +02:00
Julien Neuhart
e3c98100cc chore: update the README introduction 2021-09-27 18:47:26 +02:00
Killian Meersman
afcce04650 feat: add HTTP2 (h2c) support (#366) 2021-09-27 12:42:36 +02:00
Julien Neuhart
5fb35c2d48 fix: chromium exact colors (#361) 2021-09-21 18:51:52 +02:00
Julien Neuhart
dfe89f0d83 fix: add gotenberg flag for chromium flag --allow-file-access-from-files (#360) 2021-09-21 18:39:13 +02:00
Julien Neuhart
c500ec071e feat: support armhf & i386 architectures (#355) 2021-09-21 18:38:11 +02:00
Julien Neuhart
6e47f16fe1 fix: filename case (#351) 2021-09-15 17:41:03 +02:00
Julien Neuhart
13e6c2dbeb fix: add latest version of noto color emoji front (#345) 2021-09-09 20:08:07 +02:00
remoteexception
9d27dfb41c fix: add more supported file extensions for unovonv (#340)
* Add supported file extensions for unovonv

* Fix expected file extensions length

Co-authored-by: Jana Storch <jana.storch@dpdhl.com>
2021-09-09 20:07:45 +02:00
remoteexception
9529905875 fix: only use webhook ports between 1025 and 65536 (#346)
- ports between 0 and 1024 can only be used by root in some OS

Co-authored-by: Jana Storch <jana.storch@dpdhl.com>
2021-09-09 20:06:49 +02:00
Snyk bot
eef7fc4b51 fix: build/Dockerfile to reduce vulnerabilities (#336)
The
2021-09-03 22:25:54 +02:00
Julien Neuhart
a88435f6b6 feat: add Live Demo link in the README 2021-08-29 14:38:10 +02:00
Julien Neuhart
0b22598783 fix: typo in api module godoc -> remove pointer for middleware 2021-08-28 17:59:18 +02:00
Julien Neuhart
40cc019a51 fix: typo Docker Registry -> Docker Repository 2021-08-27 19:14:17 +02:00
Julien Neuhart
781094ab05 fix: typo arch64 -> amd64 2021-08-27 18:50:06 +02:00
Julien Neuhart
21cfe2e535 Merge branch 'main' of github.com:gotenberg/gotenberg 2021-08-27 18:48:08 +02:00
Julien Neuhart
4f72684080 chore: upgrade LibreOffice to version 7.2 (#335) 2021-08-27 18:47:48 +02:00
Julien Neuhart
5ac514366e Merge branch 'main' of github.com:gotenberg/gotenberg 2021-08-27 14:49:56 +02:00
Julien Neuhart
d5d6285eef fix: use google chrome stable on amd64 (#333) 2021-08-27 14:48:09 +02:00
Julien Neuhart
0e34b41bce Merge branch 'main' of github.com:gotenberg/gotenberg 2021-08-27 13:48:22 +02:00
Julien Neuhart
e157066bcc chore: upgrade Go version to 1.17 (#334) 2021-08-27 13:46:44 +02:00
Julien Neuhart
f43a63ac6e chore: upgrade Go version to 1.17 2021-08-27 12:54:07 +02:00
Julien Neuhart
a5828fa01a fix: add --disable-gpu flag to Chromium / chore: upgrade base image to bullseye (#331) 2021-08-27 09:41:59 +02:00
Julien Neuhart
662d3ff795 chore: use echo.WrapHandler for health route 2021-08-25 17:12:54 +02:00
Julien Neuhart
a97f05db3b chore: add markdown table sample & test 2021-08-24 19:24:16 +02:00
Julien Neuhart
5a439ca70c fix: remove internal package and move it to cmd (#321) 2021-08-23 18:33:26 +02:00
Julien Neuhart
551faca3a3 fix: wrong version number in startup message & version LABEL 2021-08-23 12:10:34 +02:00
Julien Neuhart
c25ffe9639 feat: add stale bot 2021-08-22 17:48:34 +02:00
Julien Neuhart
900021ec1b fix: typo in test error 2021-08-22 17:24:20 +02:00
Julien Neuhart
cc1ad16688 feat: add version 7.0.0 - #319 2021-08-22 17:13:46 +02:00
Julien Neuhart
fabac9fcd9 Delete CNAME 2021-08-22 17:09:56 +02:00
Julien Neuhart
da170b9b16 feat: Chromium underlying requests are not tested againt the allow/deny lists 2021-08-22 16:35:04 +02:00
Julien Neuhart
0e417430b8 chore: update issues URLs to new repository 2021-08-22 13:27:40 +02:00
Julien Neuhart
0cfac31bca fix: add missing go.sum file 2021-08-22 13:17:13 +02:00
Julien Neuhart
0f5e8fd314 feat: add 7.x source code 2021-08-22 12:52:44 +02:00
Julien Neuhart
e457155950 chore: remove 6.x source code 2021-08-22 12:50:17 +02:00
Julien Neuhart
5a98790dd3 Update Google Analytics 2021-07-25 15:27:55 +02:00
Julien Neuhart
e188f65e7c Update README link to documentation 2021-07-23 18:27:23 +02:00
Julien Neuhart
6bf3ea3c46 Create CNAME 2021-07-23 18:13:17 +02:00
Julien Neuhart
fa33ac57fb Merge pull request #314 from AlexBondor/master
Add tags on openapi.yaml endpoints
2021-07-15 09:20:11 +02:00
Alexandru Bondor
0bed9eea09 Update openapi.yaml
Fix tags
2021-07-09 21:02:39 +03:00
Alexandru Bondor
36e9f7a8cb Update openapi.yaml
Add tags on endpoints
2021-07-09 21:00:34 +03:00
Julien Neuhart
b155dbef6a Merge pull request #312 from j2L4e/patch-1
Dockerfile: Use https everywhere
2021-07-02 14:43:21 +02:00
Jan Lohage
e48b428725 Dockerfile: Use https everywhere 2021-07-02 13:36:41 +02:00
Julien Neuhart
68884c9126 Merge pull request #305 from thecodingmachine/dependabot/add-v2-config-file
Upgrade to GitHub-native Dependabot
2021-05-14 12:02:51 +02:00
Julien Neuhart
a713976f9c Merge branch 'master' into dependabot/add-v2-config-file 2021-05-14 11:54:49 +02:00
Julien Neuhart
51d7306380 Merge pull request #308 from thecodingmachine/dependencies-update
Update golang dependencies
2021-05-14 11:50:50 +02:00
Julien Neuhart
0b83f75ec8 Update golang dependencies 2021-05-14 11:41:40 +02:00
dependabot-preview[bot]
0a80635943 Upgrade to GitHub-native Dependabot 2021-04-29 21:11:47 +00:00
Julien Neuhart
e6a5f4b203 Merge pull request #303 from thecodingmachine/missing-fonts
Add missing fonts from 6.4.1
2021-04-15 09:50:36 +02:00
Julien Neuhart
061b2fdbcb Remove font duplicate 2021-04-15 09:37:57 +02:00
Julien Neuhart
7922e32618 Add missing fonts from 6.4.1 2021-04-15 09:34:12 +02:00
Julien Neuhart
0949d10da6 Merge pull request #296 from bendavies/reduce-docker-image-size
Reduce the docker image size
2021-03-31 10:53:29 +02:00
Ben Davies
0c8ad9262e use only curl 2021-03-30 20:30:43 +01:00
Ben Davies
f841aef82d install ttf-mscorefonts-installer 3.8 2021-03-30 20:29:55 +01:00
Ben Davies
d42e51315d Delete the apt-get lists after installing something.
https://github.com/hadolint/hadolint/wiki/DL3009
2021-03-30 18:00:10 +01:00
Ben Davies
e761b73cfd Avoid additional packages by specifying --no-install-recommends.
https://github.com/hadolint/hadolint/wiki/DL3015
2021-03-30 18:00:06 +01:00
Julien Neuhart
1a9c9a7767 Merge pull request #273 from owsy/pdftk
Upgrade PDFtk
2021-01-07 18:05:36 +01:00
Rhodri Pugh
eb7ce7cef1 use pdftk 3.2.2 2021-01-07 16:17:24 +00:00
Julien Neuhart
68fd8dcd76 Merge pull request #274 from thecodingmachine/github-actions
GitHub Actions
2021-01-07 16:31:54 +01:00
Julien Neuhart
ce8bb2567c Fix typo in publish script 2021-01-07 16:19:21 +01:00
Julien Neuhart
ab3d9bc120 Remove TravisCI, add GitHub Actions 2021-01-07 16:18:48 +01:00
Julien Neuhart
432ec161c1 Add warning in documentation about public facing Gotenberg instances 2020-12-30 15:47:54 +01:00
Julien Neuhart
fcaf4a9b51 Merge pull request #260 from karakanb/add-openapi-docs
Add the first version of the OpenAPI documentation
2020-11-30 15:04:06 +01:00
Burak Karakan
cbb7e6599b Add OpenAPI docs to the links section 2020-11-22 19:59:41 +01:00
Burak Karakan
8f89a144ca Add the first version of the OpenAPI documentation 2020-11-22 19:58:34 +01:00
Julien Neuhart
50bd05b351 Update 15-links.md 2020-11-18 17:30:38 +01:00
Julien Neuhart
4e7f5401ec Merge pull request #241 from Tealsky/bugfix/issue-172
[bugfix/issue-172] fixing health check
2020-09-21 15:38:18 +02:00
Ophélie Volga
3fd56e8c36 [bugfix/issue-172] fixing health check 2020-09-21 14:57:16 +02:00
Julien Neuhart
daf028ab2b Merge pull request #208 from thecodingmachine/6.3.0-dev
6.3.0
2020-06-22 10:58:01 +02:00
Julien Neuhart
5467620b32 adding warning about URL conversion to a Docker Compose service named app 2020-06-09 16:38:28 +02:00
Julien Neuhart
a3cae0f094 Merge branch '6.3.0-dev' of https://github.com/thecodingmachine/gotenberg into 6.3.0-dev 2020-06-09 16:30:22 +02:00
Julien Neuhart
f74eea4314 Merge pull request #204 from WillyReyno/patch-1
Added warning for URLRequest and service named app
2020-06-09 16:30:05 +02:00
Julien Neuhart
04e930a766 fixing typos in documentation 2020-06-09 16:29:14 +02:00
Julien Neuhart
89ac13e805 Fixing linter issues 2020-06-08 10:44:46 +02:00
Julien Neuhart
0b86d38517 go fmt and regenerating documentation 2020-06-08 09:44:37 +02:00
Julien Neuhart
7e76809860 Merge pull request #206 from tomjvdberg/URL-allow-insecure-connection
Url allow insecure connection
2020-06-08 09:28:38 +02:00
tomjvdberg
4d46b98e7f Apply suggestions from code review
Co-authored-by: Julien Neuhart <j.neuhart@thecodingmachine.com>
2020-06-08 08:31:57 +02:00
Julien Neuhart
6c1a46aee5 minor update of the documentation 2020-06-05 15:56:07 +02:00
Julien Neuhart
a7cef5a1c6 got fmt 2020-06-05 15:05:51 +02:00
Julien Neuhart
8a218a6626 using a (more) random directory name for user profile tmp dir + improving performance with a go routine 2020-06-05 15:05:27 +02:00
Julien Neuhart
542fe18dcd avoir directory traversal when uploading a file 2020-06-05 12:06:01 +02:00
Julien Neuhart
25f18ac951 fixing dependencies with master 2020-06-05 12:03:16 +02:00
Julien Neuhart
5540438595 adding missing comment from previous commit 2020-06-05 12:00:57 +02:00
Julien Neuhart
b25456835a sanitizing filename parameter of toHTML function 2020-06-05 11:52:17 +02:00
Julien Neuhart
f86091382f updating dependencies 2020-06-05 09:19:31 +02:00
Julien Neuhart
dbdf8871cc tini no more writeable by gotenberg user 2020-06-05 09:19:12 +02:00
Tom van den Berg
1e6926ee2d Add ignoreCertificateErrors tests + docs 2020-06-02 16:06:17 +02:00
Tom van den Berg
24cbe98ac2 Add feature to have chrome ignore certificate errors when printing using an URL. 2020-06-01 15:31:36 +02:00
Willy Reyno
c590b026d6 Added warning for URLRequest and service named app
A docker-compose service named "app" will systematically return a blank PDF when using `URLRequest()`.
Added a warning to recommend people to rename their service if it's named "app".
2020-06-01 11:15:55 +02:00
Julien Neuhart
d28221aec9 Merge pull request #196 from thecodingmachine/dependabot/go_modules/github.com/sirupsen/logrus-1.6.0
Bump github.com/sirupsen/logrus from 1.5.0 to 1.6.0
2020-05-04 08:32:27 +02:00
dependabot-preview[bot]
df0c6c8c2d Bump github.com/sirupsen/logrus from 1.5.0 to 1.6.0
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.5.0 to 1.6.0.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.5.0...v1.6.0)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-05-04 05:06:23 +00:00
Julien Neuhart
876f0f9a14 Improving error message when the removing of a user profile directory fails 2020-05-01 15:14:42 +02:00
Julien Neuhart
f9f0fc1d67 Removing user profile directory when an Office conversion is over 2020-05-01 15:12:51 +02:00
Julien Neuhart
42e357341a Updating dependencies and fixing lint issues 2020-05-01 14:22:42 +02:00
Julien Neuhart
edc5402aad Adding new badge for Docker pulls in README 2020-05-01 14:22:10 +02:00
Julien Neuhart
3f61ffa6fb Updating CONTRIBUTING.md (Go version) + Rebuilding documentation 2020-05-01 13:08:53 +02:00
Julien Neuhart
1c22b889fe Merge branch 'master' of https://github.com/thecodingmachine/gotenberg 2020-05-01 12:58:15 +02:00
Julien Neuhart
e80f5939b2 Updating markdown files according to some users PRs 2020-05-01 12:58:11 +02:00
Julien Neuhart
c068088cc1 Merge pull request #182 from sdawka/patch-1
Update index.html
2020-05-01 12:53:07 +02:00
Julien Neuhart
1767f371f7 Merge branch 'master' of https://github.com/thecodingmachine/gotenberg 2020-05-01 12:52:14 +02:00
Julien Neuhart
5d5caf64fa Merge pull request #148 from thecodingmachine/bugfix/typo-in-readme
Fixing documentation typo
2020-05-01 12:52:06 +02:00
Julien Neuhart
e095b9419a Updating go version 2020-05-01 12:48:48 +02:00
Sahil Dawka
a5ef072f56 Update index.html 2020-03-18 17:26:22 -04:00
Julien Neuhart
5f6a2456d4 Merge pull request #177 from thecodingmachine/dependabot/go_modules/github.com/stretchr/testify-1.5.1
Bump github.com/stretchr/testify from 1.4.0 to 1.5.1
2020-03-06 14:01:58 +01:00
dependabot-preview[bot]
b0f24889f4 Bump github.com/stretchr/testify from 1.4.0 to 1.5.1
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.4.0 to 1.5.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.4.0...v1.5.1)

Signed-off-by: dependabot-preview[bot] <support@dependabot.com>
2020-03-06 08:44:44 +00:00
Julien Neuhart
0a69f0fd51 Merge pull request #176 from pgrimaud/master
Fix typo in README.md
2020-03-05 14:47:53 +01:00
Pierre Grimaud
43c0142682 Fix typo in README.md 2020-03-05 14:43:34 +01:00
Julien Neuhart
1ff16cc54b minor refactoring: using default scale from default options 2020-02-10 10:10:49 +01:00
Julien Neuhart
3609cf54c0 Merge pull request #168 from mdjward/feature/chrome-scaling-support
Add support for Chrome scaling.
2020-02-10 10:03:24 +01:00
Matt Ward
198038f5ea Add support for Chrome scaling. 2020-02-01 19:10:40 +00:00
Julien Neuhart
b5a3df85a8 Merge pull request #163 from bgentry/fix-kerning-with-font-hinting-none
use --font-render-hinting=none to fix kerning
2020-01-06 09:32:48 +01:00
Blake Gentry
6e56c6e0d4 use --font-render-hinting=none to fix kerning
When using font render hinting on Chrome headless on Linux, the kerning
is inconsistent. Several issues cover this, including:

* https://github.com/puppeteer/puppeteer/issues/661
* https://github.com/nzzdev/Q-server/pull/193

Disabling that render hinting entirely results in correct kerning.
2019-12-30 20:56:57 -08:00
Julien Neuhart
568f3d6007 Merge pull request #158 from thecodingmachine/6.1.1
6.1.1
2019-12-12 11:35:13 +01:00
Julien Neuhart
6b505a1d06 updating dependencies 2019-12-12 11:22:04 +01:00
Julien Neuhart
acc7e91c55 fixing #157 2019-12-12 11:14:15 +01:00
Julien Neuhart
8e43df8d95 fixing missing renaming: GOTENBERG_REPOSITORY => GOTENBERG_REGISTRY 2019-12-12 11:14:03 +01:00
Julien Neuhart
ca81b382d3 Merge pull request #155 from thecodingmachine/6.1.0
6.1.0
2019-12-10 15:58:39 +01:00
Julien Neuhart
a60d7976f5 fixing typo in go client version 2019-12-10 15:58:21 +01:00
Julien Neuhart
46cf63da37 Merge pull request #122 from thecodingmachine/page_ranges
pageRanges formField
2019-12-10 15:38:01 +01:00
Julien Neuhart
099079b962 fixing typo 2019-12-10 15:25:56 +01:00
Julien Neuhart
5b8121516f updating with base branch 2019-12-10 15:17:25 +01:00
Julien Neuhart
23eacaa78a Merge branch '6.1.0' of https://github.com/thecodingmachine/gotenberg into 6.1.0 2019-12-10 14:40:12 +01:00
Julien Neuhart
659292a3f1 updating documentation for go client v7 2019-12-10 14:39:15 +01:00
Julien Neuhart
f88756d3a8 Merge pull request #153 from thecodingmachine/custom_headers
Custom headers
2019-12-09 17:55:14 +01:00
Julien Neuhart
81e7bab22a updating documentation with Golang examples for custom HTTP headers 2019-12-09 17:26:00 +01:00
Julien Neuhart
1eeb5fc2b0 typo in variable 2019-12-09 17:20:56 +01:00
Julien Neuhart
126cdd73e4 adding PHP example for custom HTTP headers in documentation + improving logs for custom HTTP headers 2019-12-09 16:50:08 +01:00
Julien Neuhart
2251267ae4 Merge branch '6.1.0' of https://github.com/thecodingmachine/gotenberg into custom_headers 2019-12-09 15:15:18 +01:00
Julien Neuhart
f3e4b6d9a2 Merge pull request #156 from thecodingmachine/custom_uid
Custom uid/gid
2019-12-09 15:15:11 +01:00
Julien Neuhart
b22379537b Merge branch '6.1.0' of https://github.com/thecodingmachine/gotenberg into custom_headers 2019-12-09 15:14:15 +01:00
Julien Neuhart
feb5bb0653 updating with current version of 6.1.0 branch 2019-12-09 14:38:31 +01:00
Julien Neuhart
ebfe99be79 adding documentation on how to change gid/uid + renamming DOCKER_REPOSITORY to DOCKER_REGISTRY 2019-12-09 14:36:01 +01:00
Julien Neuhart
49c1b939d5 Merge pull request #154 from thecodingmachine/http_namespacing
ROOT_PATH for customizing the root path
2019-12-06 17:39:18 +01:00
Julien Neuhart
84a37f124b adding documentation for ROOT_PATH 2019-12-06 17:30:36 +01:00
Julien Neuhart
f0f48f4ddf adding ROOT_PATH + tests 2019-12-06 17:23:17 +01:00
Julien Neuhart
cf15e3a9a1 updating documentation (without go & php examples) 2019-12-06 16:18:14 +01:00
Julien Neuhart
927f98c66b adding custom headers for remoteURL 2019-12-06 11:22:11 +01:00
Julien Neuhart
782f6ac27e rolling back to golangci-lint 1.20.1 + moving normize filename to resource & tests with a file with special chars in its name + webhookurl custom headers done + preparing remote url custom headers 2019-12-05 18:16:02 +01:00
Julien Neuhart
5140e4ec9a updating golangci-lint 2019-12-05 15:43:24 +01:00
Julien Neuhart
e255f9bfc5 adding c# community client 2019-12-03 15:21:14 +01:00
David Négrier
9e9b369dc2 Fixing documentation typo
s/others/other/g
2019-11-20 19:03:17 +01:00
Julien Neuhart
765dc734a4 adding new logo 2019-11-20 11:44:18 +01:00
Julien Neuhart
3abe8a1103 improving documentation about documentation generation 2019-11-20 11:43:51 +01:00
Julien Neuhart
73333074fd Merge pull request #146 from alanpoulain/doc-fix-php-param-examples
[Doc] Fix dest param in PHP examples
2019-11-15 15:47:26 +01:00
Alan Poulain
a415786e1c Modify MD files 2019-11-15 15:41:51 +01:00
Alan Poulain
a3a1077dea Return of store method is void 2019-11-15 15:22:40 +01:00
Alan Poulain
58a812c2c9 Fix dest param in PHP examples 2019-11-15 15:17:27 +01:00
Julien Neuhart
7e69ec4367 Merge pull request #141 from thecodingmachine/merge_order
fixing merge order
2019-11-04 17:14:45 +01:00
Julien Neuhart
4623c01896 fixing merging order 2019-11-04 17:01:27 +01:00
Julien Neuhart
e85322de62 Merge pull request #137 from thecodingmachine/6.0.3
6.0.3
2019-10-31 17:53:48 +01:00
Julien Neuhart
090ff3da13 fixing typos in markdown files too 2019-10-31 17:44:11 +01:00
Julien Neuhart
fe068b1bfb Merge pull request #133 from dmfaux/patch-1
Corrected typos
2019-10-31 17:42:40 +01:00
Julien Neuhart
d3b0a685b9 adding a (very) small section for Cloud Run 2019-10-31 17:37:06 +01:00
Julien Neuhart
245c04fc5a Merge pull request #138 from odarriba/use-tiny-static
Use statically linked version of tini
2019-10-31 17:14:01 +01:00
Julien Neuhart
94acb0c6ae updating documentation with details on how to make background-color and color CSS properties work in header/footer 2019-10-31 17:12:34 +01:00
Julien Neuhart
ad4a16cc4e adding community clients section 2019-10-31 17:03:51 +01:00
Óscar de Arriba
dae39113f6 use statically linked version of tini 2019-10-31 17:02:29 +01:00
taylor
1e1cb8fe57 Make the gotenberg user owner of /tini binary to fix running image in cloud environments 2019-10-31 16:31:58 +01:00
David Faux
606254d941 Corrected typos
Correct `extend` to `extent`
Correct `are` to `as`
2019-10-28 07:28:40 +02:00
Julien Neuhart
8fd51b9e9e fixing typo in package/Dockerfile 2019-10-14 11:32:37 +02:00
Julien Neuhart
f90442cfb1 Merge pull request #124 from thecodingmachine/wrong_http_error_codes
improving HTTP error code and fixing #67
2019-10-08 11:05:29 +02:00
Julien Neuhart
2a44c0f65a improving HTTP error code and fixing #67 2019-10-08 10:50:15 +02:00
Julien Neuhart
cd8d2b5e95 Merge pull request #121 from thecodingmachine/rpcc_message_too_large
PrintToPDF: rpcc: message too large
2019-10-07 18:07:09 +02:00
Julien Neuhart
8d89b2e80a rpcc buffer size may now be set from env var or form field 2019-10-07 15:31:05 +02:00
Julien Neuhart
3255f8af0c updating documentation: if office and more than one document, page ranges will be applied for each of them 2019-10-05 12:13:06 +02:00
Julien Neuhart
ca0784fa59 adding tests for page ranges 2019-10-05 12:09:39 +02:00
Julien Neuhart
827060b6ce emphazing that pageRanges does not work if more than one office document 2019-10-04 18:22:46 +02:00
Julien Neuhart
60c069cb1f updating documention with pageRanges 2019-10-04 18:19:13 +02:00
Julien Neuhart
70dfea5876 adding pageRanges formField 2019-10-04 17:49:26 +02:00
Julien Neuhart
ab475d65ee fixing PrintToPDF: rpcc: message too large 2019-10-04 16:19:53 +02:00
Julien Neuhart
fa9963a737 fixing load testing link in documentation 2019-10-04 14:11:32 +02:00
Julien Neuhart
d65d9c9a4c fixing ineffassign issues 2019-10-03 11:59:48 +02:00
Julien Neuhart
6f8c7a613f improving documentation about header/footer, fonts and fixing a typo about paper size (fixes #118, closes #119) 2019-10-03 11:28:04 +02:00
Julien Neuhart
1b45c927b2 fixing publish command (for good this time) 2019-10-02 14:49:47 +02:00
Julien Neuhart
ab1cc896de fixing publish command 2019-10-02 14:28:50 +02:00
Julien Neuhart
4b91600e35 Merge pull request #106 from thecodingmachine/6.0.0
6.0.0
2019-10-02 14:11:11 +02:00
Julien Neuhart
69e43e40eb improving documentation 2019-10-02 11:41:35 +02:00
Julien Neuhart
930622ea3c adding load testing results 2019-10-02 11:09:21 +02:00
Julien Neuhart
7ac104c3c6 refactoring process management: no more orphan processes and quicker chrome startup 2019-10-02 10:38:47 +02:00
Julien Neuhart
c47dc5ecdb office: no more orphan processes (hopefully) 2019-10-01 17:33:00 +02:00
Julien Neuhart
a3f05c1146 minor refactoring of building process 2019-10-01 17:32:15 +02:00
Julien Neuhart
04b1fa378d fixing typo in documentation 2019-10-01 09:33:31 +02:00
Julien Neuhart
cbc9d9ab8c using tini to reap zombie processes 2019-09-30 20:17:39 +02:00
Julien Neuhart
7f61c63425 adding information about how to use images in header/footer. Thanks @LoicHa 2019-09-30 17:27:52 +02:00
Julien Neuhart
3d379dec58 adding reference to the load testing use case in the documentation 2019-09-30 17:19:14 +02:00
Julien Neuhart
9968a816db improving document 2019-09-30 17:16:40 +02:00
Julien Neuhart
8f721cd12a fixing tests 2019-09-30 16:38:52 +02:00
Julien Neuhart
d45f2e1ec7 updating go modules to 1.13 2019-09-30 16:32:06 +02:00
Julien Neuhart
7e0f1794a5 updating dependencies 2019-09-30 16:24:39 +02:00
Julien Neuhart
20959fc68f updating Golang and Golang CI Lint versions 2019-09-30 15:55:01 +02:00
Julien Neuhart
a942224f46 adding back context to unoconv cmd 2019-09-30 15:54:17 +02:00
Julien Neuhart
fef16f6b28 removing useless whitespace 2019-09-30 15:53:48 +02:00
Julien Neuhart
1abc4d0365 lint: disabling funlen 2019-09-30 15:53:15 +02:00
Julien Neuhart
862761d46f removing PM2 references in README 2019-09-30 14:52:37 +02:00
Julien Neuhart
94743a16be adding TODO comment 2019-09-30 14:52:16 +02:00
Julien Neuhart
f6729834b6 removing comment 2019-09-30 14:51:49 +02:00
Julien Neuhart
1b4c435900 rolling back to an easier solution for executing unoconv as previous one did not remove zombies 2019-09-29 15:13:19 +02:00
Julien Neuhart
24e6a86bf5 rolling back from previous implementation, now using master version of unoconv 2019-09-26 18:07:43 +02:00
Julien Neuhart
88f74d5e9c process balancing: better implementation (wip) 2019-09-23 18:29:24 +02:00
Julien Neuhart
171f93662f process load balancing: broken but in progress 2019-09-19 17:28:29 +02:00
Julien Neuhart
c5429f7efa test: removing unoconv listener as soffice in server mode has a memory leak 2019-08-27 16:51:03 +02:00
Julien Neuhart
36847e6a06 fixing wrong status code if alreay echo http error 2019-08-27 16:50:27 +02:00
Julien Neuhart
438fd7d825 fixing CI permissions with coverage file 2019-08-27 11:20:25 +02:00
Julien Neuhart
767b61a3bb typos in documentation 2019-08-27 11:11:40 +02:00
Julien Neuhart
dd2542dcd6 updating Travis and GitHub Actions workflows 2019-08-27 11:07:05 +02:00
Julien Neuhart
f8b63d487a adding back travis CI badge 2019-08-27 11:03:26 +02:00
Julien Neuhart
fcd0feb7f6 adding back Travis as GitHub actions are not yet ready 2019-08-27 11:02:03 +02:00
Julien Neuhart
8287cf386b improving documentation on timeout issues 2019-08-27 11:00:26 +02:00
Julien Neuhart
8415bee5da reducing the number of concurrent dev tools connections to Chrome 2019-08-27 09:08:00 +02:00
Julien Neuhart
30eb48321c reordering code 2019-08-26 11:20:16 +02:00
Julien Neuhart
7cff2e01a4 reducing memory footprint 2019-08-26 10:22:39 +02:00
Julien Neuhart
fa94341b60 updating to Libreoffice 6.3 @tiago-soczek 2019-08-22 09:45:12 +02:00
Julien Neuhart
a1b34d5e7b adding 'restart_time' information about a process in ping endpoint if LOG_LEVEL=DEBUG 2019-08-21 15:28:34 +02:00
Julien Neuhart
14ef3cf07c fixing typo in main.go 2019-08-21 11:38:50 +02:00
Julien Neuhart
db6cddefd4 updating documentation according to changes 2019-08-20 16:20:42 +02:00
Julien Neuhart
95c6ecb724 adding PM2 processes details as response of ping endpoint if LOG_LEVEL=DEBUG 2019-08-20 15:23:02 +02:00
Julien Neuhart
fc27f6b3f0 improving speed of the command make image 2019-08-20 15:06:17 +02:00
Julien Neuhart
2ce0bb2caa updating ISSUE_TEMPLATE.md 2019-08-20 13:04:40 +02:00
Julien Neuhart
94e1c006ab commenting for now the main step for publish action: need to check what contains 2019-08-20 12:58:02 +02:00
Julien Neuhart
fc2e3586a2 renaming workflows (easier for badge) and removing travis 2019-08-20 12:56:53 +02:00
Julien Neuhart
bca8b6b606 adding codecov token + improving GitHub jobs names 2019-08-20 12:23:25 +02:00
Julien Neuhart
bb6a3471d5 updating CI process + tests for code coverage 2019-08-20 12:06:08 +02:00
Julien Neuhart
09d07e4890 removing TTY flag from lint and tests commands 2019-08-20 11:34:26 +02:00
Julien Neuhart
8826ad8990 fixing issue in cd workflow 2019-08-20 11:27:26 +02:00
Julien Neuhart
b350edc250 fixing issue in cd workflow 2019-08-20 11:21:55 +02:00
Julien Neuhart
af326c0b1e adding GitHub workflows 2019-08-20 11:20:21 +02:00
Julien Neuhart
edc346a6b3 removing testfunc as it no more needed 2019-08-20 10:51:32 +02:00
Julien Neuhart
1e3fc97f2b finalizing xhttp package tests 2019-08-20 10:46:21 +02:00
Julien Neuhart
51b78ef5f3 fixing typos 2019-08-20 10:46:05 +02:00
Julien Neuhart
4e4036382b improving load time for lint and tests commands 2019-08-20 10:45:56 +02:00
Julien Neuhart
daf7cb9d6b adding xhttp package tests for checking that endpoints return a correct status code according if google chrome and/or unoconv are disabled in the configuration 2019-08-19 17:21:30 +02:00
Julien Neuhart
08c97f280f typo in http client variable name 2019-08-19 16:50:32 +02:00
Julien Neuhart
9c836a34ee handling webhookURLTimeout 2019-08-19 16:49:59 +02:00
Julien Neuhart
e839430270 adding WebhookURLTimeoutArg for retrieving and validating webhookURLTimeout form field 2019-08-19 16:44:13 +02:00
Julien Neuhart
cf5e530ed9 special case handling: if one file has been sent and it is empty, return a correct invalid HTTP code AND remove the resource folder 2019-08-19 16:29:58 +02:00
Julien Neuhart
49ac5e647f fixes #87 (dummy commit) 2019-08-19 09:51:15 +02:00
Julien Neuhart
5a7b3ded20 fixes #92 (dummy commit) 2019-08-19 09:51:05 +02:00
Julien Neuhart
bcf06f0d69 fixes #104 2019-08-18 20:03:47 +02:00
Julien Neuhart
4f3fe28905 adding missingt test in context package 2019-08-18 19:24:08 +02:00
Julien Neuhart
f695861731 adding resource package tests 2019-08-18 18:58:54 +02:00
Julien Neuhart
2fee966fc8 adding test for printer package 2019-08-18 17:31:07 +02:00
Julien Neuhart
6964124c1b typo fix 2019-07-28 18:15:47 +02:00
Julien Neuhart
f9e2d91e48 updating where 'custom' fonts are installed 2019-07-28 18:14:55 +02:00
Julien Neuhart
2c483e7f7c Merge pull request #100 from vbaidak/noto-emoji-font
Noto emoji font
2019-07-28 18:04:49 +02:00
Julien Neuhart
3d078108a3 Merge pull request #99 from vbaidak/inconsistent-font-rendering
Inconsistent text rendering in headless mode
2019-07-28 18:04:15 +02:00
Julien Neuhart
794967bd9f fixing issue with Google fonts: now waiting for network idle event 2019-07-28 18:02:11 +02:00
Julien Neuhart
554474776c refactoring tests util methods + adding context tests 2019-07-24 14:56:12 +02:00
Vladyslav Baidak
81c5f9cd4e Merge branch '6.0.0' into noto-emoji-font 2019-07-24 13:09:13 +03:00
Vladyslav Baidak
e4735401a7 Added Noto Color Emoji Font 2019-07-24 12:57:59 +03:00
Vladyslav Baidak
758c3be3c5 Added Noto Emoji Font 2019-07-24 12:52:04 +03:00
Julien Neuhart
2be9ae7868 updating dependencies 2019-07-24 10:58:51 +02:00
Julien Neuhart
3ee4540f7d fixing method HasResource 2019-07-24 10:20:36 +02:00
Julien Neuhart
84c1175379 better debug logs for Markdown printer 2019-07-23 17:06:30 +02:00
Julien Neuhart
ad4d87a138 improving context timeout tests for office and merge printers + test with only one file to convert for the office printer 2019-07-23 16:59:32 +02:00
Julien Neuhart
5feff0d0ec adding merge printer tests 2019-07-23 16:50:24 +02:00
Julien Neuhart
08e7a2c065 fixing lint issue in xexec package 2019-07-23 16:40:54 +02:00
Julien Neuhart
a524521fdb improving lock mechanism for office conversion: now check if context has timeout when trying to acquire the lock + office printer test 2019-07-23 16:40:39 +02:00
Julien Neuhart
0b2312a407 better logical operation for xexec command output 2019-07-23 16:25:55 +02:00
Julien Neuhart
c7d9e24c00 now using Gateway timeout status code in case of timeout 2019-07-23 15:11:37 +02:00
Julien Neuhart
0d827e490e using (again) Golang binaries from official Docker image instead of standard install without checkum + added a TODO for healtheck 2019-07-23 14:25:31 +02:00
Vladyslav Baidak
6402125233 Inconsistent text rendering in headless mode 2019-07-23 15:06:33 +03:00
Julien Neuhart
7bfbda4490 huge refactoring 2019-07-23 13:57:18 +02:00
Julien Neuhart
f6b357691c minor refactoring of const + better timeout handling in pinter package + api package tests 2019-07-10 11:46:58 +02:00
Julien Neuhart
0c1e4e6888 WIP: tests of api pkg 2019-07-09 22:22:22 +02:00
Julien Neuhart
c7ecdcf625 handling context timeout/cancelled in printer package 2019-07-09 19:39:14 +02:00
Julien Neuhart
957b9b1cf4 adding config to test suite in the Dockerfile entrypoint 2019-07-08 18:28:13 +02:00
Julien Neuhart
1b4eed1a6e fixing pm2 tests + adding pkg config tests 2019-07-08 18:26:17 +02:00
Julien Neuhart
acd47b870f fixing typo in godoc in pkg random 2019-07-08 18:07:44 +02:00
Julien Neuhart
9ab8d03697 adding tests for random pkg 2019-07-08 18:06:43 +02:00
Julien Neuhart
9c6f23d7cc typo in timeout tests + adding tests for standarderror 2019-07-08 17:50:31 +02:00
Julien Neuhart
9af00d6d58 fixes waitTimeout, waitDelay not working with float 2019-07-08 17:24:17 +02:00
Julien Neuhart
186c09f29b adding op and standarderror in printer package 2019-07-08 11:16:47 +02:00
Julien Neuhart
70b185a37a improving logging 2019-07-08 11:06:26 +02:00
Julien Neuhart
5af01505c2 better op names + fixing issue with op chaining in errors 2019-07-08 10:28:43 +02:00
Julien Neuhart
5f6ab2fc08 all logs now have an op field 2019-07-07 19:54:44 +02:00
Julien Neuhart
c8f7ea934c wip refactoring: better logging and error systems 2019-07-07 17:45:07 +02:00
Julien Neuhart
a4aa8dafac WIP: refactoring logging system 2019-07-01 17:11:23 +02:00
Julien Neuhart
8ca9866440 minor refactoring of @tbflw work 2019-06-21 13:32:46 +02:00
Julien Neuhart
97eff7f21d Merge pull request #88 from tbflw/feature/run_non_root_and_log_startup
Feature/run non root and log startup
2019-06-21 11:18:26 +02:00
Thomas Bøgh Fangel
e30fd8eb9b fixed compile errors after accepting review suggestions 2019-06-17 11:55:36 +02:00
Thomas Bøgh Fangel
6fa48c9222 Apply suggestions from code review
Co-Authored-By: Julien Neuhart <j.neuhart@thecodingmachine.com>
2019-06-17 11:29:52 +02:00
Thomas Bøgh Fangel
c36c7144cb updates documentation with info about new env var and dedicated user gotenberg 2019-06-13 16:08:04 +02:00
Thomas Bøgh Fangel
bbd0a893b1 adds configurable debugging of startup of processes and print out some info from the chrome viable() function for debugging purposes 2019-06-13 16:08:04 +02:00
Thomas Bøgh Fangel
cfea2b8d9d updates Dockerfile for creating image to use the new dedicated user and sets PM2_HOME inside the /gotenberg dir to avoid permission problems 2019-06-13 15:55:52 +02:00
Thomas Bøgh Fangel
0fcbe821cd updates .gitignore and adds .dockerignore with irrelevant dirs for building images 2019-06-13 15:55:52 +02:00
Thomas Bøgh Fangel
ca41164980 add dedicated gotenberg user to the base image, create the /gotenberg directory with gotenberg user as owner, updates base debian image to newest 9.* 2019-06-13 15:55:26 +02:00
Thomas Bøgh Fangel
cf20e2a510 adds possibility of specifying docker to repo image target 2019-06-13 15:55:19 +02:00
Julien Neuhart
02f1231e7d New environment variables : DISABLE_HEALTHCHECK_LOGGING and DEFAULT_LISTEN_PORT (#78)
* skip logging when healthcheck url is called; closes #74 (#75)

Signed-off-by: Casey Kuhlman <casey@monax.io>

* Configure listen port via environment variable (#77)

* skip logging when healthcheck url is called; closes #74

Signed-off-by: Casey Kuhlman <casey@monax.io>

* skip logging when healthcheck url is called; closes #74

Signed-off-by: Casey Kuhlman <casey@monax.io>

* adds the ability to establish the listen port via env var

Signed-off-by: Casey Kuhlman <casey@monax.io>

* minor refactoring of @compleatang work

* fixing typo
2019-06-03 15:05:09 +02:00
Nicholas Jones
6846e7941f Install pm2 via npm (#72)
As noted in the Dockerfile, the previous route to install pm2 was broken at
some point. As a result, pm2 related files are now being copied over from an
image created prior to the breakage.

This change now installs pm2 via npm, which is the recommended route for this.
A small adjustment had to be made in the processManager code; an
argument was being passed with a space in it. For reasons that I've not been
able to fully trace, this no longer works. It should be noted that passing
arguments in this way can result in undesirable behaviour - in this case I
believe "--interpreter none" was being passed as a single argument, rather than
"--interpreter", "none". I've adjusted this to use an equals - splitting into 2
separate strings works well too.
2019-05-09 11:15:37 +02:00
Hong Shick Pak
719287c3a6 Fix google chrome env var error message (#68) 2019-04-25 09:13:10 +02:00
Julien Neuhart
194670c3bf 5.0.0 (#66) 2019-04-14 17:07:45 +02:00
Jonathan
c4222e0981 Fix documentation url example (#59)
* Fix documentation url example

One \ was forgotten

* fix: doc
2019-03-31 22:16:35 +02:00
Julien Neuhart
61dc4b8bb8 improving documentation about Kubernetes (fixes #40 #53) 2019-03-27 11:44:42 +01:00
Julien Neuhart
df357c1008 fixing typo in documentation 2019-03-26 17:37:25 +01:00
Julien Neuhart
871b52a288 adding filename form field + updating echo package to v4 (#57) 2019-03-26 17:35:38 +01:00
Julien Neuhart
8a4a043e9f fixing documentation about go library 2019-03-15 17:15:47 +01:00
Julien Neuhart
c0297aa061 removing go client library => now available at github.com/thecodingmachine/gotenberg-go-client 2019-03-15 17:07:51 +01:00
Julien Neuhart
2aaa2eeaff Merge branch 'master' of https://github.com/thecodingmachine/gotenberg 2019-03-14 16:39:18 +01:00
Julien Neuhart
5f62e5bacf removing 'v' from tag 2019-03-14 16:39:08 +01:00
Alexander Joseph Trelore
ab78559710 remove unused imports (#55)
Thanks!
2019-03-13 10:53:20 +01:00
Julien Neuhart
0d75bb1d3e improving documentation on merge 2019-03-05 11:27:25 +01:00
Julien Neuhart
6441062e7f adding Google analytics 2019-02-19 15:27:20 +01:00
Julien Neuhart
4827a45f22 adding flat open document support (fixes #47) 2019-02-18 13:46:58 +01:00
Julien Neuhart
40eef457ab adding latest tag (fixes #48) 2019-02-18 13:44:30 +01:00
Julien Neuhart
c22a4d109b minor fix in doc for web fonts 2019-02-01 11:22:06 +01:00
Julien Neuhart
571c3e04c4 adding form field web fonts timeout 2019-02-01 09:40:16 +01:00
Sergei Opletaev
f9fb97a572 Fix for #44 promise wait for fonts (#45)
`runtime.ExceptionDetails: Uncaught exception at 4:47: SyntaxError: Unexpected token %`
2019-02-01 09:21:02 +01:00
Julien Neuhart
e6484bb2d5 Trigger GitHub documentation 2019-01-28 15:56:13 +01:00
Julien Neuhart
19f51fdc1f fixing PHP documentation 2019-01-28 15:36:59 +01:00
Julien Neuhart
25f7ba3ee6 4.0.0 (#42)
* adding URL conversions

* updating gotenberg version in documentation

* pkg: input as variadic string (#39)

* pkg: input as variadic string

* pkg: fix link to github doc

* update doc about v4 client

* make doc

* fixing missing variadic inputs in doc

* adding more fonts

* updating PHP documentation
2019-01-28 15:18:12 +01:00
Julien Neuhart
a3cd1ff4e1 Merge pull request #41 from thecodingmachine/office-paperSize
removing paper size for Office conversion
2019-01-25 15:57:25 +01:00
Julien Neuhart
f62c717f79 fixing typo in docs 2019-01-25 15:50:15 +01:00
Julien Neuhart
3ed99133d4 removing paper size for Office conversion 2019-01-25 15:48:40 +01:00
Julien Neuhart
ab25cc331d fixes #35 2019-01-25 15:02:18 +01:00
Julien Neuhart
2648992a9b adding moar favicons 2019-01-03 16:02:02 +01:00
Julien Neuhart
7bcc3d081e adding liveness section in documentation 2019-01-03 15:56:53 +01:00
Julien Neuhart
affd1c45ca Merge pull request #32 from Peltoche/master
Add a /ping endpoint
2019-01-03 15:37:18 +01:00
Peltoche
8c7458812d Add a /ping endpoint
This endpoint can be used to check the service liveness. It can be used
for the kubernetes liveness and readiness probs.
2019-01-03 15:21:57 +01:00
Julien Neuhart
053908c772 Merge pull request #31 from thecodingmachine/merge-alt
adding PDFtk for merging PDF files < 1.7
2019-01-03 11:10:07 +01:00
Julien Neuhart
d623736f9f rollbacking to debian 9.5 as the 9.6 image has issue with git installation 2019-01-03 11:01:02 +01:00
Julien Neuhart
a3e416ff68 adding PDFtk for merging PDF files < 1.7 2019-01-03 10:06:56 +01:00
Julien Neuhart
8bf207b9f7 improving contributing doc 2018-12-14 16:13:52 +01:00
Julien Neuhart
e1520d5e7e Merge branch 'master' of https://github.com/thecodingmachine/gotenberg 2018-12-14 15:59:59 +01:00
Julien Neuhart
4f89c9f7c6 updating logo and documentation 2018-12-14 15:59:45 +01:00
Ben Davies
d34fb92211 docs: cURL write to a file instead of stdout (#25) 2018-12-14 09:28:18 +01:00
Julien Neuhart
3f7b006d16 fixing docs generation: was adding Dockerfile too.. 2018-12-14 09:15:51 +01:00
Julien Neuhart
a15cf33691 Dockerfiles: removing useless labels. Docs: now generating with Docker. Travis: publishing done with make 2018-12-14 09:13:04 +01:00
Julien Neuhart
4c1699df6c webhook: resulting file was deleted because of defer outside of goroutine 2018-12-13 18:15:28 +01:00
Julien Neuhart
404535c512 fixing/simplifying godoc 2018-12-13 17:06:29 +01:00
Julien Neuhart
2cedcecc38 adding concurrent testing 2018-12-13 14:46:59 +01:00
Julien Neuhart
ba7b02d8d4 fixing various typos in documentation 2018-12-12 17:44:27 +01:00
Julien Neuhart
6aa3c16646 fixing golang client README 2018-12-12 16:44:32 +01:00
Julien Neuhart
5a03e17e00 fixing introduction in documentation 2018-12-12 16:37:36 +01:00
Julien Neuhart
417d145b43 fixing documentation typos 2018-12-12 16:36:21 +01:00
Julien Neuhart
e5636a2afa adding .txt and .rtf files support (#20) 2018-12-12 16:34:34 +01:00
Julien Neuhart
5291d2d129 disabling pdfcpu logs (#22) 2018-12-12 16:24:54 +01:00
Julien Neuhart
c6f6a87499 now using latest version of unoconv, allowing to specify paper size and orientation + updating go client (#24) 2018-12-12 16:16:35 +01:00
Julien Neuhart
1ef21c6ed6 RPC error (#23) 2018-12-12 12:20:07 +01:00
Julien Neuhart
6ee1932cbf improving API tests: now verifying response content type and status code 2018-12-11 14:16:43 +01:00
Julien Neuhart
83cf43a314 fixing cURL commands in documentation 2018-12-11 14:09:04 +01:00
Julien Neuhart
52c14a79a1 fixing GoDoc + some test function names 2018-12-10 20:21:31 +01:00
Julien Neuhart
152db0551d fixing GoDoc links in badges 2018-12-10 20:11:20 +01:00
Julien Neuhart
3a2467ccfe improving documentation 2018-12-10 20:08:45 +01:00
Julien Neuhart
5a28f3597c fixing typos in documentation 2018-12-10 19:59:45 +01:00
Julien Neuhart
9ce179f91b adding PHP examples in documentation 2018-12-10 19:12:54 +01:00
Julien Neuhart
7aeb072cc3 fixing issue with merge endpoint: was not returning file 2018-12-10 18:42:23 +01:00
Julien Neuhart
8a652761a3 fixing Go Report Card issues (rollback gocycly) 2018-12-10 17:03:23 +01:00
Julien Neuhart
be667575d5 fixing Go Report Card issues #2 2018-12-10 17:02:48 +01:00
Julien Neuhart
59ca8a44a0 fixing Go Report Card issues 2018-12-10 16:57:08 +01:00
Julien Neuhart
c9f0d3bfd8 fixes publishing Docker images in CI 2018-12-10 16:36:34 +01:00
Julien Neuhart
06e7a1fda1 fixing website 2018-12-10 16:13:27 +01:00
Julien Neuhart
495203c112 v3.0.0 (#18) 2018-12-10 16:09:19 +01:00
539 changed files with 70987 additions and 174326 deletions

View File

@@ -1,64 +0,0 @@
FROM {{ if eq "CI" .Orbit.Image }}golang:1.10-stretch{{ else }}debian:stretch-slim{{ end }}
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |--------------------------------------------------------------------------
# | Libraries
# |--------------------------------------------------------------------------
# |
# | Installs all required libraries.
# |
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
apt-get update &&\
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
RUN apt-get install -y curl gnupg &&\
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
apt-get update &&\
apt-get install -y nodejs
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
apt-get update &&\
apt-get install -y yarn bzip2 &&\
yarn global add markdown-pdf --prefix /usr/local
# |--------------------------------------------------------------------------
# | Gotenberg
# |--------------------------------------------------------------------------
# |
# | All Gotenberg related stuff.
# |
{{- if eq "CI" .Orbit.Image }}
WORKDIR /go/src/github.com/thecodingmachine/gotenberg
# Installs lint dependencies.
RUN go get -u gopkg.in/alecthomas/gometalinter.v2 &&\
gometalinter.v2 --install
# Copies our Go source.
COPY . .
# Installs project dependencies.
RUN go get -d -v ./...
ENTRYPOINT [".ci/docker-entrypoint.sh"]
{{- else }}
COPY .ci/gotenberg /usr/bin/gotenberg
RUN ln -s /usr/bin/gotenberg /usr/local/bin/gotenberg
COPY .ci/gotenberg.yml /gotenberg/gotenberg.yml
WORKDIR /gotenberg
EXPOSE 3000
CMD ["gotenberg"]
{{- end }}

View File

@@ -1,33 +0,0 @@
#!/bin/bash
set -xe
# Statically checking Go source for errors and warnings.
gometalinter.v2 --disable-all -E vet -E gofmt -E misspell -E ineffassign -E goimports -E deadcode -E gocyclo --vendor ./...;
# Running tests according to current Gotenberg version.
if [[ "$VERSION" == "snapshot" ]]; then
for d in $(go list ./... | grep -v vendor); do
go test -race -cover -covermode=atomic $d;
done
else
echo "" > .ci/coverage.txt;
for d in $(go list ./... | grep -v vendor); do
go test -race -coverprofile=profile.out -covermode=atomic $d;
if [ -f profile.out ]; then
cat profile.out >> .ci/coverage.txt;
rm profile.out;
fi
done
fi
# Builds the Linux binary.
if [ -f .ci/gotenberg ]; then
rm .ci/gotenberg
fi
env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" && mv gotenberg .ci/;
# Bye!
exit 0;

View File

@@ -1,60 +0,0 @@
# The port the application will listen to.
port: 3000
logs:
# Accepted values, in order of severity: DEBUG, INFO, WARN, ERROR, FATAL, PANIC.
# Messages at and above the selected level will be logged.
level: "INFO"
# Accepted values: text, json.
# When a TTY is not attached, the output will be in the defined format.
formatter: "text"
# You don't like a library which is used for a conversion? You want to handle a new file type?
# You may provide here your own implementation!
commands:
# Some libraries like unoconv cannot perform concurrent conversions. That's why the API does only one conversion at a time.
# If your current implementation uses libraries which are able to perform concurrent conversions, you may
# change this value to false.
lock: true
# Unlike others commands' templates, you have access to FilesPaths instead of FilePath: it gathers all PDF files which should be merged.
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
# You may add more commands (or less, or even none).
conversions:
# The command template: you have access to FilePath and ResultFilePath variables.
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
# The binary which will call the command.
interpreter: "/bin/sh -c"
# Duration in seconds after which the command will be killed if it has not finished.
timeout: 30
# Files with the following extensions will be converted by the current command.
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
.git

View File

@@ -1,46 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at neuhart.julien@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/

View File

@@ -1,33 +0,0 @@
# Contributing
Hi! Thank your for considering contributing to Gotenberg. You'll
find below useful information about how to contribute to the Gotenberg project.
## Contributing code
### Install from sources
1. Fork this repository
2. Clone it to your local Go environment (requires *Go* >= 1.10)
3. Install the latest [Orbit](https://github.com/gulien/orbit) release
### Working with git
1. Create your feature branch (`git checkout -b my-new-feature`)
2. Commit your changes (`git commit -am 'Add some feature'`)
3. Push to the branch (`git push origin my-new-feature`)
4. Create a new pull request
## Reporting bugs and feature request
Your issue or feature request may already be reported!
Please search on the [issue tracker](../../../issues) before creating one.
If you do not find any relevant issue or feature request, feel free to
add a new one!
## Additional resources
* [Code of conduct](CODE_OF_CONDUCT.md)
* [Issue template](ISSUE_TEMPLATE.md)
* [Pull request template](PULL_REQUEST_TEMPLATE.md)

View File

@@ -1,32 +0,0 @@
Your issue may already be reported!
Please search on the [issue tracker](../../../issues) before creating one.
## Expected Behavior
<!--- If you're describing a bug, tell us what should happen -->
<!--- If you're suggesting a change/improvement, tell us how it should work -->
## Current Behavior
<!--- If describing a bug, tell us what happens instead of the expected behavior -->
<!--- If suggesting a change/improvement, explain the difference from current behavior -->
## Possible Solution
<!--- Not obligatory, but suggest a fix/reason for the bug, -->
<!--- or ideas how to implement the addition or change -->
## Steps to Reproduce (for bugs)
<!--- Provide a link to a live example, or an unambiguous set of steps to -->
<!--- reproduce this bug. Include code to reproduce, if relevant -->
1.
2.
3.
4.
## Context
<!--- How has this issue affected you? What are you trying to accomplish? -->
<!--- Providing context helps us come up with a solution that is most useful in the real world -->
## Your Environment
<!--- Include as many relevant details about the environment you experienced the bug in -->
* Version used:
* Operating System and version:
* Link to your project:

View File

@@ -1,44 +0,0 @@
A similar PR may already be submitted!
Please search among the [pull requests](../../../pulls) before creating one.
Thanks for submitting a pull request! Please provide enough information so that others can review your pull request:
For more information, see the [CONTRIBUTING](CONTRIBUTING.md) guide.
**Summary**
<!-- Summary of the PR -->
This PR fixes/implements the following **bugs/features**
* [ ] Bug 1
* [ ] Bug 2
* [ ] Feature 1
* [ ] Feature 2
* [ ] Breaking changes
<!-- You can skip this if you're fixing a typo or adding an app to the Showcase. -->
Explain the **motivation** for making this change. What existing problem does the pull request solve?
<!-- Example: When "Adding a function to do X", explain why it is necessary to have a way to do X. -->
**Test plan (required)**
Demonstrate the code is solid. Example: The exact commands you ran and their output.
<!-- Make sure tests pass on both Travis and AppVeyor. -->
**Closing issues**
<!-- Put `closes #XXXX` in your comment to auto-close the issue that your PR fixes (if such). -->
Fixes #
**Checklist**
- [ ] Have you followed the guidelines in our [CONTRIBUTING](CONTRIBUTING.md) guide?
- [ ] Have you lint your code locally prior to submission (`orbit run fmt`)?
- [ ] Have you written new tests for your core changes, as applicable?
- [ ] Have you successfully ran tests with your changes locally (`orbit run ci`)?
- [ ] I have squashed any insignificant commits
- [ ] This change has comments for package types, values, functions, and non-obvious lines of code

8
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

18
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 15
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- bug
- documentation
- enhancement
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@@ -0,0 +1,27 @@
name: Continuous Delivery
on:
release:
types: [ published ]
jobs:
release:
name: Release Docker image
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Checkout source code
uses: actions/checkout@v3
- name: Log in to Docker Hub Container Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
run: |
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REPOSITORY=thecodingmachine

View File

@@ -0,0 +1,48 @@
name: Continuous Integration
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
cache: false
- name: Checkout source code
uses: actions/checkout@v3
- name: Run linters
uses: golangci/golangci-lint-action@v3
with:
version: v1.51.2
tests:
needs:
- Lint
name: Tests
# TODO: once arm64 actions are available, also run the tests on this architecture.
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Checkout source code
uses: actions/checkout@v3
- name: Build testing environment
run: make build build-tests
- name: Run tests
run: |
make tests-once
bash <(curl -s https://codecov.io/bash)

4
.gitignore vendored
View File

@@ -1,2 +1,2 @@
.ci/coverage.txt
.ci/gotenberg
/coverage.html
/coverage.txt

27
.golangci.yml Normal file
View File

@@ -0,0 +1,27 @@
linters:
disable-all: true
enable:
- bodyclose
- errcheck
- gofmt
- goimports
- gosec
- gosimple
- govet
- ineffassign
- misspell
- prealloc
- staticcheck
- typecheck
- unconvert
- unused
run:
deadline: 5m
issues-exit-code: 1
tests: false
output:
format: 'colored-line-number'
print-issued-lines: true
print-linter-name: true

View File

@@ -1,28 +0,0 @@
---
project_name: Gotenberg
builds:
- main: ./main.go
binary: gotenberg
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- 386
- amd64
- arm
- arm64
archive:
name_template: '{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'
replacements:
darwin: Darwin
linux: Linux
windows: Windows
386: i386
amd64: x86_64
format: tar.gz
format_overrides:
- goos: windows
format: zip

View File

@@ -1,23 +0,0 @@
sudo: required
language: generic
services:
- docker
env:
global:
- secure: d6pliM7iR7KJ3TQkr1C0tHi0HgU6Vdmc/ycw11XrXs794/dgj6g9UDrhgcvT2mTzOVIUNouHx7gyEWAlwJv7p8PPyguHJfIsyqMWfaXNPoytTx1ETUiygl1G/UsvXVdry6I5tZF/U+/PJ3hBXoAvo8EOFe0/MxxvP3ghCeeCpeEwEdoYP2jbADGffywVUNI7OEdmS29A28GusXXp2cWCIZYSq5Gn5iu1ZMPaJA6SkRTXpS+iVVLUvlbz5HW09ETFfurTvsdz5Ze4JICiuQt///CR1uwdxD8K7Ils/mkenaMtja9NHA9RdQBa51Azn8iCLsObCTYc52Zb6atbCi/X4ae9M8GA8qaaLigNz5G4v1BHneJylYfWKj5GwqUA3VmRu1mlEw7d2sN2uFRmgIl5kgsYYyOVOLBOKaJWhyBhNnR4U8KeqwUkOnlSdmNZnjfexXXBEcKHen1SozpXwMnzZh8LZuN6BNYd6TVpKmjulGI3vqNMM0c4JJZ9pSm4q5FjbNGQArwCsujDDwY4bCGZSJJTPMXjYOrx4yJ1RY+0TpyV9oDU3ogEqaWnhrkgrphfiYBlDY2bEvrxMTMukLCxJb0Nru6LGCpg4X1P9hu9r0gceqUSnBgdJI4rYTNddenxlEd4j/Thqv835stJWz6YoV+rfvERuSUpntl8xvrk3U8=
- secure: CmLh169xwt3OC7l19lDUwdZYfw+l/+XXKjTGxnuJiXHa9hAH+nc4mEUSmqHMHwjquyYE6LnSVGZ94LH8sCaGed/TMm0Lj3DZtZ0VsjYu3sKsNrVJn5WdYEah+GvNJ8i8uzvHnGk1L2fAagLpXFUB2K9UraA1CFiHs1fF5Fua7Ra0mE7lMz0MFvyvzcmc9u8nMz71K2YVsXqn+I/rv8onJCTtc/Ds0hVrBXwG5Fh0T3xHqjOhME9ZI2hRHnnQVdBojSY3USifQ//qAKowfBVc0pebEtgFpyXx/I17A6xCm0KVtnA2tNqu1+XFkjWaBzryaLE9ujhJcTRC/YK0JUZ+QxfYbD5C7jVy9Jlx1lOEjJ42B/ImNxPP+9p3wi2MWNbcbZJPxIe635fnT17lI7CLK2Qg1xjfTQ4WTEKW5IhnYMiyfRKiripFh4PCkDm8cJlTbpntsX1n0uheydbsgf/11vS7o1WGhVQ8+Jelc4DvIbBHCqienQEOLJDBwm7FiK5JbwZJ6ZAGeDM/IhGU1h9PNjVbHUyJhUxYoPYUE9QsZFT7ENCrRQpq49KOzXBRfffyJNJRh+Ep9X5YoW0dUp0MNu5B7z2+6w9X9n/C+loa11xF42bFzniCvvbSOQ37J+EngDinVxJkNPeyivR4fye2ENhIAjAEjouTJLOSXL9nAOU=
before_install:
- wget -qO- https://github.com/gulien/orbit/releases/download/v3.0.0/orbit_Linux_x86_64.tar.gz | tar xvz -C .
- sudo mv ./orbit /usr/local/bin && chmod +x /usr/local/bin/orbit
script:
- orbit run ci -p "Version,${TRAVIS_BRANCH}"
- orbit run build -p "Version,${TRAVIS_BRANCH}"
# Uploads coverage.
- bash <(curl -s https://codecov.io/bash)
# Let's push to Docker hub only if not in a pull request and the branch name matches the semver format.
- if [[ "$TRAVIS_PULL_REQUEST" = false && "$TRAVIS_BRANCH" =~ ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$ ]]; then docker login -u $DOCKER_USER -p $DOCKER_PASS && docker push thecodingmachine/gotenberg:${TRAVIS_BRANCH}; fi

View File

@@ -1,46 +0,0 @@
FROM debian:stretch-slim
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |--------------------------------------------------------------------------
# | Libraries
# |--------------------------------------------------------------------------
# |
# | Installs all required libraries.
# |
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
apt-get update &&\
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
RUN apt-get install -y curl gnupg &&\
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
apt-get update &&\
apt-get install -y nodejs
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
apt-get update &&\
apt-get install -y yarn bzip2 &&\
yarn global add markdown-pdf --prefix /usr/local
# |--------------------------------------------------------------------------
# | Gotenberg
# |--------------------------------------------------------------------------
# |
# | All Gotenberg related stuff.
# |
COPY .ci/gotenberg /usr/bin/gotenberg
RUN ln -s /usr/bin/gotenberg /usr/local/bin/gotenberg
COPY .ci/gotenberg.yml /gotenberg/gotenberg.yml
WORKDIR /gotenberg
EXPOSE 3000
CMD ["gotenberg"]

View File

@@ -1,50 +0,0 @@
FROM golang:1.10-stretch
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |--------------------------------------------------------------------------
# | Libraries
# |--------------------------------------------------------------------------
# |
# | Installs all required libraries.
# |
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
apt-get update &&\
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
RUN apt-get install -y curl gnupg &&\
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
apt-get update &&\
apt-get install -y nodejs
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
apt-get update &&\
apt-get install -y yarn bzip2 &&\
yarn global add markdown-pdf --prefix /usr/local
# |--------------------------------------------------------------------------
# | Gotenberg
# |--------------------------------------------------------------------------
# |
# | All Gotenberg related stuff.
# |
WORKDIR /go/src/github.com/thecodingmachine/gotenberg
# Installs lint dependencies.
RUN go get -u gopkg.in/alecthomas/gometalinter.v2 &&\
gometalinter.v2 --install
# Copies our Go source.
COPY . .
# Installs project dependencies.
RUN go get -d -v ./...
ENTRYPOINT [".ci/docker-entrypoint.sh"]

66
Gopkg.lock generated
View File

@@ -1,66 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
branch = "master"
name = "github.com/dustin/go-humanize"
packages = ["."]
revision = "02af3965c54e8cacf948b97fef38925c4120652c"
[[projects]]
name = "github.com/gorilla/context"
packages = ["."]
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
version = "v1.1"
[[projects]]
name = "github.com/gorilla/mux"
packages = ["."]
revision = "53c1911da2b537f792e7cafcb446b05ffe33b996"
version = "v1.6.1"
[[projects]]
branch = "master"
name = "github.com/justinas/alice"
packages = ["."]
revision = "03f45bd4b7dad4734bc4620e46a35789349abb20"
[[projects]]
name = "github.com/satori/go.uuid"
packages = ["."]
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
version = "v1.2.0"
[[projects]]
name = "github.com/sirupsen/logrus"
packages = ["."]
revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc"
version = "v1.0.5"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["ssh/terminal"]
revision = "88942b9c40a4c9d203b82b3731787b672d6e809b"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = [
"unix",
"windows"
]
revision = "13d03a9a82fba647c21a0ef8fba44a795d0f0835"
[[projects]]
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "86f5ed62f8a0ee96bd888d2efdfd6d4fb100a4eb"
version = "v2.2.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "97e0dbb20631792bc25ad19e6a85a90a9d24bb03f773cd36dee11eebae152091"
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -1,54 +0,0 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
# [prune]
# non-go = false
# go-tests = true
# unused-packages = true
[prune]
go-tests = true
unused-packages = true
[[constraint]]
name = "github.com/satori/go.uuid"
version = "1.2.0"
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.6.1"
[[constraint]]
branch = "master"
name = "github.com/justinas/alice"
[[constraint]]
name = "gopkg.in/yaml.v2"
version = "2.2.0"
[[constraint]]
name = "github.com/sirupsen/logrus"
version = "1.0.5"
[[constraint]]
branch = "master"
name = "github.com/dustin/go-humanize"

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 TheCodingMachine
Copyright (c) 2023 Julien Neuhart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

160
Makefile Normal file
View File

@@ -0,0 +1,160 @@
.PHONY: help
help: ## Show the help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: it
it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.20
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.038 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.51.2 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build
build: ## Build the Gotenberg's Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOTENBERG_USER_GID=$(GOTENBERG_USER_GID) \
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
-f build/Dockerfile .
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000
API_PORT_FROM_ENV=
API_TIMEOUT=30s
API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace
API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_FAILED_STARTS_THRESHOLD=5
CHROMIUM_INCOGNITO=false
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
CHROMIUM_DISABLE_WEB_SECURITY=false
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER=
CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_DISABLES_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES=
PDFENGINES_DISABLE_ROUTES=false
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_COLLECT=false
UNO_LISTENER_START_TIMEOUT=10s
UNO_LISTENER_RESTART_THRESHOLD=10
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST=
WEBHOOK_ERROR_DENY_LIST=
WEBHOOK_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s
WEBHOOK_CLIENT_TIMEOUT=30s
WEBHOOK_DISABLE=false
.PHONY: run
run: ## Start a Gotenberg container
docker run --rm -it \
-p $(API_PORT):$(API_PORT) \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-timeout=$(API_TIMEOUT) \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--chromium-failed-starts-threshold=$(CHROMIUM_FAILED_STARTS_THRESHOLD) \
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--uno-listener-start-timeout=$(UNO_LISTENER_START_TIMEOUT) \
--uno-listener-restart-threshold=$(UNO_LISTENER_RESTART_THRESHOLD) \
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
--webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \
--webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
--webhook-disable=$(WEBHOOK_DISABLE)
.PHONY: build-tests
build-tests: ## Build the tests' Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile .
.PHONY: tests
tests: ## Start the testing environment
docker run --rm -it \
-v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
bash
.PHONY: tests-once
tests-once: ## Run the tests once (prefer the "tests" command while developing)
docker run --rm \
-v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
gotest
.PHONY: fmt
fmt: ## Format the code and "optimize" the dependencies
go fmt ./...
go mod tidy
.PHONY: godoc
godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/godoc)
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7)
godoc -http=:6060
.PHONY: release
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
./scripts/release.sh \
$(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \
$(GOTENBERG_USER_GID) \
$(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \
$(DOCKER_REPOSITORY)

211
README.md
View File

@@ -1,202 +1,45 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/38133342-11df3bd8-340f-11e8-9fe4-50baecdceeca.png" alt="Gotenberg's logo" width="250" height="250" />
</p>
<h3 align="center">Gotenberg</h3>
<p align="center">A stateless API for converting Markdown files, HTML files and Office documents to PDF</p>
<p align="center">
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:2.0.0">
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:2.0.0.svg" alt="MicroBadger layers">
</a>
<a href="https://travis-ci.org/thecodingmachine/gotenberg">
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=master" alt="Travis CI">
</a>
<a href="https://godoc.org/github.com/thecodingmachine/gotenberg">
<img src="https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg" alt="GoDoc">
</a>
<a href="https://goreportcard.com/report/thecodingmachine/gotenberg">
<img src="https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg" alt="Go Report Card">
</a>
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/master">
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/master/graph/badge.svg" alt="Codecov">
</a>
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a> &#183; 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</a></p>
</p>
---
At TheCodingMachine, we build a lot of web applications (intranets, extranets and so on) which require to generate PDF from
various sources. Each time, we ended up using some well known libraries like **wkhtmltopdf** or **unoconv** and kind of lost time by
reimplementing a solution from a project to another project. Meh.
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
# Menu
## Quick Start
* [Usage](#usage)
* [Security](#security)
* [Scalability](#scalability)
* [Custom implementation](#custom-implementation)
* [Clients](#clients)
Open a terminal and run the following command:
## Usage
Let's say you're starting the API using this simple command:
```sh
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:2.0.0
```
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
```
The API is now available on your host under `http://127.0.0.1:3000`.
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
It accepts `POST` requests with a `multipart/form-data` Content-Type. Your form data should provide one or more files to convert.
The default image accepts the following:
* Markdown files
* HTML files
* Office documents (.docx, .doc, .odt, .pptx, .ppt, .odp and so on)
* PDF files
**Heads up:** the API relies on the file extension to determine which library to use for conversion.
There are two use cases:
* If you send one file, it will convert it and return the resulting PDF
* If many files, it will convert them to PDF, merge the resulting PDFs into a single PDF and return it
### Examples:
* One file
```sh
$ curl --request POST \
--url http://127.0.0.1:3000 \
--header 'Content-Type: multipart/form-data' \
--form files=@file.docx \
> result.pdf
```
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
```
* Many files
The API is now available on your host at http://localhost:3000.
```sh
$ curl --request POST \
--url http://127.0.0.1:3000 \
--header 'Content-Type: multipart/form-data' \
--form files=@file.md \
--form files=@file.html \
--form files=@file.pdf \
--form files=@file.docx \
> result.pdf
```
Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to interact with it 🚀
## Security
## Sponsors
The API does not provide any authentication mechanisms. Make sure to not put it on a public facing port and your client(s) should always
controls what is sent to the API.
<p align="center">
<a href="https://thecodingmachine.com">
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" />
</a>
</p>
## Scalability
## Badges
Some libraries like **unoconv** cannot perform concurrent conversions. That's why the API does only one conversion at a time.
If your API is under heavy load, a request will take time to be processed.
Fortunately, you may pass through this limitation by scaling the API.
In the following example, I'll demonstrate how to do some vertical scaling (= on the same machine) with Docker Compose, but of course horizontal scaling works too!
```yaml
version: '3'
services:
# your others services
gotenberg:
image: thecodingmachine/gotenberg:2.0.0
```
You may now launch your services using:
```bash
docker-compose up --scale gotenberg=your_number_of_instances
```
When requesting the Gotenberg service with your client(s), Docker will automatically redirect a request to a Gotenberg container
according to the round-robin strategy.
## Custom implementation
The API relies on a simple YAML configuration file called `gotenberg.yml`. It allows you to tweak some values and even provides you
a way to change the commands called for each kind of conversion.
Below the default configuration file:
```yaml
# The port the application will listen to.
port: 3000
logs:
# Accepted values, in order of severity: DEBUG, INFO, WARN, ERROR, FATAL, PANIC.
# Messages at and above the selected level will be logged.
level: "INFO"
# Accepted values: text, json.
# When a TTY is not attached, the output will be in the defined format.
formatter: "text"
# You don't like a library which is used for a conversion? You want to handle a new file type?
# You may provide here your own implementation!
commands:
# Some libraries like unoconv cannot perform concurrent conversions. That's why the API does only one conversion at a time.
# If your current implementation uses libraries which are able to perform concurrent conversions, you may
# change this value to false.
lock: true
# Unlike others commands' templates, you have access to FilesPaths instead of FilePath: it gathers all PDF files which should be merged.
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
# You may add more commands (or less, or even none).
conversions:
# The command template: you have access to FilePath and ResultFilePath variables.
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
# The binary which will call the command.
interpreter: "/bin/sh -c"
# Duration in seconds after which the command will be killed if it has not finished.
timeout: 30
# Files with the following extensions will be converted by the current command.
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"
```
We provide binaries for a wide range of OS and architecture in the [releases page](../../releases),
so feel free to create your own Docker image for your implementation of the Gotenberg API :metal:
## Clients
* https://github.com/thecodingmachine/gotenberg-php-client (PHP client)
* Add your own client by submitting a [pull request](../../pulls)!
---
Would you like to update this documentation ? Feel free to open an [issue](../../issues).
[![Docker pulls](https://img.shields.io/docker/pulls/gotenberg/gotenberg)](https://hub.docker.com/r/gotenberg/gotenberg)
[![Docker pulls](https://img.shields.io/docker/pulls/thecodingmachine/gotenberg)](https://hub.docker.com/r/thecodingmachine/gotenberg)
[![Continuous Integration](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg)](https://pkg.go.dev/github.com/gotenberg/gotenberg/v7)
[![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg)

41
SECURITY.md Normal file
View File

@@ -0,0 +1,41 @@
# Security Policy
## Supported Versions
Please ensure to keep your environment up-to-date and use only the latest version of Gotenberg.
Security updates and patches will be applied only to the most recent version.
## Reporting a Vulnerability
Your help in identifying vulnerabilities in our project is much appreciated.
We take all reports regarding security seriously.
If you discover a security vulnerability, please refrain from publishing it publicly.
Instead, kindly send us the details via email to *neuhart [dot] julien [at] gmail [dot] com*.
In the subject of your email, please indicate that it's a security vulnerability report for Gotenberg.
In your message, please include:
* A detailed description of the vulnerability.
* The steps to reproduce the issue.
* Any potential impact of the vulnerability on the users or system.
Please remember that this process is done in a *'best-effort'* manner.
This means we strive to respond and act as quickly as possible, but the speed may vary depending on the severity of
the issue and our resources.
Thank you in advance for helping to keep our project safe!
## Disclosure Policy
Once we have received your vulnerability report, we will work to validate and reproduce the issue.
If we can confirm the vulnerability, we will proceed to:
* Work on a fix and a release timeline.
* Notify you when the fix has been implemented and released.
* Credit you for discovering the vulnerability (unless you request anonymity).
* Please note that we will do our best to keep you informed about the progress towards resolving the issue.
## Comments on this Policy
If you have suggestions on how this process could be improved, please submit a pull request.

View File

@@ -1,39 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: {
value: [
...
}
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,40 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: false
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 0
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 0
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "DEBUG"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "text"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

View File

@@ -1,35 +0,0 @@
port: 3000
logs:
level: "DEBUG"
formatter: "text"
commands:
lock: true
merge:
template: "pdftk {{ range $filePath := FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
conversions:
- template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".md"
- template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".html"
- ".htm"
- template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
interpreter: "/bin/sh -c"
timeout: 30
extensions:
- ".doc"
- ".docx"
- ".odt"
- ".xls"
- ".xlsx"
- ".ods"
- ".ppt"
- ".pptx"
- ".odp"

Binary file not shown.

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Some title</title>
<meta name="author" content="Julien Neuhart">
<meta name="description" content="Simple HTML page">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p>Hi! I have been converted from an HTML page!</p>
</body>
</html>

View File

@@ -1 +0,0 @@
# Hi, I've been converted from a markdown file!

Binary file not shown.

View File

@@ -1,213 +0,0 @@
/*
Package config contains all the logic allowing us to instantiate the application's configuration.
The application's configuration is loaded from a YAML file named gotenberg.yml.
It should be located where the user starts the application from the CLI.
*/
package config
import (
"fmt"
"strings"
"text/template"
"github.com/sirupsen/logrus"
)
type (
// appConfig gathers all configuration data.
appConfig struct {
port string
logsLevel logrus.Level
logsFormatter logrus.Formatter
lock bool
// commands associates a file extension with a Command instance.
// Particular case: ".pdf" extension is used for the merge command.
commands map[string]*Command
}
// Command gathers information on how to launch an external binary used for converting
// a file to PDF.
Command struct {
// Template is the data-driven template of the command.
Template *template.Template
// The binary which will call the command.
Interpreter []string
// Timeout is the duration in seconds after which the command's process will be killed
// if it does not finish before.
Timeout int
}
)
// our default instance of appConfig.
var config = &appConfig{}
// Reset reinitializes our configuration.
func Reset() {
config = &appConfig{}
}
// WithPort sets the port which will be used by the application.
func WithPort(port string) {
config.port = port
}
// GetPort returns the current port.
func GetPort() string {
return config.port
}
// levels associates logging levels as defined in the configuration file gotenberg.yml
// with its counterpart from the logrus library.
var levels = map[string]logrus.Level{
"DEBUG": logrus.DebugLevel,
"INFO": logrus.InfoLevel,
"WARN": logrus.WarnLevel,
"ERROR": logrus.ErrorLevel,
"FATAL": logrus.FatalLevel,
"PANIC": logrus.PanicLevel,
}
type wrongLogsLevelError struct{}
const wrongLogsLevelErrorMessage = "accepted values for logs level: DEBUG, INFO, WARN, ERROR, FATAL, PANIC"
func (e *wrongLogsLevelError) Error() string {
return wrongLogsLevelErrorMessage
}
// WithLogsLevel sets the logs level.
// If the given string does not match with a logrus level,
// throws an error.
func WithLogsLevel(level string) error {
l, ok := levels[level]
if !ok {
return &wrongLogsLevelError{}
}
config.logsLevel = l
return nil
}
// GetLogsLevel returns the current logs level.
func GetLogsLevel() logrus.Level {
return config.logsLevel
}
// formatters associates logging formatter as defined in the configuration file gotenberg.yml
// with its counterpart from the logrus library.
var formatters = map[string]logrus.Formatter{
"text": &logrus.TextFormatter{},
"json": &logrus.JSONFormatter{},
}
type wrongLogsFormatterError struct{}
const wrongLogsFormatterErrorMessage = "accepted value for logs formatter: text, json"
func (e *wrongLogsFormatterError) Error() string {
return wrongLogsFormatterErrorMessage
}
// WithLogsFormatter sets the logs formatter.
// If the given string does not match with a logrus formatter,
// throws an error.
func WithLogsFormatter(formatter string) error {
f, ok := formatters[formatter]
if !ok {
return &wrongLogsFormatterError{}
}
config.logsFormatter = f
return nil
}
// GetLogsFormatter returns the current logs formatter.
func GetLogsFormatter() logrus.Formatter {
return config.logsFormatter
}
// WithLock sets the lock strategy.
func WithLock(lock bool) {
config.lock = lock
}
// HasLock returns the current lock strategy.
func HasLock() bool {
return config.lock
}
type interpreterEmptyError struct {
command string
}
const interpreterEmptyErrorMessage = "the interepreter for command %s should not be empty"
func (e *interpreterEmptyError) Error() string {
return fmt.Sprintf(interpreterEmptyErrorMessage, e.command)
}
// NewCommand instantiates a Command. If the given command string
// is not a valid template, throws an error.
func NewCommand(command string, interpreter string, timeout int) (*Command, error) {
t, err := template.New(command).Parse(command)
if err != nil {
return nil, err
}
if interpreter == "" {
return nil, &interpreterEmptyError{command}
}
return &Command{t, strings.Fields(interpreter), timeout}, nil
}
type fileExtensionAlreadyUsedError struct {
extension string
command *Command
existingCommand *Command
}
const fileExtensionAlreadyUsedErrorMessage = "file extension %s from command %s is already used by command %s"
func (e *fileExtensionAlreadyUsedError) Error() string {
return fmt.Sprintf(fileExtensionAlreadyUsedErrorMessage, e.extension, e.command.Template.Name(), e.existingCommand.Template.Name())
}
// WithCommand adds a Command instance and associates it with the given
// file extension. If the file extension is already used by another Command
// instance, throws an error.
func WithCommand(extension string, command *Command) error {
if config.commands == nil {
config.commands = make(map[string]*Command)
}
existingCommand, ok := config.commands[extension]
if ok {
return &fileExtensionAlreadyUsedError{extension, command, existingCommand}
}
config.commands[extension] = command
return nil
}
type noCommandFoundForFileExtensionError struct {
extension string
}
const noCommandFoundForFileExtensionErrorMessage = "no command found for file extension %s"
func (e *noCommandFoundForFileExtensionError) Error() string {
return fmt.Sprintf(noCommandFoundForFileExtensionErrorMessage, e.extension)
}
// GetCommand returns the Command instance associated with the given
// file extension. If no Command instance found, throws an error.
func GetCommand(extension string) (*Command, error) {
c, ok := config.commands[extension]
if !ok {
return nil, &noCommandFoundForFileExtensionError{extension}
}
return c, nil
}

View File

@@ -1,201 +0,0 @@
package config
import (
"fmt"
"testing"
"github.com/sirupsen/logrus"
)
func TestReset(t *testing.T) {
c := &appConfig{}
config.port = "3000"
Reset()
if c.port != config.port {
t.Error("Configuration should have been reset")
}
}
func TestWithPort(t *testing.T) {
port := "3000"
WithPort(port)
if config.port != port {
t.Errorf("Configuration populated with a wrong port: got '%s' want '%s'", config.port, port)
}
}
func TestGetPort(t *testing.T) {
port := "3000"
config.port = port
if GetPort() != port {
t.Errorf("Configuration returned a wrong port: got '%s' want '%s'", GetPort(), port)
}
}
func TestWrongLogsLevelError(t *testing.T) {
err := &wrongLogsLevelError{}
if err.Error() != wrongLogsLevelErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLogsLevelErrorMessage)
}
}
func TestWithLogsLevel(t *testing.T) {
var lvl string
// case 1: uses a wrong logs level.
lvl = "text"
if err := WithLogsLevel(lvl); err == nil {
t.Errorf("Configuration should not have been populated by using '%s' as logs level", lvl)
}
// case 2: uses a correct logs level.
lvl = "DEBUG"
if err := WithLogsLevel(lvl); err != nil {
t.Errorf("Configuration should have been populated by using '%s' as logs level", lvl)
}
}
func TestGetLogsLevel(t *testing.T) {
lvl := logrus.DebugLevel
config.logsLevel = lvl
if GetLogsLevel() != lvl {
t.Errorf("Configuration returned a wrong logs level: got '%s' want '%s'", GetLogsLevel(), lvl)
}
}
func TestWrongLogsFormatterError(t *testing.T) {
err := &wrongLogsFormatterError{}
if err.Error() != wrongLogsFormatterErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLogsFormatterErrorMessage)
}
}
func TestWithLogsFormatter(t *testing.T) {
var formatter string
// case 1: uses a wrong logs formatter.
formatter = "DEBUG"
if err := WithLogsFormatter(formatter); err == nil {
t.Errorf("Configuration should not have been populated by using '%s' as logs formatter", formatter)
}
// case 2: uses a correct logs formatter.
formatter = "text"
if err := WithLogsFormatter(formatter); err != nil {
t.Errorf("Configuration should have been populated by using '%s' as logs formatter", formatter)
}
}
func TestGetLogsFormatter(t *testing.T) {
formatter := &logrus.TextFormatter{}
config.logsFormatter = formatter
if GetLogsFormatter() != formatter {
t.Errorf("Configuration returned a wrong logs formatter: got '%v' want '%v'", GetLogsFormatter(), formatter)
}
}
func TestWithLock(t *testing.T) {
lock := true
WithLock(lock)
if config.lock != lock {
t.Errorf("Configuration populated with a wrong lock strategy: got '%t' want '%t'", config.lock, lock)
}
}
func TestHasLock(t *testing.T) {
lock := true
config.lock = true
if HasLock() != lock {
t.Errorf("Configuration returned a wrong port: got '%t' want '%t'", HasLock(), lock)
}
}
func TestInterpreterEmptyError(t *testing.T) {
err := &interpreterEmptyError{"echo hello world"}
expected := fmt.Sprintf(interpreterEmptyErrorMessage, err.command)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
func TestNewCommand(t *testing.T) {
var cmd string
// case 1: uses a wrong command template.
cmd = "pdftk {{ range $filePath := FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
if _, err := NewCommand(cmd, "/bin/sh -c", 0); err == nil {
t.Errorf("Command should not have been instantiated by using '%s' as command template", cmd)
}
// case 2: uses a correct command template.
cmd = "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
if _, err := NewCommand(cmd, "/bin/sh -c", 0); err != nil {
t.Errorf("Command should have been instantiated by using '%s' as command template", cmd)
}
// case 3: uses an empty interpreter.
if _, err := NewCommand(cmd, "", 0); err == nil {
t.Error("Command should not have been instantiated by using an empty interpreter")
}
}
func TestFileExtensionAlreadyUsedError(t *testing.T) {
ext := ".pdf"
cmd1, _ := NewCommand("echo", "/bin/sh -c", 0)
cmd2, _ := NewCommand("echo", "/bin/sh -c", 0)
err := &fileExtensionAlreadyUsedError{ext, cmd1, cmd2}
expected := fmt.Sprintf(fileExtensionAlreadyUsedErrorMessage, err.extension, err.command.Template.Name(), err.existingCommand.Template.Name())
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
func TestWithCommand(t *testing.T) {
ext := ".pdf"
cmd, _ := NewCommand("echo", "/bin/sh -c", 0)
// case 1: uses a command with a file extension not already referenced.
if err := WithCommand(ext, cmd); err != nil {
t.Errorf("Configuration should have been populated by using a command with the file extension '%s'", ext)
}
// case 2: uses a command with a file extension already referenced.
if err := WithCommand(ext, cmd); err == nil {
t.Errorf("Configuration should not have been populated by using a command with the file extension '%s'", ext)
}
}
func TestNoCommandFoundForFileExtensionError(t *testing.T) {
err := &noCommandFoundForFileExtensionError{".pdf"}
expected := fmt.Sprintf(noCommandFoundForFileExtensionErrorMessage, err.extension)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
func TestGetCommand(t *testing.T) {
Reset()
ext := ".pdf"
cmd, _ := NewCommand("echo", "/bin/sh -c", 0)
WithCommand(ext, cmd)
// case 1: uses a file extension which has a command associated.
if _, err := GetCommand(ext); err != nil {
t.Errorf("Configuration should have been able to return a command by using the file extension '%s'", ext)
}
// case 2: uses a file extension which has no command associated.
ext = ".docx"
if _, err := GetCommand(ext); err == nil {
t.Errorf("Configuration should not have been able to return a command by using the file extension '%s'", ext)
}
}

View File

@@ -1,99 +0,0 @@
package config
import (
"io/ioutil"
"gopkg.in/yaml.v2"
)
// ParseFile instantiates the application's configuration using the given YAML file.
func ParseFile(configurationFilePath string) error {
fileConfig, err := readFile(configurationFilePath)
if err != nil {
return err
}
WithPort(fileConfig.Port)
if err := WithLogsLevel(fileConfig.Logs.Level); err != nil {
return err
}
if err := WithLogsFormatter(fileConfig.Logs.Formatter); err != nil {
return err
}
WithLock(fileConfig.Commands.Lock)
// handles merge command first...
cmd, err := NewCommand(fileConfig.Commands.Merge.Template, fileConfig.Commands.Merge.Interpreter, fileConfig.Commands.Merge.Timeout)
if err != nil {
return err
}
WithCommand(".pdf", cmd)
// ...then conversion commands!
for _, command := range fileConfig.Commands.Conversions {
cmd, err := NewCommand(command.Template, command.Interpreter, command.Timeout)
if err != nil {
return err
}
for _, ext := range command.Extensions {
if err := WithCommand(ext, cmd); err != nil {
return err
}
}
}
return nil
}
type (
// fileConfig gathers all data coming from the configuration file gotenberg.yml.
fileConfig struct {
Port string `yaml:"port"`
Logs struct {
Level string `yaml:"level"`
Formatter string `yaml:"formatter"`
} `yaml:"logs"`
Commands struct {
Lock bool `yaml:"lock"`
Merge *mergeCommand `yaml:"merge"`
Conversions []*conversionCommand `yaml:"conversions,omitempty"`
} `yaml:"commands"`
}
// mergeCommand gathers all data regarding the... merge command.
mergeCommand struct {
Template string `yaml:"template"`
Interpreter string `yaml:"interpreter"`
Timeout int `yaml:"timeout"`
}
// conversionCommand gathers all data regarding a conversion command.
conversionCommand struct {
Template string `yaml:"template"`
Interpreter string `yaml:"interpreter"`
Timeout int `yaml:"timeout"`
Extensions []string `yaml:"extensions"`
}
)
// readFile instantiates a fileConfig instance by reading
// the given YAML file.
func readFile(configurationFilePath string) (*fileConfig, error) {
c := &fileConfig{}
data, err := ioutil.ReadFile(configurationFilePath)
if err != nil {
return nil, err
}
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, err
}
return c, nil
}

View File

@@ -1,62 +0,0 @@
package config
import (
"path/filepath"
"testing"
)
func load(configurationFilePath string) error {
Reset()
return ParseFile(configurationFilePath)
}
func TestParseFile(t *testing.T) {
var path string
// case 1: uses an empty configuration file path.
if err := load(""); err == nil {
t.Error("Configuration should not have been populated by using an empty configuration file path")
}
// case 2: uses a broken configuration file.
path, _ = filepath.Abs("../../_tests/configurations/broken-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 3: uses a configuration file with a wrong logging level.
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-level-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 4: uses a configuration file with a wrong logging formatter.
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-formatter-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 5: uses a configuration file with a wrong merge command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-merge-command-template-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 6: uses a configuration file with a wrong command template.
path, _ = filepath.Abs("../../_tests/configurations/wrong-command-template-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 7: uses a configuration file with a duplicate command.
path, _ = filepath.Abs("../../_tests/configurations/duplicate-command-gotenberg.yml")
if err := load(path); err == nil {
t.Errorf("Configuration should not have been populated with '%s'", path)
}
// case 8: uses a correct configuration file.
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
if err := load(path); err != nil {
t.Errorf("Configuration should have been populated with '%s'", path)
}
}

View File

@@ -1,105 +0,0 @@
// Package context provides functions for setting and getting values from
// a request's context.
package context
import (
"context"
"net/http"
"github.com/thecodingmachine/gotenberg/app/converter"
)
type key uint32
const (
requestIDKey key = iota
converterKey
resultFilePathKey
)
// WithRequestID populates a request's context with the given request ID
// and returns the updated request.
func WithRequestID(r *http.Request, requestID string) *http.Request {
ctx := r.Context()
ctx = context.WithValue(ctx, requestIDKey, requestID)
r = r.WithContext(ctx)
return r
}
type requestIDNotFoundError struct{}
const requestIDNotFoundErrorMessage = "the request ID was not found in request context"
func (e *requestIDNotFoundError) Error() string {
return requestIDNotFoundErrorMessage
}
// GetRequestID returns the request ID if found in
// the request's context. Otherwise throws an error.
func GetRequestID(r *http.Request) (string, error) {
ID, ok := r.Context().Value(requestIDKey).(string)
if !ok {
return "", &requestIDNotFoundError{}
}
return ID, nil
}
// WithConverter populates a request's context with the given converter
// and returns the updated request.
func WithConverter(r *http.Request, converter *converter.Converter) *http.Request {
ctx := r.Context()
ctx = context.WithValue(ctx, converterKey, converter)
r = r.WithContext(ctx)
return r
}
type converterNotFoundError struct{}
const converterNotFoundErrorMessage = "the converter was not found in request context"
func (e *converterNotFoundError) Error() string {
return converterNotFoundErrorMessage
}
// GetConverter returns the converter if found in
// the request's context. Otherwise throws an error.
func GetConverter(r *http.Request) (*converter.Converter, error) {
c, ok := r.Context().Value(converterKey).(*converter.Converter)
if !ok {
return nil, &converterNotFoundError{}
}
return c, nil
}
// WithResultFilePath populates a request's context with the given result file path
// and returns the updated request.
func WithResultFilePath(r *http.Request, resultFilePath string) *http.Request {
ctx := r.Context()
ctx = context.WithValue(ctx, resultFilePathKey, resultFilePath)
r = r.WithContext(ctx)
return r
}
type resultFilePathNotFoundError struct{}
const resultFilePathNotFoundErrorMessage = "the result file path was not found in request context"
func (e *resultFilePathNotFoundError) Error() string {
return resultFilePathNotFoundErrorMessage
}
// GetResultFilePath returns the result file path if found in
// the request's context. Otherwise throws an error.
func GetResultFilePath(r *http.Request) (string, error) {
path, ok := r.Context().Value(resultFilePathKey).(string)
if !ok {
return "", &resultFilePathNotFoundError{}
}
return path, nil
}

View File

@@ -1,100 +0,0 @@
package context
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/thecodingmachine/gotenberg/app/converter"
"github.com/satori/go.uuid"
)
func TestWithRequestID(t *testing.T) {
requestID := uuid.NewV4().String()
req := WithRequestID(httptest.NewRequest(http.MethodPost, "/", nil), requestID)
if ID, _ := req.Context().Value(requestIDKey).(string); ID != requestID {
t.Errorf("Context returned a wrong converter: got '%s' want '%s'", ID, requestID)
}
}
func TestRequestIDNotFoundError(t *testing.T) {
err := &requestIDNotFoundError{}
if err.Error() != requestIDNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), requestIDNotFoundErrorMessage)
}
}
func TestGetRequestID(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
// case 1: uses a request without a request ID entry in its context.
if _, err := GetRequestID(req); err == nil {
t.Error("Context should not have a request ID entry")
}
// case 2: uses a request with a request ID entry in its context.
req = WithRequestID(req, uuid.NewV4().String())
if _, err := GetRequestID(req); err != nil {
t.Error("Context should have a request ID entry")
}
}
func TestWithConverter(t *testing.T) {
req := WithConverter(httptest.NewRequest(http.MethodPost, "/", nil), &converter.Converter{})
if c, _ := req.Context().Value(converterKey).(*converter.Converter); c == nil {
t.Errorf("Context returned a wrong converter: got '%v' want not nil", c)
}
}
func TestConverterNotFoundError(t *testing.T) {
err := &converterNotFoundError{}
if err.Error() != converterNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), converterNotFoundErrorMessage)
}
}
func TestGetConverter(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
// case 1: uses a request without a converter entry in its context.
if _, err := GetConverter(req); err == nil {
t.Error("Context should not have a converter entry")
}
// case 2: uses a request with a converter entry in its context.
req = WithConverter(req, &converter.Converter{})
if _, err := GetConverter(req); err != nil {
t.Error("Context should have a converter entry")
}
}
func TestWithResultFilePath(t *testing.T) {
filePath := "file.pdf"
req := WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), filePath)
if path, _ := req.Context().Value(resultFilePathKey).(string); path != filePath {
t.Errorf("Context returned a wrong result file path: got '%s' want '%s'", path, filePath)
}
}
func TestResultFilePathNotFoundError(t *testing.T) {
err := &resultFilePathNotFoundError{}
if err.Error() != resultFilePathNotFoundErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), resultFilePathNotFoundErrorMessage)
}
}
func TestGetResultFilePath(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
// case 1: uses a request without a result file path entry in its context.
if _, err := GetResultFilePath(req); err == nil {
t.Error("Context should not have a result file path entry")
}
// case 2: uses a request with a result file path entry in its context.
req = WithResultFilePath(req, "file.pdf")
if _, err := GetResultFilePath(req); err != nil {
t.Error("Context should have a result file path entry")
}
}

View File

@@ -1,114 +0,0 @@
// Package converter implements a solution for converting one or more files to PDF.
package converter
import (
"fmt"
"io"
"net/http"
"os"
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
"github.com/thecodingmachine/gotenberg/app/converter/process"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/satori/go.uuid"
)
// Converter handles conversion into PDF of files coming from a request.
type Converter struct {
files []*gfile.File
workingDir string
}
// NoFileToConvertError is raided when a request has not file attached to it.
type NoFileToConvertError struct{}
const noFileToConvertErrorMessage = "no file to convert"
func (e *NoFileToConvertError) Error() string {
return noFileToConvertErrorMessage
}
// NewConverter instantiates a converter by parsing a request.
func NewConverter(r *http.Request) (*Converter, error) {
c := &Converter{
workingDir: fmt.Sprintf("./%s/", uuid.NewV4().String()),
}
if err := os.Mkdir(c.workingDir, 0666); err != nil {
return nil, err
}
logger.Debugf("created working directory %s", c.workingDir)
reader, err := r.MultipartReader()
if err != nil {
return c, err
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
fileName := part.FileName()
if fileName == "" {
continue
}
f, err := gfile.NewFile(c.workingDir, part, fileName)
if err != nil {
return c, err
}
c.files = append(c.files, f)
}
if len(c.files) == 0 {
return c, &NoFileToConvertError{}
}
return c, nil
}
// Convert converts its associated files to PDF. If more than one file,
// it will merge all of them into one unique PDF file.
// Returns the new file path or an error if something bad happened.
func (c *Converter) Convert() (string, error) {
var filesPaths []string
for _, f := range c.files {
if f.Extension != ".pdf" {
path, err := process.Unconv(c.workingDir, f)
if err != nil {
return "", err
}
filesPaths = append(filesPaths, path)
} else {
filesPaths = append(filesPaths, f.Path)
}
}
if len(filesPaths) == 1 {
return filesPaths[0], nil
}
path, err := process.Merge(c.workingDir, filesPaths)
if err != nil {
return "", err
}
return path, nil
}
// Clear removes all file inside its working directory.
func (c *Converter) Clear() error {
if err := os.RemoveAll(c.workingDir); err != nil {
logger.Error(fmt.Errorf("failed to remove working directory %s", c.workingDir))
return err
}
logger.Debugf("removed working directory %s", c.workingDir)
return nil
}

View File

@@ -1,150 +0,0 @@
package converter
import (
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/thecodingmachine/gotenberg/app/config"
)
func makeRequest(filesPaths ...string) *http.Request {
r, w := io.Pipe()
mpw := multipart.NewWriter(w)
go func() {
var part io.Writer
defer w.Close()
if len(filesPaths) == 0 {
part, _ = mpw.CreateFormField("foo")
part.Write([]byte("bar"))
} else {
for _, filePath := range filesPaths {
file, _ := os.Open(filePath)
defer file.Close()
fileInfo, _ := file.Stat()
part, _ = mpw.CreateFormFile("files", fileInfo.Name())
io.Copy(part, file)
}
}
mpw.Close()
}()
req := httptest.NewRequest(http.MethodPost, "/", r)
req.Header.Set("Content-Type", mpw.FormDataContentType())
return req
}
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
config.ParseFile(path)
}
func TestNoFileToConvertError(t *testing.T) {
err := &NoFileToConvertError{}
if err.Error() != noFileToConvertErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), noFileToConvertErrorMessage)
}
}
func TestNewConverter(t *testing.T) {
var (
path string
oPath string
)
load("../../_tests/configurations/gotenberg.yml")
// case 1: uses a request with a single file.
path, _ = filepath.Abs("../../_tests/file.docx")
if _, err := NewConverter(makeRequest(path)); err != nil {
t.Errorf("Converter should have been instantiated with '%s'", path)
}
// case 2: uses a request with wrong file type.
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
if _, err := NewConverter(makeRequest(path)); err == nil {
t.Errorf("Converter should not have been instantiated with '%s'", path)
}
// case 3: uses a request with two files.
path, _ = filepath.Abs("../../_tests/file.pdf")
oPath, _ = filepath.Abs("../../_tests/file.docx")
if _, err := NewConverter(makeRequest(path, oPath)); err != nil {
t.Errorf("Converter should have been instantiated with '%s' and '%s'", path, oPath)
}
// case 4: uses a request with one Office file type and one wrong file type.
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
oPath, _ = filepath.Abs("../../_tests/file.docx")
if _, err := NewConverter(makeRequest(path, oPath)); err == nil {
t.Errorf("Converter should not have been instantiated with '%s' and '%s'", path, oPath)
}
// case 5: uses a request with no file.
if _, err := NewConverter(makeRequest()); err == nil {
t.Error("Converter should not have been instantiated with no file")
}
}
func TestConvert(t *testing.T) {
var (
path string
oPath string
c *Converter
)
load("../../_tests/configurations/gotenberg.yml")
// case 1: uses a request with a single file.
path, _ = filepath.Abs("../../_tests/file.docx")
c, _ = NewConverter(makeRequest(path))
if _, err := c.Convert(); err != nil {
t.Errorf("Converter should have been able to convert '%s' to PDF", path)
}
// case 2: uses a request with two files.
path, _ = filepath.Abs("../../_tests/file.pdf")
oPath, _ = filepath.Abs("../../_tests/file.docx")
c, _ = NewConverter(makeRequest(path, oPath))
if _, err := c.Convert(); err != nil {
t.Errorf("Converter should have been able to convert '%s' and '%s' to PDF", path, oPath)
}
load("../../_tests/configurations/timeout-gotenberg.yml")
// case 3: uses a request with a single file and a configuration with an unsuitable timeout for the conversion commands.
path, _ = filepath.Abs("../../_tests/file.docx")
c, _ = NewConverter(makeRequest(path))
if _, err := c.Convert(); err == nil {
t.Errorf("Converter should not have been able to convert '%s' to PDF", path)
}
load("../../_tests/configurations/merge-timeout-gotenberg.yml")
// case 4: uses a request with two files and a configuration with an unsuitable timeout for the merge command.
path, _ = filepath.Abs("../../_tests/file.pdf")
oPath, _ = filepath.Abs("../../_tests/file.docx")
c, _ = NewConverter(makeRequest(path, oPath))
if _, err := c.Convert(); err == nil {
t.Errorf("Converter should not have been able to merge '%s' and '%s' into PDF", path, oPath)
}
}
func TestClear(t *testing.T) {
load("../../_tests/configurations/gotenberg.yml")
path, _ := filepath.Abs("../../_tests/file.docx")
c, _ := NewConverter(makeRequest(path))
if err := c.Clear(); err != nil {
t.Error("Converter should have been able to clear itself")
}
}

View File

@@ -1,60 +0,0 @@
// Package file implements a solution for handling files coming from a request.
package file
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/thecodingmachine/gotenberg/app/config"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/dustin/go-humanize"
"github.com/satori/go.uuid"
)
// File represents a file which has been created
// from a request.
type File struct {
// Extension is the extension of the file.
Extension string
// Path is the file path.
Path string
}
// NewFile creates a file in the considered directory.
// Returns a *File instance or an error if something bad happened.
func NewFile(workingDir string, r io.Reader, fileName string) (*File, error) {
ext := filepath.Ext(fileName)
if _, err := config.GetCommand(ext); err != nil {
return nil, err
}
f := &File{ext, MakeFilePath(workingDir, ext)}
file, err := os.Create(f.Path)
if err != nil {
return nil, err
}
defer file.Close()
n, err := io.Copy(file, r)
if err != nil {
return nil, err
}
// resets the read pointer.
file.Seek(0, 0)
logger.Debugf("working file %s has been created from %s (%s copied)", f.Path, fileName, humanize.Bytes(uint64(n)))
return f, nil
}
// MakeFilePath is a simple helper which generates a random file name
// and associates it with the considered directory to make a path.
func MakeFilePath(workingDir string, ext string) string {
return fmt.Sprintf("%s%s%s", workingDir, uuid.NewV4().String(), ext)
}

View File

@@ -1,38 +0,0 @@
package file
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/thecodingmachine/gotenberg/app/config"
)
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
config.ParseFile(path)
}
func TestNewFile(t *testing.T) {
load("../../../_tests/configurations/gotenberg.yml")
workingDir := "test"
os.Mkdir(workingDir, 0666)
// case 1: uses a wrong file name.
if _, err := NewFile(workingDir, new(bytes.Buffer), "file.yml"); err == nil {
t.Error("File should not have been instantiated with an empty buffer")
}
// case 2: uses a file name.
filePath, _ := filepath.Abs("../../../_tests/file.pdf")
r, _ := os.Open(filePath)
defer r.Close()
if _, err := NewFile(workingDir, r, "file.pdf"); err != nil {
t.Errorf("File should have been instantiated using a reader of '%s'", filePath)
}
os.RemoveAll(workingDir)
}

View File

@@ -1,130 +0,0 @@
// Package process handles all commands executions.
package process
import (
"bytes"
"fmt"
"os/exec"
"sync"
"time"
"github.com/thecodingmachine/gotenberg/app/config"
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
"github.com/thecodingmachine/gotenberg/app/logger"
)
type runner struct {
mu sync.Mutex
}
var forest = &runner{}
type commandTimeoutError struct {
command string
timeout int
}
const commandTimeoutErrorMessage = "the command %s has reached the %d second(s) timeout"
func (e *commandTimeoutError) Error() string {
return fmt.Sprintf(commandTimeoutErrorMessage, e.command, e.timeout)
}
// run runs the given command. If timeout is reached or
// something bad happened, returns an error.
func (r *runner) run(command string, interpreter []string, timeout int) error {
if config.HasLock() {
r.mu.Lock()
defer r.mu.Unlock()
} else {
logger.Warn("lock disabled")
}
binary := interpreter[0]
parameters := append(interpreter[1:], command)
cmd := exec.Command(binary, parameters...)
logger.Debugf("executing command %s", cmd.Args)
if err := cmd.Start(); err != nil {
return err
}
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
// wait for the process to finish or kill it after a timeout.
select {
case <-time.After(time.Duration(timeout) * time.Second):
if err := cmd.Process.Kill(); err != nil {
return err
}
return &commandTimeoutError{command, timeout}
case err := <-done:
if err != nil {
return err
}
return nil
}
}
// conversionData will be applied to the data-driven templates of conversions commands.
type conversionData struct {
FilePath string
ResultFilePath string
}
// Unconv converts a file to PDF and returns the new file path.
func Unconv(workingDir string, file *gfile.File) (string, error) {
cmdData := &conversionData{file.Path, gfile.MakeFilePath(workingDir, ".pdf")}
cmd, err := config.GetCommand(file.Extension)
if err != nil {
return "", err
}
var data bytes.Buffer
if err := cmd.Template.Execute(&data, cmdData); err != nil {
return "", err
}
err = forest.run(data.String(), cmd.Interpreter, cmd.Timeout)
if err != nil {
return "", err
}
logger.Debugf("created %s from %s", cmdData.ResultFilePath, cmdData.FilePath)
return cmdData.ResultFilePath, nil
}
// mergeData will be applied to the data-driven template of the merge command.
type mergeData struct {
FilesPaths []string
ResultFilePath string
}
// Merge merges many PDF files to one unique PDF file and returns the new file path.
func Merge(workingDir string, filesPaths []string) (string, error) {
cmdData := &mergeData{filesPaths, gfile.MakeFilePath(workingDir, ".pdf")}
cmd, err := config.GetCommand(".pdf")
if err != nil {
return "", err
}
var data bytes.Buffer
if err := cmd.Template.Execute(&data, cmdData); err != nil {
return "", err
}
err = forest.run(data.String(), cmd.Interpreter, cmd.Timeout)
if err != nil {
return "", err
}
logger.Debugf("created %s from %+v", cmdData.ResultFilePath, cmdData.FilesPaths)
return cmdData.ResultFilePath, nil
}

View File

@@ -1,140 +0,0 @@
package process
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/thecodingmachine/gotenberg/app/config"
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
)
func makeFile(workingDir string, fileName string) *gfile.File {
filePath := fmt.Sprintf("%s%s", "../../../_tests/", fileName)
absPath, _ := filepath.Abs(filePath)
r, _ := os.Open(absPath)
defer r.Close()
f, _ := gfile.NewFile(workingDir, r, fileName)
return f
}
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
config.ParseFile(path)
}
func TestCommandTimeoutError(t *testing.T) {
err := &commandTimeoutError{"echo hello", 30}
expected := fmt.Sprintf(commandTimeoutErrorMessage, err.command, err.timeout)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}
func TestRun(t *testing.T) {
var cmd string
load("../../../_tests/configurations/gotenberg.yml")
// case 1: uses a simple command.
cmd = "echo Hello world"
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 30); err != nil {
t.Errorf("Command '%s' should have worked", cmd)
}
// case 2: uses a simple command but with an unsuitable timeout.
cmd = "sleep 5"
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 0); err == nil {
t.Errorf("Command '%s' should not have worked", cmd)
}
// case 3: uses a broken command.
cmd = "helloworld"
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 30); err == nil {
t.Errorf("Command '%s' should not have worked", cmd)
}
load("../../../_tests/configurations/no-lock-gotenberg.yml")
// case 4: uses a configuration with a no lock strategy.
cmd = "echo Hello world"
if err := forest.run(cmd, strings.Fields("/bin/sh -c"), 30); err != nil {
t.Errorf("Command '%s' should have worked", cmd)
}
}
func TestUnconv(t *testing.T) {
var file *gfile.File
workingDir := "test"
os.Mkdir(workingDir, 0666)
load("../../../_tests/configurations/gotenberg.yml")
// case 1: uses an Markdown file type.
file = makeFile(workingDir, "file.md")
if _, err := Unconv(workingDir, file); err != nil {
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
}
// case 2: uses an HTML file type.
file = makeFile(workingDir, "file.html")
if _, err := Unconv(workingDir, file); err != nil {
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
}
// case 3: uses an Office file type.
file = makeFile(workingDir, "file.docx")
if _, err := Unconv(workingDir, file); err != nil {
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
}
// case 4: uses a PDF file type.
file = makeFile(workingDir, "file.pdf")
if _, err := Unconv(workingDir, file); err == nil {
t.Errorf("Converting '%s' to PDF should not have worked", file.Path)
}
load("../../../_tests/configurations/timeout-gotenberg.yml")
// case 5: uses a command with an unsuitable timeout.
file = makeFile(workingDir, "file.docx")
if _, err := Unconv(workingDir, makeFile(workingDir, "file.docx")); err == nil {
t.Errorf("Converting '%s' to PDF should have reached timeout", file.Path)
}
os.RemoveAll(workingDir)
}
func TestMerge(t *testing.T) {
workingDir := "test"
os.Mkdir(workingDir, 0666)
load("../../../_tests/configurations/gotenberg.yml")
var filesPaths []string
path, _ := filepath.Abs("../../../_tests/file.pdf")
filesPaths = append(filesPaths, path)
filesPaths = append(filesPaths, path)
// case 1: simple merge.
if _, err := Merge(workingDir, filesPaths); err != nil {
t.Error("Merge should have worked")
}
load("../../../_tests/configurations/timeout-gotenberg.yml")
// case 2: uses a command with an unsuitable timeout.
if _, err := Merge(workingDir, filesPaths); err == nil {
t.Error("Merge should have reached timeout")
}
os.RemoveAll(workingDir)
}

View File

@@ -1,170 +0,0 @@
// Package app implements all functions on which a request will pass through.
package app
import (
"fmt"
"io"
"net/http"
"os"
"github.com/thecodingmachine/gotenberg/app/context"
"github.com/thecodingmachine/gotenberg/app/converter"
ghttp "github.com/thecodingmachine/gotenberg/app/http"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/dustin/go-humanize"
"github.com/justinas/alice"
"github.com/satori/go.uuid"
)
// GetHandlersChain returns the handlers chaining
// thanks to the alice library.
func GetHandlersChain() http.Handler {
return alice.New(enforceContentLengthHandler, enforceContentTypeHandler, convertHandler).ThenFunc(serveHandler)
}
type requestHasNoContentError struct{}
const requestHasNoContentErrorMessage = "request has not content"
func (e *requestHasNoContentError) Error() string {
return requestHasNoContentErrorMessage
}
// enforeContentLengthHandler checks if the request has content.
func enforceContentLengthHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength == 0 {
e := &requestHasNoContentError{}
http.Error(w, e.Error(), http.StatusBadRequest)
logger.Error(e)
return
}
requestID := uuid.NewV4().String()
r = context.WithRequestID(r, requestID)
logger.Infof("identified new request (%s) with %s", humanize.Bytes(uint64(r.ContentLength)), requestID)
next.ServeHTTP(w, r)
})
}
// enforceContentTypeHandler checks if the "Content-Type" entry
// from the request's header matches the allowed content type.
func enforceContentTypeHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := ghttp.CheckAuthorizedContentType(r.Header); err != nil {
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
logger.Error(err)
return
}
next.ServeHTTP(w, r)
})
}
// convertHandler is in charge of converting the file(s) from the request to PDF.
func convertHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := converter.NewConverter(r)
if err != nil {
if _, ok := err.(*converter.NoFileToConvertError); ok {
http.Error(w, err.Error(), http.StatusBadRequest)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
logger.Error(err)
if c != nil {
r = context.WithConverter(r, c)
cleanup(r)
}
return
}
r = context.WithConverter(r, c)
path, err := c.Convert()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
logger.Error(err)
cleanup(r)
return
}
r = context.WithResultFilePath(r, path)
next.ServeHTTP(w, r)
})
}
// serveHandler simply serves the created PDF.
func serveHandler(w http.ResponseWriter, r *http.Request) {
path, err := context.GetResultFilePath(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
logger.Error(err)
cleanup(r)
return
}
reader, err := os.Open(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
logger.Error(err)
cleanup(r)
return
}
defer reader.Close()
resultFileInfo, err := reader.Stat()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
logger.Error(err)
cleanup(r)
return
}
requestID, err := context.GetRequestID(r)
if err != nil {
logger.Error(err)
}
logger.Debugf("serving result file %s for request %s...", path, requestID)
done := make(chan error, 1)
go func() {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", resultFileInfo.Name()))
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Length", fmt.Sprintf("%d", resultFileInfo.Size()))
_, err := io.Copy(w, reader)
done <- err
}()
err = <-done
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
logger.Error(err)
} else {
logger.Infof("result file %s (%s) sent for request %s", path, humanize.Bytes(uint64(resultFileInfo.Size())), requestID)
}
cleanup(r)
}
// cleanup removes all files created during the conversion.
func cleanup(r *http.Request) {
c, err := context.GetConverter(r)
if err != nil {
logger.Error(err)
return
}
if err := c.Clear(); err != nil {
logger.Error(err)
}
}

View File

@@ -1,227 +0,0 @@
package app
import (
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/thecodingmachine/gotenberg/app/config"
"github.com/thecodingmachine/gotenberg/app/context"
"github.com/justinas/alice"
)
func makeRequest(filesPaths ...string) *http.Request {
r, w := io.Pipe()
mpw := multipart.NewWriter(w)
go func() {
var part io.Writer
defer w.Close()
if len(filesPaths) == 0 {
part, _ = mpw.CreateFormField("foo")
part.Write([]byte("bar"))
} else {
for _, filePath := range filesPaths {
file, _ := os.Open(filePath)
defer file.Close()
fileInfo, _ := file.Stat()
part, _ = mpw.CreateFormFile("files", fileInfo.Name())
io.Copy(part, file)
}
}
mpw.Close()
}()
req := httptest.NewRequest(http.MethodPost, "/", r)
req.Header.Set("Content-Type", mpw.FormDataContentType())
return req
}
func load(configurationFilePath string) {
config.Reset()
path, _ := filepath.Abs(configurationFilePath)
config.ParseFile(path)
}
func fakeSuccessHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func TestGetHandlersChain(t *testing.T) {
// dumb test to improve code coverage...
if GetHandlersChain() == nil {
t.Errorf("Handler chains should not be nil")
}
}
func TestRequestHasNoContentError(t *testing.T) {
err := &requestHasNoContentError{}
if err.Error() != requestHasNoContentErrorMessage {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), requestHasNoContentErrorMessage)
}
}
func TestEnforceContentLengthHandler(t *testing.T) {
var (
req *http.Request
rr *httptest.ResponseRecorder
)
h := alice.New(enforceContentLengthHandler).ThenFunc(fakeSuccessHandler)
// case 1: sends an empty request.
req = httptest.NewRequest(http.MethodPost, "/", nil)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusBadRequest)
}
// case 2: sends a real body.
path, _ := filepath.Abs("../_tests/file.docx")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, makeRequest(path))
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
}
}
func TestEnforceContentTypeHandler(t *testing.T) {
var (
req *http.Request
rr *httptest.ResponseRecorder
)
h := alice.New(enforceContentTypeHandler).ThenFunc(fakeSuccessHandler)
// case 1: sends a wrong content type.
req = httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Content-Type", "application/pdf")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusUnsupportedMediaType {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusUnsupportedMediaType)
}
// case 2: sends a good content type.
path, _ := filepath.Abs("../_tests/file.docx")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, makeRequest(path))
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned wrong a status code: got '%v' want '%v'", status, http.StatusOK)
}
}
func TestConvertHandler(t *testing.T) {
var (
req *http.Request
rr *httptest.ResponseRecorder
path string
oPath string
)
h := alice.New(convertHandler).ThenFunc(fakeSuccessHandler)
// case 1: sends a request without body.
req = httptest.NewRequest(http.MethodPost, "/", nil)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusInternalServerError {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
}
// case 2: sends a request with no file.
req = makeRequest()
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusBadRequest)
}
load("../_tests/configurations/merge-timeout-gotenberg.yml")
// case 3: sends a request with two files and using an unsuitable timeout for merge commande.
path, _ = filepath.Abs("../_tests/file.pdf")
oPath, _ = filepath.Abs("../_tests/file.docx")
req = makeRequest(path, oPath)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusInternalServerError {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
}
load("../_tests/configurations/gotenberg.yml")
// case 4: sends a request with two files.
path, _ = filepath.Abs("../_tests/file.pdf")
oPath, _ = filepath.Abs("../_tests/file.docx")
req = makeRequest(path, oPath)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
}
// case 5: sends five requests (almost) simultany.
path, _ = filepath.Abs("../_tests/file.docx")
filesPaths := []string{
path,
path,
path,
path,
path,
}
for i := 0; i < len(filesPaths); i++ {
go func(i int) {
req := makeRequest(filesPaths[i])
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
}
}(i)
}
}
func TestServeHandler(t *testing.T) {
var (
req *http.Request
rr *httptest.ResponseRecorder
)
h := alice.New().ThenFunc(serveHandler)
// case 1: sends a request without a result file path entry in its context.
req = httptest.NewRequest(http.MethodPost, "/", nil)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusInternalServerError {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
}
// case 2: sends a request with a wrong result file path entry in its context.
req = context.WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), "file")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusInternalServerError {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
}
// case 3: sends a request with a correct result file path entry in its context.
path, _ := filepath.Abs("../_tests/file.pdf")
req = context.WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), path)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
}
}

View File

@@ -1,49 +0,0 @@
// Package http provides functions for detecting a request content type.
package http
import (
"fmt"
"net/http"
"strings"
)
// ContentType is a string which represents a content type.
type ContentType string
// MultipartFormDataContentType represents... the multipart form data content type.
const MultipartFormDataContentType ContentType = "multipart/form-data"
type notAuthorizedContentTypeError struct{}
const notAuthorizedContentTypeErrorMessage = "accepted value for 'Content-Type': %s"
func (e *notAuthorizedContentTypeError) Error() string {
return fmt.Sprintf(notAuthorizedContentTypeErrorMessage, MultipartFormDataContentType)
}
// CheckAuthorizedContentType checks if the request header header has an authorized content type.
// If no authorized content type found, throws an error.
func CheckAuthorizedContentType(h http.Header) error {
ct := findContentType(h.Get("Content-Type"), MultipartFormDataContentType)
if ct == "" {
return &notAuthorizedContentTypeError{}
}
return nil
}
// findContentType parses a string representing a content type and tries to find
// one of the given content types.
func findContentType(requestContentType string, contentTypes ...ContentType) ContentType {
for _, ct := range contentTypes {
if i := strings.IndexRune(requestContentType, ';'); i != -1 {
requestContentType = requestContentType[0:i]
}
if requestContentType == string(ct) {
return ct
}
}
return ""
}

View File

@@ -1,39 +0,0 @@
package http
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestCheckAuthorizedContentType(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
// case 1: uses a request without a content type entry in its header.
if err := CheckAuthorizedContentType(req.Header); err == nil {
t.Error("Function should not have been able to retrieve an authorized content type from request's header")
}
// case 2: uses a request with a content type entry in its header.
req.Header.Set("Content-Type", string(MultipartFormDataContentType))
if err := CheckAuthorizedContentType(req.Header); err != nil {
t.Error("Function should have been able to retrieve an authorized content type from request's header")
}
// case 3: uses a request with a composed content type entry in its header.
req.Header.Set("Content-Type", "multipart/form-data; boundary=—-WebKitFormBoundary7MA4YWxkTrZu0gW")
if err := CheckAuthorizedContentType(req.Header); err != nil {
t.Error("Function should have been able to retrieve an authorized content type from request's header")
}
}
func TestNotAuthorizedContentTypeError(t *testing.T) {
err := &notAuthorizedContentTypeError{}
expected := fmt.Sprintf(notAuthorizedContentTypeErrorMessage, MultipartFormDataContentType)
if err.Error() != expected {
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
}
}

View File

@@ -1,81 +0,0 @@
// Package logger implements a simple wrapper of the logrus library.
package logger
import (
"os"
"github.com/sirupsen/logrus"
)
// logger wraps a logrus.Logger instance.
type logger struct {
logger *logrus.Logger
}
// log is our logger instance used across the application.
var log = newLogger()
// newLogger instantiates a logger instance with default values.
func newLogger() *logger {
l := &logger{logrus.New()}
l.logger.Out = os.Stdout
l.logger.Level = logrus.InfoLevel
return l
}
// SetLevel updates the level of messages which will be logged.
func SetLevel(level logrus.Level) {
log.logger.SetLevel(level)
}
// SetFormatter updates the output format.
// When a TTY is not attached, the output will be in the defined format.
func SetFormatter(formatter logrus.Formatter) {
log.logger.Formatter = formatter
}
// Debug is a wrapper of the logrus Debug function.
func Debug(message string) {
log.logger.Debug(message)
}
// Debugf is a wrapper of the logrus Debugf function.
func Debugf(format string, args ...interface{}) {
log.logger.Debugf(format, args...)
}
// Info is a wrapper of the logrus Info function.
func Info(message string) {
log.logger.Info(message)
}
// Infof is a wrapper of the logrus Infof function.
func Infof(format string, args ...interface{}) {
log.logger.Infof(format, args...)
}
// Warn is a wrapper of the logrus Warn function.
func Warn(message string) {
log.logger.Warn(message)
}
// Warnf is wrapper of the logrus Warnf function.
func Warnf(format string, args ...interface{}) {
log.logger.Warnf(format, args...)
}
// Error is a wrapper of the logrus Error function.
func Error(err error) {
log.logger.Error(err.Error())
}
// Fatal is a wrapper of the logrus Fatal function.
func Fatal(err error) {
log.logger.Fatal(err.Error())
}
// Panic is a wrapper of the logrus Panic function.
func Panic(err error) {
log.logger.Panic(err.Error())
}

195
build/Dockerfile Normal file
View File

@@ -0,0 +1,195 @@
# ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION
ARG GOTENBERG_VERSION
# ----------------------------------------------
# Gotenberg binary build stage
# ----------------------------------------------
FROM golang:$GOLANG_VERSION AS binary-stage
ARG GOTENBERG_VERSION
ENV CGO_ENABLED 0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
# Install module dependencies.
COPY go.mod go.sum ./
RUN go mod download &&\
go mod verify
# Copy the source code.
COPY cmd ./cmd
COPY pkg ./pkg
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
# ----------------------------------------------
# Final stage
# ----------------------------------------------
FROM debian:11-slim
ARG GOTENBERG_VERSION
ARG GOTENBERG_USER_GID
ARG GOTENBERG_USER_UID
ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION
LABEL author="Julien Neuhart" \
description="A Docker-powered stateless API for PDF files." \
github="https://github.com/gotenberg/gotenberg" \
version="$GOTENBERG_VERSION" \
website="https://gotenberg.dev"
RUN \
# Create a non-root user.
# All processes in the Docker container will run with this dedicated user.
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
mkdir /home/gotenberg &&\
chown gotenberg: /home/gotenberg
RUN \
# Install system dependencies required for the next instructions or debugging.
# Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg htop tini python3 default-jre-headless &&\
ln -s /usr/bin/htop /usr/bin/top &&\
# Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook.
# Therefore, there is no need for apt-get clean.
# See https://stackoverflow.com/a/24417119/3248473.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install fonts.
# Credits:
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
curl -o ./ttf-mscorefonts-installer_3.8_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8_all.deb &&\
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8_all.deb \
culmus \
fonts-beng \
fonts-hosny-amiri \
fonts-lklug-sinhala \
fonts-lohit-guru \
fonts-lohit-knda \
fonts-samyak-gujr \
fonts-samyak-mlym \
fonts-samyak-taml \
fonts-sarai \
fonts-sil-abyssinica \
fonts-sil-padauk \
fonts-telu \
fonts-thai-tlwg \
ttf-wqy-zenhei \
fonts-arphic-ukai \
fonts-arphic-uming \
fonts-ipafont-mincho \
fonts-ipafont-gothic \
fonts-unfonts-core \
# LibreOffice recommends.
fonts-crosextra-caladea \
fonts-crosextra-carlito \
fonts-dejavu \
fonts-dejavu-extra \
fonts-liberation \
fonts-liberation2 \
fonts-linuxlibertine \
fonts-noto-cjk \
fonts-noto-core \
fonts-noto-mono \
fonts-noto-ui-core \
fonts-sil-gentium \
fonts-sil-gentium-basic &&\
rm -f ./ttf-mscorefonts-installer_3.8_all.deb &&\
# Add Color and Black-and-White Noto emoji font.
# Credits:
# https://github.com/gotenberg/gotenberg/pull/325.
# https://github.com/googlefonts/noto-emoji.
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install either Google Chrome stable on amd64 architecture or
# Chromium on other architectures.
# See https://github.com/gotenberg/gotenberg/issues/328.
/bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \
curl 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 -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
else \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' &&\
# Verify installation.
chromium --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install LibreOffice & unoconv.
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libreoffice &&\
curl -Ls https://raw.githubusercontent.com/dagwieers/unoconv/master/unoconv -o /usr/bin/unoconv &&\
chmod +x /usr/bin/unoconv &&\
# unoconv will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\
# Verify installations.
libreoffice --version &&\
unoconv --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install PDFtk & QPDF (PDF engines).
# See https://github.com/gotenberg/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
chmod a+x /usr/bin/pdftk-all.jar &&\
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
# See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\
# Verify installations.
pdftk --version &&\
qpdf --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Improve fonts subpixel hinting and smoothing.
# Credits:
# https://github.com/arachnys/athenapdf/issues/69.
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy the Gotenberg binary from the builder stage.
COPY --from=binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else.
ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg"
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
ENV UNOCONV_BIN_PATH /usr/bin/unoconv
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
ENV PDFTK_BIN_PATH /usr/bin/pdftk
ENV QPDF_BIN_PATH /usr/bin/qpdf
USER gotenberg
WORKDIR /home/gotenberg
# Default API port.
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]

13
build/Dockerfile.cloudrun Normal file
View File

@@ -0,0 +1,13 @@
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
USER root
# For security reasons, the non-root user gotenberg does not own the Tini binary by default.
# However, some providers like Cloud Run from Google Cloud cannot start a Docker container in that case.
# See https://github.com/gotenberg/gotenberg/issues/90#issuecomment-543551353.
RUN chown gotenberg: /usr/bin/tini
USER gotenberg

29
build/fonts.conf Normal file
View File

@@ -0,0 +1,29 @@
<?xml version='1.0'?>
<!DOCTYPE fontconfig SYSTEM 'fonts.dtd'>
<fontconfig>
<match target="font">
<edit mode="assign" name="rgba">
<const>rgb</const>
</edit>
</match>
<match target="font">
<edit mode="assign" name="hinting">
<bool>true</bool>
</edit>
</match>
<match target="font">
<edit mode="assign" name="hintstyle">
<const>hintslight</const>
</edit>
</match>
<match target="font">
<edit mode="assign" name="antialias">
<bool>true</bool>
</edit>
</match>
<match target="font">
<edit mode="assign" name="lcdfilter">
<const>lcddefault</const>
</edit>
</match>
</fontconfig>

159
cmd/gotenberg.go Normal file
View File

@@ -0,0 +1,159 @@
package gotenbergcmd
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag"
"golang.org/x/sync/errgroup"
)
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
// Credits: https://github.com/labstack/echo/blob/v4.3.0/echo.go#L240.
const banner = `
_____ __ __
/ ___/__ / /____ ___ / / ___ _______ _
/ (_ / _ \/ __/ -_) _ \/ _ \/ -_) __/ _ '/
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/
A Docker-powered stateless API for PDF files.
Version: %s
-------------------------------------------------------
`
// Version is the... version of the Gotenberg application. We set it at the
// build stage of the Docker image.
var Version = "snapshot"
// Run starts the Gotenberg application. Call this in the main of your program.
func Run() {
fmt.Printf(banner, Version)
// Create the root FlagSet and adds the modules flags to it.
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
descriptors := gotenberg.GetModuleDescriptors()
var modsInfo string
for _, desc := range descriptors {
fs.AddFlagSet(desc.FlagSet)
modsInfo += desc.ID + " "
}
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
// Parse the flags...
err := fs.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// ...and create a wrapper around those.
parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
// Get the graceful shutdown duration.
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
ctx := gotenberg.NewContext(parsedFlags, descriptors)
// Start application modules.
apps, err := ctx.Modules(new(gotenberg.App))
if err != nil {
fmt.Printf("[FATAL] %s\n", err)
os.Exit(1)
}
for _, a := range apps {
go func(app gotenberg.App) {
id := app.(gotenberg.Module).Descriptor().ID
err = app.Start()
if err != nil {
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
os.Exit(1)
}
startupMessage := app.StartupMessage()
if startupMessage == "" {
fmt.Printf("[SYSTEM] %s: application started\n", id)
return
}
fmt.Printf("[SYSTEM] %s: %s\n", id, startupMessage)
}(a.(gotenberg.App))
}
// Get modules that want to print system messages.
sysLoggers, err := ctx.Modules(new(gotenberg.SystemLogger))
if err != nil {
fmt.Printf("[FATAL] %s\n", err)
os.Exit(1)
}
for _, l := range sysLoggers {
go func(logger gotenberg.SystemLogger) {
id := logger.(gotenberg.Module).Descriptor().ID
for _, message := range logger.SystemMessages() {
fmt.Printf("[SYSTEM] %s: %s\n", id, message)
}
}(l.(gotenberg.SystemLogger))
}
quit := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes).
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Block until we receive our signal.
<-quit
gracefulShutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownDuration)
defer cancel()
forceQuit := make(chan os.Signal, 1)
signal.Notify(forceQuit, syscall.SIGINT)
go func() {
// In case of force quit, cancel the context.
<-forceQuit
cancel()
}()
fmt.Printf("[SYSTEM] graceful shutdown of %s\n", gracefulShutdownDuration)
eg, _ := errgroup.WithContext(gracefulShutdownCtx)
for _, a := range apps {
eg.Go(func(app gotenberg.App) func() error {
return func() error {
id := app.(gotenberg.Module).Descriptor().ID
err = app.Stop(gracefulShutdownCtx)
if err != nil {
return fmt.Errorf("stopping %s: %w", id, err)
}
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
return nil
}
}(a.(gotenberg.App)))
}
err = eg.Wait()
if err != nil {
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1)
}
os.Exit(0)
}

12
cmd/gotenberg/main.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import (
gotenbergcmd "github.com/gotenberg/gotenberg/v7/cmd"
// Gotenberg modules.
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
)
func main() {
gotenbergcmd.Run()
}

744
docs/openapi.yaml Normal file
View File

@@ -0,0 +1,744 @@
openapi: 3.0.3
info:
title: Gotenberg
version: 7.x
license:
name: MIT
url: 'https://github.com/gotenberg/gotenberg/blob/main/LICENSE'
contact:
url: 'https://github.com/gotenberg/gotenberg'
description: >-
A Docker-powered stateless API for PDF files.
externalDocs:
url: https://gotenberg.dev
servers:
- url: 'http://localhost:3000'
description: Local server with the default Docker image and port
tags:
- name: chromium
description: Operations of the Chromium module
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
- name: libreoffice
description: Operations of the Libreoffice module
externalDocs:
url: https://gotenberg.dev/docs/modules/libreoffice
- name: pdfengines
description: Operations of the PDF Engines module
externalDocs:
url: https://gotenberg.dev/docs/modules/pdf-engines
paths:
/forms/chromium/convert/url:
post:
tags:
- chromium
summary: Convert the contents of a given URL to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
description: >-
Send a URL in your API request via the `url` form field
Send a remote URL in your API request via the `remoteURL` parameter, and
get the resulting PDF file. The API will fetch the given URL and render
the page to PDF using the underlying headless Chrome instance.
You can optionally include `header.html` and `footer.html` files as part of the request as well.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
required: true
description: >-
The request must be `multipart/form-data` that includes a `url` form field.
The API uses a headless Chrome instance to do the conversion, therefore print
parameter such as margins and paper size are also accepted as optional parameters.
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/URLConvertRequestBody'
examples: { }
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/chromium/convert/html:
post:
tags:
- chromium
summary: Convert a given HTML file to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
description: >-
Send an HTML file called `index.html` as a multipart form request, and
get the resulting PDF file. You can optionally include `header.html` and
`footer.html` files as part of the request as well.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/HTMLConvertRequestBody'
description: >-
The request body must have an `index.html` file in the `files` array,
as well as all the referred resources on the same level as the
`index.html` file. The request can also include `header.html` and
`footer.html`, given the limitations in the API description above.
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/chromium/convert/markdown:
post:
tags:
- chromium
summary: Convert a Markdown file to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
description: >-
Accepts an HTML file called `index.html` plus markdown files as a multipart
form request and embeds the markdown files into the HTML file using the Golang template
function `toHTML`.
The API will convert the markdown to HTML and embed it into your `index.html` file,
then render the resulting page. You can include your own styling and more in your HTML file.
Refer to the HTML conversion page for all the options you can use when converting
Markdown documents as well. You can optionally include `header.html` and
`footer.html` files as part of the request as well.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/MarkdownConvertRequestBody'
description: >-
The request body must have an `index.html` file in the `files` array,
as well as all the referred markdown resources on the same level as
the `index.html` file.
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/libreoffice/convert:
post:
tags:
- libreoffice
summary: Convert an Office document to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/libreoffice
description: >-
This route accepts multipart/form-data requests and files with the following extensions:
.bib .doc .xml .docx .fodt .html .ltx .txt .odt .ott .pdb .pdf .psw .rtf
.sdw .stw .sxw .uot .vor .wps .epub .png .bmp .emf .eps .fodg .gif .jpg
.met .odd .otg .pbm .pct .pgm .ppm .ras .std .svg .svm .swf .sxd .sxw
.tiff .xhtml .xpm .fodp .potm .pot .pptx .pps .ppt .pwp .sda .sdd .sti
.sxi .uop .wmf .csv .dbf .dif .fods .ods .ots .pxl .sdc .slk .stc .sxc
.uos .xls .xlt .xlsx .tif .jpeg .odp
By default, if you send more than one file to convert, the route returns a ZIP archive of the
resulting PDF files. However, you may prefer to merge all the PDF files into an individual PDF file.
> **Attention:** The files will be merged alphabetically for the
resulting PDF.
You may also specify the page ranges to convert from the incoming Office
documents. The expected format is the same as the one from the print
options of LibreOffice, e.g. `1-1` or `1-4`.
> **Attention:** if more than one document, the page ranges will be
applied for each document.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/OfficeConvertRequestBody'
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request, e.g. Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided
'503':
$ref: '#/components/responses/Timeout'
/forms/pdfengines/merge:
post:
tags:
- pdfengines
summary: Merge multiple PDFs into a single PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/pdf-engines
description: >-
You can send multiple PDF files to this endpoint, the API will merge
them into a single PDF and return the resulting PDF file.
> **Attention:** The PDF files will be merged alphabetically.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
files:
type: array
items:
type: string
format: binary
pdfFormat:
type: string
description: The PDF format of the resulting PDF
example: PDF/A-1a
required:
- files
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/pdfengines/convert:
post:
tags:
- pdfengines
summary: Convert PDFs into the given formats
externalDocs:
url: https://gotenberg.dev/docs/modules/pdf-engines
description: >-
This route accepts PDF files and a form field pdfFormat for converting them into the specified format.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
files:
type: array
items:
type: string
format: binary
pdfFormat:
type: string
description: The PDF format of the resulting PDF
example: PDF/A-1a
required:
- files
- pdfFormat
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: >-
Bad Request, e.g. Invalid form data: no form file found for extensions: [.pdf]; form value 'pdfFormat' is required
'503':
$ref: '#/components/responses/Timeout'
components:
schemas:
HTMLConvertRequestBody:
title: HTML Conversion Request Body
type: object
properties:
files:
type: array
description: >-
List of HTML files to be converted to PDF. An `index.html` file is
required, and any other resources that are referenced through the
HTML file must be included as well. All the referenced files must be
on the same level as the `index.html` file.
items:
type: string
format: binary
marginTop:
type: number
example: 0
default: 1
description: Top margin for the page in inches.
marginBottom:
type: number
example: 0
default: 1
description: Bottom margin for the page in inches.
marginLeft:
type: number
example: 0
default: 1
description: Left margin for the page in inches.
marginRight:
type: number
example: 0
default: 1
description: Right margin for the page in inches.
paperWidth:
type: number
example: 8.27
description: >-
Paper width to be used while rendering the PDF. The default page
size is A4.
paperHeight:
type: number
example: 11.69
description: >-
Paper height to be used while rendering the PDF. The default page
size is A4.
preferCssPageSize:
type: boolean
description: >-
Define whether to prefer page size as defined by CSS (default false)
default: false
printBackground:
type: boolean
description: >-
Print the background graphics (default false)
default: false
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
scale:
type: number
minimum: 0.1
maximum: 2.0
example: 1.5
description: >-
The scale of the page rendering
default: 1.0
waitDelay:
type: string
example: 5s
description: >-
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
you're trying to generate.
waitWindowStatus:
type: string
example: done
description: >-
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
For instance
await promises()
window.status = 'ready'
Prefer this option over waitDelay.
extraHttpHeaders:
type: string
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
required:
- files
MarkdownConvertRequestBody:
title: Markdown Conversion Request Body
type: object
properties:
files:
type: array
items:
type: string
format: binary
marginTop:
type: number
example: 0
default: 1
description: Top margin for the page in inches.
marginBottom:
type: number
example: 0
default: 1
description: Bottom margin for the page in inches.
marginLeft:
type: number
example: 0
default: 1
description: Left margin for the page in inches.
marginRight:
type: number
example: 0
default: 1
description: Right margin for the page in inches.
paperWidth:
type: number
example: 8.27
description: >-
Paper width to be used while rendering the PDF. The default page
size is A4.
paperHeight:
type: number
example: 11.69
description: >-
Paper height to be used while rendering the PDF. The default page
size is A4.
preferCssPageSize:
type: boolean
description: >-
Define whether to prefer page size as defined by CSS (default false)
default: false
printBackground:
type: boolean
description: >-
Print the background graphics (default false)
default: false
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
scale:
type: number
minimum: 0.1
maximum: 2.0
example: 1.5
description: >-
The scale of the page rendering
default: 1.0
waitDelay:
type: string
example: 5s
description: >-
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
you're trying to generate.
waitWindowStatus:
type: string
example: done
description: >-
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
For instance
await promises()
window.status = 'ready'
Prefer this option over waitDelay.
extraHttpHeaders:
type: string
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
required:
- files
URLConvertRequestBody:
title: URL Conversion Request Body
type: object
properties:
url:
type: string
example: 'https://google.com'
files:
description: Optional files named header.html and footer.html
type: array
items:
type: string
format: binary
marginTop:
type: number
example: 0
default: 1
description: Top margin for the page in inches.
marginBottom:
type: number
example: 0
default: 1
description: Bottom margin for the page in inches.
marginLeft:
type: number
example: 0
default: 1
description: Left margin for the page in inches.
marginRight:
type: number
example: 0
default: 1
description: Right margin for the page in inches.
paperWidth:
type: number
example: 8.27
description: >-
Paper width to be used while rendering the PDF. The default page
size is A4.
paperHeight:
type: number
example: 11.69
description: >-
Paper height to be used while rendering the PDF. The default page
size is A4.
preferCssPageSize:
type: boolean
description: >-
Define whether to prefer page size as defined by CSS (default false)
default: false
printBackground:
type: boolean
description: >-
Print the background graphics (default false)
default: false
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
scale:
type: number
minimum: 0.1
maximum: 2.0
example: 1.5
description: >-
The scale of the page rendering
default: 1.0
waitDelay:
type: string
example: 5s
description: >-
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
you're trying to generate.
waitWindowStatus:
type: string
example: done
description: >-
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
For instance
await promises()
window.status = 'ready'
Prefer this option over waitDelay.
extraHttpHeaders:
type: string
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
required:
- url
OfficeConvertRequestBody:
title: Office Conversion Request Body
type: object
properties:
files:
type: array
items:
type: string
format: binary
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents. **If there are multiple files sent to the API, this page
range will apply to all of the documents**. Empty means all pages.
nativePdfA1aFormat:
type: boolean
description: >-
Use unoconv to convert the resulting PDF to the 'PDF/A-1a' format.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
merge:
type: boolean
description: >-
Merge all PDF files into an individual PDF file.
required:
- files
MergeFilesRequestBody:
title: Merge Files Request Body
type: object
properties:
files:
type: array
items:
type: string
format: binary
required:
- files
securitySchemes: { }
responses:
SuccessfulPDF:
description: Resulting PDF file from the conversion.
content:
application/pdf:
schema:
type: string
format: binary
Timeout:
description: Service Unavailable

69
go.mod Normal file
View File

@@ -0,0 +1,69 @@
module github.com/gotenberg/gotenberg/v7
go 1.20
require (
github.com/alexliesenfeld/health v0.7.0
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/chromedp/cdproto v0.0.0-20230716001748-3ed7c525ec8b
github.com/chromedp/chromedp v0.9.1
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.4
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.11.1
github.com/labstack/gommon v0.4.0
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.25
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.4.1
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/prometheus/client_golang v1.16.0
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/image v0.9.0 // indirect
golang.org/x/net v0.12.0
golang.org/x/sync v0.3.0
golang.org/x/sys v0.10.0 // indirect
golang.org/x/term v0.10.0
golang.org/x/text v0.11.0
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.2.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

214
go.sum Normal file
View File

@@ -0,0 +1,214 @@
github.com/alexliesenfeld/health v0.7.0 h1:U3mSZ3ussRbGx+/rXBjNVxjLX5cKaNh8Ly9Hx3q+yfY=
github.com/alexliesenfeld/health v0.7.0/go.mod h1:6Nnjbu7vBYHoZqIuZeOnTpnW7OH14ulR+wIBE2QuJ8I=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20230716001748-3ed7c525ec8b h1:4RSQDLMb5m1XmyOgy7iQJmXuVsK24TaBoToW/FIFigA=
github.com/chromedp/cdproto v0.0.0-20230716001748-3ed7c525ec8b/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA=
github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/tiff v1.0.0 h1:T8/QVXiABO6Er7XCoExh4XPGyMO+X1ynf0V8kHui3t4=
github.com/hhrutter/tiff v1.0.0/go.mod h1:zluYmeCkNexc8HFzfc2MTVwA8gcPuFQp/ngjvIQ0CFo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4=
github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.4.1 h1:oKgcST93zXdq1vE+B8dQBlE8S7WqsBVgkLxg82M3Fgk=
github.com/pdfcpu/pdfcpu v0.4.1/go.mod h1:MojCBFW2uljNs3CBmyTDeFAvu7vI1LrJhWNMCjY3kg4=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/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/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk=
github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g=
golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

89
main.go
View File

@@ -1,89 +0,0 @@
/*
Package main handles the application startup and shutdown.
Gotenberg is a stateless API for converting Markdown files, HTML files and Office documents to PDF.
For more information, go to https://github.com/thecodingmachine/gotenberg.
*/
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"time"
"github.com/thecodingmachine/gotenberg/app"
"github.com/thecodingmachine/gotenberg/app/config"
"github.com/thecodingmachine/gotenberg/app/logger"
"github.com/gorilla/mux"
"github.com/sirupsen/logrus"
)
// version will be set on build time.
var version = "master"
// defaultConfigurationFilePath is our default configuration file to parse.
const defaultConfigurationFilePath = "gotenberg.yml"
// main initializes the application, starts it, and handles
// graceful shutdown.
func main() {
err := config.ParseFile(defaultConfigurationFilePath)
if err != nil {
logger.SetLevel(logrus.InfoLevel)
logger.Fatal(err)
os.Exit(1)
}
// defines our application logging.
logger.SetLevel(config.GetLogsLevel())
logger.SetFormatter(config.GetLogsFormatter())
// defines our application router.
r := mux.NewRouter()
r.Handle("/", app.GetHandlersChain()).Methods(http.MethodPost)
// defines our server.
s := &http.Server{
Addr: fmt.Sprintf(":%s", config.GetPort()),
Handler: r,
}
logger.Debugf("configuration loaded from file %s", defaultConfigurationFilePath)
logger.Infof("starting Gotenberg version %s", version)
logger.Infof("listening on port %s", config.GetPort())
// runs our server in a goroutine so that it doesn't block.
go func() {
if err = s.ListenAndServe(); err != nil {
logger.SetLevel(logrus.InfoLevel)
logger.Panic(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)
// blocks until we receive our signal.
<-quit
// creates a deadline to wait for.
var wait time.Duration
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
// doesn't block if no connections, but will otherwise wait
// until the timeout deadline.
s.Shutdown(ctx)
logger.SetLevel(logrus.InfoLevel)
logger.Info("bye!")
os.Exit(0)
}

View File

@@ -1,4 +0,0 @@
payload:
- key: Version
value: snapshot

View File

@@ -1,28 +0,0 @@
tasks:
- use: fmt
short: Runs go fmt ./...
run:
- go fmt ./...
- use: generate
short: Generates all files from blueprints
run:
- orbit generate -f .blueprints/Dockerfile.blueprint -o Dockerfile.ci -p "Image,CI"
- orbit generate -f .blueprints/Dockerfile.blueprint -o Dockerfile -p "Image,MAIN"
- use: ci
short: Runs CI process inside a container
run:
- docker build -t thecodingmachine/gotenberg:ci -f Dockerfile.ci .
- docker run --rm -e "VERSION={{ .Orbit.Version }}" -v "$(pwd)/.ci:/go/src/github.com/thecodingmachine/gotenberg/.ci" thecodingmachine/gotenberg:ci
- use: build
short: Builds the main Docker image
run:
- docker build . -t thecodingmachine/gotenberg:{{ .Orbit.Version }}
- use: up
short: Starts the {{ .Orbit.Version }} version of the Gotenberg image
run:
- docker run --rm -p 3000:3000/tcp thecodingmachine/gotenberg:{{ .Orbit.Version }}

206
pkg/gotenberg/cmd.go Normal file
View File

@@ -0,0 +1,206 @@
package gotenberg
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os/exec"
"strings"
"syscall"
"go.uber.org/zap"
)
// Cmd wraps an exec.Cmd.
type Cmd struct {
ctx context.Context
logger *zap.Logger
process *exec.Cmd
}
// Command creates a Cmd without a context. It configures the internal
// exec.Cmd of Cmd so that we may kill its unix process and all its children
// without creating orphans.
//
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
func Command(logger *zap.Logger, binPath string, args ...string) Cmd {
cmd := exec.Command(binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return Cmd{
ctx: nil,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd,
}
}
// CommandContext creates a Cmd with a context. It configures the internal
// exec.Cmd of Cmd so that we may kill its unix process and all its children
// without creating orphans.
//
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (Cmd, error) {
if ctx == nil {
return Cmd{}, errors.New("nil context")
}
cmd := exec.CommandContext(ctx, binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return Cmd{
ctx: ctx,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd,
}, nil
}
// Start starts the command but does not wait for its completion.
func (cmd Cmd) Start() error {
err := cmd.pipeOutput()
if err != nil {
return fmt.Errorf("pipe unix process output: %w", err)
}
cmd.logger.Debug(fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
err = cmd.process.Start()
if err != nil {
return fmt.Errorf("start unix process: %w", err)
}
return nil
}
// Wait waits for the command to complete. It should be called when using the
// Start method, so that the command does not leak zombies.
func (cmd Cmd) Wait() error {
err := cmd.process.Wait()
if err != nil {
return fmt.Errorf("wait for unix process: %w", err)
}
return nil
}
// Exec executes the command and wait for its completion or until the context
// is done. In any case, it kills the unix process and all its children.
func (cmd Cmd) Exec() (int, error) {
if cmd.ctx == nil {
return 10, errors.New("nil context")
}
err := cmd.Start()
if err != nil {
if cmd.process.ProcessState == nil {
return 131, fmt.Errorf("start command: %w", err)
}
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("start command: %w", err)
}
errChan := make(chan error, 1)
go func() {
errChan <- cmd.Wait()
}()
select {
case err = <-errChan:
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.Error(errProc.Error())
}
if err == nil {
return 0, nil
}
if cmd.process.ProcessState == nil {
return 131, fmt.Errorf("unix process error: %w", err)
}
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("unix process error: %w", err)
case <-cmd.ctx.Done():
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.Error(errProc.Error())
}
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
}
}
// pipeOutput creates logs entries according to the process stdout and stderr.
// It does nothing if the logging level is not debug.
func (cmd Cmd) pipeOutput() error {
checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output")
if checkedEntry == nil {
return nil
}
stdout, err := cmd.process.StdoutPipe()
if err != nil {
return fmt.Errorf("pipe unix process stdout: %w", err)
}
stderr, err := cmd.process.StderrPipe()
if err != nil {
return fmt.Errorf("unix process sdterr: %w", err)
}
// logCommandOutput creates logs entries according to a reader
// (either stdout or stderr).
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) {
r := bufio.NewReader(reader)
defer reader.Close()
for {
line, _, err := r.ReadLine()
if err != nil {
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("pipe unix process output error: %s", err))
}
break
}
if len(line) != 0 {
logger.Debug(string(line))
}
}
}
go logCommandOutput(cmd.logger.Named("stdout"), stdout)
go logCommandOutput(cmd.logger.Named("stderr"), stderr)
return nil
}
// Kill kills the unix process and all its children without creating orphans.
//
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
func (cmd Cmd) Kill() error {
if cmd.process == nil {
// We cannot use the logger here, because for whatever reason using it
// result to a panic.
// cmd.logger.Debug("no process, skip killing")
return nil
}
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
if err == nil {
cmd.logger.Debug("unix process killed")
return nil
}
// If the process does not exist anymore, the error is irrelevant.
if strings.Contains(err.Error(), "no such process") {
cmd.logger.Debug("unix process already killed")
return nil
}
return fmt.Errorf("kill unix process: %w", err)
}

324
pkg/gotenberg/cmd_test.go Normal file
View File

@@ -0,0 +1,324 @@
package gotenberg
import (
"context"
"testing"
"time"
"go.uber.org/zap"
)
func TestCommand(t *testing.T) {
cmd := Command(zap.NewNop(), "foo")
if !cmd.process.SysProcAttr.Setpgid {
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
}
}
func TestCommandContext(t *testing.T) {
tests := []struct {
name string
ctx context.Context
expectCommandContextErr bool
}{
{
name: "nominal behavior",
ctx: context.Background(),
},
{
name: "nil context",
expectCommandContextErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
if err == nil && !cmd.process.SysProcAttr.Setpgid {
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
}
if tc.expectCommandContextErr && err == nil {
t.Error("expected error from CommandContext(), but got none")
}
if !tc.expectCommandContextErr && err != nil {
t.Errorf("expected no error from CommandContext(), but got: %v", err)
}
})
}
}
func TestCmd_Start(t *testing.T) {
tests := []struct {
name string
cmd Cmd
expectStartErr bool
}{
{
name: "nominal behavior",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
},
{
name: "start error",
cmd: Command(zap.NewNop(), "foo"),
expectStartErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.Start()
if tc.expectStartErr && err == nil {
t.Error("expected error from cmd.Start(), but got none")
}
if !tc.expectStartErr && err != nil {
t.Errorf("expected no error from cmd.Start(), but got: %v", err)
}
})
}
}
func TestCmd_Wait(t *testing.T) {
tests := []struct {
name string
cmd Cmd
expectWaitErr bool
}{
{
name: "nominal behavior",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
}
return cmd
}(),
},
{
name: "wait error",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error from cmd.Start(), but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
}
return cmd
}(),
expectWaitErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.Wait()
if tc.expectWaitErr && err == nil {
t.Error("expected error from cmd.Wait(), but got none")
}
if !tc.expectWaitErr && err != nil {
t.Errorf("expected no error from cmd.Wait(), but got: %v", err)
}
})
}
}
func TestCmd_Exec(t *testing.T) {
tests := []struct {
name string
cmd Cmd
timeout time.Duration
expectExecErr bool
}{
{
name: "nominal behavior",
cmd: func() Cmd {
cmd, err := CommandContext(context.Background(), zap.NewNop(), "echo", "Hello", "World")
if err != nil {
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
}
return cmd
}(),
},
{
name: "nil context",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectExecErr: true,
},
{
name: "start error",
cmd: func() Cmd {
cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo")
if err != nil {
t.Fatalf("expected no error from CommandContext(), but got: %v", err)
}
return cmd
}(),
expectExecErr: true,
},
{
name: "context done",
cmd: Command(zap.NewNop(), "sleep", "2"),
timeout: time.Duration(1) * time.Second,
expectExecErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
tc.cmd.ctx = ctx
}
_, err := tc.cmd.Exec()
if tc.expectExecErr && err == nil {
t.Error("expected error from cmd.Exec(), but got none")
}
if !tc.expectExecErr && err != nil {
t.Errorf("expected no error from cmd.Exec(), but got: %v", err)
}
})
}
}
func TestCmd_pipeOutput(t *testing.T) {
tests := []struct {
name string
cmd Cmd
run bool
expectPipeOutputErr bool
}{
{
name: "nominal behavior",
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
},
{
name: "no debug, no pipe",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
},
{
name: "stdout already piped",
cmd: func() Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StdoutPipe()
if err != nil {
t.Fatalf("expected no error from cmd.process.StdoutPipe(), but got: %v", err)
}
return cmd
}(),
expectPipeOutputErr: true,
},
{
name: "stderr already piped",
cmd: func() Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StderrPipe()
if err != nil {
t.Fatalf("expected no error from cmd.process.StderrPipe(), but got: %v", err)
}
return cmd
}(),
expectPipeOutputErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.pipeOutput()
if tc.run {
errStart := tc.cmd.process.Start()
if errStart != nil {
t.Fatalf("expected no error from tc.cmd.process.Start(), but got: %v", errStart)
}
}
if tc.expectPipeOutputErr && err == nil {
t.Error("expected error from cmd.pipeOutput(), but got none")
}
if !tc.expectPipeOutputErr && err != nil {
t.Errorf("expected no error from cmd.pipeOutput(), but got: %v", err)
}
})
}
}
func TestCmd_Kill(t *testing.T) {
tests := []struct {
name string
cmd Cmd
}{
{
name: "nominal behavior",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
}
return cmd
}(),
},
{
name: "no process",
cmd: Cmd{logger: zap.NewNop()},
},
{
name: "process already killed",
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
}
return cmd
}(),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.cmd.Kill()
if err != nil {
t.Errorf("expected no error from cmd.Kill(), but got: %v", err)
}
})
}
}

125
pkg/gotenberg/context.go Normal file
View File

@@ -0,0 +1,125 @@
package gotenberg
import (
"fmt"
"reflect"
)
// Context is a struct which helps initializing modules. When provisioning, a
// module may use the context to get other modules that it needs internally.
type Context struct {
flags ParsedFlags
descriptors []ModuleDescriptor
moduleInstances map[string]interface{}
}
// NewContext creates a Context.
// In a module, prefer the Provisioner interface to get a Context.
func NewContext(
flags ParsedFlags,
descriptors []ModuleDescriptor,
) *Context {
return &Context{
flags: flags,
descriptors: descriptors,
moduleInstances: make(map[string]interface{}),
}
}
// ParsedFlags returns the parsed flags.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// flags := ctx.ParsedFlags()
// m.foo = flags.RequiredString("foo")
// }
func (ctx Context) ParsedFlags() ParsedFlags {
return ctx.flags
}
// Module returns a module which satisfies the requested interface.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// mod, _ := ctx.Module(new(ModuleInterface))
// real := mod.(ModuleInterface)
// }
//
// If the module has not yet been initialized, this method
// initializes it. Otherwise, returns the already initialized instance.
func (ctx *Context) Module(kind interface{}) (interface{}, error) {
mods, err := ctx.Modules(kind)
if err != nil {
return nil, fmt.Errorf("get module: %w", err)
}
if len(mods) != 1 {
return nil, fmt.Errorf("expected to have one and only one %s module", kind)
}
return mods[0], nil
}
// Modules returns the list of modules which satisfies the requested interface.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// mods, _ := ctx.Modules(new(ModuleInterface))
// for _, mod := range mods {
// real := mod.(ModuleInterface)
// // ...
// }
// }
//
// If one or more modules have not yet been initialized, this method
// initializes them. Otherwise, returns the already initialized instances.
func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
realKind := reflect.TypeOf(kind).Elem()
var mods []interface{}
for _, desc := range ctx.descriptors {
newInstance := desc.New()
if ok := reflect.TypeOf(newInstance).Implements(realKind); ok {
// The module implements the requested interface.
// We check if it has already been initialized.
instance, ok := ctx.moduleInstances[desc.ID]
if ok {
mods = append(mods, instance)
} else {
err := ctx.loadModule(desc.ID, newInstance)
if err != nil {
return nil, err
}
mods = append(mods, newInstance)
}
}
}
return mods, nil
}
// loadModule calls the Provision and/or Validate methods of the requested
// module if it satisfies the Provisioner and/or Validator interfaces.
func (ctx *Context) loadModule(id string, instance interface{}) error {
if prov, ok := instance.(Provisioner); ok {
// The instance can be provisioned.
err := prov.Provision(ctx)
if err != nil {
return fmt.Errorf("provision module %s: %w", id, err)
}
}
if validator, ok := instance.(Validator); ok {
// The instance can be validated.
err := validator.Validate()
if err != nil {
return fmt.Errorf("validate module %s: %w", id, err)
}
}
ctx.moduleInstances[id] = instance
return nil
}

View File

@@ -0,0 +1,195 @@
package gotenberg
import (
"errors"
"testing"
)
func TestNewContext(t *testing.T) {
if NewContext(ParsedFlags{}, nil) == nil {
t.Error("expected a non-nil value")
}
}
func TestContext_ParsedFlags(t *testing.T) {
ctx := NewContext(ParsedFlags{}, nil)
actual := ctx.ParsedFlags()
expect := ParsedFlags{}
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContext_Module(t *testing.T) {
for i, tc := range []struct {
mods []ModuleDescriptor
kind interface{}
expectErr bool
}{
{
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return errors.New("foo") }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
expectErr: true,
},
{
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
}(),
kind: new(Provisioner),
expectErr: true,
},
{
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
},
} {
ctx := NewContext(ParsedFlags{}, tc.mods)
_, err := ctx.Module(tc.kind)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestContext_Modules(t *testing.T) {
for i, tc := range []struct {
mods []ModuleDescriptor
kind interface{}
expectErr bool
}{
{
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return errors.New("foo") }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
expectErr: true,
},
{
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
}(),
kind: new(Provisioner),
},
{
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
},
} {
ctx := NewContext(ParsedFlags{}, tc.mods)
_, err := ctx.Modules(tc.kind)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestContext_loadModule(t *testing.T) {
for i, tc := range []struct {
instance interface{}
expectErr bool
}{
{
instance: func() interface{} {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return errors.New("foo") }
return mod
}(),
expectErr: true,
},
{
instance: func() interface{} {
mod := struct{ ProtoValidator }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.validate = func() error { return errors.New("foo") }
return mod
}(),
expectErr: true,
},
{
instance: func() interface{} {
mod := struct{ ProtoValidator }{}
mod.descriptor = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.validate = func() error { return nil }
return mod
}(),
},
} {
ctx := NewContext(ParsedFlags{}, nil)
err := ctx.loadModule("foo", tc.instance)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}

7
pkg/gotenberg/doc.go Normal file
View File

@@ -0,0 +1,7 @@
// Package gotenberg provides most of the logic of the module system.
//
// caddyserver/caddy, licensed under the Apache License 2.0, has significantly
// inspired this module system.
//
// More details are available on https://caddyserver.com/.
package gotenberg

198
pkg/gotenberg/flags.go Normal file
View File

@@ -0,0 +1,198 @@
package gotenberg
import (
"regexp"
"time"
"github.com/labstack/gommon/bytes"
flag "github.com/spf13/pflag"
)
// ParsedFlags wraps a flag.FlagSet so that retrieving the typed values is
// easier.
type ParsedFlags struct {
*flag.FlagSet
}
// MustString returns the string value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustString(name string) string {
val, err := f.GetString(name)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedString returns the string value of a deprecated flag if it was
// explicitly set or the string value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedString(deprecated string, newName string) string {
if f.Changed(deprecated) {
return f.MustString(deprecated)
}
return f.MustString(newName)
}
// MustStringSlice returns the string slice value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustStringSlice(name string) []string {
val, err := f.GetStringSlice(name)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedStringSlice returns the string slice value of a deprecated
// flag if it was explicitly set or the string slice value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedStringSlice(deprecated string, newName string) []string {
if f.Changed(deprecated) {
return f.MustStringSlice(deprecated)
}
return f.MustStringSlice(newName)
}
// MustBool returns the boolean value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustBool(name string) bool {
val, err := f.GetBool(name)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedBool returns the boolean value of a deprecated flag if it was
// explicitly set or the int value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool {
if f.Changed(deprecated) {
return f.MustBool(deprecated)
}
return f.MustBool(newName)
}
// MustInt returns the int value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustInt(name string) int {
val, err := f.GetInt(name)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedInt returns the int value of a deprecated flag if it was
// explicitly set or the int value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedInt(deprecated string, newName string) int {
if f.Changed(deprecated) {
return f.MustInt(deprecated)
}
return f.MustInt(newName)
}
// MustFloat64 returns the float value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustFloat64(name string) float64 {
val, err := f.GetFloat64(name)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedFloat64 returns the float value of a deprecated flag if it was
// explicitly set or the float value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedFloat64(deprecated string, newName string) float64 {
if f.Changed(deprecated) {
return f.MustFloat64(deprecated)
}
return f.MustFloat64(newName)
}
// MustDuration returns the time.Duration value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustDuration(name string) time.Duration {
val, err := f.GetDuration(name)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedDuration returns the time.Duration value of a deprecated flag
// if it was explicitly set or the time.Duration value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string) time.Duration {
if f.Changed(deprecated) {
return f.MustDuration(deprecated)
}
return f.MustDuration(newName)
}
// MustHumanReadableBytesString returns the human-readable bytes string of a
// flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string {
val, err := f.GetString(name)
if err != nil {
panic(err)
}
_, err = bytes.Parse(val)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
// string of a deprecated flag if it was explicitly set or the human-readable
// bytes string of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
if f.Changed(deprecated) {
return f.MustHumanReadableBytesString(deprecated)
}
return f.MustHumanReadableBytesString(newName)
}
// MustRegexp returns the regular expression of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
val, err := f.GetString(name)
if err != nil {
panic(err)
}
return regexp.MustCompile(val)
}
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
// it was explicitly set or the regular expression of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
if f.Changed(deprecated) {
return f.MustRegexp(deprecated)
}
return f.MustRegexp(newName)
}

668
pkg/gotenberg/flags_test.go Normal file
View File

@@ -0,0 +1,668 @@
package gotenberg
import (
"reflect"
"regexp"
"testing"
"time"
flag "github.com/spf13/pflag"
)
func TestParsedFlags_MustString(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
err := fs.Parse([]string{"--foo=foo"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustString(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedString(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue string
}{
{
rawFlags: []string{"--foo=foo"},
expectValue: "foo",
},
{
rawFlags: []string{"--bar=bar"},
expectValue: "bar",
},
{
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: "foo",
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedString("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustStringSlice(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", make([]string, 0), "")
err := fs.Parse([]string{"--foo=foo", "--foo=bar", "--foo=baz"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustStringSlice(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedStringSlice(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue []string
}{
{
rawFlags: []string{"--foo=foo"},
expectValue: []string{"foo"},
},
{
rawFlags: []string{"--bar=bar"},
expectValue: []string{"bar"},
},
{
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: []string{"foo"},
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", make([]string, 0), "")
fs.StringSlice("bar", make([]string, 0), "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
if !reflect.DeepEqual(actual, tc.expectValue) {
t.Errorf("test %d: expected %+v but got %+v", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustBool(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Bool("foo", false, "")
err := fs.Parse([]string{"--foo=true"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustBool(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedBool(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue bool
}{
{
rawFlags: []string{"--foo=true"},
expectValue: true,
},
{
rawFlags: []string{"--bar=false"},
expectValue: false,
},
{
rawFlags: []string{"--foo=true", "--bar=false"},
expectValue: true,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Bool("foo", false, "")
fs.Bool("bar", true, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedBool("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %v but got %v", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustInt(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int("foo", 0, "")
err := fs.Parse([]string{"--foo=1"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustInt(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedInt(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue int
}{
{
rawFlags: []string{"--foo=1"},
expectValue: 1,
},
{
rawFlags: []string{"--bar=2"},
expectValue: 2,
},
{
rawFlags: []string{"--foo=1", "--bar=2"},
expectValue: 1,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int("foo", 0, "")
fs.Int("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedInt("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %d but got %d", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustFloat64(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Float64("foo", 1.0, "")
err := fs.Parse([]string{"--foo=2.0"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustFloat64(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedFloat64(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue float64
}{
{
rawFlags: []string{"--foo=1.0"},
expectValue: 1.0,
},
{
rawFlags: []string{"--bar=2.0"},
expectValue: 2.0,
},
{
rawFlags: []string{"--foo=1.0", "--bar=2.0"},
expectValue: 1.0,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Float64("foo", 0, "")
fs.Float64("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %f but got %f", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustDuration(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Duration("foo", time.Duration(1)*time.Second, "")
err := fs.Parse([]string{"--foo=2m"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustDuration(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue time.Duration
}{
{
rawFlags: []string{"--foo=1s"},
expectValue: time.Duration(1) * time.Second,
},
{
rawFlags: []string{"--bar=2s"},
expectValue: time.Duration(2) * time.Second,
},
{
rawFlags: []string{"--foo=1s", "--bar=2s"},
expectValue: time.Duration(1) * time.Second,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Duration("foo", 0, "")
fs.Duration("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "1MB", "")
fs.String("bar", "1MB", "")
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
{
name: "baz",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustHumanReadableBytesString(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue string
}{
{
rawFlags: []string{"--foo=1MB"},
expectValue: "1MB",
},
{
rawFlags: []string{"--bar=2MB"},
expectValue: "2MB",
},
{
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
expectValue: "1MB",
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustRegexp(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
err := fs.Parse([]string{"--foo=", "--bar=*"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
name string
expectPanic bool
}{
{
name: "foo",
},
{
name: "bar",
expectPanic: true,
},
{
name: "baz",
expectPanic: true,
},
} {
func() {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
parsedFlags.MustRegexp(tc.name)
}()
}
}
func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue *regexp.Regexp
}{
{
rawFlags: []string{"--foo=foo"},
expectValue: regexp.MustCompile("foo"),
},
{
rawFlags: []string{"--bar=bar"},
expectValue: regexp.MustCompile("bar"),
},
{
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: regexp.MustCompile("foo"),
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
if actual.String() != tc.expectValue.String() {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue.String(), actual.String())
}
}
}

33
pkg/gotenberg/fs.go Normal file
View File

@@ -0,0 +1,33 @@
package gotenberg
import (
"fmt"
"os"
"github.com/google/uuid"
)
// TmpPath returns the default directory to use for temporary files and
// directories. Most if not all files and directories created by the
// application and its dependencies must be based on this default directory.
func TmpPath() string {
return os.TempDir()
}
// NewDirPath returns a random absolute path based on the temporary path.
func NewDirPath() string {
return fmt.Sprintf("%s/%s", TmpPath(), uuid.New())
}
// MkdirAll creates a random directory based on the temporary path and
// returns its absolute path.
func MkdirAll() (string, error) {
path := NewDirPath()
err := os.MkdirAll(path, 0755)
if err != nil {
return "", fmt.Errorf("create directory %s: %w", path, err)
}
return path, nil
}

59
pkg/gotenberg/fs_test.go Normal file
View File

@@ -0,0 +1,59 @@
package gotenberg
import (
"os"
"strings"
"testing"
)
func TestTmpPath(t *testing.T) {
osTempDir := os.TempDir()
tmpPath := TmpPath()
if tmpPath != osTempDir {
t.Errorf("expected path '%s' but got '%s'", osTempDir, tmpPath)
}
}
func TestNewDirPath(t *testing.T) {
newDirPath := NewDirPath()
tmpPath := TmpPath()
if !strings.HasPrefix(newDirPath, tmpPath) {
t.Fatalf("expected path '%s' to start with '%s'", newDirPath, tmpPath)
}
newDirPaths := make([]string, 1000)
for i := range newDirPaths {
newDirPaths[i] = NewDirPath()
}
for i, newDirPath := range newDirPaths {
for j, comparison := range newDirPaths {
if i == j {
continue
}
if newDirPath == comparison {
t.Fatalf("expected path '%s' (index %d) to be unique, but found an identical path on index %d", newDirPath, i, j)
}
}
}
}
func TestMkdirAll(t *testing.T) {
path, err := MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
tmpPath := TmpPath()
if !strings.HasPrefix(path, tmpPath) {
t.Fatalf("expected path '%s' to start with '%s'", path, tmpPath)
}
_, err = os.Stat(path)
if os.IsNotExist(err) {
t.Errorf("expected path '%s' to exist but got: %v", path, err)
}
}

14
pkg/gotenberg/logging.go Normal file
View File

@@ -0,0 +1,14 @@
package gotenberg
import "go.uber.org/zap"
// LoggerProvider is an interface for a module that supplies a method for
// creating a zap.Logger instance for use by other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
// logger, _ := provider.(gotenberg.LoggerProvider).Logger(m)
// }
type LoggerProvider interface {
Logger(mod Module) (*zap.Logger, error)
}

26
pkg/gotenberg/metrics.go Normal file
View File

@@ -0,0 +1,26 @@
package gotenberg
// Metric represents a unitary metric.
type Metric struct {
// Name is the unique identifier.
// Required.
Name string
// Description describes the metric.
// Optional.
Description string
// Read returns the current value.
// Required.
Read func() float64
}
// MetricsProvider is a module interface which provides a list of Metric.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.MetricsProvider))
// metrics, _ := provider.(gotenberg.MetricsProvider).Metrics()
// }
type MetricsProvider interface {
Metrics() ([]Metric, error)
}

66
pkg/gotenberg/mocks.go Normal file
View File

@@ -0,0 +1,66 @@
package gotenberg
import (
"context"
"go.uber.org/zap"
)
// ModuleMock is a mock for the Module interface.
type ModuleMock struct {
DescriptorMock func() ModuleDescriptor
}
func (mod ModuleMock) Descriptor() ModuleDescriptor {
return mod.DescriptorMock()
}
// ValidatorMock is a mock for the Validator interface.
type ValidatorMock struct {
ValidateMock func() error
}
func (mod ValidatorMock) Validate() error {
return mod.ValidateMock()
}
// PDFEngineMock is a mock for the PDFEngine interface.
type PDFEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
}
func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
}
func (engine PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, format, inputPath, outputPath)
}
// PDFEngineProviderMock is a mock for the PDFEngineProvider interface.
type PDFEngineProviderMock struct {
PDFEngineMock func() (PDFEngine, error)
}
func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) {
return provider.PDFEngineMock()
}
// LoggerProviderMock is a mock for the LoggerProvider interface.
type LoggerProviderMock struct {
LoggerMock func(mod Module) (*zap.Logger, error)
}
func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
return provider.LoggerMock(mod)
}
// Interface guards.
var (
_ Module = (*ModuleMock)(nil)
_ Validator = (*ValidatorMock)(nil)
_ PDFEngine = (*PDFEngineMock)(nil)
_ PDFEngineProvider = (*PDFEngineProviderMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
)

View File

@@ -0,0 +1,82 @@
package gotenberg
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestModuleMock(t *testing.T) {
mock := ModuleMock{
DescriptorMock: func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module {
return nil
}}
},
}
if mock.Descriptor().ID != "foo" {
t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID)
}
}
func TestValidatorMock(t *testing.T) {
mock := ValidatorMock{
ValidateMock: func() error {
return nil
},
}
err := mock.Validate()
if err != nil {
t.Errorf("expected no error from mock.Validate(), but got: %v", err)
}
}
func TestPDFEngineMock(t *testing.T) {
mock := PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
}
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
if err != nil {
t.Errorf("expected no error from mock.Merge(), but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), "", "", "")
if err != nil {
t.Errorf("expected no error from mock.Convert(), but got: %v", err)
}
}
func TestPDFEngineProvider(t *testing.T) {
mock := PDFEngineProviderMock{
PDFEngineMock: func() (PDFEngine, error) {
return PDFEngineMock{}, nil
},
}
_, err := mock.PDFEngine()
if err != nil {
t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err)
}
}
func TestLoggerProviderMock(t *testing.T) {
mock := LoggerProviderMock{
LoggerMock: func(mod Module) (*zap.Logger, error) {
return nil, nil
},
}
_, err := mock.Logger(ModuleMock{})
if err != nil {
t.Errorf("expected no error from mock.Logger(), but got: %v", err)
}
}

141
pkg/gotenberg/modules.go Normal file
View File

@@ -0,0 +1,141 @@
package gotenberg
import (
"context"
"fmt"
"sort"
"sync"
flag "github.com/spf13/pflag"
)
// Module is a sort of plugin which adds new functionalities to the application
// or other modules.
//
// type YourModule struct {
// property string
// }
//
// func (YourModule) Descriptor() gotenberg.ModuleDescriptor {
// return gotenberg.ModuleDescriptor{
// ID: "your_module",
// FlagSet: func() *flag.FlagSet {
// fs := flag.NewFlagSet("your_module", flag.ExitOnError)
// fs.String("your_module-property", "default value", "flag description")
//
// return fs
// }(),
// New: func() gotenberg.Module { return new(YourModule) },
// }
// }
type Module interface {
Descriptor() ModuleDescriptor
}
// ModuleDescriptor describes your module for the application.
type ModuleDescriptor struct {
// ID is the unique name (snake case) of the module.
// Required.
ID string
// FlagSet is the definition of the flags of the module.
// Optional.
FlagSet *flag.FlagSet
// New returns a new and empty instance of the module's type.
// Required.
New func() Module
}
// Provisioner is a module interface for modules which have to be initialized
// according to flags, environment variables, the context, etc.
type Provisioner interface {
Provision(*Context) error
}
// Validator is a module interface for modules which have to be validated after
// provisioning.
type Validator interface {
Validate() error
}
// App is a module interface for modules which can be started or stopped by the
// application.
type App interface {
Start() error
// StartupMessage returns a custom message to display on startup. If it
// returns an empty string, a default startup message is used instead.
StartupMessage() string
Stop(ctx context.Context) error
}
// SystemLogger is a module interface for modules which want to display
// messages on startup.
type SystemLogger interface {
SystemMessages() []string
}
// MustRegisterModule registers a module.
//
// To register a module, create an init() method in the module main go file:
//
// func init() {
// gotenberg.MustRegisterModule(YourModule{})
// }
//
// Then, in the main command (github.com/gotenberg/gotenberg/v7/cmd/gotenberg),
// import the module:
//
// imports (
// // Gotenberg modules.
// _ "your_module_path"
// )
func MustRegisterModule(mod Module) {
desc := mod.Descriptor()
if desc.ID == "" {
panic("module with an empty ID cannot be registered")
}
if desc.New == nil {
panic("module New function cannot be nil")
}
if val := desc.New(); val == nil {
panic("module New function cannot return a nil instance")
}
descriptorsMu.Lock()
defer descriptorsMu.Unlock()
if _, ok := descriptors[desc.ID]; ok {
panic(fmt.Sprintf("module %s is already registered", desc.ID))
}
descriptors[desc.ID] = desc
}
// GetModuleDescriptors returns the descriptors of all registered modules.
func GetModuleDescriptors() []ModuleDescriptor {
descriptorsMu.RLock()
defer descriptorsMu.RUnlock()
mods := make([]ModuleDescriptor, len(descriptors))
i := 0
for _, desc := range descriptors {
mods[i] = desc
i++
}
sort.Slice(mods, func(i, j int) bool {
return mods[i].ID < mods[j].ID
})
return mods
}
var (
descriptors = make(map[string]ModuleDescriptor)
descriptorsMu sync.RWMutex
)

View File

@@ -0,0 +1,135 @@
package gotenberg
import (
"reflect"
"testing"
)
type ProtoModule struct {
descriptor func() ModuleDescriptor
}
func (mod ProtoModule) Descriptor() ModuleDescriptor {
return mod.descriptor()
}
type ProtoProvisioner struct {
ProtoModule
provision func(ctx *Context) error
}
func (mod ProtoProvisioner) Provision(ctx *Context) error {
return mod.provision(ctx)
}
type ProtoValidator struct {
ProtoModule
validate func() error
}
func (mod ProtoValidator) Validate() error {
return mod.validate()
}
func TestMustRegisterModule(t *testing.T) {
descriptorsMu.RLock()
descriptors = map[string]ModuleDescriptor{
"a": {ID: "a"},
}
descriptorsMu.RUnlock()
for i, tc := range []struct {
ID string
New func() Module
expectPanic bool
}{
{
ID: "",
New: func() Module { return new(ProtoModule) },
expectPanic: true,
},
{
ID: "b",
New: nil,
expectPanic: true,
},
{
ID: "b",
New: func() Module { return nil },
expectPanic: true,
},
{
ID: "a",
New: func() Module { return new(ProtoModule) },
expectPanic: true,
},
{
ID: "b",
New: func() Module { return new(ProtoModule) },
},
} {
func() {
mod := struct{ ProtoModule }{}
mod.descriptor = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
}
}()
}
MustRegisterModule(mod)
}()
}
descriptorsMu.RLock()
descriptors = make(map[string]ModuleDescriptor)
descriptorsMu.RUnlock()
}
func TestGetModuleDescriptors(t *testing.T) {
descriptorsMu.RLock()
descriptors = map[string]ModuleDescriptor{
"d": {ID: "d"},
"c": {ID: "c"},
"b": {ID: "b"},
"a": {ID: "a"},
}
descriptorsMu.RUnlock()
expect := []ModuleDescriptor{
{ID: "a"},
{ID: "b"},
{ID: "c"},
{ID: "d"},
}
actual := GetModuleDescriptors()
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %v but got %v", expect, actual)
}
descriptorsMu.RLock()
descriptors = make(map[string]ModuleDescriptor)
descriptorsMu.RUnlock()
}
// Interface guards.
var (
_ Module = (*ProtoModule)(nil)
_ Provisioner = (*ProtoProvisioner)(nil)
_ Module = (*ProtoProvisioner)(nil)
_ Validator = (*ProtoValidator)(nil)
_ Module = (*ProtoValidator)(nil)
)

View File

@@ -0,0 +1,52 @@
package gotenberg
import (
"context"
"errors"
"go.uber.org/zap"
)
var (
// ErrPDFEngineMethodNotAvailable happens if a PDFEngine method is not
// available in the implementation.
ErrPDFEngineMethodNotAvailable = errors.New("method not available")
// ErrPDFFormatNotAvailable happens if a PDFEngine Convert's method does
// not handle a specific format.
ErrPDFFormatNotAvailable = errors.New("PDF format not available")
)
const (
FormatPDFA1a string = "PDF/A-1a"
FormatPDFA1b string = "PDF/A-1b"
FormatPDFA2a string = "PDF/A-2a"
FormatPDFA2b string = "PDF/A-2b"
FormatPDFA2u string = "PDF/A-2u"
FormatPDFA3a string = "PDF/A-3a"
FormatPDFA3b string = "PDF/A-3b"
FormatPDFA3u string = "PDF/A-3u"
)
// PDFEngine is a module interface which exposes methods for manipulating one
// or more PDFs. Implementations may abstract powerful tools like PDFtk, or
// fulfill those methods contracts in Golang directly.
type PDFEngine interface {
// Merge merges the given PDFs into a unique PDF. The pages' order reflects
// order of the given files.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
// Convert converts the given PDF to a specific PDF format.
Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
}
// PDFEngineProvider is a module interface which exposes a method for creating a
// PDFEngine for other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.PDFEngineProvider))
// pdfengines, _ := provider.(gotenberg.PDFEngineProvider).PDFEngine()
// }
type PDFEngineProvider interface {
PDFEngine() (PDFEngine, error)
}

504
pkg/modules/api/api.go Normal file
View File

@@ -0,0 +1,504 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/gc"
"github.com/labstack/echo/v4"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/http2"
)
func init() {
gotenberg.MustRegisterModule(API{})
}
// API is a module which provides an HTTP server. Other modules may add routes,
// middlewares or health checks.
type API struct {
port int
readTimeout time.Duration
writeTimeout time.Duration
timeout time.Duration
rootPath string
traceHeader string
disableHealthCheckLogging bool
routes []Route
externalMiddlewares []Middleware
healthChecks []health.CheckerOption
gcGraceDuration time.Duration
logger *zap.Logger
srv *echo.Echo
}
// Router is a module interface which adds routes to the API.
type Router interface {
Routes() ([]Route, error)
}
// Route represents a route from a Router.
type Route struct {
// Method is the HTTP method of the route (i.e., GET, POST, etc.).
// Required.
Method string
// Path is the sub path of the route. Must start with a slash.
// Required.
Path string
// IsMultipart tells if the route is "multipart/form-data".
// Optional.
IsMultipart bool
// DisableLogging disables the logging for this route.
// Optional.
DisableLogging bool
// Handler is the function which handles the request.
// Required.
Handler echo.HandlerFunc
}
// MiddlewareProvider is a module interface which adds middlewares to the API.
type MiddlewareProvider interface {
Middlewares() ([]Middleware, error)
}
// MiddlewareStack is a type which helps to determine in which stack the
// middlewares provided by the MiddlewareProvider modules should be located.
type MiddlewareStack uint32
const (
DefaultStack MiddlewareStack = iota
PreRouterStack
MultipartStack
)
// MiddlewarePriority is a type which helps to determine the execution order of
// middlewares provided by the MiddlewareProvider modules in a stack.
type MiddlewarePriority uint32
const (
VeryLowPriority MiddlewarePriority = iota
LowPriority
MediumPriority
HighPriority
VeryHighPriority
)
// Middleware is a middleware which can be added to the API's middlewares
// chain.
//
// middleware := Middleware{
// Handler: func() echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error {
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath)
//
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
//
// // Your middleware process.
// // ...
//
// // Call the next middleware in the chain.
// return next(c)
// }
// }
// }(),
// }
type Middleware struct {
// Stack tells in which stack the middleware should be located.
// Default to DefaultStack.
// Optional.
Stack MiddlewareStack
// Priority tells if the middleware should be positioned high or not in
// its stack.
// Default to VeryLowPriority.
// Optional.
Priority MiddlewarePriority
// Handler is the function of the middleware.
// Required.
Handler echo.MiddlewareFunc
}
// HealthChecker is a module interface which allows adding health checks to the
// API.
//
// See https://github.com/alexliesenfeld/health for more details.
type HealthChecker interface {
Checks() ([]health.CheckerOption, error)
}
// GarbageCollectorGraceDurationIncrementer is a module interface for
// increasing the grace duration provided by the API for the garbage collector.
type GarbageCollectorGraceDurationIncrementer interface {
AddGraceDuration() time.Duration
}
// Descriptor returns an API's module descriptor.
func (API) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "api",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int("api-port", 3000, "Set the port on which the API should listen")
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body")
fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request")
fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
var err error
err = multierr.Append(err, fs.MarkDeprecated("api-read-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-process-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-write-timeout", "use api-timeout instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the api module: %v", err))
}
return fs
}(),
New: func() gotenberg.Module { return new(API) },
}
}
// Provision sets the module properties.
func (a *API) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port")
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
// Port from env?
portEnvVar := flags.MustString("api-port-from-env")
if portEnvVar != "" {
val, ok := os.LookupEnv(portEnvVar)
if !ok {
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
}
if val == "" {
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
}
port, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
}
a.port = port
}
// Get routes from modules.
mods, err := ctx.Modules(new(Router))
if err != nil {
return fmt.Errorf("get routers: %w", err)
}
routers := make([]Router, len(mods))
for i, router := range mods {
routers[i] = router.(Router)
}
for _, router := range routers {
routes, err := router.Routes()
if err != nil {
return fmt.Errorf("get routes: %w", err)
}
a.routes = append(a.routes, routes...)
}
// Get middlewares from modules.
mods, err = ctx.Modules(new(MiddlewareProvider))
if err != nil {
return fmt.Errorf("get middleware providers: %w", err)
}
middlewareProviders := make([]MiddlewareProvider, len(mods))
for i, middlewareProvider := range mods {
middlewareProviders[i] = middlewareProvider.(MiddlewareProvider)
}
for _, middlewareProvider := range middlewareProviders {
middlewares, err := middlewareProvider.Middlewares()
if err != nil {
return fmt.Errorf("get middlewares: %w", err)
}
a.externalMiddlewares = append(a.externalMiddlewares, middlewares...)
}
// Sort middlewares by priority.
sort.Slice(a.externalMiddlewares, func(i, j int) bool {
return a.externalMiddlewares[i].Priority > a.externalMiddlewares[j].Priority
})
// Get health checks from modules.
mods, err = ctx.Modules(new(HealthChecker))
if err != nil {
return fmt.Errorf("get health checkers: %w", err)
}
healthCheckers := make([]HealthChecker, len(mods))
for i, healthChecker := range mods {
healthCheckers[i] = healthChecker.(HealthChecker)
}
for _, healthChecker := range healthCheckers {
checks, err := healthChecker.Checks()
if err != nil {
return fmt.Errorf("get health checks: %w", err)
}
a.healthChecks = append(a.healthChecks, checks...)
}
// Grace duration.
a.gcGraceDuration = a.timeout
mods, err = ctx.Modules(new(GarbageCollectorGraceDurationIncrementer))
if err != nil {
return fmt.Errorf("get garbage collector grace duration increments: %w", err)
}
for _, incrementer := range mods {
a.gcGraceDuration += incrementer.(GarbageCollectorGraceDurationIncrementer).AddGraceDuration()
}
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger
return nil
}
// Validate validates the module properties.
func (a API) Validate() error {
var err error
if a.port < 1 || a.port > 65535 {
err = multierr.Append(err,
errors.New("port must be more than 1 and less than 65535"),
)
}
if !strings.HasPrefix(a.rootPath, "/") {
err = multierr.Append(err,
errors.New("root path must start with /"),
)
}
if !strings.HasSuffix(a.rootPath, "/") {
err = multierr.Append(err,
errors.New("root path must end with /"),
)
}
if len(strings.TrimSpace(a.traceHeader)) == 0 {
err = multierr.Append(err,
errors.New("trace header must not be empty"),
)
}
if err != nil {
return err
}
routesMap := make(map[string]string, len(a.routes)+1)
routesMap["/health"] = "/health"
for _, route := range a.routes {
if route.Path == "" {
return errors.New("route with empty path cannot be registered")
}
if !strings.HasPrefix(route.Path, "/") {
return fmt.Errorf("route '%s' does not start with /", route.Path)
}
if route.IsMultipart && !strings.HasPrefix(route.Path, "/forms") {
return fmt.Errorf("multipart/form-data route '%s' does not start with /forms", route.Path)
}
if route.Method == "" {
return fmt.Errorf("route '%s' has an empty method", route.Path)
}
if route.Handler == nil {
return fmt.Errorf("route '%s' has a nil handler", route.Path)
}
if _, ok := routesMap[route.Path]; ok {
return fmt.Errorf("route '%s' is already registered", route.Path)
}
routesMap[route.Path] = route.Path
}
for _, middleware := range a.externalMiddlewares {
if middleware.Handler == nil {
return errors.New("a middleware has a nil handler")
}
}
return nil
}
// Start starts the HTTP server.
func (a *API) Start() error {
a.srv = echo.New()
a.srv.HideBanner = true
a.srv.HidePort = true
a.srv.Server.ReadTimeout = a.readTimeout
a.srv.Server.IdleTimeout = a.timeout
// See https://github.com/gotenberg/gotenberg/issues/396.
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes.
var disableLoggingForPaths []string
for i, route := range a.routes {
a.routes[i].Path = strings.TrimPrefix(route.Path, "/")
if route.DisableLogging {
disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/"))
}
}
// Check if the user wish to add logging entries related to the health
// check route.
if a.disableHealthCheckLogging {
disableLoggingForPaths = append(disableLoggingForPaths, "health")
}
// Add the API middlewares.
a.srv.Pre(
latencyMiddleware(),
rootPathMiddleware(a.rootPath),
traceMiddleware(a.traceHeader),
loggerMiddleware(a.logger, disableLoggingForPaths),
)
// Add the modules' middlewares in their respective stacks.
var externalMultipartMiddlewares []Middleware
for _, externalMiddleware := range a.externalMiddlewares {
switch externalMiddleware.Stack {
case PreRouterStack:
a.srv.Pre(externalMiddleware.Handler)
case MultipartStack:
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
default:
a.srv.Use(externalMiddleware.Handler)
}
}
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
// Add the modules' routes and their specific middlewares.
for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc
if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.timeout))
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
}
}
middlewares = append(middlewares, hardTimeoutMiddleware(hardTimeout))
a.srv.Add(
route.Method,
fmt.Sprintf("%s%s", a.rootPath, route.Path),
route.Handler,
middlewares...,
)
}
// Let's not forget the health check route.
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker))
}(),
hardTimeoutMiddleware(hardTimeout),
)
// As the following code is blocking, run it in a goroutine.
go func() {
server := &http2.Server{}
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error())
}
}()
return nil
}
// StartupMessage returns a custom startup message.
func (a API) StartupMessage() string {
return fmt.Sprintf("server listening on port %d", a.port)
}
// Stop stops the HTTP server.
func (a API) Stop(ctx context.Context) error {
return a.srv.Shutdown(ctx)
}
// GraceDuration updates the expiration time of files and directories parsed by
// the gc.GarbageCollector.
func (a API) GraceDuration() time.Duration {
return a.gcGraceDuration
}
// Interface guards.
var (
_ gotenberg.Module = (*API)(nil)
_ gotenberg.Provisioner = (*API)(nil)
_ gotenberg.Validator = (*API)(nil)
_ gotenberg.App = (*API)(nil)
_ gc.GarbageCollectorGraceDurationModifier = (*API)(nil)
)

901
pkg/modules/api/api_test.go Normal file
View File

@@ -0,0 +1,901 @@
package api
import (
"bytes"
"context"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"reflect"
"testing"
"time"
"github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
type ProtoModule struct {
descriptor func() gotenberg.ModuleDescriptor
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
type ProtoValidator struct {
ProtoModule
validate func() error
}
func (mod ProtoValidator) Validate() error {
return mod.validate()
}
type ProtoRouter struct {
ProtoValidator
routes func() ([]Route, error)
}
func (mod ProtoRouter) Routes() ([]Route, error) {
return mod.routes()
}
type ProtoMiddlewareProvider struct {
ProtoValidator
middlewares func() ([]Middleware, error)
}
func (mod ProtoMiddlewareProvider) Middlewares() ([]Middleware, error) {
return mod.middlewares()
}
type ProtoHealthChecker struct {
ProtoValidator
checks func() ([]health.CheckerOption, error)
}
func (mod ProtoHealthChecker) Checks() ([]health.CheckerOption, error) {
return mod.checks()
}
type ProtoGarbageCollectorGraceDurationIncrementer struct {
ProtoValidator
addGraceDuration func() time.Duration
}
func (mod ProtoGarbageCollectorGraceDurationIncrementer) AddGraceDuration() time.Duration {
return mod.addGraceDuration()
}
type ProtoLoggerProvider struct {
ProtoModule
logger func(mod gotenberg.Module) (*zap.Logger, error)
}
func (factory ProtoLoggerProvider) Logger(mod gotenberg.Module) (*zap.Logger, error) {
return factory.logger(mod)
}
func TestAPI_Descriptor(t *testing.T) {
descriptor := API{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(API))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestAPI_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
setEnv func(i int)
expectPort int
expectMiddlewares []Middleware
expectGraceDuration time.Duration
expectErr bool
}{
{
ctx: func() *gotenberg.Context {
fs := new(API).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=FOO"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
fs := new(API).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func(i int) {
err := os.Setenv("PORT", "")
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
},
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
fs := new(API).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func(i int) {
err := os.Setenv("PORT", "foo")
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
},
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
fs := new(API).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func(i int) {
err := os.Setenv("PORT", "1337")
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
},
expectPort: 1337,
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoRouter }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.routes = func() ([]Route, error) {
return nil, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMiddlewareProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.middlewares = func() ([]Middleware, error) {
return nil, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMiddlewareProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.middlewares = func() ([]Middleware, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoRouter }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.routes = func() ([]Route, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoHealthChecker }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.checks = func() ([]health.CheckerOption, error) {
return nil, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoHealthChecker }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.checks = func() ([]health.CheckerOption, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct {
ProtoGarbageCollectorGraceDurationIncrementer
}{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.addGraceDuration = func() time.Duration {
return 0
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct {
ProtoGarbageCollectorGraceDurationIncrementer
}{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.addGraceDuration = func() time.Duration {
return time.Duration(3) * time.Second
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectGraceDuration: time.Duration(33) * time.Second,
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoLoggerProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.logger = func(_ gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod1 := struct{ ProtoRouter }{}
mod1.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
}
mod1.validate = func() error {
return nil
}
mod1.routes = func() ([]Route, error) {
return []Route{{}}, nil
}
mod2 := struct{ ProtoMiddlewareProvider }{}
mod2.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
}
mod2.validate = func() error {
return nil
}
mod2.middlewares = func() ([]Middleware, error) {
return []Middleware{
{
Priority: VeryLowPriority,
},
{
Priority: LowPriority,
},
{
Priority: MediumPriority,
},
{
Priority: HighPriority,
},
{
Priority: VeryHighPriority,
},
}, nil
}
mod3 := struct{ ProtoHealthChecker }{}
mod3.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }}
}
mod3.validate = func() error {
return nil
}
mod3.checks = func() ([]health.CheckerOption, error) {
return []health.CheckerOption{health.WithDisabledAutostart()}, nil
}
mod4 := struct{ ProtoLoggerProvider }{}
mod4.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "qux", New: func() gotenberg.Module { return mod4 }}
}
mod4.logger = func(_ gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod1.Descriptor(),
mod2.Descriptor(),
mod3.Descriptor(),
mod4.Descriptor(),
},
)
}(),
expectMiddlewares: []Middleware{
{
Priority: VeryHighPriority,
},
{
Priority: HighPriority,
},
{
Priority: MediumPriority,
},
{
Priority: LowPriority,
},
{
Priority: VeryLowPriority,
},
},
},
} {
if tc.setEnv != nil {
tc.setEnv(i)
}
mod := new(API)
err := mod.Provision(tc.ctx)
if tc.expectPort != 0 && mod.port != tc.expectPort {
t.Errorf("test %d: expected port %d but got %d", i, tc.expectPort, mod.port)
}
if !reflect.DeepEqual(mod.externalMiddlewares, tc.expectMiddlewares) {
t.Errorf("test %d: expected %+v, but got: %+v", i, tc.expectMiddlewares, mod.externalMiddlewares)
}
if tc.expectGraceDuration != 0 && mod.gcGraceDuration != tc.expectGraceDuration {
t.Errorf("test %d: expected gc grace duration '%s' but got '%s'", i, tc.expectGraceDuration, mod.gcGraceDuration)
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestAPI_Validate(t *testing.T) {
for i, tc := range []struct {
port int
rootPath string
traceHeader string
routes []Route
middlewares []Middleware
expectErr bool
}{
{
port: 0,
expectErr: true,
},
{
port: 65536,
rootPath: "foo",
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Path: "",
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Path: "foo",
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Path: "/foo",
IsMultipart: true,
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Path: "/forms/foo",
IsMultipart: true,
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Method: http.MethodPost,
Path: "/forms/foo",
IsMultipart: true,
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Method: http.MethodPost,
Path: "/foo",
Handler: func(_ echo.Context) error { return nil },
},
{
Method: http.MethodPost,
Path: "/foo",
Handler: func(_ echo.Context) error { return nil },
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
middlewares: []Middleware{
{
Priority: HighPriority,
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Method: http.MethodGet,
Path: "/foo",
Handler: func(_ echo.Context) error { return nil },
},
},
middlewares: []Middleware{
{
Priority: HighPriority,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
},
},
} {
mod := API{
port: tc.port,
rootPath: tc.rootPath,
traceHeader: tc.traceHeader,
routes: tc.routes,
externalMiddlewares: tc.middlewares,
}
err := mod.Validate()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestAPI_Start(t *testing.T) {
mod := new(API)
mod.port = 3000
mod.rootPath = "/"
mod.disableHealthCheckLogging = true
mod.routes = []Route{
{
Method: http.MethodPost,
Path: "/forms/foo",
IsMultipart: true,
DisableLogging: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
}
return nil
},
},
{
Method: http.MethodPost,
Path: "/forms/bar",
IsMultipart: true,
Handler: func(_ echo.Context) error { return errors.New("foo") },
},
}
mod.externalMiddlewares = []Middleware{
{
Stack: PreRouterStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: DefaultStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
}
mod.logger = zap.NewNop()
err := mod.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
// health request.
recorder := httptest.NewRecorder()
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
// "multipart/form-data" request.
multipartRequest := func(URL string) *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = part.Write([]byte("foo"))
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, URL, body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
recorder = httptest.NewRecorder()
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
recorder = httptest.NewRecorder()
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar"))
if recorder.Code != http.StatusInternalServerError {
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code)
}
err = mod.srv.Shutdown(context.TODO())
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestAPI_StartupMessage(t *testing.T) {
mod := API{
port: 3000,
}
actual := mod.StartupMessage()
expect := "server listening on port 3000"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestAPI_Stop(t *testing.T) {
mod := API{
port: 3000,
routes: []Route{
{
Method: http.MethodGet,
Path: "/foo",
Handler: func(_ echo.Context) error { return nil },
},
},
logger: zap.NewNop(),
}
err := mod.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = mod.Stop(context.TODO())
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestAPI_GraceDuration(t *testing.T) {
mod := API{
gcGraceDuration: time.Duration(3) * time.Second,
}
expect := time.Duration(3) * time.Second
actual := mod.GraceDuration()
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.Module = (*ProtoValidator)(nil)
_ Router = (*ProtoRouter)(nil)
_ gotenberg.Module = (*ProtoRouter)(nil)
_ gotenberg.Validator = (*ProtoRouter)(nil)
_ MiddlewareProvider = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Module = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Validator = (*ProtoMiddlewareProvider)(nil)
_ HealthChecker = (*ProtoHealthChecker)(nil)
_ gotenberg.Module = (*ProtoHealthChecker)(nil)
_ gotenberg.Validator = (*ProtoHealthChecker)(nil)
_ GarbageCollectorGraceDurationIncrementer = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
_ gotenberg.Module = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
_ gotenberg.Validator = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
)

278
pkg/modules/api/context.go Normal file
View File

@@ -0,0 +1,278 @@
package api
import (
"compress/flate"
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3"
"go.uber.org/zap"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
var (
// ErrContextAlreadyClosed happens when the context has been canceled.
ErrContextAlreadyClosed = errors.New("context already closed")
// ErrOutOfBoundsOutputPath happens when an output path is not within
// context's working directory. It enforces having all the files in the
// same directory.
ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory")
)
// Context is the request context for a "multipart/form-data" requests.
type Context struct {
dirPath string
values map[string][]string
files map[string]string
outputPaths []string
cancelled bool
logger *zap.Logger
echoCtx echo.Context
context.Context
}
// newContext returns a Context by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
ctx := &Context{
outputPaths: make([]string, 0),
cancelled: false,
logger: logger,
echoCtx: echoCtx,
Context: processCtx,
}
// A custom cancel function which removes the context's working directory
// when called.
cancel := func() context.CancelFunc {
return func() {
if ctx.cancelled {
return
}
processCancel()
if ctx.dirPath == "" {
return
}
err := os.RemoveAll(ctx.dirPath)
if err != nil {
ctx.logger.Error(fmt.Sprintf("remove context's working directory: %s", err))
return
}
ctx.logger.Debug(fmt.Sprintf("'%s' removed", ctx.dirPath))
ctx.cancelled = true
}
}()
form, err := echoCtx.MultipartForm()
if err != nil {
if errors.Is(err, http.ErrNotMultipart) {
return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err),
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: want 'multipart/form-data'"),
)
}
if errors.Is(err, http.ErrMissingBoundary) {
return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err),
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: no boundary"),
)
}
if strings.Contains(err.Error(), io.EOF.Error()) {
return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err),
NewSentinelHTTPError(http.StatusBadRequest, "Malformed body: it does not match the 'Content-Type' header boundaries"),
)
}
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
}
dirPath, err := gotenberg.MkdirAll()
if err != nil {
return nil, cancel, fmt.Errorf("create working directory: %w", err)
}
ctx.dirPath = dirPath
ctx.values = form.Value
ctx.files = make(map[string]string)
copyToDisk := func(fh *multipart.FileHeader) error {
// Avoid directory traversal and normalize filename.
// See https://github.com/gotenberg/gotenberg/issues/104.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
if err != nil {
return fmt.Errorf("transform filename: %w", err)
}
in, err := fh.Open()
if err != nil {
return fmt.Errorf("open multipart file: %w", err)
}
defer func() {
err := in.Close()
if err != nil {
logger.Error(fmt.Sprintf("close file header: %s", err))
}
}()
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
out, err := os.Create(path)
if err != nil {
return fmt.Errorf("create local file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err))
}
}()
_, err = io.Copy(out, in)
if err != nil {
return fmt.Errorf("copy multipart file to local file: %w", err)
}
ctx.files[filename] = path
return nil
}
for _, files := range form.File {
for _, fh := range files {
err = copyToDisk(fh)
if err != nil {
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
}
}
}
ctx.Log().Debug(fmt.Sprintf("form data values: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form data files: %+v", ctx.files))
return ctx, cancel, err
}
// Request returns the http.Request.
func (ctx Context) Request() *http.Request {
return ctx.echoCtx.Request()
}
// FormData return a FormData.
func (ctx Context) FormData() *FormData {
return &FormData{
values: ctx.values,
files: ctx.files,
errors: nil,
}
}
// GeneratePath generates a path within the context's working directory. It
// does not create a file.
func (ctx Context) GeneratePath(extension string) string {
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
}
// AddOutputPaths adds the given paths. Those paths will be used later to build
// the output file.
func (ctx *Context) AddOutputPaths(paths ...string) error {
if ctx.cancelled {
return ErrContextAlreadyClosed
}
for _, path := range paths {
if !strings.HasPrefix(path, ctx.dirPath) {
return ErrOutOfBoundsOutputPath
}
ctx.outputPaths = append(ctx.outputPaths, path)
}
return nil
}
// Log returns the context zap.Logger.
func (ctx Context) Log() *zap.Logger {
return ctx.logger
}
// BuildOutputFile builds the output file according to the output paths
// registered in the context. If many output paths, an archive is created.
func (ctx Context) BuildOutputFile() (string, error) {
if ctx.cancelled {
return "", ErrContextAlreadyClosed
}
if len(ctx.outputPaths) == 0 {
return "", errors.New("no output path")
}
if len(ctx.outputPaths) == 1 {
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
return ctx.outputPaths[0], nil
}
z := archiver.Zip{
CompressionLevel: flate.DefaultCompression,
MkdirAll: true,
SelectiveCompression: true,
ContinueOnError: false,
OverwriteExisting: false,
ImplicitTopLevelFolder: false,
}
archivePath := ctx.GeneratePath(".zip")
err := z.Archive(ctx.outputPaths, archivePath)
if err != nil {
return "", fmt.Errorf("archive output files: %w", err)
}
ctx.logger.Debug(fmt.Sprintf("archive '%s' created", archivePath))
return archivePath, nil
}
// OutputFilename returns the filename based on the given output path or the
// "Gotenberg-Output-Filename" header's value.
func (ctx Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename")
if filename == "" {
return filepath.Base(outputPath)
}
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
}

View File

@@ -0,0 +1,336 @@
package api
import (
"bytes"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestNewContext(t *testing.T) {
for i, tc := range []struct {
request *http.Request
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
}{
{
request: httptest.NewRequest(http.MethodPost, "/", nil),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusUnsupportedMediaType,
},
{
request: func() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm)
return req
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusUnsupportedMediaType,
},
{
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = part.Write([]byte("foo"))
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
},
} {
handler := func(c echo.Context) error {
_, cancel, err := newContext(c, zap.NewNop(), time.Duration(10)*time.Second)
defer cancel()
// Context already cancelled.
defer cancel()
if err != nil {
return err
}
return nil
}
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
err := handler(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
var httpErr HTTPError
isHTTPErr := errors.As(err, &httpErr)
if tc.expectHTTPErr && !isHTTPErr {
t.Errorf("test %d: expected HTTP error but got: %v", i, err)
}
if !tc.expectHTTPErr && isHTTPErr {
t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr)
}
if err != nil && tc.expectHTTPErr && isHTTPErr {
status, _ := httpErr.HTTPError()
if status != tc.expectHTTPStatus {
t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status)
}
}
}
}
func TestContext_Request(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", nil)
recorder := httptest.NewRecorder()
c := echo.New().NewContext(request, recorder)
ctx := Context{
echoCtx: c,
}
if !reflect.DeepEqual(ctx.Request(), c.Request()) {
t.Errorf("expected %v but got %v", ctx.Request(), c.Request())
}
}
func TestContext_FormData(t *testing.T) {
ctx := Context{
values: map[string][]string{
"foo": {"foo"},
},
files: map[string]string{
"foo.txt": "/foo.txt",
},
}
actual := ctx.FormData()
expect := &FormData{
values: ctx.values,
files: ctx.files,
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got %+v", expect, actual)
}
}
func TestContext_GeneratePath(t *testing.T) {
ctx := Context{
dirPath: "/foo",
}
path := ctx.GeneratePath(".pdf")
if !strings.HasPrefix(path, ctx.dirPath) {
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
}
}
func TestContext_AddOutputPaths(t *testing.T) {
for i, tc := range []struct {
ctx *Context
path string
expectCount int
expectErr bool
}{
{
ctx: &Context{cancelled: true},
expectErr: true,
},
{
ctx: &Context{dirPath: "/foo"},
path: "/bar/foo.txt",
expectErr: true,
},
{
ctx: &Context{dirPath: "/foo"},
path: "/foo/foo.txt",
expectCount: 1,
},
} {
err := tc.ctx.AddOutputPaths(tc.path)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if len(tc.ctx.outputPaths) != tc.expectCount {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectCount, len(tc.ctx.outputPaths))
}
}
}
func TestContext_Log(t *testing.T) {
expect := zap.NewNop()
ctx := Context{logger: expect}
actual := ctx.Log()
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContext_BuildOutputFile(t *testing.T) {
for i, tc := range []struct {
ctx *Context
expectErr bool
}{
{
ctx: &Context{cancelled: true},
expectErr: true,
},
{
ctx: &Context{},
expectErr: true,
},
{
ctx: &Context{outputPaths: []string{"foo.txt"}},
},
{
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
expectErr: true,
},
{
ctx: &Context{
outputPaths: []string{
"/tests/test/testdata/api/sample1.txt",
"/tests/test/testdata/api/sample1.txt",
},
},
},
} {
dirPath, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("%d: expected no erro but got: %v", i, err)
}
tc.ctx.dirPath = dirPath
tc.ctx.logger = zap.NewNop()
_, err = tc.ctx.BuildOutputFile()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("%d: expected no erro but got: %v", i, err)
}
}
}
func TestContext_OutputFilename(t *testing.T) {
for i, tc := range []struct {
ctx *Context
outputPath string
expectOutputFilename string
}{
{
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
return &Context{echoCtx: c}
}(),
outputPath: "/foo/bar.txt",
expectOutputFilename: "foo.txt",
},
{
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
return &Context{echoCtx: c}
}(),
outputPath: "/foo/foo.txt",
expectOutputFilename: "foo.txt",
},
} {
actual := tc.ctx.OutputFilename(tc.outputPath)
if actual != tc.expectOutputFilename {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectOutputFilename, actual)
}
}
}

3
pkg/modules/api/doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package api provides a module which is an HTTP server. Other modules may
// add multipart/form-data routes, middlewares, and health checks.
package api

77
pkg/modules/api/errors.go Normal file
View File

@@ -0,0 +1,77 @@
package api
// Credits: https://www.joeshaw.org/error-handling-in-go-http-applications.
// HTTPError is an interface allowing to retrieve the HTTP details of an error.
type HTTPError interface {
HTTPError() (int, string)
}
// SentinelHTTPError is the HTTP sidekick of an error.
type SentinelHTTPError struct {
status int
message string
}
// NewSentinelHTTPError creates a SentinelHTTPError. The message will be sent
// as the response's body if returned from a handler, so make sure to not leak
// sensible information.
func NewSentinelHTTPError(status int, message string) SentinelHTTPError {
return SentinelHTTPError{
status: status,
message: message,
}
}
// Error returns the message.
func (err SentinelHTTPError) Error() string {
return err.message
}
// HTTPError returns the status and message.
func (err SentinelHTTPError) HTTPError() (int, string) {
return err.status, err.message
}
// sentinelWrappedError contains both the error which will logged and the
// sidekick SentinelHTTPError.
type sentinelWrappedError struct {
error
sentinel SentinelHTTPError
}
func (w sentinelWrappedError) Is(err error) bool {
return w.sentinel == err
}
func (w sentinelWrappedError) HTTPError() (int, string) {
return w.sentinel.HTTPError()
}
// WrapError wraps the given error with a SentinelHTTPError. The wrapped error
// will be displayed in a log, while the SentinelHTTPError will be sent in the
// response.
//
// return api.WrapError(
// // This first error will be logged.
// fmt.Errorf("my action: %w", err),
// // The HTTP error will be sent as a response.
// api.NewSentinelHTTPError(
// http.StatusForbidden,
// "Hey, you did something wrong!"
// ),
// )
func WrapError(err error, sentinel SentinelHTTPError) error {
return sentinelWrappedError{
error: err,
sentinel: sentinel,
}
}
// Interface guards.
var (
_ error = (*SentinelHTTPError)(nil)
_ HTTPError = (*SentinelHTTPError)(nil)
_ error = (*sentinelWrappedError)(nil)
_ HTTPError = (*sentinelWrappedError)(nil)
)

View File

@@ -0,0 +1,108 @@
package api
import (
"errors"
"net/http"
"reflect"
"testing"
)
func TestNewSentinelHTTPError(t *testing.T) {
actual := NewSentinelHTTPError(http.StatusInternalServerError, "foo")
expect := SentinelHTTPError{
status: http.StatusInternalServerError,
message: "foo",
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestSentinelHTTPError_Error(t *testing.T) {
err := SentinelHTTPError{
message: "foo",
}
actual := err.Error()
expect := "foo"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestSentinelHTTPError_HTTPError(t *testing.T) {
actualStatus, actualMessage := SentinelHTTPError{
status: http.StatusInternalServerError,
message: "foo",
}.HTTPError()
expectStatus := http.StatusInternalServerError
expectMessage := "foo"
if actualStatus != expectStatus {
t.Errorf("expected %d but got %d", expectStatus, actualStatus)
}
if actualMessage != expectMessage {
t.Errorf("expected '%s' but got '%s'", expectMessage, actualMessage)
}
}
func TestSentinelWrappedError_Is(t *testing.T) {
errSentinel := SentinelHTTPError{}
err := sentinelWrappedError{
error: errors.New("foo"),
sentinel: errSentinel,
}
if !err.Is(errSentinel) {
t.Error("expected true")
}
}
func TestSentinelWrappedError_HTTPError(t *testing.T) {
expectStatus, expectMessage := SentinelHTTPError{
status: http.StatusInternalServerError,
message: "foo",
}.HTTPError()
actualStatus, actualMessage := sentinelWrappedError{
error: errors.New("foo"),
sentinel: SentinelHTTPError{
status: http.StatusInternalServerError,
message: "foo",
},
}.HTTPError()
if actualStatus != expectStatus {
t.Errorf("expected %d but got %d", expectStatus, actualStatus)
}
if actualMessage != expectMessage {
t.Errorf("expected '%s' but got '%s'", expectMessage, actualMessage)
}
}
func TestWrapError(t *testing.T) {
errFoo := errors.New("foo")
expect := sentinelWrappedError{
error: errFoo,
sentinel: SentinelHTTPError{
status: http.StatusInternalServerError,
message: "foo",
},
}
actual := WrapError(errFoo, SentinelHTTPError{
status: http.StatusInternalServerError,
message: "foo",
})
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %v but got %v", expect, actual)
}
}

441
pkg/modules/api/formdata.go Normal file
View File

@@ -0,0 +1,441 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"go.uber.org/multierr"
)
// FormData is a helper for validating and hydrating values from a
// "multipart/form-data" request.
//
// form := ctx.FormData()
type FormData struct {
values map[string][]string
files map[string]string
errors error
}
// Validate returns nil or an error related to the FormData values, with a
// SentinelHTTPError (status code 400, errors' details as message) wrapped
// inside.
//
// var foo string
//
// err := ctx.FormData().
// MandatoryString("foo", &foo, "bar").
// Validate()
func (form FormData) Validate() error {
if form.errors == nil {
return nil
}
return WrapError(
form.errors,
NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
)
}
// String binds a form data value to a string variable.
//
// var foo string
//
// ctx.FormData().String("foo", &foo, "bar")
func (form *FormData) String(key string, target *string, defaultValue string) *FormData {
return form.mustValue(key, target, defaultValue)
}
// MandatoryString binds a form data value to a string variable. It populates
// an error if the value is empty or the "key" does not exist.
//
// var foo string
//
// ctx.FormData().MandatoryString("foo", &foo)
func (form *FormData) MandatoryString(key string, target *string) *FormData {
return form.mustMandatoryValue(key, target)
}
// Bool binds a form data value to a bool variable. It populates an error if
// the value is not bool.
//
// var foo bool
//
// ctx.FormData().Bool("foo", &foo, true)
func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormData {
return form.mustValue(key, target, defaultValue)
}
// MandatoryBool binds a form data value to a bool variable. It populates an
// error if the value is not bool, is empty, or the "key" does not exist.
//
// var foo bool
//
// ctx.FormData().MandatoryBool("foo", &foo)
func (form *FormData) MandatoryBool(key string, target *bool) *FormData {
return form.mustMandatoryValue(key, target)
}
// Int binds a form data value to an int variable. It populates an error if the
// value is not int.
//
// var foo int
//
// ctx.FormData().Int("foo", &foo, 2)
func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
return form.mustValue(key, target, defaultValue)
}
// MandatoryInt binds a form data value to an int variable. It populates an
// error if the value is not int, is empty, or the "key" does not exist.
//
// var foo int
//
// ctx.FormData().MandatoryInt("foo", &foo)
func (form *FormData) MandatoryInt(key string, target *int) *FormData {
return form.mustMandatoryValue(key, target)
}
// Float64 binds a form data value to a float64 variable. It populates an error
// if the value is not float64.
//
// var foo float64
//
// ctx.FormData().Float64("foo", &foo, 2.0)
func (form *FormData) Float64(key string, target *float64, defaultValue float64) *FormData {
return form.mustValue(key, target, defaultValue)
}
// MandatoryFloat64 binds a form data value to a float64 variable. It populates
// an error if the is not float64, is empty, or the "key" does not exist.
//
// var foo float64
//
// ctx.FormData().MandatoryFloat64("foo", &foo)
func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData {
return form.mustMandatoryValue(key, target)
}
// Duration binds a form data value to a time.Duration variable. It populates
// an error if the form data value is not time.Duration.
//
// var foo time.Duration
//
// ctx.FormData().Duration("foo", &foo, time.Duration(2) * time.Second)
func (form *FormData) Duration(key string, target *time.Duration, defaultValue time.Duration) *FormData {
return form.mustValue(key, target, defaultValue)
}
// MandatoryDuration binds a form data value to a time.Duration variable. It
// populates an error if the value is not time.Duration, is empty, or the "key"
// does not exist.
//
// var foo time.Duration
//
// ctx.FormData().MandatoryDuration("foo", &foo)
func (form *FormData) MandatoryDuration(key string, target *time.Duration) *FormData {
return form.mustMandatoryValue(key, target)
}
// Custom helps to define a custom binding function for a form data value.
//
// var foo map[string]string
//
// ctx.FormData().Custom("foo", func(value string) error {
// if value == "" {
// foo = "bar"
//
// return nil
// }
//
// err := json.Unmarshal([]byte(value), &foo)
// if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err)
// }
//
// return nil
// })
func (form *FormData) Custom(key string, assign func(value string) error) *FormData {
var value string
form.mustValue(key, &value, "")
err := assign(value)
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}
return form
}
// MandatoryCustom helps to define a custom binding function for a form data
// value. It populates an error if the value is empty or the "key" does not
// exist.
//
// var foo map[string]string
//
// ctx.FormData().MandatoryCustom("foo", func(value string) error {
// err := json.Unmarshal([]byte(value), &foo)
// if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err)
// }
//
// return nil
// })
func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData {
var value string
form.mustMandatoryValue(key, &value)
if value == "" {
return form
}
err := assign(value)
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}
return form
}
// Path binds the absolute path of a form data file to a string variable.
//
// var path string
//
// ctx.FormData().Path("foo.txt", &path)
func (form *FormData) Path(filename string, target *string) *FormData {
return form.path(filename, target)
}
// MandatoryPath binds the absolute path ofa form data file to a string
// variable. It populates an error if the file does not exist.
//
// var path string
//
// ctx.FormData().MandatoryPath("foo.txt", &path)
func (form *FormData) MandatoryPath(filename string, target *string) *FormData {
return form.mandatoryPath(filename, target)
}
// Content binds the content of a form data file to a string variable.
//
// var content string
//
// ctx.FormData().Content("foo.txt", &content, "bar")
func (form *FormData) Content(filename string, target *string, defaultValue string) *FormData {
var path string
form.path(filename, &path)
if path == "" {
*target = defaultValue
return form
}
return form.readFile(path, filename, target)
}
// MandatoryContent binds the content of a form data file to a string variable.
// It populates an error if the file does not exist.
//
// var content string
//
// ctx.FormData().MandatoryContent("foo.txt", &content)
func (form *FormData) MandatoryContent(filename string, target *string) *FormData {
var path string
form.mandatoryPath(filename, &path)
if path == "" {
return form
}
return form.readFile(path, filename, target)
}
// Paths binds the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable.
//
// var paths []string
//
// ctx.FormData().Paths([]string{".txt"}, &paths)
func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
return form.paths(extensions, target)
}
// MandatoryPaths binds the absolute paths of form data files, according to a
// list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions.
//
// var paths []string
//
// ctx.FormData().MandatoryPaths([]string{".txt"}, &paths)
func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *FormData {
form.paths(extensions, target)
if len(*target) > 0 {
return form
}
form.append(
fmt.Errorf("no form file found for extensions: %v", extensions),
)
return form
}
// paths binds the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable.
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
for filename, path := range form.files {
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext {
*target = append(*target, path)
}
}
}
// See https://github.com/gotenberg/gotenberg/issues/139.
sort.Strings(*target)
return form
}
// append adds an error to the list of errors.
func (form *FormData) append(err error) {
form.errors = multierr.Append(form.errors, err)
}
// mustValue binds the target interface with a form data value. If the value is
// empty or the "key" does not exist, it binds the default value. Currently,
// only the string, bool, int, float64 and time.Duration types are bindable.
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData {
val, ok := form.values[key]
if !ok || val[0] == "" {
switch t := (target).(type) {
case *string:
*t = defaultValue.(string)
case *bool:
*t = defaultValue.(bool)
case *int:
*t = defaultValue.(int)
case *float64:
*t = defaultValue.(float64)
case *time.Duration:
*t = defaultValue.(time.Duration)
default:
panic("target type not supported")
}
return form
}
return form.mustAssign(key, val[0], target)
}
// mustMandatoryValue binds the target interface with a form data value. It
// populates an error if the value is empty or the "key" does not exist.
// Currently, only the string, bool, int, float64 and time.Duration types are
// bindable.
func (form *FormData) mustMandatoryValue(key string, target interface{}) *FormData {
val, ok := form.values[key]
if !ok || val[0] == "" {
form.append(
fmt.Errorf("form value '%s' is required", key),
)
return form
}
form.mustAssign(key, val[0], target)
return form
}
// mustAssign parses the string value and tries to convert it to the target
// interface real type. Currently, only the string, bool, int, float64 and
// time.Duration types are bindable.
func (form *FormData) mustAssign(key, value string, target interface{}) *FormData {
var err error
switch t := (target).(type) {
case *string:
*t = value
case *bool:
*t, err = strconv.ParseBool(value)
case *int:
*t, err = strconv.Atoi(value)
case *float64:
*t, err = strconv.ParseFloat(value, 64)
case *time.Duration:
*t, err = time.ParseDuration(value)
default:
panic("target type not supported")
}
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}
return form
}
// path binds the absolute path of a form data file to a string variable.
func (form *FormData) path(filename string, target *string) *FormData {
for name, path := range form.files {
// See https://github.com/gotenberg/gotenberg/issues/228.
nameLowerExt := strings.TrimSuffix(name, filepath.Ext(name)) + strings.ToLower(filepath.Ext(name))
if name == filename || nameLowerExt == filename {
*target = path
return form
}
}
return form
}
// mandatoryPath binds the absolute path of a form data file to a string
// variable. It populates an error if the file does not exist.
func (form *FormData) mandatoryPath(filename string, target *string) *FormData {
form.path(filename, target)
if *target != "" {
return form
}
form.append(
fmt.Errorf("form file '%s' is required", filename),
)
return form
}
// readFile binds the content of a file to a string variable. It populates an
// error if it fails to read the file content.
func (form *FormData) readFile(path, filename string, target *string) *FormData {
b, err := os.ReadFile(path)
if err != nil {
form.append(
fmt.Errorf("form file '%s' is invalid (%w)", filename, err),
)
return form
}
*target = string(b)
return form
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
// ErrAsyncProcess happens when a handler or middleware handles a request in an
// asynchronous fashion.
var ErrAsyncProcess = errors.New("async process")
// ParseError parses an error and returns the corresponding HTTP status and
// HTTP message.
func ParseError(err error) (int, string) {
echoErr, ok := err.(*echo.HTTPError)
if ok {
return echoErr.Code, http.StatusText(echoErr.Code)
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
}
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.HTTPError()
}
// Default 500 status code.
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
}
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
// returns a response as "text/plain; charset=UTF-8".
func httpErrorHandler() echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
logger := c.Get("logger").(*zap.Logger)
status, message := ParseError(err)
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
err = c.String(status, message)
if err != nil {
logger.Error(fmt.Sprintf("send error response: %s", err.Error()))
}
}
}
// latencyMiddleware sets the start time in the echo.Context under "startTime".
// Its value will be used later to calculate a request latency.
//
// startTime := c.Get("startTime").(time.Time)
func latencyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// First piece for calculating the latency.
startTime := time.Now()
c.Set("startTime", startTime)
// Call the next middleware in the chain.
return next(c)
}
}
}
// rootPathMiddleware sets the root path in the echo.Context under "rootPath".
// Its value may be used to skip a middleware execution based on a request
// URI.
//
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%s/health", rootPath)
//
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("rootPath", rootPath)
// Call the next middleware in the chain.
return next(c)
}
}
}
// traceMiddleware sets the request identifier in the echo.Context under
// "trace". Its value is either retrieved from the trace header or generated if
// the header is not present / its value is empty.
//
// trace := c.Get("trace").(string)
// traceHeader := c.Get("traceHeader").(string).
func traceMiddleware(header string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Get or create the request identifier.
trace := c.Request().Header.Get(header)
if trace == "" {
trace = uuid.New().String()
}
c.Set("trace", trace)
c.Set("traceHeader", header)
c.Response().Header().Add(header, trace)
// Call the next middleware in the chain.
return next(c)
}
}
}
// loggerMiddleware sets the logger in the echo.Context under "logger" and logs
// a synchronous request result.
//
// logger := c.Get("logger").(*zap.Logger)
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
startTime := c.Get("startTime").(time.Time)
trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string)
// Create the request logger and add it to our locals.
reqLogger := logger.With(zap.String("trace", trace))
c.Set("logger", reqLogger.Named(func() string {
return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
"",
)
}()))
// Call the next middleware in the chain.
err := next(c)
if err != nil {
c.Error(err)
}
for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
if c.Request().RequestURI == URI {
return nil
}
}
// Last piece for calculating the latency.
finishTime := time.Now()
// Now, let's log!
fields := make([]zap.Field, 12)
fields[0] = zap.String("remote_ip", c.RealIP())
fields[1] = zap.String("host", c.Request().Host)
fields[2] = zap.String("uri", c.Request().RequestURI)
fields[3] = zap.String("method", c.Request().Method)
fields[4] = zap.String("path", func() string {
path := c.Request().URL.Path
if path == "" {
path = "/"
}
return path
}())
fields[5] = zap.String("referer", c.Request().Referer())
fields[6] = zap.String("user_agent", c.Request().UserAgent())
fields[7] = zap.Int("status", c.Response().Status)
fields[8] = zap.Int64("latency", int64(finishTime.Sub(startTime)))
fields[9] = zap.String("latency_human", finishTime.Sub(startTime).String())
fields[10] = zap.Int64("bytes_in", c.Request().ContentLength)
fields[11] = zap.Int64("bytes_out", c.Response().Size)
if err != nil {
reqLogger.Error(err.Error(), fields...)
} else {
reqLogger.Info("request handled", fields...)
}
return nil
}
}
}
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
// Context and related context.CancelFunc in the echo.Context under "context"
// and "cancel". If the process is synchronous, it also handles the result of a
// "multipart/form-data" request.
//
// ctx := c.Get("context").(*api.Context)
// cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
// We create a context with a timeout so that underlying processes are
// able to stop early and handle correctly a timeout scenario.
ctx, cancel, err := newContext(c, logger, timeout)
if err != nil {
cancel()
return fmt.Errorf("create request context: %w", err)
}
c.Set("context", ctx)
c.Set("cancel", cancel)
// Call the next middleware in the chain.
err = next(c)
if errors.Is(err, ErrAsyncProcess) {
// A middleware/handler tells us that it's handling the process
// in an asynchronous fashion. Therefore, we must not cancel
// the context nor send an output file.
return c.NoContent(http.StatusNoContent)
}
defer cancel()
if err != nil {
return err
}
// No error, let's build the output file.
outputPath, err := ctx.BuildOutputFile()
if err != nil {
return fmt.Errorf("build output file: %w", err)
}
// Send the output file.
err = c.Attachment(outputPath, ctx.OutputFilename(outputPath))
if err != nil {
return fmt.Errorf("send response: %w", err)
}
return nil
}
}
}
// hardTimeoutMiddleware manages hard timeout scenarios, i.e., when a route
// handler fails to timeout as expected.
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
// Define a hard timeout if the route handler fails to timeout as
// expected.
hardTimeoutCtx, hardTimeoutCancel := context.WithTimeout(
context.Background(),
hardTimeout,
)
defer hardTimeoutCancel()
errChan := make(chan error, 1)
go func() {
// In case of hard timeout, a panic may occur.
// This deferred function allows us to recover from such scenarios.
defer func() {
if r := recover(); r != nil {
logger.Debug(fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r))
}
}()
// Call the next middleware in the chain.
errChan <- next(c)
}()
select {
case err := <-errChan:
return err
case <-hardTimeoutCtx.Done():
logger.Debug("hard timeout as the route handler did not timeout as expected")
return fmt.Errorf("hard timeout: %w", hardTimeoutCtx.Err())
}
}
}
}

View File

@@ -0,0 +1,506 @@
package api
import (
"bytes"
"context"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestParseError(t *testing.T) {
for i, tc := range []struct {
err error
expectStatus int
expectMessage string
}{
{
err: echo.ErrInternalServerError,
expectStatus: http.StatusInternalServerError,
expectMessage: http.StatusText(http.StatusInternalServerError),
},
{
err: context.DeadlineExceeded,
expectStatus: http.StatusServiceUnavailable,
expectMessage: http.StatusText(http.StatusServiceUnavailable),
},
{
err: WrapError(
errors.New("foo"),
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
),
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
},
} {
actualStatus, actualMessage := ParseError(tc.err)
if actualStatus != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, actualStatus)
}
if actualMessage != tc.expectMessage {
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, actualMessage)
}
}
}
func TestHttpErrorHandler(t *testing.T) {
for i, tc := range []struct {
err error
expectStatus int
expectMessage string
}{
{
err: echo.ErrInternalServerError,
expectStatus: http.StatusInternalServerError,
expectMessage: http.StatusText(http.StatusInternalServerError),
},
{
err: context.DeadlineExceeded,
expectStatus: http.StatusServiceUnavailable,
expectMessage: http.StatusText(http.StatusServiceUnavailable),
},
{
err: WrapError(
errors.New("foo"),
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
),
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
c.Set("logger", zap.NewNop())
handler := httpErrorHandler()
handler(tc.err, c)
contentType := recorder.Header().Get(echo.HeaderContentType)
if contentType != echo.MIMETextPlainCharsetUTF8 {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8, contentType)
}
// Note: we cannot test the trace header in the response here, as it is set in the trace middleware.
if recorder.Code != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
}
if recorder.Body.String() != tc.expectMessage {
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, recorder.Body.String())
}
}
}
func TestLatencyMiddleware(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
err := latencyMiddleware()(
func(c echo.Context) error {
return nil
},
)(c)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
startTime := c.Get("startTime").(time.Time)
now := time.Now()
if now.Before(startTime) {
t.Errorf("expected start time %s to be < %s", startTime, now)
}
}
func TestRootPathMiddleware(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
err := rootPathMiddleware("foo")(
func(c echo.Context) error {
return nil
},
)(c)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
rootPath := c.Get("rootPath").(string)
if rootPath != "foo" {
t.Errorf("expected '%s' but got '%s", "foo", rootPath)
}
}
func TestTraceMiddleware(t *testing.T) {
for i, tc := range []struct {
trace string
}{
{
trace: "foo",
},
{
trace: "",
},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
if tc.trace != "" {
c.Request().Header.Set("Gotenberg-Trace", tc.trace)
}
err := traceMiddleware("Gotenberg-Trace")(
func(c echo.Context) error {
return nil
},
)(c)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
trace := c.Get("trace").(string)
if trace == "" {
t.Errorf("test %d: expected non empty trace in context", i)
}
if tc.trace != "" && trace != tc.trace {
t.Errorf("test %d: expected context trace '%s' but got '%s'", i, tc.trace, trace)
}
if tc.trace == "" && trace == tc.trace {
t.Errorf("test %d: expected context trace different from '%s' but got '%s'", i, tc.trace, trace)
}
responseTrace := recorder.Header().Get("Gotenberg-Trace")
if tc.trace != "" && responseTrace != tc.trace {
t.Errorf("test %d: expected header trace '%s' but got '%s'", i, tc.trace, responseTrace)
}
if tc.trace == "" && responseTrace == tc.trace {
t.Errorf("test %d: expected header trace different from '%s' but got '%s'", i, tc.trace, responseTrace)
}
}
}
func TestLoggerMiddleware(t *testing.T) {
for i, tc := range []struct {
request *http.Request
next echo.HandlerFunc
skipLogging bool
}{
{
request: httptest.NewRequest(http.MethodGet, "/", nil),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
},
{
request: httptest.NewRequest(http.MethodGet, "/health", nil),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
skipLogging: true,
},
{
request: httptest.NewRequest(http.MethodGet, "/health", nil),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
},
} {
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
c.Set("startTime", time.Now())
c.Set("trace", "foo")
c.Set("rootPath", "/")
var disableLoggingForPaths []string
if tc.skipLogging {
disableLoggingForPaths = append(disableLoggingForPaths, tc.request.RequestURI)
}
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestContextMiddleware(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
for i, tc := range []struct {
request *http.Request
next echo.HandlerFunc
expectErr bool
expectStatus int
expectContentType string
expectFilename string
}{
{
request: httptest.NewRequest(http.MethodGet, "/", nil),
expectErr: true,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return ErrAsyncProcess
}
}(),
expectStatus: http.StatusNoContent,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
expectErr: true,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
expectErr: true,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Output-Filename", "foo")
return req
}(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample2.pdf",
}
return nil
}
}(),
expectStatus: http.StatusOK,
expectContentType: "application/pdf",
expectFilename: "foo.pdf",
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
"/tests/test/testdata/api/sample2.pdf",
}
return nil
}
}(),
expectStatus: http.StatusOK,
expectContentType: "application/zip",
},
} {
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
c.Set("logger", zap.NewNop())
c.Set("trace", "foo")
c.Set("startTime", time.Now())
err := contextMiddleware(time.Duration(10) * time.Second)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if err != nil {
continue
}
if recorder.Code != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
}
if tc.expectStatus == http.StatusNoContent {
continue
}
contentType := recorder.Header().Get(echo.HeaderContentType)
if contentType != tc.expectContentType {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, tc.expectContentType, contentType)
}
contentDisposition := recorder.Header().Get(echo.HeaderContentDisposition)
if !strings.Contains(contentDisposition, tc.expectFilename) {
t.Errorf("test %d: expected %s '%s' to contain '%s'", i, echo.HeaderContentDisposition, contentDisposition, tc.expectFilename)
}
}
}
func TestHardTimeoutMiddleware(t *testing.T) {
for i, tc := range []struct {
next echo.HandlerFunc
timeout time.Duration
expectErr bool
expectHardTimeout bool
}{
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
timeout: time.Duration(100) * time.Millisecond,
},
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
panic("foo")
}
}(),
timeout: time.Duration(100) * time.Millisecond,
expectErr: true,
expectHardTimeout: true,
},
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
timeout: time.Duration(100) * time.Millisecond,
expectErr: true,
},
{
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
time.Sleep(time.Duration(200) * time.Millisecond)
return nil
}
}(),
timeout: time.Duration(100) * time.Millisecond,
expectErr: true,
expectHardTimeout: true,
},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/foo", nil)
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(request, recorder)
c.Set("logger", zap.NewNop())
err := hardTimeoutMiddleware(tc.timeout)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
var isHardTimeout bool
if err != nil {
isHardTimeout = strings.Contains(err.Error(), "hard timeout")
}
if tc.expectHardTimeout && !isHardTimeout {
t.Errorf("test %d: expected hard timeout error but got: %v", i, err)
}
if !tc.expectHardTimeout && isHardTimeout {
t.Errorf("test %d: expected no hard timeout error but got one: %v", i, err)
}
}
}

75
pkg/modules/api/mocks.go Normal file
View File

@@ -0,0 +1,75 @@
package api
import (
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
// ContextMock is a helper for tests.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
type ContextMock struct {
*Context
}
// SetDirPath sets the context's working directory path.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetDirPath("/foo")
func (ctx *ContextMock) SetDirPath(path string) {
ctx.dirPath = path
}
// SetValues sets the values.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetValues(map[string][]string{
// "url": {
// "foo",
// },
// })
func (ctx *ContextMock) SetValues(values map[string][]string) {
ctx.values = values
}
// SetFiles sets the files.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetFiles(map[string]string{
// "foo": "/foo",
// })
func (ctx *ContextMock) SetFiles(files map[string]string) {
ctx.files = files
}
// SetCancelled sets if the context is cancelled or not.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetCancelled(true)
func (ctx *ContextMock) SetCancelled(cancelled bool) {
ctx.cancelled = cancelled
}
// OutputPaths returns the registered output paths.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// outputPaths := ctx.OutputPaths()
func (ctx ContextMock) OutputPaths() []string {
return ctx.outputPaths
}
// SetLogger sets the logger.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetLogger(zap.NewNop())
func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
ctx.logger = logger
}
// SetEchoContext sets the echo.Context.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setEchoContext(c)
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c
}

View File

@@ -0,0 +1,105 @@
package api
import (
"reflect"
"testing"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestContextMock_SetDirPath(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetDirPath("/foo")
actual := mock.dirPath
expect := "/foo"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestContextMock_SetValues(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetValues(map[string][]string{
"foo": {"foo"},
})
actual := mock.values
expect := map[string][]string{
"foo": {"foo"},
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestContextMock_SetFiles(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetFiles(map[string]string{
"foo": "/foo",
})
actual := mock.files
expect := map[string]string{
"foo": "/foo",
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestContextMock_SetCancelled(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetCancelled(true)
actual := mock.cancelled
if !actual {
t.Errorf("expected %t but got %t", true, actual)
}
}
func TestContextMock_OutputPaths(t *testing.T) {
mock := ContextMock{
&Context{
outputPaths: []string{"/foo"},
},
}
actual := mock.OutputPaths()
expect := []string{"/foo"}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestContextMock_SetLogger(t *testing.T) {
mock := ContextMock{&Context{}}
expect := zap.NewNop()
mock.SetLogger(expect)
actual := mock.logger
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContextMock_SetEchoContext(t *testing.T) {
mock := ContextMock{&Context{}}
expect := echo.New().NewContext(nil, nil)
mock.SetEchoContext(expect)
actual := mock.echoCtx
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}

View File

@@ -0,0 +1,991 @@
package chromium
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/alexliesenfeld/health"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
flag "github.com/spf13/pflag"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
func init() {
gotenberg.MustRegisterModule(Chromium{})
}
var (
// ErrURLNotAuthorized happens if a URL is not acceptable according to the
// allowed/denied lists.
ErrURLNotAuthorized = errors.New("URL not authorized")
// ErrOmitBackgroundWithoutPrintBackground happens if
// Options.OmitBackground is set to true but not Options.PrintBackground.
ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background")
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
// "screen" nor "print". Empty value are allowed though.
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
// ErrInvalidEvaluationExpression happens if an evaluation expression
// returns an exception or undefined.
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
// ErrInvalidPrinterSettings happens if the Options have one or more
// aberrant values.
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
// ErrPageRangesSyntaxError happens if the Options have an invalid page
// ranges.
ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
// ErrRpccMessageTooLarge happens when the messages received by
// ChromeDevTools are larger than 100 MB.
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
// ErrConsoleExceptions happens when there are exceptions in the Chromium
// console. It also happens only if the Options.FailOnConsoleExceptions is
// set to true.
ErrConsoleExceptions = errors.New("console exceptions")
)
// Chromium is a module which provides both an API and routes for converting
// HTML document to PDF.
type Chromium struct {
binPath string
engine gotenberg.PDFEngine
failedStartsThreshold int
userAgent string
incognito bool
allowInsecureLocalhost bool
ignoreCertificateErrors bool
disableWebSecurity bool
allowFileAccessFromFiles bool
hostResolverRules string
proxyServer string
allowList *regexp.Regexp
denyList *regexp.Regexp
disableJavaScript bool
disableRoutes bool
}
// LinkTag represents an HTML <link> element.
type LinkTag struct {
// Href is the "href" attribute of the HTML <link> element.
// Required.
Href string `json:"href"`
}
// ScriptTag represents an HTML <script> element.
type ScriptTag struct {
// Src is the "src" attribute of the HTML <script> element.
// Required.
Src string `json:"src"`
}
// Options are the available options for converting HTML document to PDF.
type Options struct {
// FailOnConsoleExceptions sets if the conversion should fail if there are
// exceptions in the Chromium console.
// Optional.
FailOnConsoleExceptions bool
// WaitDelay is the duration to wait when loading an HTML document before
// converting it to PDF.
// Optional.
WaitDelay time.Duration
// WaitWindowStatus is the window.status value to wait for before
// converting an HTML document to PDF.
// Optional.
WaitWindowStatus string
// WaitForExpression is the custom JavaScript expression to wait before
// converting an HTML document to PDF until it returns true
// Optional.
WaitForExpression string
// UserAgent overrides the default User-Agent header.
// Optional.
UserAgent string
// ExtraHTTPHeaders are the HTTP headers to send by Chromium while loading
// the HTML document.
// Optional.
ExtraHTTPHeaders map[string]string
// ExtraLinkTags are HTML <link> attributes that are added on the fly.
// Optional.
ExtraLinkTags []LinkTag
// EmulatedMediaType is the media type to emulate, either "screen" or
// "print".
// Optional.
EmulatedMediaType string
// ExtraScriptTags are HTML <script> attributes that are added on the fly.
// Optional.
ExtraScriptTags []ScriptTag
// Landscape sets the paper orientation.
// Optional.
Landscape bool
// PrintBackground prints the background graphics.
// Optional.
PrintBackground bool
// OmitBackground hides default white background and allows generating PDFs
// with transparency.
// Optional.
OmitBackground bool
// Scale is the scale of the page rendering.
// Optional.
Scale float64
// PaperWidth is the paper width, in inches.
// Optional.
PaperWidth float64
// PaperHeight is the paper height, in inches.
// Optional.
PaperHeight float64
// MarginTop is the top margin, in inches.
// Optional.
MarginTop float64
// MarginBottom is the bottom margin, in inches.
// Optional.
MarginBottom float64
// MarginLeft is the left margin, in inches.
// Optional.
MarginLeft float64
// MarginRight is the right margin, in inches.
// Optional.
MarginRight float64
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
// Optional.
PageRanges string
// HeaderTemplate is the HTML template of the header. It should be valid
// HTML markup with following classes used to inject printing values into
// them:
// - date: formatted print date
// - title: document title
// - url: document location
// - pageNumber: current page number
// - totalPages: total pages in the document
// For example, <span class=title></span> would generate span containing
// the title.
// Optional.
HeaderTemplate string
// FooterTemplate is the HTML template of the footer. It should use the
// same format as the HeaderTemplate.
// Optional.
FooterTemplate string
// PreferCSSPageSize defines whether to prefer page size as defined by CSS.
// If false, the content will be scaled to fit the paper size.
// Optional.
PreferCSSPageSize bool
}
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
UserAgent: "",
ExtraHTTPHeaders: nil,
ExtraLinkTags: nil,
EmulatedMediaType: "",
ExtraScriptTags: nil,
Landscape: false,
PrintBackground: false,
OmitBackground: false,
Scale: 1.0,
PaperWidth: 8.5,
PaperHeight: 11,
MarginTop: 0.39,
MarginBottom: 0.39,
MarginLeft: 0.39,
MarginRight: 0.39,
PageRanges: "",
HeaderTemplate: "<html><head></head><body></body></html>",
FooterTemplate: "<html><head></head><body></body></html>",
PreferCSSPageSize: false,
}
}
// API helps to interact with Chromium for converting HTML documents to PDF.
type API interface {
PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error
}
// Provider is a module interface which exposes a method for creating an API
// for other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(chromium.Provider))
// chromium, _ := provider.(chromium.Provider).Chromium()
// }
type Provider interface {
Chromium() (API, error)
}
// Descriptor returns a Chromium's module descriptor.
func (mod Chromium) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "chromium",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int("chromium-failed-starts-threshold", 5, "Set the number of consecutive failed starts after which the module is considered unhealthy - 0 means ignore")
fs.String("chromium-user-agent", "", "Override the default User-Agent header")
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
fs.Bool("chromium-disable-web-security", false, "Don't enforce the same-origin policy")
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
fs.Bool("chromium-disable-routes", false, "Disable the routes")
err := fs.MarkDeprecated("chromium-user-agent", "use the userAgent form field instead")
if err != nil {
panic(fmt.Errorf("create deprecated flags for chromium module: %v", err))
}
return fs
}(),
New: func() gotenberg.Module { return new(Chromium) },
}
}
// Provision sets the module properties.
func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.failedStartsThreshold = flags.MustInt("chromium-failed-starts-threshold")
mod.userAgent = flags.MustString("chromium-user-agent")
mod.allowInsecureLocalhost = flags.MustBool("chromium-allow-insecure-localhost")
mod.ignoreCertificateErrors = flags.MustBool("chromium-ignore-certificate-errors")
mod.disableWebSecurity = flags.MustBool("chromium-disable-web-security")
mod.allowFileAccessFromFiles = flags.MustBool("chromium-allow-file-access-from-files")
mod.hostResolverRules = flags.MustString("chromium-host-resolver-rules")
mod.proxyServer = flags.MustString("chromium-proxy-server")
mod.allowList = flags.MustRegexp("chromium-allow-list")
mod.denyList = flags.MustRegexp("chromium-deny-list")
mod.disableJavaScript = flags.MustBool("chromium-disable-javascript")
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
if !ok {
return errors.New("CHROMIUM_BIN_PATH environment variable is not set")
}
mod.binPath = binPath
provider, err := ctx.Module(new(gotenberg.PDFEngineProvider))
if err != nil {
return fmt.Errorf("get PDF engine provider: %w", err)
}
engine, err := provider.(gotenberg.PDFEngineProvider).PDFEngine()
if err != nil {
return fmt.Errorf("get PDF engine: %w", err)
}
mod.engine = engine
return nil
}
// Validate validates the module properties.
func (mod Chromium) Validate() error {
_, err := os.Stat(mod.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium binary path does not exist: %w", err)
}
return nil
}
// Metrics returns the metrics.
func (mod Chromium) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "chromium_active_instances_count",
Description: "Current number of active Chromium instances.",
Read: func() float64 {
activeInstancesCountMu.RLock()
defer activeInstancesCountMu.RUnlock()
return activeInstancesCount
},
},
{
Name: "chromium_failed_starts_count",
Description: "Current number of Chromium consecutive starting failures.",
Read: func() float64 {
failedStartsCountMu.RLock()
defer failedStartsCountMu.RUnlock()
return failedStartsCount
},
},
}, nil
}
// Checks adds a health check that verifies if Chromium consecutive failed
// starts threshold has been reached or not.
// See https://github.com/gotenberg/gotenberg/issues/633.
func (mod Chromium) Checks() ([]health.CheckerOption, error) {
if mod.failedStartsThreshold == 0 {
return nil, nil
}
return []health.CheckerOption{
health.WithCheck(health.Check{
Name: "chromium",
Check: func(_ context.Context) error {
failedStartsCountMu.RLock()
defer failedStartsCountMu.RUnlock()
if int(failedStartsCount) < mod.failedStartsThreshold {
return nil
}
return errors.New("failed starts threshold reached")
},
}),
}, nil
}
// Chromium returns an API for interacting with Chromium for converting HTML
// documents to PDF.
func (mod Chromium) Chromium() (API, error) {
return mod, nil
}
// Routes returns the HTTP routes.
func (mod Chromium) Routes() ([]api.Route, error) {
if mod.disableRoutes {
return nil, nil
}
return []api.Route{
convertURLRoute(mod, mod.engine),
convertHTMLRoute(mod, mod.engine),
convertMarkdownRoute(mod, mod.engine),
}, nil
}
// PDF converts a URL to PDF. It creates a dedicated Chromium instance.
// Substantial calls to this method may increase CPU and memory usage
// drastically. In such a scenario, the given context may also be done before
// the end of the conversion.
func (mod Chromium) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error {
debug := debugLogger{logger: logger.Named("browser")}
userProfileDirPath := gotenberg.NewDirPath()
args := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.CombinedOutput(debug),
chromedp.ExecPath(mod.binPath),
chromedp.NoSandbox,
// See:
// https://github.com/gotenberg/gotenberg/issues/327
// https://github.com/chromedp/chromedp/issues/904
chromedp.DisableGPU,
// See:
// https://github.com/puppeteer/puppeteer/issues/661
// https://github.com/puppeteer/puppeteer/issues/2410
chromedp.Flag("font-render-hinting", "none"),
chromedp.UserDataDir(userProfileDirPath),
)
if mod.userAgent != "" && options.UserAgent == "" {
// Deprecated.
args = append(args, chromedp.UserAgent(mod.userAgent))
}
if mod.incognito {
args = append(args, chromedp.Flag("incognito", mod.incognito))
}
if mod.allowInsecureLocalhost {
// See https://github.com/gotenberg/gotenberg/issues/488.
args = append(args, chromedp.Flag("allow-insecure-localhost", true))
}
if mod.ignoreCertificateErrors {
args = append(args, chromedp.IgnoreCertErrors)
}
if mod.disableWebSecurity {
args = append(args, chromedp.Flag("disable-web-security", true))
}
if mod.allowFileAccessFromFiles {
// See https://github.com/gotenberg/gotenberg/issues/356.
args = append(args, chromedp.Flag("allow-file-access-from-files", true))
}
if mod.hostResolverRules != "" {
// See https://github.com/gotenberg/gotenberg/issues/488.
args = append(args, chromedp.Flag("host-resolver-rules", mod.hostResolverRules))
}
if mod.proxyServer != "" {
// See https://github.com/gotenberg/gotenberg/issues/376.
args = append(args, chromedp.ProxyServer(mod.proxyServer))
}
if options.UserAgent != "" {
args = append(args, chromedp.UserAgent(options.UserAgent))
}
// See https://github.com/gotenberg/gotenberg/issues/524.
deadline, ok := ctx.Deadline()
if !ok {
return errors.New("context has no deadline")
}
args = append(args, chromedp.WSURLReadTimeout(time.Until(deadline)))
allocatorCtx, cancel := chromedp.NewExecAllocator(ctx, args...)
defer cancel()
taskCtx, cancel := chromedp.NewContext(allocatorCtx,
chromedp.WithDebugf(debug.Printf),
)
defer cancel()
// We validate the "main" URL against our allow / deny lists.
if !mod.allowList.MatchString(URL) {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", URL, ErrURLNotAuthorized)
}
if mod.denyList.String() != "" && mod.denyList.MatchString(URL) {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", URL, ErrURLNotAuthorized)
}
var (
consoleExceptions error
consoleExceptionsMu sync.RWMutex
)
printToPDF := func(URL string, options Options, outputPath string) chromedp.Tasks {
// We validate the underlying requests against our allow / deny lists.
// If a request does not pass the validation, we make it fail.
listenForEventRequestPaused(taskCtx, logger, mod.allowList, mod.denyList)
// See https://github.com/gotenberg/gotenberg/issues/262.
if options.FailOnConsoleExceptions && !mod.disableJavaScript {
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
}
return chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
chromedp.ActionFunc(func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/175.
if !mod.disableJavaScript {
logger.Debug("JavaScript not disabled")
return nil
}
logger.Debug("disable JavaScript")
err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("disable JavaScript: %w", err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
if len(options.ExtraHTTPHeaders) == 0 {
logger.Debug("no extra HTTP headers")
return nil
}
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.ExtraHTTPHeaders))
headers := make(network.Headers, len(options.ExtraHTTPHeaders))
for key, value := range options.ExtraHTTPHeaders {
headers[key] = value
}
err := network.SetExtraHTTPHeaders(headers).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("set extra HTTP headers: %w", err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("navigate to '%s'", URL))
_, _, _, err := page.Navigate(URL).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", URL, err)
}
err = runBatch(
ctx,
waitForEventDomContentEventFired(ctx, logger),
waitForEventLoadEventFired(ctx, logger),
waitForEventNetworkIdle(ctx, logger),
waitForEventLoadingFinished(ctx, logger),
)
if err == nil {
return nil
}
return fmt.Errorf("wait for events: %w", err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/226.
if !options.OmitBackground {
logger.Debug("default white background not hidden")
return nil
}
if !options.PrintBackground {
// See https://github.com/chromedp/chromedp/issues/1179#issuecomment-1284794416.
return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground)
}
logger.Debug("hide default white background")
err := emulation.SetDefaultBackgroundColorOverride().WithColor(
&cdp.RGBA{
R: 0,
G: 0,
B: 0,
A: 0,
}).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("hide default white background: %w", err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
// See:
// https://github.com/gotenberg/gotenberg/issues/354
// https://github.com/puppeteer/puppeteer/issues/2685
// https://github.com/chromedp/chromedp/issues/520
script := `
(() => {
const css = 'html { -webkit-print-color-adjust: exact !important; }';
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
})();
`
evaluate := chromedp.Evaluate(script, nil)
err := evaluate.Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("add CSS for exact colors: %w", err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
if len(options.ExtraLinkTags) == 0 {
logger.Debug("no extra link tags")
return nil
}
logger.Debug(fmt.Sprintf("extra link tags: %+v", options.ExtraLinkTags))
addLinkTag := func(i int, linkTag LinkTag) func() error {
return func() error {
script := `
(() => {
const link = document.createElement('link');
link.href = '%s';
link.rel = 'stylesheet'
document.head.appendChild(link);
})();
`
evaluate := chromedp.Evaluate(fmt.Sprintf(script, linkTag.Href), nil)
err := evaluate.Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("add extra link tag %d: %w", i, err)
}
}
eg, _ := errgroup.WithContext(ctx)
for i, linkTag := range options.ExtraLinkTags {
eg.Go(addLinkTag(i, linkTag))
}
err := eg.Wait()
if err == nil {
return nil
}
return fmt.Errorf("add extra link tags: %w", err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
if options.EmulatedMediaType == "" {
logger.Debug("no emulated media type")
return nil
}
if options.EmulatedMediaType != "screen" && options.EmulatedMediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", options.EmulatedMediaType, ErrInvalidEmulatedMediaType)
}
logger.Debug(fmt.Sprintf("emulate media type '%s'", options.EmulatedMediaType))
emulatedMedia := emulation.SetEmulatedMedia()
err := emulatedMedia.WithMedia(options.EmulatedMediaType).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("emulate media type '%s': %w", options.EmulatedMediaType, err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
if mod.disableJavaScript {
logger.Debug("JavaScript disabled, skipping extra script tags")
return nil
}
if len(options.ExtraScriptTags) == 0 {
logger.Debug("no extra script tags")
return nil
}
logger.Debug(fmt.Sprintf("extra script tags: %+v", options.ExtraScriptTags))
addScriptTag := func(i int, scriptTag ScriptTag) func() error {
return func() error {
script := `
(() => {
const script = document.createElement('script');
script.src = '%s';
document.head.appendChild(script);
})();
`
evaluate := chromedp.Evaluate(fmt.Sprintf(script, scriptTag.Src), nil)
err := evaluate.Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("add extra script tag %d: %w", i, err)
}
}
eg, _ := errgroup.WithContext(ctx)
for i, scriptTag := range options.ExtraScriptTags {
eg.Go(addScriptTag(i, scriptTag))
}
err := eg.Wait()
if err == nil {
return nil
}
return fmt.Errorf("add extra script tags: %w", err)
}),
chromedp.ActionFunc(func(ctx context.Context) error {
if mod.disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait delay")
return nil
}
if options.WaitDelay <= 0 {
logger.Debug("no wait delay")
return nil
}
// We wait for a given amount of time so that JavaScript
// scripts have a chance to finish before printing the page
// to PDF.
logger.Debug(fmt.Sprintf("wait '%s' before print", options.WaitDelay))
select {
case <-ctx.Done():
return fmt.Errorf("wait delay: %w", ctx.Err())
case <-time.After(options.WaitDelay):
return nil
}
}),
chromedp.ActionFunc(func(ctx context.Context) error {
if mod.disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait for window status / wait for expression")
return nil
}
if options.WaitWindowStatus == "" && options.WaitForExpression == "" {
logger.Debug("no wait for window status nor wait for expression")
return nil
}
evaluate := func(expression string) error {
// We wait until the evaluation of the expression is true or
// until the context is done.
logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression))
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
case <-ticker.C:
var ok bool
evaluate := chromedp.Evaluate(expression, &ok)
err := evaluate.Do(ctx)
if err != nil {
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
}
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
if options.WaitWindowStatus != "" {
logger.Warn("option 'WaitWindowStatus' is deprecated; prefer 'WaitForExpression' instead")
err := evaluate(fmt.Sprintf("window.status === '%s'", options.WaitWindowStatus))
if err != nil {
return fmt.Errorf("wait for window.status === '%s': %w", options.WaitWindowStatus, err)
}
}
if options.WaitForExpression != "" {
err := evaluate(options.WaitForExpression)
if err != nil {
return fmt.Errorf("wait for expression '%s': %w", options.WaitForExpression, err)
}
}
return nil
}),
chromedp.ActionFunc(func(ctx context.Context) error {
printToPDF := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(options.PaperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(options.PageRanges).
WithPreferCSSPageSize(options.PreferCSSPageSize)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultOptions().HeaderTemplate ||
options.FooterTemplate != DefaultOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.Debug("no custom header nor footer")
printToPDF = printToPDF.WithDisplayHeaderFooter(false)
} else {
logger.Debug("with custom header and/or footer")
printToPDF = printToPDF.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPDF))
_, stream, err := printToPDF.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err := reader.Close()
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err := file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}),
}
}
activeInstancesCountMu.Lock()
activeInstancesCount += 1
activeInstancesCountMu.Unlock()
err := chromedp.Run(taskCtx, printToPDF(URL, options, outputPath))
activeInstancesCountMu.Lock()
activeInstancesCount -= 1
activeInstancesCountMu.Unlock()
// Always remove the user profile directory created by Chromium.
go func() {
logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath))
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove user profile directory: %s", err))
}
}()
if err != nil {
errMessage := err.Error()
if strings.Contains(errMessage, "Show invalid printer settings error (-32000)") {
return ErrInvalidPrinterSettings
}
if strings.Contains(errMessage, "Page range syntax error") {
return ErrPageRangesSyntaxError
}
if strings.Contains(errMessage, "rpcc: message too large") {
return ErrRpccMessageTooLarge
}
// See https://github.com/gotenberg/gotenberg/issues/633.
if strings.Contains(errMessage, "chrome failed to start") {
failedStartsCountMu.Lock()
failedStartsCount += 1
failedStartsCountMu.Unlock()
}
return fmt.Errorf("chromium PDF: %w", err)
}
// See https://github.com/gotenberg/gotenberg/issues/633.
failedStartsCountMu.Lock()
failedStartsCount = 0
failedStartsCountMu.Unlock()
// See https://github.com/gotenberg/gotenberg/issues/262.
consoleExceptionsMu.RLock()
defer consoleExceptionsMu.RUnlock()
if consoleExceptions != nil {
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
}
return nil
}
var (
activeInstancesCount float64
activeInstancesCountMu sync.RWMutex
failedStartsCount float64
failedStartsCountMu sync.RWMutex
)
// Interface guards.
var (
_ gotenberg.Module = (*Chromium)(nil)
_ gotenberg.Provisioner = (*Chromium)(nil)
_ gotenberg.Validator = (*Chromium)(nil)
_ gotenberg.MetricsProvider = (*Chromium)(nil)
_ api.HealthChecker = (*Chromium)(nil)
_ api.Router = (*Chromium)(nil)
_ API = (*Chromium)(nil)
_ Provider = (*Chromium)(nil)
)

View File

@@ -0,0 +1,686 @@
package chromium
import (
"context"
"errors"
"os"
"reflect"
"regexp"
"testing"
"time"
"github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
)
type ProtoModule struct {
descriptor func() gotenberg.ModuleDescriptor
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
type ProtoAPI struct {
pdf func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error
}
func (mod ProtoAPI) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error {
return mod.pdf(ctx, logger, URL, outputPath, options)
}
type ProtoPDFEngineProvider struct {
ProtoModule
pdfEngine func() (gotenberg.PDFEngine, error)
}
func (mod ProtoPDFEngineProvider) PDFEngine() (gotenberg.PDFEngine, error) {
return mod.pdfEngine()
}
type ProtoPDFEngine struct {
merge func(_ context.Context, _ *zap.Logger, _ []string, _ string) error
convert func(_ context.Context, _ *zap.Logger, _, _, _ string) error
}
func (mod ProtoPDFEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return mod.merge(ctx, logger, inputPaths, outputPath)
}
func (mod ProtoPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return mod.convert(ctx, logger, format, inputPath, outputPath)
}
func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions()
notExpect := Options{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestChromium_Descriptor(t *testing.T) {
descriptor := Chromium{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Chromium))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestChromium_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectErr bool
}{
{
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoPDFEngineProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.pdfEngine = func() (gotenberg.PDFEngine, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoPDFEngineProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.pdfEngine = func() (gotenberg.PDFEngine, error) {
return struct{ ProtoPDFEngine }{}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
},
} {
mod := new(Chromium)
err := mod.Provision(tc.ctx)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestChromium_Validate(t *testing.T) {
for i, tc := range []struct {
binPath string
expectErr bool
}{
{
expectErr: true,
},
{
binPath: "/foo",
expectErr: true,
},
{
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
},
} {
mod := new(Chromium)
mod.binPath = tc.binPath
err := mod.Validate()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestChromium_Metrics(t *testing.T) {
metrics, err := new(Chromium).Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
}
actual := metrics[0].Read()
if actual != 0 {
t.Errorf("expected %d Chromium instances, but got %f", 0, actual)
}
actual = metrics[1].Read()
if actual != 0 {
t.Errorf("expected %d Chromium failed starts, but got %f", 0, actual)
}
}
func TestChromium_Checks(t *testing.T) {
tests := []struct {
name string
mod Chromium
tearUp func()
tearDown func()
expectAvailabilityStatus health.AvailabilityStatus
}{
{
name: "ignore Chromium failed starts",
mod: Chromium{
failedStartsThreshold: 0,
},
},
{
name: "with Chromium failed starts threshold not reached",
mod: Chromium{
failedStartsThreshold: 1,
},
expectAvailabilityStatus: health.StatusUp,
},
{
name: "with Chromium failed starts threshold reached",
mod: Chromium{
failedStartsThreshold: 1,
},
tearUp: func() {
failedStartsCount = 1
},
tearDown: func() {
failedStartsCount = 0
},
expectAvailabilityStatus: health.StatusDown,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.tearUp != nil {
tc.tearUp()
}
checks, err := tc.mod.Checks()
if err != nil {
t.Fatalf("expected no error from mod.Checks(), but got: %v", err)
}
if len(checks) == 0 {
return
}
if len(checks) != 1 {
t.Fatalf("expected 1 check from mod.Checks(), but got %d", len(checks))
}
checker := health.NewChecker(checks...)
result := checker.Check(context.Background())
if result.Status != tc.expectAvailabilityStatus {
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
}
if tc.tearDown != nil {
tc.tearDown()
}
})
}
}
func TestChromium_Chromium(t *testing.T) {
mod := new(Chromium)
_, err := mod.Chromium()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestChromium_Routes(t *testing.T) {
for i, tc := range []struct {
expectRoutes int
disableRoutes bool
}{
{
expectRoutes: 3,
},
{
disableRoutes: true,
},
} {
mod := new(Chromium)
mod.disableRoutes = tc.disableRoutes
routes, err := mod.Routes()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
}
}
}
func TestChromium_PDF(t *testing.T) {
for _, tc := range []struct {
name string
timeout time.Duration
cancel context.CancelFunc
URL string
options Options
userAgent string
incognito bool
allowInsecureLocalhost bool
ignoreCertificateErrors bool
disableWebSecurity bool
allowFileAccessFromFiles bool
hostResolverRules string
proxyServer string
allowList *regexp.Regexp
denyList *regexp.Regexp
disableJavaScript bool
expectErr bool
}{
{
name: "context has no deadline",
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
expectErr: true,
},
{
name: "URL does not match the expression from the allowed list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
allowList: regexp.MustCompile("file:///tmp/*"),
expectErr: true,
},
{
name: "URL does not match the expression from the denied list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
denyList: regexp.MustCompile("file:///tests/*"),
expectErr: true,
},
{
name: "with user agent",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
UserAgent: "foo",
},
},
{
name: "fail on console exceptions",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample10/index.html",
options: Options{
FailOnConsoleExceptions: true,
},
expectErr: true,
},
{
name: "disable JavaScript",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample9/index.html",
disableJavaScript: true,
},
{
name: "with extra HTTP headers",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
ExtraHTTPHeaders: map[string]string{
"foo": "bar",
},
},
},
{
name: "with extra link tags",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraLinkTags: []LinkTag{
{
Href: "font.woff",
},
{
Href: "style.css",
},
},
},
},
{
name: "with invalid emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "foo",
},
expectErr: true,
},
{
name: "with screen emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "screen",
},
},
{
name: "with print emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "print",
},
},
{
name: "with omit background but not print background",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
OmitBackground: true,
},
expectErr: true,
},
{
name: "with omit background and print background",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
OmitBackground: true,
PrintBackground: true,
},
},
{
name: "with extra script tags",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraScriptTags: []ScriptTag{
{
Src: "script.js",
},
},
},
},
{
name: "with wait delay",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitDelay: time.Duration(1) * time.Nanosecond,
},
},
{
name: "with invalid wait window status",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitWindowStatus: "foo",
},
expectErr: true,
},
{
name: "with wait window status",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitWindowStatus: "ready",
},
},
{
name: "with wait for expression that should not happen",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'foo'",
},
expectErr: true,
},
{
name: "with valid wait for expression",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'ready'",
},
},
{
name: "with invalid wait for expression",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitForExpression: "return undefined",
},
expectErr: true,
},
{
name: "with too big margin bottom",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
MarginBottom: 100,
},
expectErr: true,
},
{
name: "with invalid page ranges",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
PageRanges: "foo",
},
expectErr: true,
},
{
name: "with a lot of properties",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
userAgent: "foo",
incognito: true,
ignoreCertificateErrors: true,
allowInsecureLocalhost: true,
disableWebSecurity: true,
allowFileAccessFromFiles: true,
hostResolverRules: "foo",
proxyServer: "foo",
},
{
name: "with file using local and remote assets",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
},
{
name: "URL does match the expression from the allowed list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
allowList: regexp.MustCompile("file:///tests/*"),
},
{
name: "URL does match the expression from the denied list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
denyList: regexp.MustCompile("file:///etc/*"),
},
{
name: "with custom header and footer templates",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/header.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
FooterTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
},
},
{
name: "with custom header template only",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/header.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
FooterTemplate: DefaultOptions().FooterTemplate,
},
},
{
name: "with custom footer template only",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: DefaultOptions().HeaderTemplate,
FooterTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
},
},
{
name: "without custom header and footer templates",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: DefaultOptions().HeaderTemplate,
FooterTemplate: DefaultOptions().FooterTemplate,
},
},
{
name: "with file using a .gif",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample5/index.html",
},
{
name: "with allow file access from files",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample6/index.html",
allowFileAccessFromFiles: true,
},
{
name: "with file using a style attribute",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample7/index.html",
},
} {
func() {
mod := new(Chromium)
mod.binPath = os.Getenv("CHROMIUM_BIN_PATH")
mod.userAgent = tc.userAgent
mod.incognito = tc.incognito
mod.allowInsecureLocalhost = tc.allowInsecureLocalhost
mod.ignoreCertificateErrors = tc.ignoreCertificateErrors
mod.disableWebSecurity = tc.disableWebSecurity
mod.allowFileAccessFromFiles = tc.allowFileAccessFromFiles
mod.hostResolverRules = tc.hostResolverRules
mod.proxyServer = tc.proxyServer
if tc.allowList == nil {
tc.allowList = regexp.MustCompile("")
}
if tc.denyList == nil {
tc.denyList = regexp.MustCompile("")
}
mod.allowList = tc.allowList
mod.denyList = tc.denyList
mod.disableJavaScript = tc.disableJavaScript
outputDir, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("test %s: expected error but got: %v", tc.name, err)
}
defer func() {
err := os.RemoveAll(outputDir)
if err != nil {
t.Fatalf("test %s: expected no error but got: %v", tc.name, err)
}
}()
if tc.timeout == 0 {
err = mod.PDF(context.Background(), zap.NewNop(), tc.URL, outputDir+"/foo.pdf", tc.options)
} else {
ctx, cancel := context.WithTimeout(context.Background(), tc.timeout)
defer cancel()
err = mod.PDF(ctx, zap.NewNop(), tc.URL, outputDir+"/foo.pdf", tc.options)
}
if tc.expectErr && err == nil {
t.Errorf("test %s: expected error but got: %v", tc.name, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %s: expected no error but got: %v", tc.name, err)
}
}()
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ API = (*ProtoAPI)(nil)
_ gotenberg.PDFEngineProvider = (*ProtoPDFEngineProvider)(nil)
_ gotenberg.Module = (*ProtoPDFEngineProvider)(nil)
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil)
)

View File

@@ -0,0 +1,31 @@
package chromium
import (
"fmt"
"io"
"go.uber.org/zap"
)
// debugLogger is wrapper around a zap.Logger which is used for debugging
// Chromium.
type debugLogger struct {
logger *zap.Logger
}
// Write logs the bytes in a debug message.
func (debug debugLogger) Write(p []byte) (n int, err error) {
debug.logger.Debug(string(p))
return len(p), nil
}
// Printf logs a debug message.
func (debug debugLogger) Printf(format string, v ...interface{}) {
debug.logger.Debug(fmt.Sprintf(format, v...))
}
// Interface guards.
var (
_ io.Writer = (*debugLogger)(nil)
)

View File

@@ -0,0 +1,24 @@
package chromium
import (
"testing"
"go.uber.org/zap"
)
func TestDebugLogger_Write(t *testing.T) {
actual, err := debugLogger{logger: zap.NewNop()}.Write([]byte("foo"))
expected := len([]byte("foo"))
if actual != expected {
t.Errorf("expected %d but got %d", expected, actual)
}
if err != nil {
t.Errorf("expected not error but got: %v", err)
}
}
func TestDebugLogger_Printf(t *testing.T) {
debugLogger{logger: zap.NewNop()}.Printf("%s", "foo")
}

View File

@@ -0,0 +1,4 @@
// Package chromium provides a module which adds routes for converting HTML
// documents to PDF. Other modules may also retrieve the API provided by this
// module.
package chromium

Some files were not shown because too many files have changed in this diff Show More