Compare commits

..

300 Commits

Author SHA1 Message Date
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
191 changed files with 31333 additions and 7287 deletions

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,38 +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. Install and run the latest version of Docker
2. Fork this repository
3. Clone it to your local Go environment (requires *Go* >= 1.11)
### 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
### Testing
1. Run all linters (`make lint`)
2. Run all tests (`make tests`)
## 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,45 +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 Travis. -->
**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 (`make lint`)?
- [ ] Have you written new tests for your core changes, as applicable?
- [ ] Have you successfully ran tests with your changes locally (`make tests`)?
- [ ] Have you updated the documentation (`make doc`)?
- [ ] I have squashed any insignificant commits
- [ ] This change has comments for package types, values, functions, and non-obvious lines of code

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@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Checkout source code
uses: actions/checkout@v2
- name: Log in to Docker Hub Container Registry
uses: docker/login-action@v1
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_REGISTRY=thecodingmachine

View File

@@ -0,0 +1,43 @@
name: Continuous Integration
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Run linters
uses: golangci/golangci-lint-action@v2
with:
version: v1.39
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@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Checkout source code
uses: actions/checkout@v2
- name: Build testing environment
run: make build build-tests
- name: Run tests
run: |
make tests-once
bash <(curl -s https://codecov.io/bash)

2
.gitignore vendored
View File

@@ -0,0 +1,2 @@
/coverage.html
/coverage.txt

30
.golangci.yml Normal file
View File

@@ -0,0 +1,30 @@
linters:
disable-all: true
enable:
- bodyclose
- deadcode
- errcheck
- gofmt
- goimports
- gosec
- gosimple
- govet
- ineffassign
- misspell
- prealloc
- staticcheck
- structcheck
- typecheck
- unconvert
- unused
- varcheck
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,25 +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=
stages:
- tests
- publish
jobs:
include:
- stage: tests
script: make lint
- stage: tests
script: make tests
- stage: publish
if: tag IS present
script: ./scripts/publish.sh $TRAVIS_TAG $DOCKER_USER $DOCKER_PASS

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018 TheCodingMachine
Copyright (c) 2021 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

153
Makefile
View File

@@ -1,30 +1,133 @@
VERSION=snapshot
GOLANG_VERSION=1.11.2
.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}'
# generate documentation.
doc:
static-docs --in build/docs --out docs --theme gotenberg --title Gotenberg --subtitle "A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF."
.PHONY: it
it: build build-tests ## Initialize the development environment
# gofmt and goimports all go files.
fmt:
GOLANG_VERSION=1.16
DOCKER_REGISTRY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
PDFTK_VERSION=1353200058 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.39.0 # 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 PDFTK_VERSION=$(PDFTK_VERSION) \
-t $(DOCKER_REGISTRY)/gotenberg:$(GOTENBERG_VERSION) \
-f build/Dockerfile .
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000
API_PORT_FROM_ENV=
API_READ_TIMEOUT=30s
API_PROCESS_TIMEOUT=30s
API_WRITE_TIMEOUT=30s
API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace
API_DISABLE_HEALTH_CHECK_LOGGING=false
API_WEBHOOK_ALLOW_LIST=
API_WEBHOOK_DENY_LIST=
API_WEBHOOK_ERROR_ALLOW_LIST=
API_WEBHOOK_ERROR_DENY_LIST=
API_WEBHOOK_MAX_RETRY=4
API_WEBHOOK_RETRY_MIN_WAIT=1s
API_WEBHOOK_RETRY_MAX_WAIT=30s
API_DISABLE_WEBHOOK=false
CHROMIUM_USER_AGENT=
CHROMIUM_INCOGNITO=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_DISABLES_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
PDFENGINES_ENGINES=
PDFENGINES_DISABLE_ROUTES=false
.PHONY: run
run: ## Start a Gotenberg container
docker run --rm -it \
-p $(API_PORT):$(API_PORT) \
$(DOCKER_REGISTRY)/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-read-timeout=$(API_READ_TIMEOUT) \
--api-process-timeout=$(API_PROCESS_TIMEOUT) \
--api-write-timeout=$(API_WRITE_TIMEOUT) \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--api-webhook-allow-list=$(API_WEBHOOK_ALLOW_LIST) \
--api-webhook-deny-list=$(API_WEBHOOK_DENY_LIST) \
--api-webhook-error-allow-list=$(API_WEBHOOK_ERROR_ALLOW_LIST) \
--api-webhook-error-deny-list=$(API_WEBHOOK_ERROR_DENY_LIST) \
--api-webhook-max-retry=$(API_WEBHOOK_MAX_RETRY) \
--api-webhook-retry-min-wait=$(API_WEBHOOK_RETRY_MIN_WAIT) \
--api-webhook-retry-max-wait=$(API_WEBHOOK_RETRY_MAX_WAIT) \
--api-disable-webhook=$(API_DISABLE_WEBHOOK) \
--chromium-user-agent=$(CHROMIUM_USER_AGENT) \
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES)
.PHONY: build-tests
build-tests: ## Build the tests' Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REGISTRY)/gotenberg:$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile .
.PHONY: tests
tests: ## Start the testing environment
docker run --rm -it \
-v $(PWD):/tests \
$(DOCKER_REGISTRY)/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_REGISTRY)/gotenberg:$(GOTENBERG_VERSION)-tests \
gotest
.PHONY: fmt
fmt: ## Format the code and "optimize" the dependencies
go fmt ./...
go mod tidy
# run all linters.
lint:
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:lint -f build/lint/Dockerfile .
docker run --rm -it -v "$(PWD):/lint" thecodingmachine/gotenberg:lint
.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
# run all tests.
tests:
docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile .
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:tests -f build/tests/Dockerfile .
docker run --rm -it -v "$(PWD):/tests" thecodingmachine/gotenberg:tests
# build Docker image.
image:
docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile .
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) --build-arg VERSION=$(VERSION) -t thecodingmachine/gotenberg:$(VERSION) -f build/package/Dockerfile .
# start the API using previously built Docker image.
gotenberg:
docker run -it --rm -p "3000:3000" thecodingmachine/gotenberg:$(VERSION)
.PHONY: release
release: ## Build the Gotenberg's Docker image for linux/amd64 and linux/arm64 platforms, then push it to a Docker Registry
./scripts/release.sh \
$(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \
$(GOTENBERG_USER_GID) \
$(GOTENBERG_USER_UID) \
$(PDFTK_VERSION) \
$(DOCKER_REGISTRY)

View File

@@ -1,42 +1,46 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/49701110-4c8b9280-fbe8-11e8-895c-a4b9c7d2515b.png" alt="Gotenberg logo" width="250" height="250" />
</p>
<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 converting HTML, Markdown and Office documents to PDF.</p>
<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="#">OpenAPI</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 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 to convert many
documents to PDF, transform them, merge them, and more!
## Features
* HTML and Markdown conversions using Google Chrome headless
* Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv)
* Performance :zap:: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2
* Failure prevention :broken_heart:: PM2 automatically restarts previous processes if they fail
* Assets :package:: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs!
* Easily interact with the API using our [Go](/pkg) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries
## Quick start
## Quick Start
Open a terminal and run the following command:
```bash
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:3
```
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
```
The API is now available on your host at `http://localhost:3000`.
Alternatively, using the historic Docker registry from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
Head to the [documentation](https://thecodingmachine.github.io/gotenberg)
to learn how to interact with it!
```
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
```
The API is now available on your host at http://localhost:3000.
Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to interact with it 🚀
## Sponsors
<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>
## Badges
[![Docker image layers](https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:3.svg)](https://microbadger.com/images/thecodingmachine/gotenberg:3)
[![Travis CI](https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=master)](https://travis-ci.org/thecodingmachine/gotenberg)
[![GoDoc](https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg)](https://godoc.org/github.com/thecodingmachine/gotenberg)
[![Go Report Card](https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg)](https://goreportcard.com/report/thecodingmachine/gotenberg)
---
Psst: TheCodingMachine is always looking for [talented coders](https://coders.thecodingmachine.com).
[![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)
[![Go Report Card](https://goreportcard.com/badge/github.com/gotenberg/gotenberg)](https://goreportcard.com/report/gotenberg/gotenberg)

145
build/Dockerfile Normal file
View File

@@ -0,0 +1,145 @@
# Note: ARG instructions do not create additional layers.
# Instead, next layers will concatenate them.
ARG GOLANG_VERSION
ARG GOTENBERG_VERSION
FROM golang:$GOLANG_VERSION AS builder
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 internal ./internal
COPY pkg ./pkg
# Build the binary.
RUN go build -o gotenberg -ldflags "-X gotenberg.version=$GOTENBERG_VERSION" cmd/gotenberg/main.go
FROM debian:buster-slim
LABEL author="Julien Neuhart" \
description="A Docker-powered stateless API for PDF files." \
github="https://github.com/gotenberg/gotenberg" \
version="$GOTENBER_VERSION" \
website="https://gotenberg.dev"
# 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
# Simple wrapper around Java and PDFtk.
COPY build/pdftk.sh /usr/bin/pdftk
# Setup the Docker image.
ARG GOTENBERG_USER_GID
ARG GOTENBERG_USER_UID
ARG PDFTK_VERSION
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 &&\
# Install dependencies required for the next instructions or debugging.
# Note: procps for "top" command (useful when debugging processes).
# 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 procps tini &&\
# 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 &&\
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-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-core \
fonts-noto-mono \
fonts-noto-ui-core \
fonts-sil-gentium \
fonts-sil-gentium-basic &&\
rm -f ./ttf-mscorefonts-installer_3.8_all.deb &&\
echo "deb https://httpredir.debian.org/debian/ buster-backports main contrib non-free" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
# Install Chromium and LibreOffice.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t buster-backports libreoffice &&\
# Download unoconv (Python script).
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 &&\
# Download PDFtk.
# Credits: https://github.com/thecodingmachine/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/pdftk-java/pdftk/-/jobs/$PDFTK_VERSION/artifacts/raw/build/libs/pdftk-all.jar" &&\
chmod a+x /usr/bin/pdftk-all.jar &&\
# See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jre-headless &&\
# 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/* &&\
# Print versions of main dependencies.
chromium --version &&\
libreoffice --version &&\
unoconv --version &&\
pdftk --version
# Copy the Gotenberg binary from the builder stage.
COPY --from=builder /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 PDFTK_BIN_PATH /usr/bin/pdftk
USER gotenberg
WORKDIR /home/gotenberg
# Default API port.
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]

15
build/Dockerfile.cloudrun Normal file
View File

@@ -0,0 +1,15 @@
# Note: ARG instructions do not create additional layers.
# Instead, next layers will concatenate them.
ARG DOCKER_REGISTRY
ARG GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/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

View File

@@ -1,48 +0,0 @@
FROM debian:stretch-slim
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |--------------------------------------------------------------------------
# | Common libraries
# |--------------------------------------------------------------------------
# |
# | Libraries used in the build process of this image.
# |
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
apt-get update &&\
apt-get install -y curl wget python3-pip
# |--------------------------------------------------------------------------
# | PM2
# |--------------------------------------------------------------------------
# |
# | Installs PM2 for launching programs in background and with failure
# | recovering. In our case: Chrome (headless) and Office (headless).
# |
RUN curl -sL https://raw.githubusercontent.com/Unitech/pm2/master/packager/setup.deb.sh | bash -
# |--------------------------------------------------------------------------
# | Chrome
# |--------------------------------------------------------------------------
# |
# | Installs Chrome for HTML and Markdown conversions.
# |
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
apt-get update &&\
apt-get -y install google-chrome-stable
# |--------------------------------------------------------------------------
# | Unoconv
# |--------------------------------------------------------------------------
# |
# | Installs unoconv and LibreOffice for Office documents conversions.
# |
RUN pip3 install unoconv &&\
# https://github.com/nextcloud/docker/issues/380
mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\
apt-get -y install libreoffice

View File

@@ -1,12 +0,0 @@
---
title: Introduction
---
[Gotenberg](https://github.com/thecodingmachine/gotenberg/) is a Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.
* HTML and Markdown conversions using Google Chrome headless
* Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv)
* Performance: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2
* Failure prevention: PM2 automatically restarts previous processes if they fail
* Assets: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs!
* Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg/pkg) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries

View File

@@ -1,53 +0,0 @@
---
title: Install
---
Gotenberg is shipped within a Docker image.
You may start it with:
```bash
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:3
```
> The API will be available at [http://localhost:3000](http://localhost:3000).
Or add it in your Docker Compose stack:
```yaml
version: '3'
services:
# your others services
gotenberg:
image: thecodingmachine/gotenberg:3
```
> The API will be available under `gotenberg:3000` in your Docker Compose network.
It may also be deployed with Kubernetes.
In the following examples, we will assume your
Gotenberg API is available at [http://localhost:3000](http://localhost:3000).
## Go client
```bash
$ go get -u github.com/thecodingmachine/gotenberg
```
## PHP client
Unless your project already has a PSR7 `HttpClient`, install `php-http/guzzle6-adapter`:
```bash
$ composer require php-http/guzzle6-adapter
```
Then the PHP client:
```bash
$ composer require thecodingmachine/gotenberg-php-client
```

View File

@@ -1,299 +0,0 @@
---
title: HTML
---
Gotenberg provides the endpoint `/convert/html` for HTML conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
The only requirement is to send a file named `index.html`: it is the file
which will be converted to PDF.
For instance:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
</body>
</html>
```
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```
## Header and footer
You may also add a header and/or a footer in the resulting PDF.
Respectively, a file named `header.html` and `footer.html`.
Each of them **has to be a complete HTML document**:
```html
<html>
<head>
<style>
body {
font-size: 8rem;
margin: 4rem auto;
}
</style>
</head>
<body>
<p>
<span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>
```
The following classes allow you to inject printing values:
* `date`: formatted print date
* `title`: document title
* `pageNumber`: current page number
* `totalPage`: total pages in the document
> **Attention:** the CSS properties are independant of the ones used in the `index.html` file.
> Also, `footer.html` CSS properties override the ones from `header.html`.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form files=@header.html \
--form files=@footer.html \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.SetHeader("header.html")
req.SetFooter("footer.html")
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$header = DocumentFactory::makeFromPath('header.html', 'header.html');
$footer = DocumentFactory::makeFromPath('footer.html', 'footer.html');
$request = new HTMLRequest($index);
$request->setHeader($header);
$request->setFooter($footer);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```
## Assets
You may also send additional files. For instance: images, fonts, stylesheets and so on.
The only requirement is to make sure that their paths
are on the same level as the `index.html` file.
In others words, this will work:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
<img src="img.png">
</body>
</html>
```
But this won't:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
<img src="/foo/img.png">
</body>
</html>
```
You may also use *remote* paths for Google fonts, images and so on.
> If you want to install fonts directly in the Gotenberg Docker image,
> see to the [fonts section](#office.fonts).
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form files=@style.css \
--form files=@img.png \
--form files=@font.woff \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.SetAssets([]string{
"font.woff",
"img.gif",
"style.css",
})
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$assets = [
DocumentFactory::makeFromPath('style.css', 'style.css'),
DocumentFactory::makeFromPath('img.png', 'img.png'),
DocumentFactory::makeFromPath('font.woff', 'font.woff'),
];
$request = new HTMLRequest($index);
$request->setAssets($assets);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```
## Paper size, margins, orientation
You may also customize the resulting PDF format.
By default, it will be rendered with `A4` size, `1 inch` margins and `portrait` orientation.
> Paper size and margins have to be provided in `inches`.
> Also, you have to set both `paperWidth` and `paperHeight`. Same for margins.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form paperWidth=8.27 \
--form paperHeight=11.27 \
--form marginTop=0 \
--form marginBottom=0 \
--form marginLeft=0 \
--form marginRight=0 \
--form landscape=true \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.SetPaperSize(gotenberg.A4)
req.SetMargins(gotenberg.NoMargins)
req.SetLandscape(true)
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
use TheCodingMachine\Gotenberg\Request;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$request->setPaperSize(Request::A4);
$request->setMargins(Request::NO_MARGINS);
$request->setLandscape(true);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```

View File

@@ -1,70 +0,0 @@
---
title: Markdown
---
Gotenberg provides the endpoint `/convert/markdown` for Markdown conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
Markdown conversions work the same as HTML conversions.
Only difference is that you have access to the Go template function `toHTML`
in the file `index.html`. This function will convert a given markdown file to HTML.
For instance:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
{{ toHTML .DirPath "file.md" }}
</body>
</html>
```
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/markdown \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form files=@file.md \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewMarkdownRequest("index.html", []string{"file.md"})
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\MarkdownRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$markdowns = [
DocumentFactory::makeFromPath('file.md', 'file.md'),
];
$request = new MarkdownRequest($index, $markdowns);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```

View File

@@ -1,137 +0,0 @@
---
title: Office
---
Gotenberg provides the endpoint `/convert/office` for Office document conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
You may send one or more Office documents. Following file extensions are accepted:
* `.txt`
* `.rtf`
* `.doc`
* `.docx`
* `.odt`
* `.xls`
* `.xlsx`
* `.ods`
* `.ppt`
* `.pptx`
* `.odp`
All files will be merged into a single resulting PDF.
> **Attention:** currently, `unoconv` cannot perform concurrent conversions.
> That's why for Office conversions, the API does only one conversion at a time.
> See the [scalability section](#scalability) to find how to mitigate this issue.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/office \
--header 'Content-Type: multipart/form-data' \
--form files=@document.docx \
--form files=@document2.docx \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewOfficeRequest([]string{"document.docx", "document2.docx"})
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\OfficeRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$files = [
DocumentFactory::makeFromPath('document.docx', 'document.docx'),
DocumentFactory::makeFromPath('document2.docx', 'document2.docx'),
];
$request = new OfficeRequest($files);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```
## Paper size and orientation
You may also customize the resulting PDF format.
By default, it will be rendered with `A4` size and `portrait` orientation.
> Paper size has to be provided in `inches`.
> Also, you have to set both `paperWidth` and `paperHeight`.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/office \
--header 'Content-Type: multipart/form-data' \
--form files=@document.docx \
--form paperWidth=8.27 \
--form paperHeight=11.27 \
--form landscape=true \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewOfficeRequest([]string{"document.docx"})
req.SetPaperSize(gotenberg.A4)
req.SetLandscape(true)
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\OfficeRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$files = [
DocumentFactory::makeFromPath('document.docx', 'document.docx'),
];
$request = new OfficeRequest($files);
$request->setPaperSize(Request::A4);
$request->setLandscape(true);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```
## Fonts
By default, only `ttf-mscorefonts` fonts are installed.
If you wish to use more fonts, you will have to create your own image:
```Dockerfile
FROM thecodingmachine/gotenberg:3
RUN apt-get -y install yourfonts
```

View File

@@ -1,53 +0,0 @@
---
title: Merge
---
Gotenberg provides the endpoint `/merge` for merging PDFs.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
Nothing special here: you may send one or more PDF files and the API
will merge them and return the resulting PDF file.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/merge \
--header 'Content-Type: multipart/form-data' \
--form files=@file.pdf \
--form files=@file2.pdf \
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewMergeRequest([]string{"file.pdf", "file2.pdf"})
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\MergeRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$files = [
DocumentFactory::makeFromPath('file.pdf', 'file.pdf'),
DocumentFactory::makeFromPath('file2.pdf', 'file2.pdf'),
];
$request = new MergeRequest($files);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```

View File

@@ -1,50 +0,0 @@
---
title: Webhook
---
All endpoints accept a form field named `webhookURL`.
If provided, the API will send the resulting PDF file in a `POST` request with the `application/pdf` Content-Type
to given URL.
By doing so, your requests to the API will be over before the conversions are actually done!
## Examples
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form webhookURL='http://myapp.com/webhook/'
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.SetWebhookURL("http://myapp.com/webhook/")
dest := "result.pdf"
resp, _ := c.Post(req)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$request->setWebhookURL('http://myapp.com/webhook/');
$resp = $client->post($request);
```

View File

@@ -1,27 +0,0 @@
---
title: Scalability
---
The API being stateless, you may scale it as much as you want.
For instance, using the following Docker Compose file:
```yaml
version: '3'
services:
# your others services
gotenberg:
image: thecodingmachine/gotenberg:3
```
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.

View File

@@ -1,8 +0,0 @@
---
title: Links
---
* Follow the progress on the [GitHub repository](https://github.com/thecodingmachine/gotenberg)
* Follow [@gulnap](https://twitter.com/gulnap) on Twitter
Psst: TheCodingMachine is always looking for [talented coders](https://coders.thecodingmachine.com).

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>

View File

@@ -1,37 +0,0 @@
ARG GOLANG_VERSION
FROM golang:${GOLANG_VERSION}-stretch
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |--------------------------------------------------------------------------
# | GolangCI-Lint
# |--------------------------------------------------------------------------
# |
# | Installs GolangCI-Lint, a linters Runner for Go. 5x faster
# | than gometalinter.
# |
ENV GOLANGCI_LINT_VERSION 1.12.3
RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b /usr/local/bin v${GOLANGCI_LINT_VERSION} &&\
golangci-lint --version
# |--------------------------------------------------------------------------
# | Final touch
# |--------------------------------------------------------------------------
# |
# | Last instructions of this build.
# |
# Define our workding outside of $GOPATH (we're using go modules).
WORKDIR /lint
# Copy our module dependencies definitions.
COPY go.mod .
COPY go.sum .
# Install module dependencies.
RUN go mod download
CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl", "--disable=lll", "--disable=errcheck", "--disable=gosec", "--disable=gochecknoglobals", "--disable=gochecknoinits" ]

View File

@@ -1,43 +0,0 @@
ARG GOLANG_VERSION
# |--------------------------------------------------------------------------
# | Binary
# |--------------------------------------------------------------------------
# |
# | Buils Gotenberg binary.
# |
FROM golang:${GOLANG_VERSION}-stretch AS golang
ARG VERSION
ENV GOOS=linux \
GOARCH=amd64 \
CGO_ENABLED=0
# Define our workding outside of $GOPATH (we're using go modules).
WORKDIR /gotenberg
# Copy our source code.
COPY . .
# Build our binary.
RUN go build -o /gotenberg/gotenberg -ldflags "-X main.version=${VERSION}" cmd/gotenberg/main.go
# |--------------------------------------------------------------------------
# | Final touch
# |--------------------------------------------------------------------------
# |
# | Last instructions of this build.
# |
FROM thecodingmachine/gotenberg:base
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
COPY --from=golang /gotenberg/gotenberg /usr/local/bin/
WORKDIR /gotenberg
EXPOSE 3000
CMD [ "gotenberg" ]

3
build/pdftk.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
exec java -jar /usr/bin/pdftk-all.jar "$@"

View File

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

View File

@@ -1,25 +0,0 @@
#!/bin/bash
set -xe
# Testing PM2 processes launch separatly for avoiding
# spending to much time on each tests depending on
# them.
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeLaunch
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvLaunch
# Running others tests.
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/printer
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand
# Finally testing processes shutdown.
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvShutdown
# Testing Go client.
go build -o /usr/local/bin/gotenberg cmd/gotenberg/main.go
gotenberg &
sleep 10
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/pkg
sleep 5 # allows Gotenberg to remove generated files (concurrent requests).

View File

@@ -1,21 +1,16 @@
package main
import (
"fmt"
"os"
gotenbergapp "github.com/gotenberg/gotenberg/v7/internal/app/gotenberg"
"github.com/thecodingmachine/gotenberg/internal/app/api"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
// Gotenberg modules.
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
// PDF engines.
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfcpu"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdftk"
)
// version will be set on build time.
var version = "snapshot"
func main() {
notify.Println(fmt.Sprintf("Gotenberg %s", version))
if err := api.Start(); err != nil {
notify.ErrPrintln(err)
os.Exit(1)
}
os.Exit(0)
gotenbergapp.Run()
}

View File

@@ -1,869 +0,0 @@
<!DOCTYPE html><html><head>
<meta charset="utf-8"/>
<meta http-equiv="x-ua-compatible" content="ie=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Gotenberg · A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</title>
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-32x32.png" sizes="32x32"/>
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-16x16.png" sizes="16x16"/>
<link rel="stylesheet" href="theme/gotenberg/css/index.css"/>
</head>
<body>
<div class="Wrapper">
<div class="Container">
<div class="Header">
<div class="Title center">
<img src="https://user-images.githubusercontent.com/8983173/49701110-4c8b9280-fbe8-11e8-895c-a4b9c7d2515b.png" alt="Gotenberg logo" width="250" height="250"/>
<span class="text">Gotenberg</span>
<span class="subtext">A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</span>
</div>
</div>
<div class="Content-wrapper">
<div class="Sidebar">
<div class="Menu">
<div class="item">
<a href="#introduction">Introduction</a>
</div>
<div class="item">
<a href="#install">Install</a>
</div>
<div class="item">
<a href="#html">HTML</a>
</div>
<div class="item">
<a href="#markdown">Markdown</a>
</div>
<div class="item">
<a href="#office">Office</a>
</div>
<div class="item">
<a href="#merge">Merge</a>
</div>
<div class="item">
<a href="#webhook">Webhook</a>
</div>
<div class="item">
<a href="#scalability">Scalability</a>
</div>
<div class="item">
<a href="#links">Links</a>
</div>
</div>
</div>
<div class="Content">
<div class="Page" id="introduction">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="introduction" href="#introduction">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Introduction</h1>
<p><a href="https://github.com/thecodingmachine/gotenberg/">Gotenberg</a> is a Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</p>
<ul>
<li>HTML and Markdown conversions using Google Chrome headless</li>
<li>Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using <a href="https://github.com/dagwieers/unoconv">unoconv</a></li>
<li>Performance: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2</li>
<li>Failure prevention: PM2 automatically restarts previous processes if they fail</li>
<li>Assets: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs!</li>
<li>Easily interact with the API using our <a href="https://github.com/thecodingmachine/gotenberg/pkg">Go</a> and <a href="https://github.com/thecodingmachine/gotenberg-php-client">PHP</a> libraries</li>
</ul>
</div>
<div class="Page" id="install">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="install" href="#install">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Install</h1>
<p>Gotenberg is shipped within a Docker image.</p>
<p>You may start it with:</p>
<pre class="chroma">$ docker run --rm -p <span class="m">3000</span>:3000 thecodingmachine/gotenberg:3
</pre>
<blockquote>
<p>The API will be available at <a href="http://localhost:3000">http://localhost:3000</a>.</p>
</blockquote>
<p>Or add it in your Docker Compose stack:</p>
<pre class="chroma">version<span class="p">:</span><span class="w"> </span><span class="s1">&#39;3&#39;</span><span class="w">
</span><span class="w">
</span><span class="w"></span>services<span class="p">:</span><span class="w">
</span><span class="w">
</span><span class="w"> </span><span class="c"># your others services</span><span class="w">
</span><span class="w">
</span><span class="w"> </span>gotenberg<span class="p">:</span><span class="w">
</span><span class="w"> </span>image<span class="p">:</span><span class="w"> </span>thecodingmachine/gotenberg<span class="p">:</span><span class="m">3</span><span class="w">
</span><span class="w"></span></pre>
<blockquote>
<p>The API will be available under <code>gotenberg:3000</code> in your Docker Compose network.</p>
</blockquote>
<p>It may also be deployed with Kubernetes.</p>
<p>In the following examples, we will assume your
Gotenberg API is available at <a href="http://localhost:3000">http://localhost:3000</a>.</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="install.go_client" href="#install.go_client">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go client</h2>
<pre class="chroma">$ go get -u github.com/thecodingmachine/gotenberg
</pre>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="install.php_client" href="#install.php_client">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP client</h2>
<p>Unless your project already has a PSR7 <code>HttpClient</code>, install <code>php-http/guzzle6-adapter</code>:</p>
<pre class="chroma">$ composer require php-http/guzzle6-adapter
</pre>
<p>Then the PHP client:</p>
<pre class="chroma">$ composer require thecodingmachine/gotenberg-php-client
</pre>
</div>
<div class="Page" id="html">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="html" href="#html">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>HTML</h1>
<p>Gotenberg provides the endpoint <code>/convert/html</code> for HTML conversions.</p>
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic" href="#html.basic">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Basic</h2>
<p>The only requirement is to send a file named <code>index.html</code>: it is the file
which will be converted to PDF.</p>
<p>For instance:</p>
<pre class="chroma"><span class="cp">&lt;!doctype html&gt;</span>
<span class="p">&lt;</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">&#34;en&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">head</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">&#34;utf-8&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">title</span><span class="p"></span><span class="p">&gt;</span>My PDF<span class="p">&lt;</span><span class="p">/</span><span class="nt">title</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">head</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">body</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">h1</span><span class="p"></span><span class="p">&gt;</span>Hello world!<span class="p">&lt;</span><span class="p">/</span><span class="nt">h1</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">html</span><span class="p">&gt;</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic.c_url" href="#html.basic.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic.go" href="#html.basic.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewHTMLRequest</span><span class="p">(</span><span class="s">&#34;index.html&#34;</span><span class="p">)</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic.php" href="#html.basic.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$index = DocumentFactory::makeFromPath(&#39;index.html&#39;, &#39;index.html&#39;);
</span><span class="x">$request = new HTMLRequest($index);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer" href="#html.header_and_footer">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Header and footer</h2>
<p>You may also add a header and/or a footer in the resulting PDF.
Respectively, a file named <code>header.html</code> and <code>footer.html</code>.</p>
<p>Each of them <strong>has to be a complete HTML document</strong>:</p>
<pre class="chroma"><span class="p">&lt;</span><span class="nt">html</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">head</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">style</span><span class="p"></span><span class="p">&gt;</span>
<span class="nt">body</span> <span class="p">{</span>
<span class="k">font-size</span><span class="p">:</span> <span class="mi">8</span><span class="kt">rem</span><span class="p">;</span>
<span class="k">margin</span><span class="p">:</span> <span class="mi">4</span><span class="kt">rem</span> <span class="kc">auto</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">style</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">head</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">body</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">p</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;pageNumber&#34;</span><span class="p"></span><span class="p">&gt;&lt;</span><span class="p">/</span><span class="nt">span</span><span class="p">&gt;</span> of <span class="p">&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;totalPages&#34;</span><span class="p"></span><span class="p">&gt;&lt;</span><span class="p">/</span><span class="nt">span</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">p</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">html</span><span class="p">&gt;</span>
</pre>
<p>The following classes allow you to inject printing values:</p>
<ul>
<li><code>date</code>: formatted print date</li>
<li><code>title</code>: document title</li>
<li><code>pageNumber</code>: current page number</li>
<li><code>totalPage</code>: total pages in the document</li>
</ul>
<blockquote>
<p><strong>Attention:</strong> the CSS properties are independant of the ones used in the <code>index.html</code> file.
Also, <code>footer.html</code> CSS properties override the ones from <code>header.html</code>.</p>
</blockquote>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer.c_url" href="#html.header_and_footer.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@header.html <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@footer.html <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer.go" href="#html.header_and_footer.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewHTMLRequest</span><span class="p">(</span><span class="s">&#34;index.html&#34;</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetHeader</span><span class="p">(</span><span class="s">&#34;header.html&#34;</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetFooter</span><span class="p">(</span><span class="s">&#34;footer.html&#34;</span><span class="p">)</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer.php" href="#html.header_and_footer.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$index = DocumentFactory::makeFromPath(&#39;index.html&#39;, &#39;index.html&#39;);
</span><span class="x">$header = DocumentFactory::makeFromPath(&#39;header.html&#39;, &#39;header.html&#39;);
</span><span class="x">$footer = DocumentFactory::makeFromPath(&#39;footer.html&#39;, &#39;footer.html&#39;);
</span><span class="x">$request = new HTMLRequest($index);
</span><span class="x">$request-&gt;setHeader($header);
</span><span class="x">$request-&gt;setFooter($footer);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets" href="#html.assets">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Assets</h2>
<p>You may also send additional files. For instance: images, fonts, stylesheets and so on.</p>
<p>The only requirement is to make sure that their paths
are on the same level as the <code>index.html</code> file.</p>
<p>In others words, this will work:</p>
<pre class="chroma"><span class="cp">&lt;!doctype html&gt;</span>
<span class="p">&lt;</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">&#34;en&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">head</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">&#34;utf-8&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">title</span><span class="p"></span><span class="p">&gt;</span>My PDF<span class="p">&lt;</span><span class="p">/</span><span class="nt">title</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">head</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">body</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">h1</span><span class="p"></span><span class="p">&gt;</span>Hello world!<span class="p">&lt;</span><span class="p">/</span><span class="nt">h1</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">img</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;img.png&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">html</span><span class="p">&gt;</span>
</pre>
<p>But this wont:</p>
<pre class="chroma"><span class="cp">&lt;!doctype html&gt;</span>
<span class="p">&lt;</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">&#34;en&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">head</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">&#34;utf-8&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">title</span><span class="p"></span><span class="p">&gt;</span>My PDF<span class="p">&lt;</span><span class="p">/</span><span class="nt">title</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">head</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">body</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">h1</span><span class="p"></span><span class="p">&gt;</span>Hello world!<span class="p">&lt;</span><span class="p">/</span><span class="nt">h1</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">img</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;/foo/img.png&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">html</span><span class="p">&gt;</span>
</pre>
<p>You may also use <em>remote</em> paths for Google fonts, images and so on.</p>
<blockquote>
<p>If you want to install fonts directly in the Gotenberg Docker image,
see to the <a href="#office.fonts">fonts section</a>.</p>
</blockquote>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets.c_url" href="#html.assets.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@style.css <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@img.png <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@font.woff <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets.go" href="#html.assets.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewHTMLRequest</span><span class="p">(</span><span class="s">&#34;index.html&#34;</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetAssets</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span>
<span class="s">&#34;font.woff&#34;</span><span class="p">,</span>
<span class="s">&#34;img.gif&#34;</span><span class="p">,</span>
<span class="s">&#34;style.css&#34;</span><span class="p">,</span>
<span class="p">})</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets.php" href="#html.assets.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$index = DocumentFactory::makeFromPath(&#39;index.html&#39;, &#39;index.html&#39;);
</span><span class="x">$assets = [
</span><span class="x"> DocumentFactory::makeFromPath(&#39;style.css&#39;, &#39;style.css&#39;),
</span><span class="x"> DocumentFactory::makeFromPath(&#39;img.png&#39;, &#39;img.png&#39;),
</span><span class="x"> DocumentFactory::makeFromPath(&#39;font.woff&#39;, &#39;font.woff&#39;),
</span><span class="x">];
</span><span class="x">$request = new HTMLRequest($index);
</span><span class="x">$request-&gt;setAssets($assets);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation" href="#html.paper_size_margins_orientation">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Paper size, margins, orientation</h2>
<p>You may also customize the resulting PDF format.</p>
<p>By default, it will be rendered with <code>A4</code> size, <code>1 inch</code> margins and <code>portrait</code> orientation.</p>
<blockquote>
<p>Paper size and margins have to be provided in <code>inches</code>.
Also, you have to set both <code>paperWidth</code> and <code>paperHeight</code>. Same for margins.</p>
</blockquote>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation.c_url" href="#html.paper_size_margins_orientation.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
</span><span class="se"></span> --form <span class="nv">paperWidth</span><span class="o">=</span><span class="m">8</span>.27 <span class="se">\
</span><span class="se"></span> --form <span class="nv">paperHeight</span><span class="o">=</span><span class="m">11</span>.27 <span class="se">\
</span><span class="se"></span> --form <span class="nv">marginTop</span><span class="o">=</span><span class="m">0</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">marginBottom</span><span class="o">=</span><span class="m">0</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">marginLeft</span><span class="o">=</span><span class="m">0</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">marginRight</span><span class="o">=</span><span class="m">0</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">landscape</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation.go" href="#html.paper_size_margins_orientation.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewHTMLRequest</span><span class="p">(</span><span class="s">&#34;index.html&#34;</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetPaperSize</span><span class="p">(</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">A4</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetMargins</span><span class="p">(</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NoMargins</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetLandscape</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation.php" href="#html.paper_size_margins_orientation.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
</span><span class="x">use TheCodingMachine\Gotenberg\Request;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$index = DocumentFactory::makeFromPath(&#39;index.html&#39;, &#39;index.html&#39;);
</span><span class="x">$request = new HTMLRequest($index);
</span><span class="x">$request-&gt;setPaperSize(Request::A4);
</span><span class="x">$request-&gt;setMargins(Request::NO_MARGINS);
</span><span class="x">$request-&gt;setLandscape(true);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
</div>
<div class="Page" id="markdown">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown" href="#markdown">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Markdown</h1>
<p>Gotenberg provides the endpoint <code>/convert/markdown</code> for Markdown conversions.</p>
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic" href="#markdown.basic">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Basic</h2>
<p>Markdown conversions work the same as HTML conversions.</p>
<p>Only difference is that you have access to the Go template function <code>toHTML</code>
in the file <code>index.html</code>. This function will convert a given markdown file to HTML.</p>
<p>For instance:</p>
<pre class="chroma"><span class="cp">&lt;!doctype html&gt;</span>
<span class="p">&lt;</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">&#34;en&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">head</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">&#34;utf-8&#34;</span><span class="p"></span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">title</span><span class="p"></span><span class="p">&gt;</span>My PDF<span class="p">&lt;</span><span class="p">/</span><span class="nt">title</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">head</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">body</span><span class="p"></span><span class="p">&gt;</span>
{{ toHTML .DirPath &#34;file.md&#34; }}
<span class="p">&lt;</span><span class="p">/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="p">/</span><span class="nt">html</span><span class="p">&gt;</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic.c_url" href="#markdown.basic.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/markdown <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@file.md <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic.go" href="#markdown.basic.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewMarkdownRequest</span><span class="p">(</span><span class="s">&#34;index.html&#34;</span><span class="p">,</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;file.md&#34;</span><span class="p">})</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic.php" href="#markdown.basic.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\MarkdownRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$index = DocumentFactory::makeFromPath(&#39;index.html&#39;, &#39;index.html&#39;);
</span><span class="x">$markdowns = [
</span><span class="x"> DocumentFactory::makeFromPath(&#39;file.md&#39;, &#39;file.md&#39;),
</span><span class="x">];
</span><span class="x">$request = new MarkdownRequest($index, $markdowns);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
</div>
<div class="Page" id="office">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="office" href="#office">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Office</h1>
<p>Gotenberg provides the endpoint <code>/convert/office</code> for Office document conversions.</p>
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic" href="#office.basic">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Basic</h2>
<p>You may send one or more Office documents. Following file extensions are accepted:</p>
<ul>
<li><code>.txt</code></li>
<li><code>.rtf</code></li>
<li><code>.doc</code></li>
<li><code>.docx</code></li>
<li><code>.odt</code></li>
<li><code>.xls</code></li>
<li><code>.xlsx</code></li>
<li><code>.ods</code></li>
<li><code>.ppt</code></li>
<li><code>.pptx</code></li>
<li><code>.odp</code></li>
</ul>
<p>All files will be merged into a single resulting PDF.</p>
<blockquote>
<p><strong>Attention:</strong> currently, <code>unoconv</code> cannot perform concurrent conversions.
Thats why for Office conversions, the API does only one conversion at a time.
See the <a href="#scalability">scalability section</a> to find how to mitigate this issue.</p>
</blockquote>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic.c_url" href="#office.basic.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/office <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@document.docx <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@document2.docx <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic.go" href="#office.basic.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewOfficeRequest</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;document.docx&#34;</span><span class="p">,</span> <span class="s">&#34;document2.docx&#34;</span><span class="p">})</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic.php" href="#office.basic.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\OfficeRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$files = [
</span><span class="x"> DocumentFactory::makeFromPath(&#39;document.docx&#39;, &#39;document.docx&#39;),
</span><span class="x"> DocumentFactory::makeFromPath(&#39;document2.docx&#39;, &#39;document2.docx&#39;),
</span><span class="x">];
</span><span class="x">$request = new OfficeRequest($files);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="office.paper_size_and_orientation" href="#office.paper_size_and_orientation">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Paper size and orientation</h2>
<p>You may also customize the resulting PDF format.</p>
<p>By default, it will be rendered with <code>A4</code> size and <code>portrait</code> orientation.</p>
<blockquote>
<p>Paper size has to be provided in <code>inches</code>.
Also, you have to set both <code>paperWidth</code> and <code>paperHeight</code>.</p>
</blockquote>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.paper_size_and_orientation.c_url" href="#office.paper_size_and_orientation.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/office <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@document.docx <span class="se">\
</span><span class="se"></span> --form <span class="nv">paperWidth</span><span class="o">=</span><span class="m">8</span>.27 <span class="se">\
</span><span class="se"></span> --form <span class="nv">paperHeight</span><span class="o">=</span><span class="m">11</span>.27 <span class="se">\
</span><span class="se"></span> --form <span class="nv">landscape</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.paper_size_and_orientation.go" href="#office.paper_size_and_orientation.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewOfficeRequest</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;document.docx&#34;</span><span class="p">})</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetPaperSize</span><span class="p">(</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">A4</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetLandscape</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.paper_size_and_orientation.php" href="#office.paper_size_and_orientation.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\OfficeRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$files = [
</span><span class="x"> DocumentFactory::makeFromPath(&#39;document.docx&#39;, &#39;document.docx&#39;),
</span><span class="x">];
</span><span class="x">$request = new OfficeRequest($files);
</span><span class="x">$request-&gt;setPaperSize(Request::A4);
</span><span class="x">$request-&gt;setLandscape(true);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="office.fonts" href="#office.fonts">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Fonts</h2>
<p>By default, only <code>ttf-mscorefonts</code> fonts are installed.</p>
<p>If you wish to use more fonts, you will have to create your own image:</p>
<pre class="chroma"><span class="k">FROM</span><span class="s"> thecodingmachine/gotenberg:3</span><span class="err">
</span><span class="err">
</span><span class="err"></span><span class="k">RUN</span> apt-get -y install yourfonts<span class="err">
</span><span class="err"></span></pre>
</div>
<div class="Page" id="merge">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="merge" href="#merge">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Merge</h1>
<p>Gotenberg provides the endpoint <code>/merge</code> for merging PDFs.</p>
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic" href="#merge.basic">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Basic</h2>
<p>Nothing special here: you may send one or more PDF files and the API
will merge them and return the resulting PDF file.</p>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic.c_url" href="#merge.basic.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/merge <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@file.pdf <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@file2.pdf <span class="se">\
</span><span class="se"></span> &gt; result.pdf
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic.go" href="#merge.basic.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewMergeRequest</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;file.pdf&#34;</span><span class="p">,</span> <span class="s">&#34;file2.pdf&#34;</span><span class="p">})</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic.php" href="#merge.basic.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\MergeRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$files = [
</span><span class="x"> DocumentFactory::makeFromPath(&#39;file.pdf&#39;, &#39;file.pdf&#39;),
</span><span class="x"> DocumentFactory::makeFromPath(&#39;file2.pdf&#39;, &#39;file2.pdf&#39;),
</span><span class="x">];
</span><span class="x">$request = new MergeRequest($files);
</span><span class="x">$dirPath = &#34;/foo&#34;;
</span><span class="x">$filename = $client-&gt;store($request, $dirPath);
</span><span class="x"></span></pre>
</div>
<div class="Page" id="webhook">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook" href="#webhook">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Webhook</h1>
<p>All endpoints accept a form field named <code>webhookURL</code>.</p>
<p>If provided, the API will send the resulting PDF file in a <code>POST</code> request with the <code>application/pdf</code> Content-Type
to given URL.</p>
<p>By doing so, your requests to the API will be over before the conversions are actually done!</p>
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples" href="#webhook.examples">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Examples</h2>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples.c_url" href="#webhook.examples.c_url">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>cURL</h3>
<pre class="chroma">$ curl --request POST <span class="se">\
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
</span><span class="se"></span> --header <span class="s1">&#39;Content-Type: multipart/form-data&#39;</span> <span class="se">\
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
</span><span class="se"></span> --form <span class="nv">webhookURL</span><span class="o">=</span><span class="s1">&#39;http://myapp.com/webhook/&#39;</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples.go" href="#webhook.examples.go">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Go</h3>
<pre class="chroma"><span class="kn">import</span> <span class="s">&#34;github.com/thecodingmachine/gotenberg/pkg&#34;</span>
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">&#34;http://localhost:3000&#34;</span><span class="p">}</span>
<span class="nx">req</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NewHTMLRequest</span><span class="p">(</span><span class="s">&#34;index.html&#34;</span><span class="p">)</span>
<span class="nx">req</span><span class="p">.</span><span class="nx">SetWebhookURL</span><span class="p">(</span><span class="s">&#34;http://myapp.com/webhook/&#34;</span><span class="p">)</span>
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">&#34;result.pdf&#34;</span>
<span class="nx">resp</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">Post</span><span class="p">(</span><span class="nx">req</span><span class="p">)</span>
<span class="p">}</span>
</pre>
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples.php" href="#webhook.examples.php">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>PHP</h3>
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
</span><span class="x">
</span><span class="x">$client = new Client(&#39;http://localhost:3000&#39;, new \Http\Adapter\Guzzle6\Client());
</span><span class="x">$index = DocumentFactory::makeFromPath(&#39;index.html&#39;, &#39;index.html&#39;);
</span><span class="x">$request = new HTMLRequest($index);
</span><span class="x">$request-&gt;setWebhookURL(&#39;http://myapp.com/webhook/&#39;);
</span><span class="x">$resp = $client-&gt;post($request);
</span><span class="x"></span></pre>
</div>
<div class="Page" id="scalability">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="scalability" href="#scalability">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Scalability</h1>
<p>The API being stateless, you may scale it as much as you want.</p>
<p>For instance, using the following Docker Compose file:</p>
<pre class="chroma">version<span class="p">:</span><span class="w"> </span><span class="s1">&#39;3&#39;</span><span class="w">
</span><span class="w">
</span><span class="w"></span>services<span class="p">:</span><span class="w">
</span><span class="w">
</span><span class="w"> </span><span class="c"># your others services</span><span class="w">
</span><span class="w">
</span><span class="w"> </span>gotenberg<span class="p">:</span><span class="w">
</span><span class="w"> </span>image<span class="p">:</span><span class="w"> </span>thecodingmachine/gotenberg<span class="p">:</span><span class="m">3</span><span class="w">
</span><span class="w"></span></pre>
<p>You may now launch your services using:</p>
<pre class="chroma">$ docker-compose up --scale <span class="nv">gotenberg</span><span class="o">=</span>your_number_of_instances
</pre>
<p>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.</p>
</div>
<div class="Page" id="links">
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="links" href="#links">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</a>Links</h1>
<ul>
<li>Follow the progress on the <a href="https://github.com/thecodingmachine/gotenberg">GitHub repository</a></li>
<li>Follow <a href="https://twitter.com/gulnap">@gulnap</a> on Twitter</li>
</ul>
<p>Psst: TheCodingMachine is always looking for <a href="https://coders.thecodingmachine.com">talented coders</a>.</p>
</div>
<div class="Footer"></div>
<script src="theme/gotenberg/js/index.js"></script>
</div>
</div>
</div>
</div></body></html>

View File

@@ -1,342 +0,0 @@
:root {
--ease: cubic-bezier(.82, 0, .12, 1);
--width: 800px;
--header-height: 400px;
--tracking: 0.05rem;
--tracking-medium: 0.5rem;
--tracking-large: 0.8rem;
--dark: #000;
--blue: #33A9FF;
--light-gray: #fafafa;
--bg: #fff;
--fg: #868E96;
--fg-dark: #212529;
--selection-bg: var(--blue);
--selection-fg: white;
}
* {
box-sizing: border-box;
}
html, body {
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 16px;
font-weight: 300;
background: var(--bg);
color: var(--fg-dark);
line-height: 1;
margin: 0;
padding: 0;
}
::selection {
background: var(--selection-bg);
color: var(--selection-fg);
}
h1, h2, h3, h4 {
margin-top: 75px;
margin-bottom: 0;
font-size: 1.2rem;
font-weight: 600;
line-height: 1.5rem;
color: var(--fg-dark);
}
h1 {
margin-top: 0;
font-size: 1.5rem;
}
h1 + p {
font-size: 1.5rem;
line-height: 1.6;
}
h3 {
font-size: 1.0rem;
font-style: italic;
}
a {
color: inherit;
text-decoration: none;
}
p {
margin: 25px 0;
line-height: 1.6;
color: var(--fg-color-light);
}
li strong,
p strong {
color: var(--fg-dark);
font-weight: 500;
}
li a,
p a {
color: var(--fg-dark);
font-weight: 400;
padding-bottom: 3px;
border-bottom: 1px dotted #ddd;
}
li a:hover,
p a:hover {
color: var(--blue);
border-bottom: none;
}
p a:hover {
border-bottom-color: var(--color);
}
ul {
margin: 50px 0 50px 30px;
padding: 0;
}
ul ul {
margin: 10px 0 10px 30px;
}
ul li {
margin: 5px 0;
color: var(--fg-color-light);
line-height: 1.5em;
}
ul li strong {
color: var(--fg-color);
font-weight: 500;
}
pre {
background: var(--light-gray);
color: var(--dark);
padding: 30px;
border-radius: 2px;
overflow: auto;
font: "Source Code Pro", Menlo, monospace;
font-size: .8em;
line-height: 1.5em;
}
blockquote {
margin: 0;
padding: 0 1em;
color: var(--fg-dark);
border-left: 0.25em solid var(--dark);
}
li > code,
p > code {
border: 1px solid #DEE2E6;
font-size: 0.75rem;
padding: 3px 10px;
border-radius: 3px;
white-space: nowrap;
font-weight: 600;
font-family: inherit;
}
details > summary {
cursor: pointer;
outline: none;
user-select: none;
}
details > p {
border-left: 3px solid var(--dark);
padding-left: 15px;
}
.Wrapper {
display: flex;
justify-content: center;
}
.Container {
width: var(--width);
display: flex;
flex-direction: column;
}
.Content-wrapper {
display: flex;
}
.Header {
display: flex;
justify-content: center;
align-items: center;
height: var(--header-height);
}
.Sidebar {
flex: 1 1 auto;
}
.Content {
width: 75%;
}
.Content img {
max-width: 100%;
}
.Menu {
position: -webkit-sticky;
position: sticky;
top: 50px;
color: var(--fg-dark);
}
.Menu > .item {
padding-bottom: 15px;
}
.Menu > .item > a {
position: relative;
user-select: none;
font-weight: 400;
transition: color 200ms;
color: var(--fg);
}
.Menu > .item > a.active {
color: var(--fg-dark);
}
.Menu > .item > a:before {
content: "";
position: absolute;
width: 100%;
height: 1px;
bottom: -5px;
left: 0;
background-color: var(--fg-dark);
visibility: hidden;
transform: scaleX(0);
transform-origin: left center;
transition: all 250ms var(--ease);
}
.Menu > .item > a:hover {
color: var(--fg-dark);
}
.Menu > .item > a:hover:before {
visibility: visible;
transform: scaleX(1);
}
.Page {
margin-top: 100px;
padding-top: 50px;
}
.Page:first-child {
margin-top: 0;
padding-top: 0;
}
.Title {
margin: 5px 0;
line-height: 2.2em;
}
.Title.center {
margin-left: auto;
margin-right: auto;
text-align: center;
max-width: 500px;
}
.Title.margin {
margin-bottom: 80px;
}
.Title > span {
display: block;
}
.Title .subtext {
color: var(--fg-color-light);
font-size: 0.8rem;
text-transform: uppercase;
display: none;
}
.Title .text {
letter-spacing: var(--tracking);
text-transform: uppercase;
font-weight: bold;
font-size: 16px;
}
.Title.small .text {
font-size: 14px;
text-transform: none;
letter-spacing: normal;
line-height: 2rem;
}
.Anchor {
position: relative;
margin-left: -14px;
opacity: 0.15;
display: inline-block;
width: 14px;
height: 14px;
}
.Anchor svg {
position: absolute;
right: 5px;
top: 0;
}
.Anchor svg path {
pointer-events: all
}
.Anchor:hover {
opacity: 1;
}
.Footer {
height: 100px;
}
@media screen and (max-width: 850px) {
html, body {
--header-height: 300px;
--width: 80%;
font-size: 14px;
}
.Content-wrapper {
display: flex;
flex-direction: column;
}
.Sidebar {
margin-bottom: 30px;
}
.Content {
width: 100%;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,46 +0,0 @@
/**
* Setup.
*/
const items = document.querySelectorAll('.Page')
const links = document.querySelectorAll('.Menu a')
/**
* Check if `el` is out out of view.
*/
function isBelowScroll(el) {
return el.getBoundingClientRect().bottom > 0
}
/**
* Activate item `i`.
*/
function activateItem(i) {
links.forEach(e => e.classList.remove('active'))
links[i].classList.add('active')
}
/**
* Activate the correct menu item for the
* contents in the viewport.
*/
function activate() {
let i = 0
for (; i < items.length; i++) {
if (isBelowScroll(items[i])) {
break
}
}
activateItem(i)
}
/**
* Activate scroll spy thingy.
*/
window.addEventListener('scroll', e => activate())

View File

@@ -1,47 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} &#183; {{.Subtitle}}</title>
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-16x16.png" sizes="16x16" />
<link rel="stylesheet" href="theme/gotenberg/css/index.css">
</head>
<body>
<div class="Wrapper">
<div class="Container">
<div class="Header">
<div class="Title center">
<img src="https://user-images.githubusercontent.com/8983173/49701110-4c8b9280-fbe8-11e8-895c-a4b9c7d2515b.png" alt="Gotenberg logo" width="250" height="250" />
<span class="text">{{.Title}}</span>
<span class="subtext">{{.Subtitle}}</span>
</div>
</div>
<div class="Content-wrapper">
<div class="Sidebar">
<div class="Menu">
{{range .Pages}}
<div class="item">
<a href="#{{.Slug}}">{{.Title}}</a>
</div>
{{end}}
</div>
</div>
<div class="Content">
{{range .Pages}}
<div class="Page" id="{{.Slug}}">
<h1>{{.Title}}</h1>
{{.Content}}
</div>
{{end}}
<div class="Footer"></div>
<script src="theme/gotenberg/js/index.js"></script>
</div>
</div>
</div>
</body>
</html>

53
go.mod
View File

@@ -1,21 +1,38 @@
module github.com/thecodingmachine/gotenberg
module github.com/gotenberg/gotenberg/v7
go 1.16
require (
github.com/derekparker/delve v1.1.0
github.com/google/go-cmp v0.2.0 // indirect
github.com/gorilla/websocket v1.4.0 // indirect
github.com/hhrutter/pdfcpu v0.1.19
github.com/labstack/echo v0.0.0-20181123063703-c7eb8da9ec73
github.com/labstack/gommon v0.2.8
github.com/mafredri/cdp v0.20.0
github.com/microcosm-cc/bluemonday v1.0.1
github.com/pkg/errors v0.8.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect
github.com/stretchr/objx v0.1.1 // indirect
github.com/stretchr/testify v1.2.2
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 // indirect
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 // indirect
github.com/alexliesenfeld/health v0.6.0
github.com/andybalholm/brotli v1.0.3 // indirect
github.com/chromedp/cdproto v0.0.0-20210808225517-c36c1bd4c35e
github.com/chromedp/chromedp v0.7.4
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.0
github.com/klauspost/compress v1.13.4 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/labstack/echo/v4 v4.5.0
github.com/labstack/gommon v0.3.0
github.com/mattn/go-isatty v0.0.13 // indirect
github.com/mholt/archiver/v3 v3.5.0
github.com/microcosm-cc/bluemonday v1.0.15
github.com/nwaples/rardecode v1.1.2 // indirect
github.com/pdfcpu/pdfcpu v0.3.12
github.com/pierrec/lz4/v4 v4.1.8 // indirect
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.10 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.7.0
go.uber.org/zap v1.19.0
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55 // indirect
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
golang.org/x/text v0.3.7
golang.org/x/tools v0.1.5 // indirect
)

239
go.sum
View File

@@ -1,55 +1,198 @@
github.com/alexliesenfeld/health v0.6.0 h1:HRBTCgybNSe4lqGEk7nU82c3bjwh9W+3b46W6UvD4CQ=
github.com/alexliesenfeld/health v0.6.0/go.mod h1:N4NDIeQtlWumG+6z1ne1v62eQxktz5ylEgGgH9emdMw=
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM=
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
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/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/chromedp/cdproto v0.0.0-20210713064928-7d28b402946a/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
github.com/chromedp/cdproto v0.0.0-20210808225517-c36c1bd4c35e h1:uYQIvPKwL7YQh+tQC+EUXmvWosBeOYbJZKrh4NnS0UM=
github.com/chromedp/cdproto v0.0.0-20210808225517-c36c1bd4c35e/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
github.com/chromedp/chromedp v0.7.4 h1:U+0d3WbB/Oj4mDuBOI0P7S3PJEued5UZIl5AJ3QulwU=
github.com/chromedp/chromedp v0.7.4/go.mod h1:dBj+SXuQHznp6ZPwZeDDEBZKwclUwDLbZ0hjMialMYs=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
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/derekparker/delve v1.1.0 h1:icd65nMp7s2HiLz6y/6RCVXBdoED3xxYLwX09EMaRCc=
github.com/derekparker/delve v1.1.0/go.mod h1:pMSZMfp0Nhbm8qdZJkuE/yPGOkLpGXLS1I4poXQpuJU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hhrutter/pdfcpu v0.1.18 h1:GX3d6tZxmQlmB5X9VUyOtx7+HZZpR+8ISLqBineP6dc=
github.com/hhrutter/pdfcpu v0.1.18/go.mod h1:iaCmXGnOXPIvoGLELZZc4m/GpIopVsvLQ00thrbu8LU=
github.com/hhrutter/pdfcpu v0.1.19 h1:J6GjNFDPjurQWallPW9gmz3CcNqBfvgB7Dad9T3e8Hc=
github.com/hhrutter/pdfcpu v0.1.19/go.mod h1:iaCmXGnOXPIvoGLELZZc4m/GpIopVsvLQ00thrbu8LU=
github.com/labstack/echo v0.0.0-20181123063703-c7eb8da9ec73 h1:oQvA3Xy8274W5Ldgo6Nogy4lX8XO5AaIBsjF5Ggk0IU=
github.com/labstack/echo v0.0.0-20181123063703-c7eb8da9ec73/go.mod h1:rS0D1UPvC8/3sXjhSwEq+K1olh7ipbDhjDWATN2KSgA=
github.com/labstack/echo v3.3.5+incompatible h1:9PfxPUmasKzeJor9uQTaXLT6WUG/r+vSTmvXxvv3JO4=
github.com/labstack/echo v3.3.5+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.7/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/mafredri/cdp v0.20.0 h1:dRq16FwXCAAbolR4W2iyRNsL14Bp6jFHLbRp2EkICVs=
github.com/mafredri/cdp v0.20.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
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 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.3/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/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.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
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.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4=
github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hhrutter/lzw v0.0.0-20190827003112-58b82c5a41cc/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 h1:1yY/RQWNSBjJe2GDCIYoLmpWVidrooriUr4QS/zaATQ=
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 h1:o1wMw7uTNyA58IlEdDpxIrtFHTgnvYzA8sCQz8luv94=
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7/go.mod h1:WkUxfS2JUu3qPo6tRld7ISb8HiC0gVSU91kooBMDVok=
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.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.4/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo/v4 v4.5.0 h1:JXk6H5PAw9I3GwizqUHhYyS4f45iyGebR/c1xNCeOCY=
github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y=
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
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.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mholt/archiver/v3 v3.5.0 h1:nE8gZIrw66cu4osS/U7UW7YDuGMHssxKutU8IfWxwWE=
github.com/mholt/archiver/v3 v3.5.0/go.mod h1:qqTTPUK/HZPFgFQ/TJ3BzvTpF/dPtFVJXdQbCmeMxwc=
github.com/microcosm-cc/bluemonday v1.0.15 h1:J4uN+qPng9rvkBZBoBb8YGR+ijuklIMpSOZZLjYpbeY=
github.com/microcosm-cc/bluemonday v1.0.15/go.mod h1:ZLvAzeakRwrGnzQEvstVzVt3ZpqOF2+sdFr0Om+ce30=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.2 h1:Cj0yZY6T1Zx1R7AhTbyGSALm44/Mmq+BAPc4B/p/d3M=
github.com/nwaples/rardecode v1.1.2/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5 h1:1SoBaSPudixRecmlHXb/GxmaD3fLMtHIDN13QujwQuc=
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.3.12 h1:B+MdKisilWNSk5OCO58Z9U6H93usH73xqk6hMOaZCls=
github.com/pdfcpu/pdfcpu v0.3.12/go.mod h1:8XVBtVxuuIuSZL4Ez15Q4QoC+H8zeAaGnuiOEwAk8jA=
github.com/pierrec/lz4/v4 v4.0.3/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4=
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY=
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
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 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
github.com/ulikunitz/xz v0.5.10/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 v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8=
github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw=
golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 h1:YAFjXN64LMvktoUZH9zgY4lGc/msGN7HQfoSuKCgaDU=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/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.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.19.0 h1:mZQZefskPPCMIBCSEH0v2/iUqqLrYtaeqwD6FUGUnFE=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55 h1:rw6UNGRMfarCepjI8qOepea/SXwIBVfTKjztZ5gBbq4=
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55/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-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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 h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,134 +0,0 @@
package api
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"time"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
// Start starts the API server on port 3000.
func Start() error {
e := setup()
// start Chrome headless and
// unoconv listener with PM2.
chrome := &pm2.Chrome{}
unoconv := &pm2.Unoconv{}
if err := chrome.Launch(); err != nil {
return err
}
if err := unoconv.Launch(); err != nil {
return err
}
// run our API in a goroutine so that it doesn't block.
go func() {
notify.Println("http server started on port 3000")
if err := e.Start(":3000"); err != nil {
e.Logger.Fatalf("%v", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
// we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
signal.Notify(quit, os.Interrupt)
// block until we receive our signal.
<-quit
// create a deadline to wait for.
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
// doesn't block if no connections, but will otherwise wait
// until the timeout deadline.
notify.Println("shutting down http server... (Ctrl+C to force)")
return e.Shutdown(ctx)
}
func setup() *echo.Echo {
e := echo.New()
e.HideBanner = true
e.HidePort = true
e.Use(middleware.Logger())
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if err := next(c); err != nil {
// TODO should return a better HTTP status code
// than 500 for some cases.
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%v", err))
}
return nil
}
})
e.POST("/merge", merge)
g := e.Group("/convert")
g.POST("/html", convertHTML)
g.POST("/markdown", convertMarkdown)
g.POST("/office", convertOffice)
return e
}
func newContext(r *resource) (context.Context, context.CancelFunc) {
webhookURL := r.webhookURL()
if webhookURL == "" {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
return ctx, cancel
}
return context.Background(), nil
}
func print(c echo.Context, p printer.Printer, r *resource) error {
baseFilename, err := rand.Get()
if err != nil {
return hijackErr(fmt.Errorf("getting result file name: %v", err), r)
}
filename := fmt.Sprintf("%s.pdf", baseFilename)
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
if r.webhookURL() == "" {
defer r.removeAll()
// if no webhook URL given, run conversion
// and directly return the resulting PDF file
// or an error.
if err := p.Print(fpath); err != nil {
return err
}
return c.Attachment(fpath, filename)
}
// as a webhook URL has been given, we
// run the following lines in a goroutine so that
// it doesn't block.
go func() {
defer r.removeAll()
if err := p.Print(fpath); err != nil {
c.Logger().Errorf("%v", err)
return
}
f, err := os.Open(fpath)
if err != nil {
c.Logger().Errorf("%v", err)
return
}
defer f.Close()
resp, err := http.Post(r.webhookURL(), "application/pdf", f)
if err != nil {
c.Logger().Errorf("%v", err)
return
}
defer resp.Body.Close()
}()
return nil
}
func hijackErr(err error, r *resource) error {
if r != nil {
defer r.removeAll()
}
return err
}

View File

@@ -1,9 +0,0 @@
/*
Package api starts a HTTP server on port 3000.
It accepts POST requests with a multipart/form-data Content-Type
for converting HTML, Markdown and Office documents to PDF.
It is also able to merge a list of PDF files.
*/
package api

View File

@@ -1,51 +0,0 @@
package api
import (
"github.com/labstack/echo"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
func convertHTML(c echo.Context) error {
r, err := newResource(c)
if err != nil {
return hijackErr(err, r)
}
ctx, cancel := newContext(r)
if cancel != nil {
defer cancel()
}
p := &printer.HTML{Context: ctx}
indexPath, err := r.filePath("index.html")
if err != nil {
return hijackErr(err, r)
}
p.WithLocalURL(indexPath)
headerPath, _ := r.filePath("header.html")
if err := p.WithHeaderFile(headerPath); err != nil {
return hijackErr(err, r)
}
footerPath, _ := r.filePath("footer.html")
if err := p.WithFooterFile(footerPath); err != nil {
return hijackErr(err, r)
}
paperSize, err := r.paperSize()
if err != nil {
return hijackErr(err, r)
}
p.PaperWidth = paperSize[0]
p.PaperHeight = paperSize[1]
paperMargins, err := r.paperMargins()
if err != nil {
return hijackErr(err, r)
}
p.MarginTop = paperMargins[0]
p.MarginBottom = paperMargins[1]
p.MarginLeft = paperMargins[2]
p.MarginRight = paperMargins[3]
landscape, err := r.landscape()
if err != nil {
return hijackErr(err, r)
}
p.Landscape = landscape
return print(c, p, r)
}

View File

@@ -1,24 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test"
)
func TestHTML(t *testing.T) {
body, contentType := test.HTMLTestMultipartForm(t)
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
e := echo.New()
c := e.NewContext(req, rec)
if assert.NoError(t, convertHTML(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
}
}

View File

@@ -1,50 +0,0 @@
package api
import (
"github.com/labstack/echo"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
func convertMarkdown(c echo.Context) error {
r, err := newResource(c)
if err != nil {
return hijackErr(err, r)
}
ctx, cancel := newContext(r)
if cancel != nil {
defer cancel()
}
indexPath, err := r.filePath("index.html")
if err != nil {
return hijackErr(err, r)
}
p := &printer.Markdown{Context: ctx, TemplatePath: indexPath}
headerPath, _ := r.filePath("header.html")
if err := p.WithHeaderFile(headerPath); err != nil {
return hijackErr(err, r)
}
footerPath, _ := r.filePath("footer.html")
if err := p.WithFooterFile(footerPath); err != nil {
return hijackErr(err, r)
}
paperSize, err := r.paperSize()
if err != nil {
return hijackErr(err, r)
}
p.PaperWidth = paperSize[0]
p.PaperHeight = paperSize[1]
paperMargins, err := r.paperMargins()
if err != nil {
return hijackErr(err, r)
}
p.MarginTop = paperMargins[0]
p.MarginBottom = paperMargins[1]
p.MarginLeft = paperMargins[2]
p.MarginRight = paperMargins[3]
landscape, err := r.landscape()
if err != nil {
return hijackErr(err, r)
}
p.Landscape = landscape
return print(c, p, r)
}

View File

@@ -1,24 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMarkdown(t *testing.T) {
body, contentType := test.MarkdownTestMultipartForm(t)
req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
e := echo.New()
c := e.NewContext(req, rec)
if assert.NoError(t, convertMarkdown(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
}
}

View File

@@ -1,65 +0,0 @@
package api
import (
"errors"
"fmt"
"net/http"
"os"
"github.com/labstack/echo"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
func merge(c echo.Context) error {
r, err := newResource(c)
if err != nil {
return hijackErr(err, r)
}
fpaths, err := r.filePaths([]string{".pdf"})
if err != nil {
return hijackErr(err, r)
}
if len(fpaths) == 0 {
return hijackErr(errors.New("no suitable PDF files to merge"), r)
}
baseFilename, err := rand.Get()
if err != nil {
return hijackErr(fmt.Errorf("getting result file name: %v", err), r)
}
filename := fmt.Sprintf("%s.pdf", baseFilename)
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
if r.webhookURL() == "" {
defer r.removeAll()
// if no webhook URL given, run merge
// and directly return the resulting PDF file
// or an error.
if err := printer.Merge(fpaths, fpath); err != nil {
return err
}
return c.Attachment(fpath, filename)
}
// as a webhook URL has been given, we
// run the following lines in a goroutine so that
// it doesn't block.
go func() {
defer r.removeAll()
if err := printer.Merge(fpaths, fpath); err != nil {
c.Logger().Errorf("%v", err)
return
}
f, err := os.Open(fpath)
if err != nil {
c.Logger().Errorf("%v", err)
return
}
defer f.Close()
resp, err := http.Post(r.webhookURL(), "application/pdf", f)
if err != nil {
c.Logger().Errorf("%v", err)
return
}
defer resp.Body.Close()
}()
return nil
}

View File

@@ -1,24 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMerge(t *testing.T) {
body, contentType := test.PDFTestMultipartForm(t)
req := httptest.NewRequest(http.MethodPost, "/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
e := echo.New()
c := e.NewContext(req, rec)
if assert.NoError(t, merge(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
}
}

View File

@@ -1,53 +0,0 @@
package api
import (
"errors"
"github.com/labstack/echo"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
)
var officeExts = []string{
".txt",
".rtf",
".doc",
".docx",
".odt",
".xls",
".xlsx",
".ods",
".ppt",
".pptx",
".odp",
}
func convertOffice(c echo.Context) error {
r, err := newResource(c)
if err != nil {
return hijackErr(err, r)
}
ctx, cancel := newContext(r)
if cancel != nil {
defer cancel()
}
fpaths, err := r.filePaths(officeExts)
if err != nil {
return hijackErr(err, r)
}
if len(fpaths) == 0 {
return hijackErr(errors.New("no suitable office documents to convert"), r)
}
p := &printer.Office{Context: ctx, FilePaths: fpaths}
paperSize, err := r.paperSize()
if err != nil {
return hijackErr(err, r)
}
p.PaperWidth = paperSize[0]
p.PaperHeight = paperSize[1]
landscape, err := r.landscape()
if err != nil {
return hijackErr(err, r)
}
p.Landscape = landscape
return print(c, p, r)
}

View File

@@ -1,24 +0,0 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test"
)
func TestOffice(t *testing.T) {
body, contentType := test.OfficeTestMultipartForm(t)
req := httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
e := echo.New()
c := e.NewContext(req, rec)
if assert.NoError(t, convertOffice(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
}
}

View File

@@ -1,184 +0,0 @@
package api
import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"github.com/labstack/echo"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
const (
webhookURL string = "webhookURL"
paperWidth string = "paperWidth"
paperHeight string = "paperHeight"
marginTop string = "marginTop"
marginBottom string = "marginBottom"
marginLeft string = "marginLeft"
marginRight string = "marginRight"
landscape string = "landscape"
)
// resource facilitates storing and accessing
// data from a multipart/form-data request.
type resource struct {
values map[string]string
dirPath string
}
func newResource(c echo.Context) (*resource, error) {
v := make(map[string]string)
v[webhookURL] = c.FormValue(webhookURL)
v[paperWidth] = c.FormValue(paperWidth)
v[paperHeight] = c.FormValue(paperHeight)
v[marginTop] = c.FormValue(marginTop)
v[marginBottom] = c.FormValue(marginBottom)
v[marginLeft] = c.FormValue(marginLeft)
v[marginRight] = c.FormValue(marginRight)
v[landscape] = c.FormValue(landscape)
dirPath, err := rand.Get()
if err != nil {
return nil, err
}
if err := os.MkdirAll(dirPath, 0755); err != nil {
return nil, fmt.Errorf("%s: making directory: %v", dirPath, err)
}
r := &resource{values: v, dirPath: dirPath}
form, err := c.MultipartForm()
if err != nil {
return r, fmt.Errorf("getting multipart form: %v", err)
}
for _, files := range form.File {
for _, fh := range files {
in, err := fh.Open()
if err != nil {
return r, fmt.Errorf("%s: opening file: %v", fh.Filename, err)
}
defer in.Close()
if err := r.writeFile(fh.Filename, in); err != nil {
return r, err
}
}
}
return r, nil
}
func (r *resource) writeFile(filename string, in io.Reader) error {
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
out, err := os.Create(fpath)
if err != nil {
return fmt.Errorf("%s: creating new file: %v", fpath, err)
}
defer out.Close()
if err := out.Chmod(0644); err != nil {
return fmt.Errorf("%s: changing file mode: %v", fpath, err)
}
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("%s: writing file: %v", fpath, err)
}
if _, err := out.Seek(0, 0); err != nil {
return fmt.Errorf("%s: resetting read pointer: %v", fpath, err)
}
return nil
}
func (r *resource) filePath(filename string) (string, error) {
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
_, err := os.Stat(fpath)
if os.IsNotExist(err) {
return "", fmt.Errorf("%s: file does not exist", filename)
}
absPath, err := filepath.Abs(fpath)
if err != nil {
return "", fmt.Errorf("%s: getting absolute path: %v", fpath, err)
}
return absPath, nil
}
func (r *resource) filePaths(exts []string) ([]string, error) {
var fpaths []string
err := filepath.Walk(r.dirPath, func(path string, info os.FileInfo, _ error) error {
if info.IsDir() {
return nil
}
fpath, err := r.filePath(info.Name())
if err != nil {
return err
}
for _, ext := range exts {
if filepath.Ext(fpath) == ext {
fpaths = append(fpaths, fpath)
return nil
}
}
return nil
})
if err != nil {
return nil, err
}
return fpaths, nil
}
func (r *resource) paperSize() ([2]float64, error) {
defaultSize := [2]float64{8.27, 11.7}
widthStr := r.values[paperWidth]
heightStr := r.values[paperHeight]
if widthStr == "" || heightStr == "" {
return defaultSize, nil
}
width, err := strconv.ParseFloat(widthStr, 64)
if err != nil {
return defaultSize, fmt.Errorf("paper width: %v", err)
}
height, err := strconv.ParseFloat(heightStr, 64)
if err != nil {
return defaultSize, fmt.Errorf("paper height: %v", err)
}
return [2]float64{width, height}, nil
}
func (r *resource) paperMargins() ([4]float64, error) {
defaultMargins := [4]float64{1, 1, 1, 1}
topStr := r.values[marginTop]
bottomStr := r.values[marginBottom]
leftStr := r.values[marginLeft]
rightStr := r.values[marginRight]
if topStr == "" || bottomStr == "" || leftStr == "" || rightStr == "" {
return defaultMargins, nil
}
top, err := strconv.ParseFloat(topStr, 64)
if err != nil {
return defaultMargins, fmt.Errorf("margin top: %v", err)
}
bottom, err := strconv.ParseFloat(bottomStr, 64)
if err != nil {
return defaultMargins, fmt.Errorf("margin bottom: %v", err)
}
left, err := strconv.ParseFloat(leftStr, 64)
if err != nil {
return defaultMargins, fmt.Errorf("margin left: %v", err)
}
right, err := strconv.ParseFloat(rightStr, 64)
if err != nil {
return defaultMargins, fmt.Errorf("margin right: %v", err)
}
return [4]float64{top, bottom, left, right}, nil
}
func (r *resource) landscape() (bool, error) {
landscapeStr := r.values[landscape]
if landscapeStr == "" {
return false, nil
}
landscape, err := strconv.ParseBool(landscapeStr)
if err != nil {
return false, fmt.Errorf("landscape: %v", err)
}
return landscape, nil
}
func (r *resource) webhookURL() string { return r.values[webhookURL] }
func (r *resource) removeAll() error { return os.RemoveAll(r.dirPath) }

View File

@@ -0,0 +1,116 @@
package gotenberg
import (
"context"
"fmt"
"os"
"os/signal"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag"
)
// 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
-------------------------------------------------------
`
var version = "snapshot"
func Run() {
fmt.Printf(banner, version)
// Creates the roo` 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)
// Parses the flags...
err := fs.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// ...and creates 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)
// Starts 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))
}
quit := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C).
signal.Notify(quit, os.Interrupt)
// Block until we receive our signal.
<-quit
gracefulShutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownDuration)
defer cancel()
fmt.Printf("[SYSTEM] graceful shutdown of %s\n", gracefulShutdownDuration)
for _, a := range apps {
id := a.(gotenberg.Module).Descriptor().ID
app := a.(gotenberg.App)
err = app.Stop(gracefulShutdownCtx)
if err != nil {
fmt.Printf("[ERROR] stopping %s: %s\n", id, err)
}
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
}
os.Exit(0)
}

View File

@@ -1,5 +0,0 @@
/*
Package notify is used across the application
to display nice outputs to the user.
*/
package notify

View File

@@ -1,30 +0,0 @@
package notify
import (
"fmt"
"os"
"github.com/labstack/gommon/color"
)
var (
stdout *color.Color
stderr *color.Color
)
func init() {
stdout = color.New()
stdout.SetOutput(os.Stdout)
stderr = color.New()
stderr.SetOutput(os.Stderr)
}
// Println prints a message to stdout.
func Println(message string) {
stdout.Printf("⇨ %s\n", message)
}
// ErrPrintln prints an error to stderr.
func ErrPrintln(err error) {
stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err)))
}

View File

@@ -1,71 +0,0 @@
package pm2
import (
"context"
"fmt"
"time"
"github.com/mafredri/cdp/devtool"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
)
// Chrome facilitates starting or shutting down
// Chrome headless with PM2.
type Chrome struct{}
// Launch starts Chrome headless with PM2.
func (c *Chrome) Launch() error {
return launch(c)
}
// Shutdown stops Chrome headless and
// removes it from the list of PM2
// processes.
func (c *Chrome) Shutdown() error {
return shutdown(c)
}
func (c *Chrome) getArgs() []string {
return []string{
"--no-sandbox",
"--headless",
"--remote-debugging-port=9222",
"--disable-gpu",
"--disable-translate",
"--disable-extensions",
"--disable-background-networking",
"--safebrowsing-disable-auto-update",
"--disable-sync",
"--disable-default-apps",
"--hide-scrollbars",
"--metrics-recording-only",
"--mute-audio",
"--no-first-run",
}
}
func (c *Chrome) getName() string {
return "google-chrome-stable"
}
func (c *Chrome) getFullname() string {
return "Chrome headless"
}
func (c *Chrome) isViable() bool {
// check if Chrome is correctly running.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err := devtool.New("http://localhost:9222").Version(ctx)
return err == nil
}
func (c *Chrome) warmup() {
notify.Println(fmt.Sprintf("warming-up %s", c.getFullname()))
time.Sleep(5 * time.Second)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(Chrome))
)

View File

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

View File

@@ -1,12 +0,0 @@
/*
Package pm2 facilitates starting external
processes on which our API depends.
For instance, it starts Chrome headless and
unoconv listener with PM2.
The PM2 process manager launch those processes and keep
them running in the background. If for some reason they
crash, it will also restart them.
*/
package pm2

View File

@@ -1,71 +0,0 @@
package pm2
import (
"fmt"
"os/exec"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
)
// Process is a type that can launch or
// shutdown a process with PM2.
type Process interface {
Launch() error
Shutdown() error
getArgs() []string
getName() string
getFullname() string
isViable() bool
warmup()
}
const maxRestartAttempts int = 5
var humanNames = map[string]string{
"start": "started",
"restart": "restarted",
"stop": "stopped",
}
func launch(p Process) error {
if err := run(p, "start"); err != nil {
return err
}
p.warmup()
if !p.isViable() {
attempts := 0
for attempts < maxRestartAttempts && !p.isViable() {
run(p, "restart")
p.warmup()
attempts++
}
if !p.isViable() {
return fmt.Errorf("failed to launch %s", p.getFullname())
}
}
return nil
}
func shutdown(p Process) error {
return run(p, "stop")
}
func run(p Process, cmdName string) error {
cmdArgs := []string{
cmdName,
p.getName(),
}
if cmdName == "start" {
cmdArgs = append(cmdArgs, "--interpreter none", "--")
cmdArgs = append(cmdArgs, p.getArgs()...)
}
cmd := exec.Command(
"pm2",
cmdArgs...,
)
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s %s with PM2: %v", cmdName, p.getFullname(), err)
}
notify.Println(fmt.Sprintf("%s %s with PM2", p.getFullname(), humanNames[cmdName]))
return nil
}

View File

@@ -1,47 +0,0 @@
package pm2
// Unoconv facilitates starting or shutting down
// unoconv listener with PM2.
type Unoconv struct{}
// Launch starts unoconv listener with PM2.
func (u *Unoconv) Launch() error {
return launch(u)
}
// Shutdown stops unoconv listener and
// removes it from the list of PM2
// processes.
func (u *Unoconv) Shutdown() error {
return shutdown(u)
}
func (u *Unoconv) getArgs() []string {
return []string{
"--listener",
"--verbose",
}
}
func (u *Unoconv) getName() string {
return "unoconv"
}
func (u *Unoconv) getFullname() string {
return "unoconv listener"
}
func (u *Unoconv) isViable() bool {
// TODO find a way to check if
// unoconv is correctly started?
return true
}
func (u *Unoconv) warmup() {
// let's do nothing.
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(Unoconv))
)

View File

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

View File

@@ -1,7 +0,0 @@
/*
Package printer contains structs which convert
a specific file type to PDF.
It is also able to merge a list of PDF files.
*/
package printer

View File

@@ -1,189 +0,0 @@
package printer
import (
"context"
"errors"
"fmt"
"io/ioutil"
"strings"
"github.com/mafredri/cdp"
"github.com/mafredri/cdp/devtool"
"github.com/mafredri/cdp/protocol/network"
"github.com/mafredri/cdp/protocol/page"
"github.com/mafredri/cdp/protocol/runtime"
"github.com/mafredri/cdp/protocol/target"
"github.com/mafredri/cdp/rpcc"
)
// HTML facilitates HTML to PDF conversion.
type HTML struct {
Context context.Context
URL string
HeaderHTML string
FooterHTML string
PaperWidth float64
PaperHeight float64
MarginTop float64
MarginBottom float64
MarginLeft float64
MarginRight float64
Landscape bool
}
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
// Print converts HTML to PDF.
// Credits: https://medium.com/compass-true-north/go-service-to-convert-web-pages-to-pdf-using-headless-chrome-5fd9ffbae1af
func (html *HTML) Print(destination string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// use the DevTools HTTP/JSON API to manage targets (e.g. pages, webworkers).
devt, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
return fmt.Errorf("creating DevTools target: %v", err)
}
// open a new RPC connection to the Chrome Debugging Protocol target.
conn, err := rpcc.DialContext(html.Context, devt.WebSocketDebuggerURL)
if err != nil {
return fmt.Errorf("creating RPC connection: %v", err)
}
defer conn.Close()
// create new browser context.
baseBrowser := cdp.NewClient(conn)
newContextTarget, err := baseBrowser.Target.CreateBrowserContext(html.Context)
if err != nil {
return fmt.Errorf("creating new browser context: %v", err)
}
// create a new blank target with the new browser context.
newTargetArgs := target.NewCreateTargetArgs("about:blank").
SetBrowserContextID(newContextTarget.BrowserContextID)
newTarget, err := baseBrowser.Target.CreateTarget(html.Context, newTargetArgs)
if err != nil {
return fmt.Errorf("creating new blank target: %v", err)
}
// connect the client to the new target.
newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID)
newContextConn, err := rpcc.DialContext(html.Context, newTargetWsURL)
if err != nil {
return fmt.Errorf("connecting client to blank target: %v", err)
}
defer newContextConn.Close()
// close the target when done.
closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID)
defer baseBrowser.Target.CloseTarget(html.Context, closeTargetArgs)
c := cdp.NewClient(newContextConn)
// enable the runtime.
if err := c.Runtime.Enable(html.Context); err != nil {
return fmt.Errorf("enabling runtime: %v", err)
}
// enable the network.
if err := c.Network.Enable(html.Context, network.NewEnableArgs()); err != nil {
return fmt.Errorf("enabling network: %v", err)
}
// enable events on the page domain.
if err := c.Page.Enable(html.Context); err != nil {
return fmt.Errorf("enabling events on page domain: %v", err)
}
// create a client to listen for the load event to be fired.
loadEventFiredClient, err := c.Page.LoadEventFired(html.Context)
if err != nil {
return fmt.Errorf("creating client listening for load event: %v", err)
}
defer loadEventFiredClient.Close()
// tell the page to navigate to the URL.
navArgs := page.NewNavigateArgs(html.URL)
_, err = c.Page.Navigate(html.Context, navArgs)
if err != nil {
return fmt.Errorf("%s: navigating to URL: %v", html.URL, err)
}
// wait for the page to finish loading.
_, err = loadEventFiredClient.Recv()
if err != nil {
return fmt.Errorf("waiting for page loading: %v", err)
}
// inject a script to make sure web fonts are loaded.
script := `new Promise((resolve, reject) => {
document.fonts.ready.then(function () {
resolve('fonts loaded');
});
setTimeout(resolve.bind(resolve, 'timeout'), %.0f);
});`
scriptArg := runtime.NewEvaluateArgs(script).SetAwaitPromise(true)
returnObj, _ := c.Runtime.Evaluate(html.Context, scriptArg)
loadFontsResult := string(returnObj.Result.Value)
if strings.Contains(loadFontsResult, "timeout") {
return errors.New("timed out loading fonts")
}
// if no header or footer, use the default template
// for avoiding displaying default Chrome templates.
if html.HeaderHTML == "" {
html.HeaderHTML = defaultHeaderFooterHTML
}
if html.FooterHTML == "" {
html.FooterHTML = defaultHeaderFooterHTML
}
print, err := c.Page.PrintToPDF(
html.Context,
page.NewPrintToPDFArgs().
SetPaperWidth(html.PaperWidth).
SetPaperHeight(html.PaperHeight).
SetMarginTop(html.MarginTop).
SetMarginBottom(html.MarginBottom).
SetMarginLeft(html.MarginLeft).
SetMarginRight(html.MarginRight).
SetLandscape(html.Landscape).
SetDisplayHeaderFooter(true).
SetHeaderTemplate(html.HeaderHTML).
SetFooterTemplate(html.FooterHTML).
SetPrintBackground(true),
)
if err != nil {
return fmt.Errorf("printing page to PDF: %v", err)
}
return writeBytesToFile(destination, print.Data)
}
// WithLocalURL sets a local URL from a file path.
func (html *HTML) WithLocalURL(fpath string) {
html.URL = fmt.Sprintf("file://%s", fpath)
}
// WithHeaderFile sets header content from a file.
func (html *HTML) WithHeaderFile(fpath string) error {
if fpath == "" {
return nil
}
contentHTML, err := fileContentToString(fpath)
if err != nil {
return err
}
html.HeaderHTML = contentHTML
return nil
}
// WithFooterFile sets footer content from a file.
func (html *HTML) WithFooterFile(fpath string) error {
if fpath == "" {
return nil
}
contentHTML, err := fileContentToString(fpath)
if err != nil {
return err
}
html.FooterHTML = contentHTML
return nil
}
func fileContentToString(fpath string) (string, error) {
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading file: %v", fpath, err)
}
return string(b), nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(HTML))
)

View File

@@ -1,39 +0,0 @@
package printer
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestHTML(t *testing.T) {
dirPath := test.HTMLTestDirPath(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
html := &HTML{
Context: ctx,
PaperWidth: 8.27,
PaperHeight: 11.7,
MarginTop: 1,
MarginBottom: 1,
MarginLeft: 1,
MarginRight: 1,
}
html.WithLocalURL(fmt.Sprintf("%s/%s", dirPath, "index.html"))
err := html.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html"))
require.Nil(t, err)
err = html.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html"))
require.Nil(t, err)
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err = html.Print(dst)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -1,111 +0,0 @@
package printer
import (
"bytes"
"context"
"fmt"
"html/template"
"io/ioutil"
"path/filepath"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
// Markdown facilitates Markdown to PDF conversion.
type Markdown struct {
Context context.Context
TemplatePath string
HeaderHTML string
FooterHTML string
PaperWidth float64
PaperHeight float64
MarginTop float64
MarginBottom float64
MarginLeft float64
MarginRight float64
Landscape bool
html *HTML
}
type templateData struct {
DirPath string
}
// Print converts markdown to PDF.
func (md *Markdown) Print(destination string) error {
if md.html == nil {
md.html = &HTML{Context: md.Context}
}
if md.HeaderHTML != "" {
md.html.HeaderHTML = md.HeaderHTML
}
if md.FooterHTML != "" {
md.html.FooterHTML = md.FooterHTML
}
md.html.PaperWidth = md.PaperWidth
md.html.PaperHeight = md.PaperHeight
md.html.MarginTop = md.MarginTop
md.html.MarginBottom = md.MarginBottom
md.html.MarginLeft = md.MarginLeft
md.html.MarginRight = md.MarginRight
md.html.Landscape = md.Landscape
tmpl, err := template.
New(filepath.Base(md.TemplatePath)).
Funcs(template.FuncMap{"toHTML": toHTML}).
ParseFiles(md.TemplatePath)
if err != nil {
return fmt.Errorf("%s: parsing template: %v", md.TemplatePath, err)
}
dirPath := filepath.Dir(md.TemplatePath)
data := &templateData{DirPath: dirPath}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return fmt.Errorf("%s: executing template: %v", md.TemplatePath, err)
}
baseFilename, err := rand.Get()
if err != nil {
return err
}
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
if err := writeBytesToFile(dst, buffer.Bytes()); err != nil {
return err
}
md.html.WithLocalURL(dst)
return md.html.Print(destination)
}
func toHTML(dirPath, filename string) (template.HTML, error) {
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading file: %v", fpath, err)
}
unsafe := blackfriday.Run(b)
contentHTML := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
return template.HTML(contentHTML), nil
}
// WithHeaderFile sets header content from a file.
func (md *Markdown) WithHeaderFile(fpath string) error {
if md.html == nil {
md.html = &HTML{Context: md.Context}
}
return md.html.WithHeaderFile(fpath)
}
// WithFooterFile sets footer content from a file.
func (md *Markdown) WithFooterFile(fpath string) error {
if md.html == nil {
md.html = &HTML{Context: md.Context}
}
return md.html.WithFooterFile(fpath)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(Markdown))
)

View File

@@ -1,39 +0,0 @@
package printer
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMarkdown(t *testing.T) {
dirPath := test.MarkdownTestDirPath(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
markdown := &Markdown{
Context: ctx,
TemplatePath: fmt.Sprintf("%s/%s", dirPath, "index.html"),
PaperWidth: 8.27,
PaperHeight: 11.7,
MarginTop: 1,
MarginBottom: 1,
MarginLeft: 1,
MarginRight: 1,
}
err := markdown.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html"))
require.Nil(t, err)
err = markdown.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html"))
require.Nil(t, err)
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err = markdown.Print(dst)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -1,88 +0,0 @@
package printer
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
var mu sync.Mutex
// Office facilitates Office documents to PDF conversion.
type Office struct {
Context context.Context
FilePaths []string
PaperWidth float64
PaperHeight float64
Landscape bool
}
// Print converts Office documents to PDF.
func (o *Office) Print(destination string) error {
mu.Lock()
defer mu.Unlock()
fpaths := make([]string, len(o.FilePaths))
dirPath := filepath.Dir(destination)
for i, fpath := range o.FilePaths {
baseFilename, err := rand.Get()
if err != nil {
return err
}
tmpDest := fmt.Sprintf("%s/%s.pdf", dirPath, baseFilename)
paperSize, err := unoconvPaperSize(o.PaperWidth, o.PaperHeight)
if err != nil {
return err
}
cmdArgs := []string{
"--format",
"pdf",
"--printer",
paperSize,
}
if o.Landscape {
cmdArgs = append(cmdArgs, "--printer", "PaperOrientation=landscape")
}
cmdArgs = append(cmdArgs, "--output", tmpDest, fpath)
cmd := exec.CommandContext(
o.Context,
"unoconv",
cmdArgs...,
)
_, err = cmd.Output()
if o.Context.Err() == context.DeadlineExceeded {
return errors.New("unoconv: command timed out")
}
if err != nil {
return fmt.Errorf("unoconv: non-zero exit code: %v", err)
}
fpaths[i] = tmpDest
}
if len(fpaths) == 1 {
return os.Rename(fpaths[0], destination)
}
return Merge(fpaths, destination)
}
func unoconvPaperSize(paperWidth, paperHeight float64) (string, error) {
width, err := strconv.Atoi(fmt.Sprintf("%.0f", paperWidth*25.4))
if err != nil {
return "", fmt.Errorf("%0.f: converting width to millimiter: %v", paperWidth, err)
}
height, err := strconv.Atoi(fmt.Sprintf("%.0f", paperHeight*25.4))
if err != nil {
return "", fmt.Errorf("%0.f: converting height to millimiter: %v", paperHeight, err)
}
return fmt.Sprintf("PaperSize=%dx%d", width, height), nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(Office))
)

View File

@@ -1,33 +0,0 @@
package printer
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestOffice(t *testing.T) {
dirPath := test.OfficeTestDirPath(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
office := &Office{
Context: ctx,
FilePaths: []string{
fmt.Sprintf("%s/%s", dirPath, "document.docx"),
fmt.Sprintf("%s/%s", dirPath, "document.txt"),
fmt.Sprintf("%s/%s", dirPath, "document.rtf"),
},
}
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err := office.Print(dst)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -1,36 +0,0 @@
package printer
import (
"fmt"
"io/ioutil"
pdfcpuAPI "github.com/hhrutter/pdfcpu/pkg/api"
pdfcpuLog "github.com/hhrutter/pdfcpu/pkg/log"
pdfcpuConfig "github.com/hhrutter/pdfcpu/pkg/pdfcpu"
)
func init() {
// disable loggers when merging
// PDFs.
pdfcpuLog.DisableLoggers()
}
// Printer is a type that can create a PDF file from a source.
// The source is defined in the underlying implementation.
type Printer interface {
Print(destination string) error
}
// Merge merges PDF files.
func Merge(fpaths []string, destination string) error {
cmd := pdfcpuAPI.MergeCommand(fpaths, destination, pdfcpuConfig.NewDefaultConfiguration())
_, err := pdfcpuAPI.Merge(cmd)
return err
}
func writeBytesToFile(dst string, b []byte) error {
if err := ioutil.WriteFile(dst, b, 0644); err != nil {
return fmt.Errorf("%s: writing file: %v", dst, err)
}
return nil
}

View File

@@ -1,27 +0,0 @@
package printer
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMerge(t *testing.T) {
dirPath := test.PDFTestDirPath(t)
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err := Merge(
[]string{
fmt.Sprintf("%s/%s", dirPath, "gotenberg.pdf"),
fmt.Sprintf("%s/%s", dirPath, "gotenberg_bis.pdf"),
},
dst,
)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

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

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
# Gotenberg Go client
A simple Go client for interacting with a Gotenberg API.
## Install
```bash
$ go get -u github.com/thecodingmachine/gotenberg
```
## Usage
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
// HTML conversion example.
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.SetHeader("header.html")
req.SetFooter("footer.html")
req.SetAssets([]string{
"font.woff",
"img.gif",
"style.css",
})
req.SetPaperSize(gotenberg.A4)
req.SetMargins(gotenberg.NormalMargins)
req.SetLandscape(false)
dest := "foo.pdf"
c.Store(req, dest)
}
```
For more complete usages, head to the [documentation](https://thecodingmachine.github.io/gotenberg).

View File

@@ -1,172 +0,0 @@
package gotenberg
import (
"bytes"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
)
const (
webhookURL string = "webhookURL"
paperWidth string = "paperWidth"
paperHeight string = "paperHeight"
marginTop string = "marginTop"
marginBottom string = "marginBottom"
marginLeft string = "marginLeft"
marginRight string = "marginRight"
landscape string = "landscape"
)
var (
// A3 paper size.
A3 = [2]float64{11.7, 16.5}
// A4 paper size.
A4 = [2]float64{8.27, 11.7}
// A5 paper size.
A5 = [2]float64{5.8, 8.3}
// A6 paper size.
A6 = [2]float64{4.1, 5.8}
// Letter paper size.
Letter = [2]float64{8.5, 11}
// Legal paper size.
Legal = [2]float64{8.5, 14}
// Tabloid paper size.
Tabloid = [2]float64{11, 17}
)
var (
// NoMargins removes margins.
NoMargins = [4]float64{0, 0, 0, 0}
// NormalMargins uses 1 inche margins.
NormalMargins = [4]float64{1, 1, 1, 1}
// LargeMargins uses 2 inche margins.
LargeMargins = [4]float64{2, 2, 2, 2}
)
// Client facilitates interacting with
// the Gotenberg API.
type Client struct {
Hostname string
}
// Request is a type for sending
// form values and form files to
// the Gotenberg API.
type Request interface {
SetWebhookURL(webhookURL string)
getPostURL() string
getFormValues() map[string]string
getFormFiles() map[string]string
}
// ChromeRequest is a type for sending
// conversion requests which will be
// handle by Google Chrome.
type ChromeRequest interface {
SetHeader(fpath string) error
SetFooter(fpath string) error
SetAssets(fpaths []string) error
SetPaperSize(size [2]float64)
SetMargins(margins [4]float64)
SetLandscape(isLandscape bool)
}
// UnoconvRequest is a type for sending
// conversion requests which will be
// handle by unoconv.
type UnoconvRequest interface {
SetPaperSize(size [2]float64)
SetLandscape(landscape bool)
}
// Post sends a request to the Gotenberg API
// and returns the response.
func (c *Client) Post(req Request) (*http.Response, error) {
body, contentType, err := multipartForm(req)
if err != nil {
return nil, err
}
URL := fmt.Sprintf("%s%s", c.Hostname, req.getPostURL())
resp, err := http.Post(URL, contentType, body)
if err != nil {
return nil, err
}
return resp, nil
}
// Store creates the resulting PDF to given destination.
func (c *Client) Store(req Request, dest string) error {
if hasWebhook(req) {
return errors.New("cannot use Store method with a webhook")
}
resp, err := c.Post(req)
if err != nil {
return err
}
return writeNewFile(dest, resp.Body)
}
func hasWebhook(req Request) bool {
webhookURL, ok := req.getFormValues()[webhookURL]
if !ok {
return false
}
return webhookURL != ""
}
func writeNewFile(fpath string, in io.Reader) error {
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return fmt.Errorf("%s: making directory for file: %v", fpath, err)
}
out, err := os.Create(fpath)
if err != nil {
return fmt.Errorf("%s: creating new file: %v", fpath, err)
}
defer out.Close()
err = out.Chmod(0644)
if err != nil && runtime.GOOS != "windows" {
return fmt.Errorf("%s: changing file mode: %v", fpath, err)
}
_, err = io.Copy(out, in)
if err != nil {
return fmt.Errorf("%s: writing file: %v", fpath, err)
}
return nil
}
func fileExists(name string) bool {
_, err := os.Stat(name)
return !os.IsNotExist(err)
}
func multipartForm(req Request) (*bytes.Buffer, string, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer writer.Close()
for filename, fpath := range req.getFormFiles() {
in, err := os.Open(fpath)
if err != nil {
return nil, "", fmt.Errorf("%s: opening file: %v", filename, err)
}
part, err := writer.CreateFormFile("files", filename)
if err != nil {
return nil, "", fmt.Errorf("%s: creating form file: %v", filename, err)
}
_, err = io.Copy(part, in)
if err != nil {
return nil, "", fmt.Errorf("%s: copying file: %v", filename, err)
}
}
for name, value := range req.getFormValues() {
if err := writer.WriteField(name, value); err != nil {
return nil, "", fmt.Errorf("%s: writing form field: %v", name, err)
}
}
return body, writer.FormDataContentType(), nil
}

View File

@@ -1,8 +0,0 @@
/*
Package gotenberg is a Go client for
interacting with a Gotenberg API.
For more complete usages, head to the documentation:
https://thecodingmachine.gotenberg.github.io.
*/
package gotenberg

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

@@ -0,0 +1,187 @@
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("cmd"),
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("cmd"),
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
}
// 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() error {
if cmd.ctx == nil {
return errors.New("nil context")
}
err := cmd.Start()
if err != nil {
return fmt.Errorf("start command: %w", err)
}
errChan := make(chan error, 1)
go func() {
errChan <- cmd.process.Wait()
}()
select {
case err = <-errChan:
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.Error(errProc.Error())
}
if err == nil {
return nil
}
return fmt.Errorf("unix process error: %w", err)
case <-cmd.ctx.Done():
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.Error(errProc.Error())
}
return 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)
}

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

@@ -0,0 +1,220 @@
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 Setpgid to be true")
}
}
func TestCommandContext(t *testing.T) {
for i, tc := range []struct {
ctx context.Context
expectErr bool
}{
{
ctx: nil,
expectErr: true,
},
{
ctx: context.TODO(),
},
} {
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
if err == nil && !cmd.process.SysProcAttr.Setpgid {
t.Fatalf("test %d: expected Setpgid to be true", i)
}
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 TestCmd_Start(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
expectErr bool
}{
{
cmd: Command(zap.NewNop(), "foo"),
expectErr: true,
},
{
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
},
} {
err := tc.cmd.Start()
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 TestCmd_Exec(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
timeout time.Duration
expectErr bool
}{
{
cmd: Command(zap.NewNop(), "foo"),
expectErr: true,
},
{
cmd: Command(zap.NewNop(), "foo"),
timeout: time.Duration(5) * time.Second,
expectErr: true,
},
{
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
timeout: time.Duration(5) * time.Second,
},
{
cmd: Command(zap.NewNop(), "sleep", "3"),
timeout: time.Duration(2) * time.Second,
expectErr: true,
},
} {
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
tc.cmd.ctx = ctx
}
err := tc.cmd.Exec()
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 TestCmd_pipeOutput(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
run bool
expectErr bool
}{
{
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
},
{
cmd: func() Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StdoutPipe()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectErr: true,
},
{
cmd: func() Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StderrPipe()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectErr: true,
},
{
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
},
} {
err := tc.cmd.pipeOutput()
if tc.run {
errStart := tc.cmd.process.Start()
if errStart != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
}
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 TestCmd_Kill(t *testing.T) {
for i, tc := range []struct {
cmd Cmd
expectErr bool
}{
{
cmd: Cmd{logger: zap.NewNop()},
},
{
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
},
{
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.process.Run()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
},
} {
err := tc.cmd.Kill()
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)
}
}
}

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

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

@@ -0,0 +1,109 @@
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
}
// 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
}
// 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
}
// 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
}
// 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
}
// 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
}
// 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
}
// 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)
}

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

@@ -0,0 +1,378 @@
package gotenberg
import (
"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_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_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_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_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_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_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_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)
}()
}
}

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 a module interface which exposes a method for creating a
// zap.Logger for 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)
}

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

@@ -0,0 +1,135 @@
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
}
// 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)
}

View File

@@ -1,104 +0,0 @@
package gotenberg
import (
"fmt"
"path/filepath"
"strconv"
)
// HTMLRequest facilitates HTML conversion
// with the Gotenberg API.
type HTMLRequest struct {
indexFilePath string
headerFilePath string
footerFilePath string
assetFilePaths []string
values map[string]string
}
// NewHTMLRequest create HTMLRequest.
func NewHTMLRequest(indexFilePath string) (*HTMLRequest, error) {
if !fileExists(indexFilePath) {
return nil, fmt.Errorf("%s: index file does not exist", indexFilePath)
}
return &HTMLRequest{indexFilePath: indexFilePath, values: make(map[string]string)}, nil
}
// SetWebhookURL sets webhookURL form field.
func (html *HTMLRequest) SetWebhookURL(webhookURL string) {
html.values[webhookURL] = webhookURL
}
// SetHeader sets header form file.
func (html *HTMLRequest) SetHeader(fpath string) error {
if !fileExists(fpath) {
return fmt.Errorf("%s: header file does not exist", fpath)
}
html.headerFilePath = fpath
return nil
}
// SetFooter sets footer form file.
func (html *HTMLRequest) SetFooter(fpath string) error {
if !fileExists(fpath) {
return fmt.Errorf("%s: footer file does not exist", fpath)
}
html.footerFilePath = fpath
return nil
}
// SetAssets sets assets form files.
func (html *HTMLRequest) SetAssets(fpaths []string) error {
for _, fpath := range fpaths {
if !fileExists(fpath) {
return fmt.Errorf("%s: file does not exist", fpath)
}
}
html.assetFilePaths = fpaths
return nil
}
// SetPaperSize sets paperWidth and paperHeight form fields.
func (html *HTMLRequest) SetPaperSize(size [2]float64) {
html.values[paperWidth] = fmt.Sprintf("%f", size[0])
html.values[paperHeight] = fmt.Sprintf("%f", size[1])
}
// SetMargins sets marginTop, marginBottom,
// marginLeft and marginRight form fields.
func (html *HTMLRequest) SetMargins(margins [4]float64) {
html.values[marginTop] = fmt.Sprintf("%f", margins[0])
html.values[marginBottom] = fmt.Sprintf("%f", margins[1])
html.values[marginLeft] = fmt.Sprintf("%f", margins[2])
html.values[marginRight] = fmt.Sprintf("%f", margins[3])
}
// SetLandscape sets landscape form field.
func (html *HTMLRequest) SetLandscape(isLandscape bool) {
html.values[landscape] = strconv.FormatBool(isLandscape)
}
func (html *HTMLRequest) getPostURL() string {
return "/convert/html"
}
func (html *HTMLRequest) getFormValues() map[string]string {
return html.values
}
func (html *HTMLRequest) getFormFiles() map[string]string {
files := make(map[string]string)
files["index.html"] = html.indexFilePath
files["header.html"] = html.headerFilePath
files["footer.html"] = html.footerFilePath
for _, fpath := range html.assetFilePaths {
files[filepath.Base(fpath)] = fpath
}
return files
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Request(new(HTMLRequest))
_ = ChromeRequest(new(HTMLRequest))
)

View File

@@ -1,47 +0,0 @@
package gotenberg
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/test"
)
func TestHTML(t *testing.T) {
c := &Client{Hostname: "http://localhost:3000"}
req, err := NewHTMLRequest(test.HTMLTestFilePath(t, "index.html"))
require.Nil(t, err)
err = req.SetHeader(test.HTMLTestFilePath(t, "header.html"))
require.Nil(t, err)
err = req.SetFooter(test.HTMLTestFilePath(t, "footer.html"))
require.Nil(t, err)
err = req.SetAssets([]string{
test.HTMLTestFilePath(t, "font.woff"),
test.HTMLTestFilePath(t, "img.gif"),
test.HTMLTestFilePath(t, "style.css"),
})
require.Nil(t, err)
req.SetPaperSize(A4)
req.SetMargins(NormalMargins)
req.SetLandscape(false)
dirPath, err := rand.Get()
require.Nil(t, err)
dest := fmt.Sprintf("%s/foo.pdf", dirPath)
err = c.Store(req, dest)
assert.Nil(t, err)
assert.FileExists(t, dest)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}
func TestConcurrentHTML(t *testing.T) {
for i := 0; i < 10; i++ {
go func() {
TestHTML(t)
}()
}
}

View File

@@ -1,117 +0,0 @@
package gotenberg
import (
"fmt"
"path/filepath"
"strconv"
)
// MarkdownRequest facilitates Markdown conversion
// with the Gotenberg API.
type MarkdownRequest struct {
indexFilePath string
markdownFilePaths []string
headerFilePath string
footerFilePath string
assetFilePaths []string
values map[string]string
}
// NewMarkdownRequest create MarkdownRequest.
func NewMarkdownRequest(indexFilePath string, markdownFilePaths []string) (*MarkdownRequest, error) {
if !fileExists(indexFilePath) {
return nil, fmt.Errorf("%s: index file does not exist", indexFilePath)
}
for _, fpath := range markdownFilePaths {
if !fileExists(fpath) {
return nil, fmt.Errorf("%s: markdown file does not exist", fpath)
}
}
return &MarkdownRequest{
indexFilePath: indexFilePath,
markdownFilePaths: markdownFilePaths,
values: make(map[string]string),
}, nil
}
// SetWebhookURL sets webhookURL form field.
func (markdown *MarkdownRequest) SetWebhookURL(webhookURL string) {
markdown.values[webhookURL] = webhookURL
}
// SetHeader sets header form file.
func (markdown *MarkdownRequest) SetHeader(fpath string) error {
if !fileExists(fpath) {
return fmt.Errorf("%s: header file does not exist", fpath)
}
markdown.headerFilePath = fpath
return nil
}
// SetFooter sets footer form file.
func (markdown *MarkdownRequest) SetFooter(fpath string) error {
if !fileExists(fpath) {
return fmt.Errorf("%s: footer file does not exist", fpath)
}
markdown.footerFilePath = fpath
return nil
}
// SetAssets sets assets form files.
func (markdown *MarkdownRequest) SetAssets(fpaths []string) error {
for _, fpath := range fpaths {
if !fileExists(fpath) {
return fmt.Errorf("%s: file does not exist", fpath)
}
}
markdown.assetFilePaths = fpaths
return nil
}
// SetPaperSize sets paperWidth and paperHeight form fields.
func (markdown *MarkdownRequest) SetPaperSize(size [2]float64) {
markdown.values[paperWidth] = fmt.Sprintf("%f", size[0])
markdown.values[paperHeight] = fmt.Sprintf("%f", size[1])
}
// SetMargins sets marginTop, marginBottom,
// marginLeft and marginRight form fields.
func (markdown *MarkdownRequest) SetMargins(margins [4]float64) {
markdown.values[marginTop] = fmt.Sprintf("%f", margins[0])
markdown.values[marginBottom] = fmt.Sprintf("%f", margins[1])
markdown.values[marginLeft] = fmt.Sprintf("%f", margins[2])
markdown.values[marginRight] = fmt.Sprintf("%f", margins[3])
}
// SetLandscape sets landscape form field.
func (markdown *MarkdownRequest) SetLandscape(isLandscape bool) {
markdown.values[landscape] = strconv.FormatBool(isLandscape)
}
func (markdown *MarkdownRequest) getPostURL() string {
return "/convert/markdown"
}
func (markdown *MarkdownRequest) getFormValues() map[string]string {
return markdown.values
}
func (markdown *MarkdownRequest) getFormFiles() map[string]string {
files := make(map[string]string)
files["index.html"] = markdown.indexFilePath
files["header.html"] = markdown.headerFilePath
files["footer.html"] = markdown.footerFilePath
for _, fpath := range markdown.markdownFilePaths {
files[filepath.Base(fpath)] = fpath
}
for _, fpath := range markdown.assetFilePaths {
files[filepath.Base(fpath)] = fpath
}
return files
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Request(new(MarkdownRequest))
_ = ChromeRequest(new(MarkdownRequest))
)

View File

@@ -1,54 +0,0 @@
package gotenberg
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMarkdown(t *testing.T) {
c := &Client{Hostname: "http://localhost:3000"}
req, err := NewMarkdownRequest(
test.MarkdownTestFilePath(t, "index.html"),
[]string{
test.MarkdownTestFilePath(t, "paragraph1.md"),
test.MarkdownTestFilePath(t, "paragraph2.md"),
test.MarkdownTestFilePath(t, "paragraph3.md"),
},
)
require.Nil(t, err)
err = req.SetHeader(test.MarkdownTestFilePath(t, "header.html"))
require.Nil(t, err)
err = req.SetFooter(test.MarkdownTestFilePath(t, "footer.html"))
require.Nil(t, err)
err = req.SetAssets([]string{
test.MarkdownTestFilePath(t, "font.woff"),
test.MarkdownTestFilePath(t, "img.gif"),
test.MarkdownTestFilePath(t, "style.css"),
})
require.Nil(t, err)
req.SetPaperSize(A4)
req.SetMargins(NormalMargins)
req.SetLandscape(false)
dirPath, err := rand.Get()
require.Nil(t, err)
dest := fmt.Sprintf("%s/foo.pdf", dirPath)
err = c.Store(req, dest)
assert.Nil(t, err)
assert.FileExists(t, dest)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}
func TestConcurrentMarkdown(t *testing.T) {
for i := 0; i < 10; i++ {
go func() {
TestMarkdown(t)
}()
}
}

View File

@@ -1,49 +0,0 @@
package gotenberg
import (
"fmt"
"path/filepath"
)
// MergeRequest facilitates merging PDF
// with the Gotenberg API.
type MergeRequest struct {
filePaths []string
values map[string]string
}
// NewMergeRequest create MergeRequest.
func NewMergeRequest(fpaths []string) (*MergeRequest, error) {
for _, fpath := range fpaths {
if !fileExists(fpath) {
return nil, fmt.Errorf("%s: file does not exist", fpath)
}
}
return &MergeRequest{filePaths: fpaths, values: make(map[string]string)}, nil
}
// SetWebhookURL sets webhookURL form field.
func (merge *MergeRequest) SetWebhookURL(webhookURL string) {
merge.values[webhookURL] = webhookURL
}
func (merge *MergeRequest) getPostURL() string {
return "/merge"
}
func (merge *MergeRequest) getFormValues() map[string]string {
return merge.values
}
func (merge *MergeRequest) getFormFiles() map[string]string {
files := make(map[string]string)
for _, fpath := range merge.filePaths {
files[filepath.Base(fpath)] = fpath
}
return files
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Request(new(MergeRequest))
)

View File

@@ -1,37 +0,0 @@
package gotenberg
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMerge(t *testing.T) {
c := &Client{Hostname: "http://localhost:3000"}
req, err := NewMergeRequest([]string{
test.PDFTestFilePath(t, "gotenberg.pdf"),
test.PDFTestFilePath(t, "gotenberg.pdf"),
})
require.Nil(t, err)
dirPath, err := rand.Get()
require.Nil(t, err)
dest := fmt.Sprintf("%s/foo.pdf", dirPath)
err = c.Store(req, dest)
assert.Nil(t, err)
assert.FileExists(t, dest)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}
func TestConcurrentMerge(t *testing.T) {
for i := 0; i < 10; i++ {
go func() {
TestMerge(t)
}()
}
}

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

@@ -0,0 +1,494 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"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"
)
func init() {
gotenberg.MustRegisterModule(API{})
}
// API is a module which provides an HTTP server. Other modules may add
// "multipart/form-data" routes, middlewares or health checks.
type API struct {
port int
readTimeout time.Duration
processTimeout time.Duration
writeTimeout time.Duration
rootPath string
traceHeader string
disableHealthCheckLogging bool
webhookAllowList *regexp.Regexp
webhookDenyList *regexp.Regexp
webhookErrorAllowList *regexp.Regexp
webhookErrorDenyList *regexp.Regexp
webhookMaxRetry int
webhookRetryMinWait time.Duration
webhookRetryMaxWait time.Duration
disableWebhook bool
multipartFormDataRoutes []MultipartFormDataRoute
externalMiddlewares []Middleware
healthChecks []health.CheckerOption
logger *zap.Logger
srv *echo.Echo
}
// MultipartFormDataRouter is a module interface which adds
// "multipart/form-data" routes to the API.
type MultipartFormDataRouter interface {
Routes() ([]MultipartFormDataRoute, error)
}
// MultipartFormDataRoute represents a "multipart/form-data" route. All routes
// uses the HTTP POST method.
type MultipartFormDataRoute struct {
// Path is the sub path of the route. Must start with a slash.
// Required.
Path string
// Handler is the function which handles the request.
// Required.
Handler func(ctx *Context) error
}
// MiddlewareProvider is a module interface which adds middlewares to the API.
type MiddlewareProvider interface {
Middlewares() ([]Middleware, error)
}
// MiddlewarePriority is a type which helps to determine the execution order of
// middlewares provided by the MiddlewareProvider modules.
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 {
// RunBeforeRouter tells if the middleware should run before the router
// process an HTTP request.
// Optional.
RunBeforeRouter bool
// Priority tells if the middleware should be positioned high or not in
// the middlewares chain.
// 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)
}
// 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.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")
fs.String("api-webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("api-webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("api-webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
fs.String("api-webhook-error-deny-list", "", "Set the denied URLs in case of an error for the webhook feature using a regular expression")
fs.Int("api-webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
fs.Duration("api-webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
fs.Duration("api-webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
fs.Bool("api-disable-webhook", false, "Disable the webhook feature")
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.MustDuration("api-read-timeout")
a.processTimeout = flags.MustDuration("api-process-timeout")
a.writeTimeout = flags.MustDuration("api-write-timeout")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
a.webhookAllowList = flags.MustRegexp("api-webhook-allow-list")
a.webhookDenyList = flags.MustRegexp("api-webhook-deny-list")
a.webhookErrorAllowList = flags.MustRegexp("api-webhook-error-allow-list")
a.webhookErrorDenyList = flags.MustRegexp("api-webhook-error-deny-list")
a.webhookMaxRetry = flags.MustInt("api-webhook-max-retry")
a.webhookRetryMinWait = flags.MustDuration("api-webhook-retry-min-wait")
a.webhookRetryMaxWait = flags.MustDuration("api-webhook-retry-max-wait")
a.disableWebhook = flags.MustBool("api-disable-webhook")
// 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(MultipartFormDataRouter))
if err != nil {
return fmt.Errorf("get multipart/form-data routers: %w", err)
}
routers := make([]MultipartFormDataRouter, len(mods))
for i, router := range mods {
routers[i] = router.(MultipartFormDataRouter)
}
for _, router := range routers {
routes, err := router.Routes()
if err != nil {
return fmt.Errorf("get routes: %w", err)
}
a.multipartFormDataRoutes = append(a.multipartFormDataRoutes, 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...)
}
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]MultipartFormDataRoute, len(a.multipartFormDataRoutes))
for _, route := range a.multipartFormDataRoutes {
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.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
}
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.WriteTimeout = a.writeTimeout
a.srv.HTTPErrorHandler = httpErrorHandler(a.traceHeader)
a.srv.Pre(
latencyMiddleware(),
rootPathMiddleware(a.rootPath),
traceMiddleware(a.traceHeader),
loggerMiddleware(a.logger, a.disableHealthCheckLogging),
)
for _, externalMiddleware := range a.externalMiddlewares {
if externalMiddleware.RunBeforeRouter {
a.srv.Pre(externalMiddleware.Handler)
continue
}
a.srv.Use(externalMiddleware.Handler)
}
hardTimeout := a.processTimeout + (time.Duration(5) * time.Second)
a.srv.GET(
fmt.Sprintf("%shealth", a.rootPath),
func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.processTimeout))
checker := health.NewChecker(checks...)
return func(echoCtx echo.Context) error {
health.NewHandler(checker).ServeHTTP(echoCtx.Response().Writer, echoCtx.Request())
return nil
}
}(),
timeoutMiddleware(hardTimeout),
)
formsGroup := a.srv.Group(
fmt.Sprintf("%sforms", a.rootPath),
contextMiddleware(
contextMiddlewareConfig{
traceHeader: a.traceHeader,
timeout: struct {
process time.Duration
write time.Duration
}{
process: a.processTimeout,
write: a.writeTimeout,
},
webhook: struct {
allowList *regexp.Regexp
denyList *regexp.Regexp
errorAllowList *regexp.Regexp
errorDenyList *regexp.Regexp
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
disable bool
}{
allowList: a.webhookAllowList,
denyList: a.webhookDenyList,
errorAllowList: a.webhookErrorAllowList,
errorDenyList: a.webhookErrorDenyList,
maxRetry: a.webhookMaxRetry,
retryMinWait: a.webhookRetryMinWait,
retryMaxWait: a.webhookRetryMaxWait,
disable: a.disableWebhook,
},
},
),
timeoutMiddleware(hardTimeout),
)
// Add routes from other modules.
for _, route := range a.multipartFormDataRoutes {
formsGroup.POST(
route.Path,
func(route MultipartFormDataRoute) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
err := route.Handler(ctx)
if err != nil {
return fmt.Errorf("handle request: %w", err)
}
return nil
}
}(route),
)
}
// As the listen method is blocking, run it in a goroutine.
go func() {
err := a.srv.Start(fmt.Sprintf(":%d", a.port))
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 {
duration := a.readTimeout + a.processTimeout + a.writeTimeout
if a.disableWebhook {
return duration
}
for i := 0; i < a.webhookMaxRetry; i++ {
// Yep... Golang does not allow int * time.Duration.
duration += a.webhookRetryMaxWait
}
return duration
}
// Interface guards.
var (
_ gotenberg.Module = (*API)(nil)
_ gotenberg.Provisioner = (*API)(nil)
_ gotenberg.Validator = (*API)(nil)
_ gotenberg.App = (*API)(nil)
_ gc.GarbageCollectorGraceDurationModifier = (*API)(nil)
)

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

@@ -0,0 +1,793 @@
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 ProtoMultipartFormDataRouter struct {
ProtoValidator
routes func() ([]MultipartFormDataRoute, error)
}
func (mod ProtoMultipartFormDataRouter) Routes() ([]MultipartFormDataRoute, 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 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
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{ ProtoMultipartFormDataRouter }{}
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() ([]MultipartFormDataRoute, 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{ ProtoMultipartFormDataRouter }{}
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() ([]MultipartFormDataRoute, 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 {
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{ ProtoMultipartFormDataRouter }{}
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() ([]MultipartFormDataRoute, error) {
return []MultipartFormDataRoute{{}}, 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("expected port %d but got %d", tc.expectPort, mod.port)
}
if !reflect.DeepEqual(mod.externalMiddlewares, tc.expectMiddlewares) {
t.Errorf("expected %+v, but got: %+v", tc.expectMiddlewares, mod.externalMiddlewares)
}
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 []MultipartFormDataRoute
middlewares []Middleware
expectErr bool
}{
{
port: 0,
expectErr: true,
},
{
port: 65536,
rootPath: "foo",
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
{
Path: "",
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
{
Path: "foo",
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
{
Path: "/foo",
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
{
Path: "/foo",
Handler: func(_ *Context) error { return nil },
},
{
Path: "/foo",
Handler: func(_ *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: []MultipartFormDataRoute{
{
Path: "/foo",
Handler: func(_ *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,
multipartFormDataRoutes: 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.multipartFormDataRoutes = []MultipartFormDataRoute{
{
Path: "/foo",
Handler: func(ctx *Context) error {
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
}
return nil
},
},
{
Path: "/bar",
Handler: func(_ *Context) error { return errors.New("foo") },
},
}
mod.externalMiddlewares = []Middleware{
{
RunBeforeRouter: true,
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,
multipartFormDataRoutes: []MultipartFormDataRoute{
{
Path: "/foo",
Handler: func(_ *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) {
for i, tc := range []struct {
mod API
expect time.Duration
}{
{
mod: API{
readTimeout: time.Duration(1) * time.Second,
processTimeout: time.Duration(1) * time.Second,
writeTimeout: time.Duration(1) * time.Second,
disableWebhook: true,
},
expect: time.Duration(3) * time.Second,
},
{
mod: API{
readTimeout: time.Duration(1) * time.Second,
processTimeout: time.Duration(1) * time.Second,
writeTimeout: time.Duration(1) * time.Second,
webhookMaxRetry: 5,
webhookRetryMaxWait: time.Duration(5) * time.Second,
},
expect: time.Duration(28) * time.Second,
},
} {
actual := tc.mod.GraceDuration()
if actual != tc.expect {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expect, actual)
}
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.Module = (*ProtoValidator)(nil)
_ MultipartFormDataRouter = (*ProtoMultipartFormDataRouter)(nil)
_ gotenberg.Module = (*ProtoMultipartFormDataRouter)(nil)
_ gotenberg.Validator = (*ProtoMultipartFormDataRouter)(nil)
_ MiddlewareProvider = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Module = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Validator = (*ProtoMiddlewareProvider)(nil)
_ HealthChecker = (*ProtoHealthChecker)(nil)
_ gotenberg.Module = (*ProtoHealthChecker)(nil)
_ gotenberg.Validator = (*ProtoHealthChecker)(nil)
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
)

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

@@ -0,0 +1,319 @@
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.
// See https://github.com/gotenberg/gotenberg/issues/228.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
filename, _, err := transform.String(t, strings.ToLower(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
}
// MockContext is a helper for tests.
//
// ctx := &api.MockContext{Context: &api.Context{}}
type MockContext struct {
*Context
}
// SetDirPath sets the context's working directory path.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetDirPath("/foo")
func (ctx *MockContext) SetDirPath(path string) {
ctx.dirPath = path
}
// SetValues sets the values.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetValues(map[string][]string{
// "url": {
// "foo",
// },
// })
func (ctx *MockContext) SetValues(values map[string][]string) {
ctx.values = values
}
// SetFiles sets the files.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetFiles(map[string]string{
// "foo": "/foo",
// })
func (ctx *MockContext) SetFiles(files map[string]string) {
ctx.files = files
}
// SetCancelled sets if the context is cancelled or not.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetCancelled(true)
func (ctx *MockContext) SetCancelled(cancelled bool) {
ctx.cancelled = cancelled
}
// OutputPaths returns the registered output paths.
// ctx := &api.MockContext{Context: &api.Context{}}
// outputPaths := ctx.OutputPaths()
func (ctx MockContext) OutputPaths() []string {
return ctx.outputPaths
}

View File

@@ -0,0 +1,373 @@
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 TestMockContext_SetDirPath(t *testing.T) {
mock := &MockContext{&Context{}}
mock.SetDirPath("/foo")
actual := mock.dirPath
expect := "/foo"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestMockContext_SetValues(t *testing.T) {
mock := &MockContext{&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 TestMockContext_SetFiles(t *testing.T) {
mock := &MockContext{&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 TestMockContext_SetCancelled(t *testing.T) {
mock := &MockContext{&Context{}}
mock.SetCancelled(true)
actual := mock.cancelled
if !actual {
t.Errorf("expected %t but got %t", true, actual)
}
}
func TestMockContext_OutputPaths(t *testing.T) {
mock := MockContext{
&Context{
outputPaths: []string{"/foo"},
},
}
actual := mock.OutputPaths()
expect := []string{"/foo"}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, 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

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