Compare commits

...

288 Commits

Author SHA1 Message Date
Julien Neuhart
674f1de183 chore(deps): update Go dependencies 2025-03-28 12:20:27 +01:00
Julien Neuhart
368c4fa1a7 chore(logging): better description for --log-enable-gcp-fields flag [skip ci] 2025-03-28 12:17:18 +01:00
Julien Neuhart
c737579b7f ci(lint): fix golangci-lint action version 2025-03-26 20:11:16 +01:00
Julien Neuhart
0b7c51a323 chore(deps): update golangci-lint to v2 2025-03-26 20:08:34 +01:00
Julien Neuhart
e7c77b7bf3 ci(continous-delivery): fix wrong version 2025-03-26 11:38:56 +01:00
Julien Neuhart
6c727b7e51 refactor(logging): use log-enable-gcp-fields 2025-03-26 11:37:48 +01:00
Hector Zarate
a9754d6a1c fix(logging): correctly map GCP structured log keys (#1159)
* fix: correctly map GCP structured log keys

* Update pkg/modules/logging/logging.go

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-03-26 10:28:58 +01:00
Julien Neuhart
218052d5dd chore(tests): fix typo [skip ci] 2025-03-22 18:05:47 +01:00
Julien Neuhart
f112cb748f ci(tests): add go test timeout 2025-03-22 18:02:26 +01:00
Julien Neuhart
67a65c66b6 ci(edge): fix wrong repository and tag 2025-03-21 16:52:57 +01:00
Julien Neuhart
cd6add14a0 ci(build-test-push): add missing Cloud Run tags 2025-03-21 14:15:44 +01:00
Julien Neuhart
33f9f1ddf4 docs(test): add TODOs [ci skip] 2025-03-21 14:14:00 +01:00
Julien Neuhart
2131de77b0 ci(test): no concurrency 2025-03-21 14:09:34 +01:00
Julien Neuhart
0febb60a3f test: add integration tests 2025-03-21 14:09:34 +01:00
Julien Neuhart
820b914291 feat(formatting): add prettier-plugin-sh 2025-03-21 14:09:34 +01:00
Julien Neuhart
536d3ee70d feat(formatting): add prettier 2025-03-21 14:09:34 +01:00
Julien Neuhart
2544d82486 docs(README): add GitHub Trending badge [ci skip] 2025-03-18 10:08:13 +01:00
Julien Neuhart
ea6854fadc docs(godoc): add link to issue 1149 [ci skip] 2025-03-18 08:55:09 +01:00
Julien Neuhart
147e3b1173 docs(godoc): improve the UpdateIndexes godoc 2025-03-17 14:51:24 +01:00
Julien Neuhart
8252142e65 chore(deps): update Go dependencies 2025-03-17 14:01:07 +01:00
Julien Neuhart
6e2270632e chore(logging): remove unit tests - integration tests are inc anyway 2025-03-17 13:56:48 +01:00
Julien Neuhart
7d14546d3b feat(logging): add GCP severity mapping 2025-03-17 13:56:48 +01:00
Julien Neuhart
b31024c362 feat(libreoffice): add updateIndexes form field 2025-03-17 13:32:26 +01:00
Julien Neuhart
85eaef05ad fix(deps): update Go dependencies - chromedp ERROR: unhandled page event *page.EventFrameStartedNavigating 2025-03-06 08:55:57 +01:00
Julien Neuhart
16e4c10862 chore(deps): update Go dependencies 2025-03-05 08:57:48 +01:00
Julien Neuhart
904e0f61a8 fix(pdfengines): split and read metadata pdf engines calls 2025-03-04 18:08:57 +01:00
Zdravko
70953aa66e fix(qpdf): allow warnings (#1135)
* Optionally allow warnings in QPDF operations

* warnings are not errors by default

* Apply suggestions from code review

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* fix some merge artifacts

* follow the args convention

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-02-28 10:43:38 +01:00
Julien Neuhart
d41de5d270 docs(README): switch to new short description [skip ci] 2025-02-21 11:55:12 +01:00
Julien Neuhart
d8603f4f34 feat: update short description 2025-02-21 11:22:22 +01:00
Julien Neuhart
b13a8094ea ci(continous-delivery): remove wrong outputs 2025-02-19 19:42:30 +01:00
Kyohei Fukuda
28e07bad54 docs(README): add pdfme sponsor (#1131) 2025-02-14 14:28:53 +01:00
Julien Neuhart
81cc483166 test: fix issues with go 1.24 2025-02-13 09:21:38 +01:00
Julien Neuhart
8d7cc55c28 chore(go): upgrade to 1.24 2025-02-13 08:44:58 +01:00
Julien Neuhart
083e0e307a chore(deps): update Go dependencies 2025-02-12 16:24:16 +01:00
Julien Neuhart
80f3f89a89 fix(pdfcpu): correct sorting for the output paths of the split method 2025-02-12 11:47:45 +01:00
Julien Neuhart
3dc8c18849 fix(fs): get file by ext sorted by mod time 2025-02-11 17:00:18 +01:00
Julien Neuhart
0a6a97cf07 ci(merge): fix thecodingmachine target 2025-02-07 09:59:08 +01:00
Julien Neuhart
7676d7a239 ci(build-push): fix typo in tag 2025-02-07 09:47:47 +01:00
Julien Neuhart
679715f896 chore(deps): update Go dependencies 2025-02-07 08:43:44 +01:00
Julien Neuhart
d5aa880336 ci(build-push): fix typo in tag name 2025-02-07 08:41:04 +01:00
Julien Neuhart
08daa86766 test(api): add missing context 2025-02-06 09:07:09 +01:00
Julien Neuhart
f0c1952dfe ci(lint): fix missing error handling 2025-02-06 08:51:38 +01:00
Julien Neuhart
198d9115cc chore(deps): switch to github.com/mholt/archives 2025-02-05 17:00:48 +01:00
Julien Neuhart
6b0c01bd3e chore(deps): update Go dependencies 2025-02-05 09:51:58 +01:00
Julien Neuhart
a1848d3c75 feat(conf): override flags' values with their corresponding environment variables' values 2025-02-05 09:51:38 +01:00
Julien Neuhart
a1596f7c62 feat(api): add debug route 2025-02-04 10:20:29 +01:00
Julien Neuhart
740e701ad3 chore(deps): update Go dependencies 2025-02-03 11:53:51 +01:00
Julien Neuhart
71d571a25c fix(Makefile): wrong arg for --pdfengines-convert-engines 2025-02-03 11:36:05 +01:00
Julien Neuhart
2293300182 ci(merge): fix alternate registry target image #2 2025-01-31 15:58:06 +01:00
Julien Neuhart
85c7f2806f ci(merge): fix alternate registry target image 2025-01-31 15:38:14 +01:00
dependabot[bot]
27ecb4fe49 chore(deps): bump codecov/codecov-action from 4 to 5
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-31 14:43:21 +01:00
Julien Neuhart
7afeefe84f ci(actions): add local actions 2025-01-31 14:39:54 +01:00
Julien Neuhart
651922194f chore(deps): update Go dependencies 2025-01-29 20:48:43 +01:00
Julien Neuhart
1d1b4fb04d feat(pdfengines): add flatten form field to split route 2025-01-28 17:15:14 +01:00
Peter Chakalov
b418f1eb05 feat(pdfengines): add support for flattening annotations (#1105)
* initial changes

* Add tests

* Fix edge case when we need to regenerate appearances

* Fix comments

* Add missing comment

* Add missing comment

* Add missing comment

* Add flatten option to the merge route

* Add flatten option to the libreoffice convert route

* Add flatten option to the chromium convert route

* Revert "Add flatten option to the chromium convert route"

This reverts commit cdab8b4e6b.

* Ignore lint false positives

* Add missing tests

* Add flatten route tests

* Replace input instead of creating a new file

* create copy before flatten in tests

---------

Co-authored-by: Peter Chakalov <peter.chakalov@abraxa.com>
2025-01-28 14:38:27 +01:00
Julien Neuhart
a9b44ee39e chore(lint): remove rule G115 (gosec) exception 2025-01-27 16:05:52 +01:00
Julien Neuhart
cb48355898 fix(ci): missing checkout step on reusable workflow delete 2025-01-24 10:46:31 +01:00
Julien Neuhart
6ce47e3a20 ci(workflows): cleanup PR Docker images 2025-01-24 10:27:04 +01:00
Julien Neuhart
09e511bc49 ci(workflows): use sub-actions 2025-01-24 10:27:04 +01:00
Julien Neuhart
66ade27383 docs(README): fix continuous integration badge 2025-01-23 20:18:18 +01:00
Julien Neuhart
d96058d589 fix(Dockerfile): use latest Chromium for armhf 2025-01-23 20:04:13 +01:00
Julien Neuhart
8631a15933 ci(comment): remove comment about arm64 testing 2025-01-23 20:04:13 +01:00
Julien Neuhart
c0bf81ad7c ci(workflows): multiple jobs multiple architectures build and push 2025-01-23 18:03:23 +01:00
Julien Neuhart
8fd1fa203e fix(Dockerfile): remove apt-get upgrade -yqq from armv7 Chromium install 2025-01-17 18:01:44 +01:00
Julien Neuhart
51b907bcf5 chore(deps): update Go dependencies 2025-01-17 15:25:17 +01:00
Julien Neuhart
ecb5d974c5 chore(Dockerfile): add apt-get upgrade -yqq 2025-01-17 15:24:37 +01:00
Julien Neuhart
19c91aff19 feat(api): add root, favicon and version routes to basic auth middleware if enabled 2025-01-17 14:08:01 +01:00
Julien Neuhart
8e0cc7dd2f fix(typo): root route HTML 2025-01-17 10:00:08 +01:00
Khiet Tam Nguyen
6478528ebc feat(api): add dummy root route pointing to docs (#1099)
* feat(api): added dummy root route pointing to docs

* test(api): root request test added

* feat(api): added NoContent return for favicon

from @gulien

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* test(api): added favicon

* lint(api): fix format

* refactor(api): use %s for favicon.ico for consistency with /health

* test(api): added new recorder for each check

* docs(api): consistent comment for favicon

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-01-17 09:58:58 +01:00
Julien Neuhart
3e3b24f611 ci(workflows): add permissions 2025-01-11 11:28:27 +01:00
Julien Neuhart
6dc7d70a1f ci(continous integration): add concurrency to avoid concurrent runs 2025-01-11 11:17:50 +01:00
Julien Neuhart
a6f4c5651c feat(chromium): set the default value of the flag --chromium-restart-after to 10 2025-01-11 11:08:19 +01:00
Julien Neuhart
291a4d39fe chore(deps): upgrade to golangci-lint v1.63.4 2025-01-10 17:02:16 +01:00
Julien Neuhart
e639a1dd73 chore(deps): update Go dependencies 2025-01-10 16:59:01 +01:00
Julien Neuhart
e3a961623b feat(logging): add new field log_type with access or application as values 2025-01-10 16:58:26 +01:00
Hugo Tacla
1ca27fdba2 fix(pdfengines): wrong variable name (#1093) 2025-01-08 19:24:43 +01:00
Julien Neuhart
f72428056a chore(code): fmt 2025-01-08 17:15:30 +01:00
Hugo Tacla
bdb3f11ac7 fix(typo): medata -> metadata (#1092)
* Update pdfengines.go

* Update pdfengines_test.go

* Update multi.go

* Update multi_test.go
2025-01-08 17:14:53 +01:00
Julien Neuhart
2f69af1a52 chore(LICENSE): remove year 2025-01-06 09:17:13 +01:00
Julien Neuhart
984cf77ee0 fix(chromium): info log into debug log + slight improvement of the killing of stuck processes 2025-01-02 17:22:13 +01:00
Julien Neuhart
1762596fce chore(dep)s: update dependencies 2025-01-02 17:18:53 +01:00
Julien Neuhart
910eb9b770 feat(state): improve clean up when LibreOffice and Chromium are restarted 2024-12-25 17:04:25 +01:00
Julien Neuhart
16807bd57f fix(split): wrong output paths when converting to PDF/A & PDF/UA 2024-12-23 10:04:24 +01:00
Julien Neuhart
8bc29ad92d feat(split): add splitUnify form field 2024-12-21 12:45:20 +01:00
Julien Neuhart
51a913a5e4 chore(deps): update Go depencies 2024-12-20 16:16:32 +01:00
Julien Neuhart
c30da805b3 feat(pdfengines): add split feature 2024-12-20 16:16:32 +01:00
Julien Neuhart
42ee593708 chore(pdfengines): add stubs for common workflows 2024-11-25 09:28:42 +01:00
Julien Neuhart
1b49b35365 fix(exiftool): convert interface{} array to string array 2024-11-21 09:55:10 +01:00
Julien Neuhart
333ab7066c chore(libreoffice): remove non-relevant comment about old gc module 2024-11-19 14:25:07 +01:00
Julien Neuhart
66dbdc09b5 chore(deps): update Go dependencies 2024-11-18 09:39:32 +01:00
Tam Nguyen
79f396f6ff feat(chromium): add GenerateDocumentOutline option 2024-11-18 08:58:26 +01:00
Julien Neuhart
62ba4877ac chore(deps): update golangci-lint 2024-11-05 11:53:20 +01:00
Julien Neuhart
e65a5e6d22 chore(deps): update Go dependencies 2024-11-05 11:44:29 +01:00
Julien Neuhart
711b43070d feat(chromium): add failOnResourceLoadingFailed and failOnResourceHttpStatusCodes form fields 2024-11-05 11:44:01 +01:00
Julien Neuhart
b2f957d1d8 chore(pdfengines): update new flags with a description of the empty value behavior 2024-10-14 09:24:42 +02:00
Julien Neuhart
249745f256 feat(pdfengines): one flag per PDF engine method for a more granular selection of PDF engines 2024-10-13 11:59:26 +02:00
Julien Neuhart
8ff9d3bcf1 feat(chromium): add scope to extraHttpHeaders 2024-10-11 13:45:07 +02:00
Julien Neuhart
99c328c302 fix(ci): LINUX_AMD64_RELEASE is no more ignored thanks to curly braces 2024-10-10 13:27:36 +02:00
Julien Neuhart
8a9f0a245e chore(api): improve --api-body-limit flag description to include examples 2024-10-08 19:58:08 +02:00
Julien Neuhart
9daecc127a chore(deps): update Go dependencies 2024-10-08 19:58:08 +02:00
Julien Neuhart
119bba4f04 feat(api): add flag --api-bind-ip 2024-10-08 19:58:08 +02:00
Julien Neuhart
bf205c579d fix(pdfcpu): switch to CLI 2024-10-06 18:32:09 +02:00
Julien Neuhart
aa57b17254 chore(deps): update Go dependencies 2024-10-06 09:52:48 +02:00
Julien Neuhart
7df786c5c6 fix(webhook): retrieve values from echo.Context before it get recycled 2024-10-05 14:24:22 +02:00
Julien Neuhart
b315464e98 chore(deps): update Noto Color Emoji to Unicode 16.0 2024-10-05 11:46:00 +02:00
Julien Neuhart
1252ea076b feat(chromium): skipNetworkIdleEvent is now true by default 2024-09-29 18:04:37 +02:00
Julien Neuhart
d970e446a8 chore(deps): update Go dependencies 2024-09-29 17:47:51 +02:00
Julien Neuhart
fe40f3727b feat(api): add --api-body-limit flag that set the body limit for multipart/form-data requests 2024-09-29 17:46:20 +02:00
Julien Neuhart
10a290b065 chore(libreoffice): improve HTTP error messages 2024-09-29 13:19:44 +02:00
Julien Neuhart
0abb11b42a chore(deps): update Go dependencies 2024-09-21 09:52:48 +02:00
Julien Neuhart
1eb6eb729a chore(downloadFrom): add godoc for issue https://github.com/golang/go/issues/69551 2024-09-20 21:06:11 +02:00
Julien Neuhart
e371a4a3c3 fix: add DOCKER_REGISTRY for tests image 2024-09-20 21:06:11 +02:00
Julien Neuhart
f2b6bd3d4b feat(api): add 'downloadForm' form field 2024-09-20 21:06:11 +02:00
Julien Neuhart
d21f87b543 feat(ci): add snapshot release 2024-09-20 21:06:11 +02:00
Julien Neuhart
b8cdee4f45 fix(cmd): failing test 2024-09-20 20:58:17 +02:00
Julien Neuhart
b06d7445d9 feat(libreoffice): add password form field for converting protected document 2024-09-20 20:36:45 +02:00
Julien Neuhart
2aa9cceb32 fix(README): wrong link to thecodingmachine/gotenberg repository 2024-09-18 07:57:22 +02:00
Julien Neuhart
ae2a2a8a07 chore: improve README 2024-09-17 16:47:52 +02:00
Julien Neuhart
286555411b chore(supervisor): add a comment about an unlikely issue 2024-09-17 16:47:41 +02:00
Julien Neuhart
3c42aec180 chore(deps): update Go dependencies 2024-09-13 16:47:31 +02:00
Julien Neuhart
a203ae00c6 fix(cmd): leaking error logs if reader already closed 2024-09-13 16:47:03 +02:00
Julien Neuhart
68784616a4 chore(godoc): removing dots 2024-09-13 16:44:12 +02:00
Julien Neuhart
5cd8c6a5da feat(api): add HEAD method for health route (#963) 2024-09-07 17:31:43 +02:00
Julien Neuhart
a3647fea8a fix(chromium): increase the scope of loading failed errors (#962) 2024-09-07 15:54:01 +02:00
Julien Neuhart
300e73f07d chore(deps): update Go dependencies 2024-09-05 09:23:33 +02:00
Gabriel Vasile
03ced23f05 fix(deps): download go1.23 toolchain not available (#957) 2024-09-05 09:22:59 +02:00
Julien Neuhart
da8eeb5d8f fix(golangci-lint): false positive and remove deprecated exportloopref linter 2024-09-03 09:59:40 +02:00
Julien Neuhart
16cf02896f chore(deps): update Go dependencies + Golang to 1.23 2024-09-03 09:45:36 +02:00
Julien Neuhart
c9f10d248e fix(libreoffice): use default options in PDF engines method - resolves #939 2024-08-11 14:53:05 +02:00
Julien Neuhart
3297e41289 chore(deps): update Golang dependencies 2024-07-29 10:22:08 +02:00
Julien Neuhart
4e1b347797 feat(libreoffice): retry on core dumped (#930) 2024-07-23 14:21:09 +02:00
Julien Neuhart
4602332dbb chore(Dockerfile): remove legacy alerts 2024-07-19 15:16:30 +02:00
Julien Neuhart
e1f69ee533 chore(deps): update Golang dependencies 2024-07-18 14:35:38 +02:00
Julien Neuhart
d90e431b5b fix(libreoffice): add PDFUACompliance arg 2024-07-11 15:14:50 +02:00
Julien Neuhart
45a9eca0ce chore(sponsors): new Zolsec logo 2024-07-04 15:58:24 +02:00
Julien Neuhart
af202f4871 chore(sponsors): move down sponsorship link 2024-07-04 15:04:57 +02:00
Julien Neuhart
7b32fa35f3 feat(sponsors): add Zolsec sponsorship 2024-07-04 15:03:30 +02:00
Julien Neuhart
0c40b230dc chore(libreoffice): fix wrong comment on quality option 2024-07-03 10:35:23 +02:00
Maxime Lapointe
2baba67bfc feat: eagerly auto restart processes (#916)
* Fix auto-restart's initial number of requests
It would need 1 less requests on the first restart
The number of request was technically off by one after the first restart

* Supervisor now eagerly restarts processes
When the maxReqLimit is reached, restart the process in the background

* Update pkg/gotenberg/supervisor.go

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2024-07-02 18:29:57 +02:00
Julien Neuhart
b1e69a9059 feat: add FUNDING.yml 2024-07-02 17:07:42 +02:00
Julien Neuhart
f87362ec1b feat(chromium): HTTP Bad Request on net::ERR_CONNECTION_REFUSED 2024-07-01 13:40:12 +02:00
Julien Neuhart
84dc57373a feat(libreoffice): add many more options for conversions 2024-06-30 21:34:57 +02:00
Julien Neuhart
b3f4a02658 chore(chromium): add missing tests on default options 2024-06-30 20:40:40 +02:00
Julien Neuhart
50304dbefe chore(chromium): remove some 'Optional' comments 2024-06-27 11:53:18 +02:00
Julien Neuhart
bf01af0bf3 chore(deps): update Golang dependencies 2024-06-27 11:32:49 +02:00
Julien Neuhart
0e3fe3463b chore(stale): recude number of days of inactivity 2024-06-21 20:39:34 +02:00
Julien Neuhart
3956763825 fix(api): basic auth only for modules' routes 2024-06-21 20:38:30 +02:00
maniack
d6fe895f6e feat(libreoffice): add exportNotesInMargin form field (#904)
* ExportNotesInMargin allows to export comments in margin

https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html

* Resolve requested changes

Move ExportNotesInMargin above LosslessImageCompression
Remove overhead ExportNotes=true
2024-06-17 08:31:42 +02:00
Julien Neuhart
697fa08d8b chore(godoc): add breaklines 2024-06-13 11:14:24 +02:00
garethjudson
669a35afef feat(libreoffice): add image options (#898)
* feat: libre office image options

* refactor: PR comment changes

* fix: change lossLessImageCompression default to false

---------

Co-authored-by: Gareth Judson <gareth.judson@delwp.vic.gov.au>
2024-06-13 10:28:36 +02:00
Julien Neuhart
b1d94a3845 feat(chromium): add user agent override 2024-06-12 20:51:10 +02:00
Julien Neuhart
06869cf491 chore(deps): update Golang dependencies 2024-06-12 20:51:10 +02:00
Julien Neuhart
ed67d8514c chore(deps): update golangci-lint 2024-06-04 17:02:05 +02:00
Julien Neuhart
07ec2f3d7b chore(deps): update Golang dependencies 2024-06-04 17:00:44 +02:00
Jonas Geiler
73d02e85c4 refactor(api): improved tests and test messages
Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2024-05-27 14:11:37 +02:00
Jonas Geiler
c1cf1eb37a chore: removed TODO.txt from .gitignore file 2024-05-27 14:11:37 +02:00
Jonas Geiler
7aecc94049 refactor(api): re-use error when starting the API server
Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2024-05-27 14:11:37 +02:00
Jonas Geiler
b55fbfeb8e refactor(api): updated tls validation error message
Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2024-05-27 14:11:37 +02:00
Jonas Geiler
20b8991fa4 feat(api): added tls/ssl support 2024-05-27 14:11:37 +02:00
Julien Neuhart
e50de4201d chore(deps): update Go dependencies 2024-05-22 09:44:19 +02:00
Julien Neuhart
ba2b876ee4 chore(deps): update golangci-lint 2024-05-22 09:43:41 +02:00
dependabot[bot]
9b1399c9d5 chore(deps): bump golangci/golangci-lint-action from 5 to 6
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 5 to 6.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 07:41:40 +02:00
Julien Neuhart
020a2f00c2 fix(pdfengine): PDFcpu -> pdfcpu 2024-05-02 10:04:25 +02:00
Julien Neuhart
8713946f35 fix(ci): codecov 2024-04-29 13:03:01 +02:00
IMATIASL\jaime.pineiro
bed8c25e40 feat(logging): Write log time in local timezone rather than UTC 2024-04-29 10:18:27 +02:00
dependabot[bot]
7c51a09068 chore(deps): bump golangci/golangci-lint-action from 4 to 5
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 4 to 5.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-29 09:48:09 +02:00
Julien Neuhart
0f0a6ce042 feat(chromium): add clip options to screenshot routes 2024-04-25 17:24:23 +02:00
Julien Neuhart
a890968ee4 chore(deps): update Go dependencies 2024-04-25 16:06:28 +02:00
Julien Neuhart
2267c5be00 feat(api): add version route 2024-04-17 09:55:38 +02:00
Julien Neuhart
e853cfb98a chore(deps): update Go dependencies 2024-04-17 09:08:43 +02:00
Julien Neuhart
f89a0db471 chore(deps): update Go dependencies 2024-04-11 09:57:55 +02:00
Julien Neuhart
1d66b43da1 feat(chromium): set cookies 2024-04-10 13:43:31 +02:00
Julien Neuhart
75c4a4f253 feat(api): add basic auth support 2024-04-05 17:20:34 +02:00
Julien Neuhart
6a9c95b3b1 chore(deps): update Go dependencies 2024-04-05 14:53:50 +02:00
Julien Neuhart
c11a2d0081 chore(golangci-lint): remove unused linters 2024-04-03 11:55:24 +02:00
Martin
43a13ff2c5 feat(libreoffice): add singlePageSheets option 2024-03-25 15:46:48 +01:00
Julien Neuhart
8454d163eb chore(golangci-lint): add missing EOF 2024-03-23 20:01:26 +01:00
Julien Neuhart
4a11f6a39f chore(deps): update Go dependencies 2024-03-23 16:50:07 +01:00
Julien Neuhart
c63235cd3d feat(exiftool): handle boolean metadata 2024-03-23 16:47:28 +01:00
Julien Neuhart
f4ce196a34 fix(pdfengines): return a 400 Bad Request if no metadata to write 2024-03-23 16:47:28 +01:00
Julien Neuhart
e644b27201 feat(pdfengines): add write metadata option on merge route 2024-03-23 16:47:28 +01:00
Julien Neuhart
d2b14582ee feat(pdfengines): add write metadata route 2024-03-23 16:47:28 +01:00
Julien Neuhart
ca9194fd96 fix(pdfengine): missing mock tests 2024-03-23 16:47:28 +01:00
Julien Neuhart
773d3ab13c feat(pdfengines): add read metadata route 2024-03-23 16:47:28 +01:00
Julien Neuhart
a02a4a07a5 chore(golangci-lint): update conf 2024-03-23 16:47:28 +01:00
Julien Neuhart
0a8625227f feat(exiftool): refactor read write metadata 2024-03-23 16:47:28 +01:00
Julien Neuhart
dc613aa2bf chore(deps): update Go dependencies 2024-03-23 16:47:28 +01:00
Julien Neuhart
2008ac5663 chore: improve the godoc of all PDF engine packages 2024-03-23 16:47:28 +01:00
Julien Neuhart
da326f1925 fix(Dockerfile): missing ExitTool in RUN comment 2024-03-23 16:47:28 +01:00
Piyush Srivastava
71911cb1a7 feat(ExifTool): add capability to overwrite metadata of the PDF generated (#776)
* add new metadata functions to pdf engine interface

* add exiftool module with relevant test cases

* use exiftool to overwrite metadata in libreoffice

* use exiftool to overwrite metadata in chromium

* fix linter issues

* fix more linter issues

* more test cases for better coverage

* remove utils

* minor changes

* remove metadata from pdfoptions

* correct indentation

* read/write metadata one file at a time.
2024-03-23 16:47:28 +01:00
Julien Neuhart
31e7582216 feat(libreoffice): improve ExportFormFields godoc 2024-03-22 15:50:34 +01:00
Martin
91f63047e0 feat(libreoffice): add exportFormFields option 2024-03-22 15:50:34 +01:00
Andrea
c9aa4938f4 feat(libreoffice): add more extensions according to online docs (#841)
* #840: added more extensions according to online docs @ libreoffice

* #840: align test

---------

Co-authored-by: Andrea Esposito <a.esposito@hyperborea.com>
2024-03-19 15:46:36 +01:00
Julien Neuhart
b9e6a38037 fix(tests): test script now returns the correct exit code 2024-03-19 14:23:34 +01:00
Julien Neuhart
eb054146d2 feat(chromium): support more units for paper size and margins 2024-03-17 14:34:28 +01:00
Julien Neuhart
a780373cfc chore(go): update to 1.22 2024-03-15 16:23:27 +01:00
Julien Neuhart
5ad90ccdb8 fix: file rename now correctly works for zip archive 2024-03-15 16:12:18 +01:00
Julien Neuhart
ef97d81ca8 fix(libreoffice): wrong number of file extensions 2024-03-15 11:37:34 +01:00
Julien Neuhart
c9f7b642c5 fix(api): wrong expected message in tests 2024-03-15 11:25:55 +01:00
Julien Neuhart
770962be3f chore(deps): update golangci-lint 2024-03-15 11:22:32 +01:00
Julien Neuhart
d6c857f4a8 chore(libreoffice): disable PDF tagging by default - for consistency with Chromium 2024-03-15 11:11:51 +01:00
Julien Neuhart
7b7ffb427d fix(chromium): disable PDF tagging 2024-03-15 10:56:35 +01:00
Julien Neuhart
b80318d1b6 chore(deps): update Go dependencies 2024-03-15 09:50:19 +01:00
dependabot[bot]
3997d69e49 chore(deps): bump google.golang.org/protobuf from 1.32.0 to 1.33.0
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-14 07:10:17 +01:00
Julien Neuhart
4af41b33c0 chore: update Go dependencies 2024-03-04 11:33:10 +01:00
Julien Neuhart
0f8bbead66 fix(api): do not catch gotenberg.ErrPdfEngineMethodNotSupported as it may hides other errors 2024-03-04 11:17:36 +01:00
Julien Neuhart
804fcff5b6 fix(chromium): remove tagged PDF by default 2024-03-04 10:28:54 +01:00
Julien Neuhart
d18e6920fc chore(libreoffice): order ext list alphabetically 2024-03-04 10:09:42 +01:00
eMerzh
c95174ff11 feat(libreoffice): add macOS *.key support 2024-02-26 16:47:52 +01:00
Julien Neuhart
c70329c22b chore(api): improve gotenberg.ErrPdfFormatNotSupported HTTP message 2024-02-24 14:55:48 +01:00
Julien Neuhart
34f69263a2 feat(libreoffice): explicity set tagged PDF as default 2024-02-24 13:45:01 +01:00
Julien Neuhart
bf5056f8fe feat(chromium): single page PDF 2024-02-23 21:27:18 +01:00
Julien Neuhart
c47f5b06ba feat(libreoffice): add macOS *.pages support 2024-02-23 18:16:42 +01:00
Julien Neuhart
63055e3df2 feat: better file sorting 2024-02-23 17:13:37 +01:00
Julien Neuhart
8ee555140b chore: rename regexp file to filter 2024-02-23 17:13:37 +01:00
Julien Neuhart
859f78e986 chore: update Go dependencies 2024-02-23 17:13:37 +01:00
Julien Neuhart
366eebb68c chore: using error middleware to handle common errors and lighten codebase 2024-02-23 14:52:43 +01:00
Julien Neuhart
25ce0b9aee fix(pdfengines): only preserve filenames in zip archive 2024-02-18 18:27:34 +01:00
Julien Neuhart
376a7db545 feat(pdfengines): preserve filenames in convert route 2024-02-18 18:17:12 +01:00
Julien Neuhart
f906ed741d fix(flags): typos 2024-02-18 17:52:51 +01:00
Julien Neuhart
ad152e62e5 fix(chromium): better default deny list regexp 2024-02-18 17:18:40 +01:00
Julien Neuhart
7f3a61ef43 chore: update dependencies 2024-02-18 17:18:08 +01:00
Julien Neuhart
a7ba8c8213 chore(testdata): add more data 2024-02-18 13:13:01 +01:00
Julien Neuhart
b1f3d6d4c0 feat: handling more ErrMaximumQueueSizeExceeded scenarios 2024-02-18 13:05:48 +01:00
Julien Neuhart
5707ae027f fix(libreoffice): expect output paths tests 2024-02-18 12:00:37 +01:00
timgrohmann
770208024d feat: enable max queue size for chromium and libreoffice
fix gotenberg/gotenberg#463
2024-02-18 11:35:49 +01:00
Julien Neuhart
975a9f5344 fix(libreoffice): wrong archive filename 2024-02-16 19:50:17 +01:00
Julien Neuhart
a45008411e chore: update dependencies 2024-02-16 19:50:17 +01:00
Julien Neuhart
9091ffd1b3 fix(libreoffice): keep original extension all the time for preserved filenames in zipped output 2024-02-16 19:50:17 +01:00
Chris Hughes
089f161d1e feat(libreoffice): preserve filenames for zipped files instead of using randomly generated UUID filenames (#793)
* feat(libreoffice): preserve filenames for zipped files instead of using generated UUID filenames

* chore: move github.com/google/uuid back to its original line

* fix: add libreoffice/routes.go to PR
2024-02-16 19:50:17 +01:00
dependabot[bot]
e6d7131701 chore(deps): bump golangci/golangci-lint-action from 3 to 4
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 3 to 4.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-12 08:12:05 +01:00
Julien Neuhart
ed82170c2c chore(testdata): switch to px instead of rem in header.html & footer.html 2024-02-11 11:38:56 +01:00
Julien Neuhart
3472d42cdd chore: update dependencies 2024-02-07 10:44:30 +01:00
Julien Neuhart
b8926350b9 fix(libreoffice): wrong HTTP status codes if invalid PDF formats 2024-01-18 12:15:39 +01:00
Julien Neuhart
e5194b313d chore: update Go dependencies 2024-01-18 12:15:39 +01:00
Julien Neuhart
94228375ad chore: update Go dependencies 2024-01-10 11:55:05 +01:00
Julien Neuhart
0e8385a5a7 fix(chromium): missing webp validation 2024-01-08 11:44:56 +01:00
Julien Neuhart
afd3763d96 chore: update LICENSE year 2024-01-04 19:39:35 +01:00
Julien Neuhart
1122f09093 chore: remove OpenAPI documentation 2024-01-04 19:30:40 +01:00
Julien Neuhart
1123af15c9 chore: upgrade to v8 (#767) 2024-01-04 19:30:03 +01:00
Julien Neuhart
b2f6e865a7 feat(chromium): capture screenshot (#765) 2024-01-04 19:14:38 +01:00
Julien Neuhart
93b9ac2d13 feat(chromium): flags for clearing cache/cookies between each conversion (#756) 2023-12-14 22:06:13 +01:00
Julien Neuhart
64af65b0e7 feat(chromium): return 409 Conflict if HTTP status code from main page is not acceptable (#755) 2023-12-14 21:39:36 +01:00
Julien Neuhart
390c8437c5 fix(supervisor): if process is already restarting, requeue a task (#754) 2023-12-14 20:02:49 +01:00
Julien Neuhart
cc65ca9b6a fix(api): wait for modules readiness before starting server (#752) 2023-12-13 14:37:35 +01:00
Julien Neuhart
4bd7cba247 fix(libreoffice): accept PDF/A-1b instead of PDF/A-1a (#751) 2023-12-13 12:40:09 +01:00
Julien Neuhart
92e2f7f8b7 feat(chromium): skip networkIdle event 2023-12-12 18:40:55 +01:00
Julien Neuhart
98054ddff6 feat: upgrade pdfcpu 2023-12-12 18:40:55 +01:00
Julien Neuhart
4e5c1aba2e fix(armhf): now download latest working version from snapshots 2023-12-11 11:47:08 +01:00
Julien Neuhart
5eb44320ae fix(typo): Pdf -> PDF 2023-12-11 10:23:09 +01:00
dependabot[bot]
f751269289 chore(deps): bump actions/setup-go from 4 to 5 (#744)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 5.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-11 10:15:20 +01:00
Julien Neuhart
0e836622ec chore(chromium): typo in a comment 2023-12-08 09:38:17 +01:00
Julien Neuhart
a6ac0a813d fix: special characters issues with filenames (#736) 2023-12-05 16:34:35 +01:00
Julien Neuhart
21c47f60f2 feat: remove deprecated stuff (#726) 2023-12-01 09:45:07 +01:00
Julien Neuhart
66142b1dd8 fix: wrong fs type 2023-11-24 08:26:29 +01:00
Julien Neuhart
3a28f4a0cb chore: minor refactor of api module 2023-11-22 12:11:23 +01:00
Julien Neuhart
71eeca96ee chore: upgrade Noto emoji 2023-11-22 12:11:23 +01:00
Julien Neuhart
b56cde47ca chore: minor refactor of gotenberg pkg 2023-11-20 22:10:18 +01:00
Julien Neuhart
793e65bac0 chore: minor refactor of webhook module 2023-11-20 20:39:49 +01:00
Julien Neuhart
4d1a569269 chore: minor godoc refactoring 2023-11-19 15:02:41 +01:00
Julien Neuhart
5c56317d50 chore: minor refactor of the prometheus module 2023-11-19 14:43:19 +01:00
Julien Neuhart
32491ae2ad fix(armhf): add an earlier version of Chromium (#717) 2023-11-11 20:07:07 +01:00
Julien Neuhart
28aeb11565 fix(ci): wrong release name for build try on armhf 2023-11-09 19:46:24 +01:00
Julien Neuhart
61eb3ff908 chore: add a dedicated GitHub action job to test armhf build, hoping for some upstream fix 2023-11-09 17:12:09 +01:00
Julien Neuhart
ff5455881e chore(libreoffice): switch to unoconverter (#715) 2023-11-05 20:50:33 +01:00
Julien Neuhart
143b7ce678 feat(pdfengines): add PDF/UA (#714) 2023-11-05 20:14:36 +01:00
Julien Neuhart
9c3dfc78df fix: add missing 'login to Docker Hub' step to multi-arch build job 2023-10-29 11:52:08 +01:00
Julien Neuhart
017872c5ef chore: improving scripts and add thecodingmachine to edge release 2023-10-29 11:43:47 +01:00
Julien Neuhart
b9ad67d147 fix: put the whole multiarch_build job under a condition instead of last step 2023-10-29 10:47:57 +01:00
Julien Neuhart
62ea3b1a1a chore: edge Docker image now build & push after tests 2023-10-29 10:46:14 +01:00
Julien Neuhart
325b668d00 fix: edge release not working with armhf 2023-10-28 20:34:22 +02:00
Julien Neuhart
9ce6bf8e14 fix: exit 0 if edge release 2023-10-28 17:57:01 +02:00
Julien Neuhart
052448c59a chore(libreoffice): switch to supervisor (#708) 2023-10-28 17:55:53 +02:00
Julien Neuhart
c8b318b315 feat: add a new edge release 2023-10-28 17:55:22 +02:00
Julien Neuhart
54daac329e fix: chromium memory leaks (#705) 2023-10-23 17:52:30 +02:00
Julien Neuhart
b5a59e4de0 chore: switch all LABELs to org.opencontainers.image (#706) 2023-10-23 17:43:27 +02:00
Julien Neuhart
258876d13f fix: LibreOffice newer versions stability (#697) 2023-10-23 17:05:10 +02:00
Maxwell Weru
9cc8e16d64 chore: add org.opencontainers.image.source label (#692) 2023-09-25 11:56:31 +02:00
Julien Neuhart
85d1254638 fix: tidy 2023-09-21 10:00:49 +02:00
Julien Neuhart
45eb261dcd chore: update Go to version 1.21, update Go dependencies & others 2023-09-21 09:56:33 +02:00
dependabot[bot]
8b2ff5f269 chore(deps): bump docker/setup-buildx-action from 2 to 3 (#687)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 17:29:14 +02:00
dependabot[bot]
79ab568c80 chore(deps): bump docker/login-action from 2 to 3 (#686)
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 17:28:13 +02:00
dependabot[bot]
382b1737cb chore(deps): bump docker/setup-qemu-action from 2 to 3 (#685)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 17:27:57 +02:00
dependabot[bot]
c9b3a83592 chore(deps): bump actions/checkout from 3 to 4 (#681)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-18 17:26:56 +02:00
Julien Neuhart
0b21ce7ce9 chore: update documentation links 2023-07-30 19:47:55 +02:00
Julien Neuhart
523bf8c689 fix(Dockerfile): typo 2023-07-21 11:38:36 +02:00
Julien Neuhart
8ead5a9d1f fix(pdfcpu): update to v0.4.2 (close #644) 2023-07-20 12:37:15 +02:00
Julien Neuhart
46e3199438 fix(core): typo in mocks test 2023-07-20 11:36:18 +02:00
252 changed files with 19439 additions and 64422 deletions

View File

@@ -1 +1 @@
.git
.git

13
.env Normal file
View File

@@ -0,0 +1,13 @@
GOLANG_VERSION=1.24
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOTENBERG_VERSION=snapshot
DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
DOCKER_BUILD_CONTEXT='.'

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [gulien]

View File

@@ -0,0 +1,87 @@
name: Build Test Push
description: Build, test and push Docker images for a given platform
author: Julien Neuhart
inputs:
github_token:
description: The GitHub token
required: true
default: ${{ github.token }}
docker_hub_username:
description: The Docker Hub username
required: true
docker_hub_password:
description: The Docker Hub password
required: true
platform:
description: linux/amd64, linux/386, linux/arm64, linux/arm/v7
required: true
version:
description: Gotenberg version
required: true
skip_integrations_tests:
description: Define whether to skip integration testing
default: false
alternate_repository:
description: Alternate repository to push the tags to
dry_run:
description: Dry run this action
outputs:
tags:
description: Comma separated list of tag
value: ${{ steps.build.outputs.tags }}
tags_cloud_run:
description: Comma separated list of Cloud Run tags (linux/amd64 only)
value: ${{ steps.build.outputs.tags_cloud_run }}
runs:
using: composite
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.docker_hub_username }}
password: ${{ inputs.docker_hub_password }}
- name: Build ${{ inputs.platform }}
id: build
shell: bash
run: |
.github/actions/build-test-push/build.sh \
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Run integration tests
if: ${{ inputs.skip_integrations_tests != 'true' }}
shell: bash
run: |
.github/actions/build-test-push/test.sh \
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Push
shell: bash
run: |
.github/actions/build-test-push/push.sh \
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }}" \
--dry-run "${{ inputs.dry_run }}"
- name: Outputs
shell: bash
run: |
echo "tags=${{ steps.build.outputs.tags }}"
echo "tags_cloud_run=${{ steps.build.outputs.tags_cloud_run }}"

167
.github/actions/build-test-push/build.sh vendored Executable file
View File

@@ -0,0 +1,167 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
version=""
platform=""
alternate_repository=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--version)
version="${2//v/}"
shift 2
;;
--platform)
platform="$2"
shift 2
;;
--alternate-repository)
alternate_repository="$2"
shift 2
;;
--dry-run)
dry_run="$2"
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Build and push 👷"
echo
echo "Gotenberg version: $version"
echo "Target platform: $platform"
if [ -n "$alternate_repository" ]; then
DOCKER_REPOSITORY=$alternate_repository
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
fi
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
# Build tags arrays.
tags=()
tags_cloud_run=()
IFS='/' read -ra arch <<< "$platform"
IFS='.' read -ra semver <<< "$version"
if [ "${#semver[@]}" -eq 3 ]; then
echo
echo "Semver version detected"
major="${semver[0]}"
minor="${semver[1]}"
patch="${semver[2]}"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-${arch[1]}")
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor-cloudrun")
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$major.$minor.$patch-cloudrun")
fi
else
echo
echo "Non-semver version detected, fallback to $version"
tags+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-${arch[1]}")
if [ "$platform" = "linux/amd64" ]; then
tags_cloud_run+=("$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version-cloudrun")
fi
fi
tags_flags=()
tags_cloud_run_flags=()
echo "Will use the following tags:"
for tag in "${tags[@]}"; do
tags_flags+=("-t" "$tag")
echo "- $tag"
done
for tag in "${tags_cloud_run[@]}"; do
tags_cloud_run_flags+=("-t" "$tag")
echo "- $tag"
done
echo
# Build images.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
join() {
local delimiter="$1"
shift
local IFS="$delimiter"
echo "$*"
}
no_arch_tag="$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$version"
cmd="docker buildx build \
--build-arg GOLANG_VERSION=$GOLANG_VERSION \
--build-arg GOTENBERG_VERSION=$version \
--build-arg GOTENBERG_USER_GID=$GOTENBERG_USER_GID \
--build-arg GOTENBERG_USER_UID=$GOTENBERG_USER_UID \
--build-arg NOTO_COLOR_EMOJI_VERSION=$NOTO_COLOR_EMOJI_VERSION \
--build-arg PDFTK_VERSION=$PDFTK_VERSION \
--build-arg PDFCPU_VERSION=$PDFCPU_VERSION \
--platform $platform \
--load \
${tags_flags[*]} \
-t $no_arch_tag \
-f $DOCKERFILE $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
if [ "$platform" != "linux/amd64" ]; then
echo "⚠️ Skip Cloud Run variant(s)"
echo "✅ Done!"
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
exit 0
fi
cmd="docker build \
--build-arg DOCKER_REGISTRY=$DOCKER_REGISTRY \
--build-arg DOCKER_REPOSITORY=$DOCKER_REPOSITORY \
--build-arg GOTENBERG_VERSION=$version \
${tags_cloud_run_flags[*]} \
-f $DOCKERFILE_CLOUDRUN $DOCKER_BUILD_CONTEXT
"
run_cmd "$cmd"
echo "✅ Done!"
echo "tags=$(join "," "${tags[@]}")" >> "$GITHUB_OUTPUT"
echo "tags_cloud_run=$(join "," "${tags_cloud_run[@]}")" >> "$GITHUB_OUTPUT"
exit 0

70
.github/actions/build-test-push/push.sh vendored Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
tags=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--tags)
tags="$2"
shift 2
;;
--dry-run)
dry_run=$2
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Push tag(s) 📦"
echo
echo "Tag(s) to push:"
IFS=',' read -ra tags_to_push <<< "$tags"
for tag in "${tags_to_push[@]}"; do
echo "- $tag"
done
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Push tags.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
for tag in "${tags_to_push[@]}"; do
cmd="docker push $tag"
run_cmd "$cmd"
echo "➡️ $tag pushed"
echo
done
echo "✅ Done!"
exit 0

79
.github/actions/build-test-push/test.sh vendored Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
version=""
platform=""
alternate_repository=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--version)
version="${2//v/}"
shift 2
;;
--platform)
platform="$2"
shift 2
;;
--alternate-repository)
alternate_repository="$2"
shift 2
;;
--dry-run)
dry_run="$2"
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Integration testing 🧪"
echo
echo "Gotenberg version: $version"
echo "Target platform: $platform"
repository=$DOCKER_REPOSITORY
if [ -n "$alternate_repository" ]; then
echo "⚠️ Using $alternate_repository for DOCKER_REPOSITORY"
repository=$alternate_repository
fi
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Test image.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
cmd="make test-integration DOCKER_REPOSITORY=$repository GOTENBERG_VERSION=$version PLATFORM=$platform NO_CONCURRENCY=true"
run_cmd "$cmd"
echo "✅ Done!"
exit 0

31
.github/actions/clean/action.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Clean
description: Clean tags from Docker Hub
author: Julien Neuhart
inputs:
docker_hub_username:
description: The Docker Hub username
required: true
docker_hub_password:
description: The Docker Hub password
required: true
tags:
description: Comma separated list of tags to clean
snapshot_version:
description: Snapshot version to clean
dry_run:
description: Dry run this action
runs:
using: composite
steps:
- name: Clean tags from Docker Hub
env:
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
shell: bash
run: |
.github/actions/clean/clean.sh \
--tags "${{ inputs.tags }}" \
--snapshot-version "${{ inputs.snapshot_version }}" \
--dry-run "${{ inputs.dry_run }}"

122
.github/actions/clean/clean.sh vendored Executable file
View File

@@ -0,0 +1,122 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
tags=""
snapshot_version=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--tags)
tags="$2"
shift 2
;;
--snapshot-version)
snapshot_version="${2//v/}"
shift 2
;;
--dry-run)
dry_run="$2"
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Clean tag(s) from Docker Hub 🧹"
echo
IFS=',' read -ra tags_to_delete <<< "$tags"
if [ -n "$snapshot_version" ]; then
tags_to_delete+=("$DOCKER_REGISTRY/snapshot:$snapshot_version")
tags_to_delete+=("$DOCKER_REGISTRY/snapshot:$snapshot_version-cloudrun")
fi
echo "Will delete the following tag(s):"
for tag in "${tags_to_delete[@]}"; do
echo "- $tag"
done
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Delete tags.
base_url="https://hub.docker.com/v2"
token=""
if [ "$dry_run" = "true" ]; then
token="placeholder"
echo "🚧 Dry run - would call $base_url to get a token"
echo
else
echo "🌐 Get token from $base_url"
readarray -t lines < <(
curl -s -X POST \
-H "Content-Type: application/json" \
-d "{\"username\":\"$DOCKERHUB_USERNAME\", \"password\":\"$DOCKERHUB_TOKEN\"}" \
-w "\n%{http_code}" \
"$base_url/users/login"
)
http_code="${lines[-1]}"
unset 'lines[-1]'
json_body=$(printf "%s\n" "${lines[@]}")
if [ "$http_code" -ne "200" ]; then
echo "❌ Wrong HTTP status - $http_code"
echo "$json_body"
exit 1
fi
token=$(jq -r '.token' <<< "$json_body")
echo
fi
if [ -z "$token" ]; then
echo "❌ No token from Docker Hub"
exit 1
fi
for tag in "${tags_to_delete[@]}"; do
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would call $base_url to delete tag $tag"
echo
else
echo "🌐 Delete tag $tag"
IFS=':' read -ra tag_parts <<< "$tag"
readarray -t lines < <(
curl -s -X DELETE \
-H "Authorization: Bearer $token" \
-w "\n%{http_code}" \
"$base_url/repositories/${tag_parts[0]}/tags/${tag_parts[1]}/"
)
http_code="${lines[-1]}"
unset 'lines[-1]'
if [ "$http_code" -ne "200" ] && [ "$http_code" -ne "204" ]; then
echo "❌ Wrong HTTP status - $http_code"
printf '%s\n' "${lines[@]}"
exit 1
fi
echo
fi
done
echo "✅ Done!"
exit 0

48
.github/actions/merge/action.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Merge
description: Merge tags to single multi-platform tags
author: Julien Neuhart
inputs:
github_token:
description: The GitHub token
required: true
default: ${{ github.token }}
docker_hub_username:
description: The Docker Hub username
required: true
docker_hub_password:
description: The Docker Hub password
required: true
tags:
description: Comma separated tags to merge
required: true
alternate_registry:
description: Alternate registry to also push resulting tags
dry_run:
description: Dry run this action
runs:
using: composite
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ inputs.docker_hub_username }}
password: ${{ inputs.docker_hub_password }}
- name: Merge
shell: bash
run: |
.github/actions/merge/merge.sh \
--tags "${{ inputs.tags }}" \
--alternate-registry "${{ inputs.alternate_registry }}" \
--dry-run "${{ inputs.dry_run }}"

107
.github/actions/merge/merge.sh vendored Executable file
View File

@@ -0,0 +1,107 @@
#!/bin/bash
# Exit early.
# See: https://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#The-Set-Builtin.
set -e
# Source dot env file.
source .env
# Arguments.
tags=""
alternate_registry=""
dry_run=""
while [[ $# -gt 0 ]]; do
case $1 in
--tags)
tags="$2"
shift 2
;;
--alternate-registry)
alternate_registry="$2"
shift 2
;;
--dry-run)
dry_run=$2
shift 2
;;
*)
echo "Unknown option $1"
exit 1
;;
esac
done
echo "Merge tag(s) 👷"
echo
echo "Tag(s) to merge:"
IFS=',' read -ra tags_to_merge <<< "$tags"
for tag in "${tags_to_merge[@]}"; do
echo "- $tag"
done
if [ -n "$alternate_registry" ]; then
echo "⚠️ Will also push to $alternate_registry registry"
fi
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run"
fi
echo
# Build merge map.
declare -A merge_map
for tag in "${tags_to_merge[@]}"; do
target_tag="${tag//-amd64/}"
target_tag="${target_tag//-arm64/}"
target_tag="${target_tag//-arm/}"
target_tag="${target_tag//-386/}"
merge_map["$target_tag"]+="$tag "
done
# Merge tags.
run_cmd() {
local cmd="$1"
if [ "$dry_run" = "true" ]; then
echo "🚧 Dry run - would run the following command:"
echo "$cmd"
echo
else
echo "⚙️ Running command:"
echo "$cmd"
eval "$cmd"
echo
fi
}
for target in "${!merge_map[@]}"; do
IFS=' ' read -ra source_tags <<< "${merge_map[$target]}"
cmd="docker buildx imagetools create \
-t $target \
${source_tags[*]}
"
run_cmd "$cmd"
echo "➡️ $target pushed"
echo
if [ -n "$alternate_registry" ]; then
alternate_target="${target/$DOCKER_REGISTRY/$alternate_registry}"
cmd="docker buildx imagetools create \
-t $alternate_target \
$target
"
run_cmd "$cmd"
echo "➡️ $alternate_target pushed"
echo
fi
done
echo "✅ Done!"
exit 0

View File

@@ -1,6 +1,5 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"

6
.github/stale.yml vendored
View File

@@ -1,7 +1,7 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 15
daysUntilStale: 7
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
daysUntilClose: 3
# Issues with these labels will never be considered stale
exemptLabels:
- bug
@@ -15,4 +15,4 @@ markComment: >
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
closeComment: false

View File

@@ -0,0 +1,116 @@
name: Continuous Delivery
on:
release:
types: [published]
permissions:
contents: read
jobs:
release_amd64:
name: Release linux/amd64
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/amd64
skip_integrations_tests: true
release_386:
name: Release linux/386
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/386
skip_integrations_tests: true
release_arm64:
name: Release linux/arm64
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/arm64
skip_integrations_tests: true
release_arm_v7:
name: Release linux/arm/v7
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build and push
id: build_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ github.event.release.tag_name }}
platform: linux/arm/v7
skip_integrations_tests: true
merge_clean_release_tags:
needs:
- release_amd64
- release_386
- release_arm64
- release_arm_v7
name: Merge and clean release tags
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Merge
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"

View File

@@ -0,0 +1,303 @@
name: Continuous Integration
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ (github.event_name == 'pull_request' && github.event.pull_request.number) || 'main' }}
cancel-in-progress: true
permissions:
contents: write
jobs:
lint:
name: Lint Golang codebase
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run linters
uses: golangci/golangci-lint-action@v7
with:
version: v2.0.2
lint-prettier:
name: Lint non-Golang codebase
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Install Dependencies
run: npm i
- name: Run linters
run: make lint-prettier
test-unit:
needs:
- lint
- lint-prettier
name: Run unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run tests
run: make test-unit
snapshot_amd64:
if: github.event_name == 'pull_request'
needs:
- test-unit
name: Snapshot linux/amd64
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/amd64
alternate_repository: snapshot
snapshot_386:
if: github.event_name == 'pull_request'
needs:
- test-unit
name: Snapshot linux/386
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/386
alternate_repository: snapshot
snapshot_arm64:
if: github.event_name == 'pull_request'
needs:
- test-unit
name: Snapshot linux/arm64
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/arm64
alternate_repository: snapshot
snapshot_arm_v7:
if: github.event_name == 'pull_request'
needs:
- test-unit
name: Snapshot linux/arm/v7
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: pr-${{ github.event.pull_request.number }}
platform: linux/arm/v7
alternate_repository: snapshot
merge_clean_snapshot_tags:
needs:
- snapshot_amd64
- snapshot_386
- snapshot_arm64
- snapshot_arm_v7
name: Merge and clean snapshot tags
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Merge
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
edge_amd64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-unit
name: Edge linux/amd64
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/amd64
edge_386:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-unit
name: Edge linux/386
runs-on: ubuntu-latest
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/386
edge_arm64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-unit
name: Edge linux/arm64
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/arm64
edge_arm_v7:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-unit
name: Edge linux/arm/v7
runs-on: ubuntu-24.04-arm
outputs:
tags: ${{ steps.build_test_push.outputs.tags }}
tags_cloud_run: ${{ steps.build_test_push.outputs.tags_cloud_run }}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Build, test and push
id: build_test_push
uses: ./.github/actions/build-test-push
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: edge
platform: linux/arm/v7
merge_clean_edge_tags:
needs:
- edge_amd64
- edge_386
- edge_arm64
- edge_arm_v7
name: Merge and clean edge tags
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Merge
uses: ./.github/actions/merge
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
name: Pull Request Cleanup
on:
pull_request:
types: [closed]
permissions:
contents: read
jobs:
cleanup:
name: Cleanup Docker images
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Cleanup
uses: ./.github/actions/clean
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
snapshot_version: pr-${{ github.event.pull_request.number }}

6
.gitignore vendored
View File

@@ -1,2 +1,6 @@
/coverage.html
/coverage.txt
/coverage.txt
.idea
.vscode
.DS_Store
/node_modules/

View File

@@ -1,27 +1,65 @@
version: "2"
run:
issues-exit-code: 1
tests: false
linters:
disable-all: true
default: none
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- copyloopvar
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- errcheck
- gofmt
- goimports
- errname
- exhaustive
- gosec
- gosimple
- govet
- importas
- ineffassign
- misspell
- prealloc
- promlinter
- staticcheck
- typecheck
- testableexamples
- tparallel
- unconvert
- unused
run:
deadline: 5m
issues-exit-code: 1
tests: false
output:
format: 'colored-line-number'
print-issued-lines: true
print-linter-name: true
- usetesting
- wastedassign
- whitespace
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
- gofmt
- gofumpt
- goimports
settings:
gci:
sections:
- standard
- default
- prefix(github.com/gotenberg/gotenberg/v8)
custom-order: true
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

1
.node-version Normal file
View File

@@ -0,0 +1 @@
23.9.0

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
build

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-gherkin", "prettier-plugin-sh"],
"escapeBackslashes": true
}

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 Julien Neuhart
Copyright (c) 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

174
Makefile
View File

@@ -1,18 +1,8 @@
include .env
.PHONY: help
help: ## Show the help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: it
it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.20
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.038 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.51.2 # See https://github.com/golangci/golangci-lint/releases.
@grep -hE '^[A-Za-z0-9_ \-]*?:.*##.*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: build
build: ## Build the Gotenberg's Docker image
@@ -23,17 +13,32 @@ build: ## Build the Gotenberg's Docker image
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
-f build/Dockerfile .
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000
API_PORT_FROM_ENV=
API_BIND_IP=
API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_ROOT_PATH=/
API_BODY_LIMIT=
API_ROOT_PATH="/"
API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST=
API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_FAILED_STARTS_THRESHOLD=5
API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=10
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
CHROMIUM_INCOGNITO=false
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
@@ -42,21 +47,31 @@ CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER=
CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_DISABLES_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES=
LOG_ENABLE_GCP_FIELDS=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_DISABLE_ROUTES=false
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_COLLECT=false
UNO_LISTENER_START_TIMEOUT=10s
UNO_LISTENER_RESTART_THRESHOLD=10
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST=
@@ -71,16 +86,30 @@ WEBHOOK_DISABLE=false
run: ## Start a Gotenberg container
docker run --rm -it \
-p $(API_PORT):$(API_PORT) \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-bind-ip=$(API_BIND_IP) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \
--api-body-limit="$(API_BODY_LIMIT)" \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--chromium-failed-starts-threshold=$(CHROMIUM_FAILED_STARTS_THRESHOLD) \
--api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
@@ -88,24 +117,34 @@ run: ## Start a Gotenberg container
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--uno-listener-start-timeout=$(UNO_LISTENER_START_TIMEOUT) \
--uno-listener-restart-threshold=$(UNO_LISTENER_RESTART_THRESHOLD) \
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
@@ -114,47 +153,44 @@ run: ## Start a Gotenberg container
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
--webhook-disable=$(WEBHOOK_DISABLE)
.PHONY: build-tests
build-tests: ## Build the tests' Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile .
.PHONY: test-unit
test-unit: ## Run unit tests
go test -race ./...
.PHONY: tests
tests: ## Start the testing environment
docker run --rm -it \
-v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
bash
PLATFORM=
NO_CONCURRENCY=false
.PHONY: tests-once
tests-once: ## Run the tests once (prefer the "tests" command while developing)
docker run --rm \
-v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
gotest
.PHONY: test-integration
test-integration: ## Run integration tests
go test -timeout 20m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
--gotenberg-version=$(GOTENBERG_VERSION) \
--gotenberg-container-platform=$(PLATFORM) \
--no-concurrency=$(NO_CONCURRENCY)
.PHONY: lint
lint: ## Lint Golang codebase
golangci-lint run
.PHONY: lint-prettier
lint-prettier: ## Lint non-Golang codebase
npx prettier --check .
.PHONY: lint-todo
lint-todo: ## Find TODOs in Golang codebase
golangci-lint run --no-config --disable-all --enable godox
.PHONY: fmt
fmt: ## Format the code and "optimize" the dependencies
go fmt ./...
fmt: ## Format Golang codebase and "optimize" the dependencies
golangci-lint fmt
go mod tidy
.PHONY: godoc
godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/godoc)
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7)
godoc -http=:6060
.PHONY: prettify
prettify: ## Format non-Golang codebase
npx prettier --write .
.PHONY: release
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
./scripts/release.sh \
$(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \
$(GOTENBERG_USER_GID) \
$(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \
$(DOCKER_REPOSITORY)
# go install golang.org/x/tools/cmd/godoc@latest
.PHONY: godoc
godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060

View File

@@ -1,13 +1,22 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a> &#183; 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</a></p>
<p align="center">A containerized API for seamless PDF conversion</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
</p>
---
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
## Quick Start
@@ -15,31 +24,31 @@ numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, an
Open a terminal and run the following command:
```
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
```
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8
```
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 🚀
Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduction) 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" />
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
</a>
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
</a>
<a href="https://pdfme.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/user-attachments/assets/2a75dd40-ca18-4d34-acd5-5dd474595168" alt="pdfme Logo" width="333" height="163" />
</a>
</p>
## Badges
[![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)
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️

View File

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

View File

@@ -2,15 +2,39 @@
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION
ARG GOTENBERG_VERSION
# ----------------------------------------------
# pdfcpu binary build stage
# ----------------------------------------------
# Note: this stage is required as pdfcpu does not release an armhf variant by
# default.
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
ARG PDFCPU_VERSION
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
tar --strip-components=1 -xvzf pdfcpu.tar.gz
# Install module dependencies.
RUN go mod download &&\
go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
# Verify installation.
./pdfcpu version
# ----------------------------------------------
# Gotenberg binary build stage
# ----------------------------------------------
FROM golang:$GOLANG_VERSION AS binary-stage
FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage
ARG GOTENBERG_VERSION
ENV CGO_ENABLED 0
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
@@ -25,12 +49,12 @@ RUN go mod download &&\
COPY cmd ./cmd
COPY pkg ./pkg
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
# ----------------------------------------------
# Final stage
# ----------------------------------------------
FROM debian:11-slim
FROM debian:12-slim
ARG GOTENBERG_VERSION
ARG GOTENBERG_USER_GID
@@ -38,11 +62,12 @@ ARG GOTENBERG_USER_UID
ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION
LABEL author="Julien Neuhart" \
description="A Docker-powered stateless API for PDF files." \
github="https://github.com/gotenberg/gotenberg" \
version="$GOTENBERG_VERSION" \
website="https://gotenberg.dev"
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
org.opencontainers.image.version="$GOTENBERG_VERSION" \
org.opencontainers.image.authors="Julien Neuhart <neuhart.julien@gmail.com>" \
org.opencontainers.image.documentation="https://gotenberg.dev" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
RUN \
# Create a non-root user.
@@ -56,8 +81,8 @@ RUN \
# Install system dependencies required for the next instructions or debugging.
# Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg htop tini python3 default-jre-headless &&\
ln -s /usr/bin/htop /usr/bin/top &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 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.
@@ -69,10 +94,11 @@ RUN \
# 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 &&\
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8_all.deb \
./ttf-mscorefonts-installer_3.8.1_all.deb \
culmus \
fonts-beng \
fonts-hosny-amiri \
@@ -107,7 +133,7 @@ RUN \
fonts-noto-ui-core \
fonts-sil-gentium \
fonts-sil-gentium-basic &&\
rm -f ./ttf-mscorefonts-installer_3.8_all.deb &&\
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
# Add Color and Black-and-White Noto emoji font.
# Credits:
# https://github.com/gotenberg/gotenberg/pull/325.
@@ -126,10 +152,12 @@ RUN \
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' &&\
# Verify installation.
@@ -138,33 +166,37 @@ RUN \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install LibreOffice & unoconv.
# Install LibreOffice & unoconverter.
echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libreoffice &&\
curl -Ls https://raw.githubusercontent.com/dagwieers/unoconv/master/unoconv -o /usr/bin/unoconv &&\
chmod +x /usr/bin/unoconv &&\
# unoconv will look for the Python binary, which has to be at version 3.
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bookworm-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.1.1/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\
# unoconverter will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\
# Verify installations.
libreoffice --version &&\
unoconv --version &&\
unoconverter --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install PDFtk & QPDF (PDF engines).
# Install PDFtk, QPDF & ExifTool (PDF engines).
# See https://github.com/gotenberg/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
chmod a+x /usr/bin/pdftk-all.jar &&\
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
# See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\
# Verify installations.
pdftk --version &&\
qpdf --version &&\
exiftool --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
@@ -174,16 +206,20 @@ RUN \
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy the Gotenberg binary from the builder stage.
COPY --from=binary-stage /home/gotenberg /usr/bin/
# Copy the pdfcpu binary from the pdfcpu-binary-stage.
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
# Copy the Gotenberg binary from the gotenberg-binary-stage.
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else.
ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg"
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
ENV UNOCONV_BIN_PATH /usr/bin/unoconv
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
ENV PDFTK_BIN_PATH /usr/bin/pdftk
ENV QPDF_BIN_PATH /usr/bin/qpdf
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
USER gotenberg
WORKDIR /home/gotenberg

View File

@@ -1,7 +1,8 @@
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root

View File

@@ -5,12 +5,14 @@ import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
@@ -22,7 +24,7 @@ const banner = `
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/
A Docker-powered stateless API for PDF files.
A containerized API for seamless PDF conversion.
Version: %s
-------------------------------------------------------
`
@@ -34,6 +36,7 @@ var Version = "snapshot"
// Run starts the Gotenberg application. Call this in the main of your program.
func Run() {
fmt.Printf(banner, Version)
gotenberg.Version = Version
// Create the root FlagSet and adds the modules flags to it.
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
@@ -48,14 +51,42 @@ func Run() {
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
// Parse the flags...
// Parse the flags.
err := fs.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// ...and create a wrapper around those.
// Override their values if the corresponding environment variables are
// set.
fs.VisitAll(func(f *flag.Flag) {
envName := strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
val, ok := os.LookupEnv(envName)
if !ok {
return
}
sliceVal, ok := f.Value.(flag.SliceValue)
if ok {
// We don't want to append the values (default pflag behavior).
items := strings.Split(val, ",")
err = sliceVal.Replace(items)
if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
}
return
}
err = f.Value.Set(val)
if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
}
})
// Create a wrapper around our flags.
parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
// Get the graceful shutdown duration.
@@ -74,7 +105,6 @@ func Run() {
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)
@@ -83,7 +113,6 @@ func Run() {
startupMessage := app.StartupMessage()
if startupMessage == "" {
fmt.Printf("[SYSTEM] %s: application started\n", id)
return
}
@@ -108,6 +137,9 @@ func Run() {
}(l.(gotenberg.SystemLogger))
}
// Build the debug data.
gotenberg.BuildDebug(ctx)
quit := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes).
@@ -143,7 +175,6 @@ func Run() {
}
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
return nil
}
}(a.(gotenberg.App)))

View File

@@ -1,10 +1,9 @@
package main
import (
gotenbergcmd "github.com/gotenberg/gotenberg/v7/cmd"
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
// Gotenberg modules.
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
_ "github.com/gotenberg/gotenberg/v8/pkg/standard"
)
func main() {

View File

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

160
go.mod
View File

@@ -1,69 +1,127 @@
module github.com/gotenberg/gotenberg/v7
module github.com/gotenberg/gotenberg/v8
go 1.20
go 1.24.0
require (
github.com/alexliesenfeld/health v0.7.0
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/chromedp/cdproto v0.0.0-20230716001748-3ed7c525ec8b
github.com/chromedp/chromedp v0.9.1
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8
github.com/chromedp/chromedp v0.13.3
github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.4
github.com/klauspost/compress v1.16.7 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.11.1
github.com/labstack/gommon v0.4.0
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.25
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.4.1
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/prometheus/client_golang v1.16.0
github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microcosm-cc/bluemonday v1.0.27
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/prometheus/client_golang v1.21.1
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/atomic v1.11.0 // indirect
github.com/spf13/pflag v1.0.6
github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/image v0.9.0 // indirect
golang.org/x/net v0.12.0
golang.org/x/sync v0.3.0
golang.org/x/sys v0.10.0 // indirect
golang.org/x/term v0.10.0
golang.org/x/text v0.11.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.38.0
golang.org/x/sync v0.12.0
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0
golang.org/x/text v0.23.0
)
require (
github.com/cucumber/godog v0.15.0
github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.0.4+incompatible
github.com/docker/go-connections v0.5.0
github.com/mholt/archives v0.1.0
github.com/shirou/gopsutil/v4 v4.25.2
github.com/testcontainers/testcontainers-go v0.36.0
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.2.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.1.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sorairolake/lzip-go v0.3.7 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/therootcompany/xz v1.0.1 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

605
go.sum
View File

@@ -1,214 +1,555 @@
github.com/alexliesenfeld/health v0.7.0 h1:U3mSZ3ussRbGx+/rXBjNVxjLX5cKaNh8Ly9Hx3q+yfY=
github.com/alexliesenfeld/health v0.7.0/go.mod h1:6Nnjbu7vBYHoZqIuZeOnTpnW7OH14ulR+wIBE2QuJ8I=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/STARRY-S/zip v0.2.2 h1:8QeCbIi1Z9U5MgoDARJR1ClbBo9RD46SmVy+dl0woCk=
github.com/STARRY-S/zip v0.2.2/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
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/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20230716001748-3ed7c525ec8b h1:4RSQDLMb5m1XmyOgy7iQJmXuVsK24TaBoToW/FIFigA=
github.com/chromedp/cdproto v0.0.0-20230716001748-3ed7c525ec8b/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A=
github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 h1:AqW2bDQf67Zbq6Tpop/+yJSIknxhiQecO2B8jNYTAPs=
github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.13.3 h1:c6nTn97XQBykzcXiGYL5LLebw3h3CEyrCihm4HquYh0=
github.com/chromedp/chromedp v0.13.3/go.mod h1:khsDP9OP20GrowpJfZ7N05iGCwcAYxk7qf9AZBzR3Qw=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
github.com/cucumber/godog v0.15.0 h1:51AL8lBXF3f0cyA5CV4TnJFCTHpgiy+1x1Hb3TtZUmo=
github.com/cucumber/godog v0.15.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces=
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY=
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.4 h1:ZQgVdpTdAL7WpMIwLzCfbalOcSUdkDZnpUv3/+BxzFA=
github.com/hashicorp/go-retryablehttp v0.7.4/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/tiff v1.0.0 h1:T8/QVXiABO6Er7XCoExh4XPGyMO+X1ynf0V8kHui3t4=
github.com/hhrutter/tiff v1.0.0/go.mod h1:zluYmeCkNexc8HFzfc2MTVwA8gcPuFQp/ngjvIQ0CFo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4=
github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mholt/archives v0.1.0 h1:FacgJyrjiuyomTuNA92X5GyRBRZjE43Y/lrzKIlF35Q=
github.com/mholt/archives v0.1.0/go.mod h1:j/Ire/jm42GN7h90F5kzj6hf6ZFzEH66de+hmjEKu+I=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.1.1 h1:OJaYalXdliBUXPmC8CZGQ7oZDxzX1/5mQmgn0/GASew=
github.com/nwaples/rardecode/v2 v2.1.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.4.1 h1:oKgcST93zXdq1vE+B8dQBlE8S7WqsBVgkLxg82M3Fgk=
github.com/pdfcpu/pdfcpu v0.4.1/go.mod h1:MojCBFW2uljNs3CBmyTDeFAvu7vI1LrJhWNMCjY3kg4=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk=
github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
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/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sorairolake/lzip-go v0.3.7 h1:vP2uiD/NoklLyzYMdgOWkZME0ulkSfVTTE4MNRKCwNs=
github.com/sorairolake/lzip-go v0.3.7/go.mod h1:THOHr0FlNVCw2eOIEE9shFJAG1QxQg/pf2XUPAmNIqg=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
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/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/testcontainers/testcontainers-go v0.36.0 h1:YpffyLuHtdp5EUsI5mT4sRw8GZhO/5ozyDT1xWGXt00=
github.com/testcontainers/testcontainers-go v0.36.0/go.mod h1:yk73GVJ0KUZIHUtFna6MO7QS144qYpoY8lEEtU9Hed0=
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g=
golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/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.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

180
package-lock.json generated Normal file
View File

@@ -0,0 +1,180 @@
{
"name": "gotenberg",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"prettier": "3.5.2",
"prettier-plugin-gherkin": "^3.1.1",
"prettier-plugin-sh": "^0.15.0"
}
},
"node_modules/@cucumber/gherkin": {
"version": "27.0.0",
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-27.0.0.tgz",
"integrity": "sha512-j5rCsjqzRiC3iVTier3sa0kzyNbkcAmF7xr7jKnyO7qDeK3Z8Ye1P3KSVpeQRMY+KCDJ3WbTDdyxH0FwfA/fIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cucumber/messages": ">=19.1.4 <=22"
}
},
"node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-22.0.0.tgz",
"integrity": "sha512-EuaUtYte9ilkxcKmfqGF9pJsHRUU0jwie5ukuZ/1NPTuHS1LxHPsGEODK17RPRbZHOFhqybNzG2rHAwThxEymg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/uuid": "9.0.1",
"class-transformer": "0.5.1",
"reflect-metadata": "0.1.13",
"uuid": "9.0.0"
}
},
"node_modules/@cucumber/gherkin/node_modules/@types/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
"dev": true,
"license": "MIT"
},
"node_modules/@cucumber/gherkin/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@cucumber/messages": {
"version": "23.0.0",
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-23.0.0.tgz",
"integrity": "sha512-2ZWKNnikNMBnle3P3cgy+fgzhABrY4oBbiWp9wcI8ANdT4LlYRN6jQ6j/9CQGTDGRfkyRtr/5VkYq7q24XCsuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/uuid": "9.0.6",
"class-transformer": "0.5.1",
"reflect-metadata": "0.1.13",
"uuid": "9.0.1"
}
},
"node_modules/@types/uuid": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz",
"integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==",
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"dev": true,
"license": "MIT"
},
"node_modules/mvdan-sh": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/mvdan-sh/-/mvdan-sh-0.10.1.tgz",
"integrity": "sha512-kMbrH0EObaKmK3nVRKUIIya1dpASHIEusM13S4V1ViHFuxuNxCo+arxoa6j/dbV22YBGjl7UKJm9QQKJ2Crzhg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/prettier": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-gherkin": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.1.tgz",
"integrity": "sha512-cCfjqKMdR2a8jOf8yKg32iUfRq0Dfmx+K2qTMOD/ixJJq0Rp7QS8BaoABWC7CDGXbvOkVeWOvzhxWvYcEbjglw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cucumber/gherkin": "^27.0.0",
"@cucumber/messages": "^23.0.0",
"prettier": "^3.0.0"
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.15.0.tgz",
"integrity": "sha512-U0PikJr/yr2bzzARl43qI0mApBj0C1xdAfA04AZa6LnvIKawXHhuy2fFo6LNA7weRzGlAiNbaEFfKMFo0nZr/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mvdan-sh": "^0.10.1",
"sh-syntax": "^0.4.2"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
},
"peerDependencies": {
"prettier": "^3.0.3"
}
},
"node_modules/reflect-metadata": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/sh-syntax": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.4.2.tgz",
"integrity": "sha512-/l2UZ5fhGZLVZa16XQM9/Vq/hezGGbdHeVEA01uWjOL1+7Ek/gt6FquW0iKKws4a9AYPYvlz6RyVvjh3JxOteg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
}
}
}

7
package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"devDependencies": {
"prettier": "3.5.2",
"prettier-plugin-gherkin": "^3.1.1",
"prettier-plugin-sh": "^0.15.0"
}
}

View File

@@ -13,43 +13,43 @@ import (
"go.uber.org/zap"
)
// Cmd wraps an exec.Cmd.
// 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.
// 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 {
func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
cmd := exec.Command(binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return Cmd{
return &Cmd{
ctx: nil,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd,
}
}
// CommandContext creates a Cmd with a context. It configures the internal
// exec.Cmd of Cmd so that we may kill its unix process and all its children
// without creating orphans.
// 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) {
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (*Cmd, error) {
if ctx == nil {
return Cmd{}, errors.New("nil context")
return nil, errors.New("nil context")
}
cmd := exec.CommandContext(ctx, binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return Cmd{
return &Cmd{
ctx: ctx,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd,
@@ -57,7 +57,7 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
}
// Start starts the command but does not wait for its completion.
func (cmd Cmd) Start() error {
func (cmd *Cmd) Start() error {
err := cmd.pipeOutput()
if err != nil {
return fmt.Errorf("pipe unix process output: %w", err)
@@ -75,7 +75,7 @@ func (cmd Cmd) Start() error {
// Wait waits for the command to complete. It should be called when using the
// Start method, so that the command does not leak zombies.
func (cmd Cmd) Wait() error {
func (cmd *Cmd) Wait() error {
err := cmd.process.Wait()
if err != nil {
return fmt.Errorf("wait for unix process: %w", err)
@@ -86,7 +86,7 @@ func (cmd Cmd) Wait() error {
// Exec executes the command and wait for its completion or until the context
// is done. In any case, it kills the unix process and all its children.
func (cmd Cmd) Exec() (int, error) {
func (cmd *Cmd) Exec() (int, error) {
if cmd.ctx == nil {
return 10, errors.New("nil context")
}
@@ -134,7 +134,7 @@ func (cmd Cmd) Exec() (int, error) {
// 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 {
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
@@ -154,11 +154,15 @@ func (cmd Cmd) pipeOutput() error {
// (either stdout or stderr).
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) {
r := bufio.NewReader(reader)
defer reader.Close()
defer func(reader io.ReadCloser) {
err := reader.Close()
if err != nil && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
}(reader)
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))
@@ -182,7 +186,7 @@ func (cmd Cmd) pipeOutput() error {
// 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 {
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.

View File

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

View File

@@ -5,7 +5,7 @@ import (
"reflect"
)
// Context is a struct which helps initializing modules. When provisioning, a
// Context is a struct which helps to initialize modules. When provisioning, a
// module may use the context to get other modules that it needs internally.
type Context struct {
flags ParsedFlags
@@ -13,8 +13,8 @@ type Context struct {
moduleInstances map[string]interface{}
}
// NewContext creates a Context.
// In a module, prefer the Provisioner interface to get a Context.
// NewContext creates a [Context].
// In a module, prefer the [Provisioner] interface to get a [Context].
func NewContext(
flags ParsedFlags,
descriptors []ModuleDescriptor,
@@ -32,7 +32,7 @@ func NewContext(
// flags := ctx.ParsedFlags()
// m.foo = flags.RequiredString("foo")
// }
func (ctx Context) ParsedFlags() ParsedFlags {
func (ctx *Context) ParsedFlags() ParsedFlags {
return ctx.flags
}
@@ -47,7 +47,6 @@ func (ctx Context) ParsedFlags() ParsedFlags {
// 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)
}
@@ -101,7 +100,7 @@ func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
}
// loadModule calls the Provision and/or Validate methods of the requested
// module if it satisfies the Provisioner and/or Validator interfaces.
// 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.

View File

@@ -5,191 +5,213 @@ import (
"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
for _, tc := range []struct {
scenario string
mods []ModuleDescriptor
kind interface{}
expectError bool
}{
{
scenario: "module with error on provision",
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ProvisionerMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return errors.New("foo") }
mod.ProvisionMock = func(ctx *Context) error { return errors.New("foo") }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
expectErr: true,
kind: new(Provisioner),
expectError: true,
},
{
scenario: "two modules instead of one",
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ProvisionerMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
mod.ProvisionMock = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
}(),
kind: new(Provisioner),
expectErr: true,
kind: new(Provisioner),
expectError: true,
},
{
scenario: "success",
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ProvisionerMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
mod.ProvisionMock = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
kind: new(Provisioner),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ctx := NewContext(ParsedFlags{}, tc.mods)
_, err := ctx.Module(tc.kind)
ctx := NewContext(ParsedFlags{}, tc.mods)
_, err := ctx.Module(tc.kind)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", 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)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestContext_Modules(t *testing.T) {
for i, tc := range []struct {
mods []ModuleDescriptor
kind interface{}
expectErr bool
for _, tc := range []struct {
scenario string
mods []ModuleDescriptor
kind interface{}
expectError bool
}{
{
scenario: "module with error on provision",
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ProvisionerMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return errors.New("foo") }
mod.ProvisionMock = func(ctx *Context) error { return errors.New("foo") }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
expectErr: true,
kind: new(Provisioner),
expectError: true,
},
{
scenario: "success (module)",
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ProvisionerMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
mod.ProvisionMock = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor(), mod.Descriptor()}
}(),
kind: new(Provisioner),
kind: new(Provisioner),
expectError: false,
},
{
scenario: "success (one module)",
mods: func() []ModuleDescriptor {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ProvisionerMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return nil }
mod.ProvisionMock = func(ctx *Context) error { return nil }
return []ModuleDescriptor{mod.Descriptor()}
}(),
kind: new(Provisioner),
kind: new(Provisioner),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ctx := NewContext(ParsedFlags{}, tc.mods)
_, err := ctx.Modules(tc.kind)
ctx := NewContext(ParsedFlags{}, tc.mods)
_, err := ctx.Modules(tc.kind)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", 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)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestContext_loadModule(t *testing.T) {
for i, tc := range []struct {
instance interface{}
expectErr bool
for _, tc := range []struct {
scenario string
instance interface{}
expectError bool
}{
{
scenario: "module with error on provision",
instance: func() interface{} {
mod := struct{ ProtoProvisioner }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ProvisionerMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.provision = func(ctx *Context) error { return errors.New("foo") }
mod.ProvisionMock = func(ctx *Context) error { return errors.New("foo") }
return mod
}(),
expectErr: true,
expectError: true,
},
{
scenario: "module with error on validation",
instance: func() interface{} {
mod := struct{ ProtoValidator }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ValidatorMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.validate = func() error { return errors.New("foo") }
mod.ValidateMock = func() error { return errors.New("foo") }
return mod
}(),
expectErr: true,
expectError: true,
},
{
scenario: "success",
instance: func() interface{} {
mod := struct{ ProtoValidator }{}
mod.descriptor = func() ModuleDescriptor {
mod := &struct {
ModuleMock
ValidatorMock
}{}
mod.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod }}
}
mod.validate = func() error { return nil }
mod.ValidateMock = func() error { return nil }
return mod
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ctx := NewContext(ParsedFlags{}, nil)
err := ctx.loadModule("foo", tc.instance)
ctx := NewContext(ParsedFlags{}, nil)
err := ctx.loadModule("foo", tc.instance)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", 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)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}

68
pkg/gotenberg/debug.go Normal file
View File

@@ -0,0 +1,68 @@
package gotenberg
import (
"runtime"
"sort"
"sync"
flag "github.com/spf13/pflag"
)
// DebugInfo gathers data for debugging.
type DebugInfo struct {
Version string `json:"version"`
Architecture string `json:"architecture"`
Modules []string `json:"modules"`
ModulesAdditionalData map[string]map[string]interface{} `json:"modules_additional_data"`
Flags map[string]interface{} `json:"flags"`
}
// BuildDebug builds the debug data from modules.
func BuildDebug(ctx *Context) {
debugMu.Lock()
defer debugMu.Unlock()
debug = &DebugInfo{
Version: Version,
Architecture: runtime.GOARCH,
Modules: make([]string, len(ctx.moduleInstances)),
ModulesAdditionalData: make(map[string]map[string]interface{}),
Flags: make(map[string]interface{}),
}
i := 0
for ID, mod := range ctx.moduleInstances {
debug.Modules[i] = ID
i++
debuggable, ok := mod.(Debuggable)
if !ok {
continue
}
debug.ModulesAdditionalData[ID] = debuggable.Debug()
}
sort.Sort(AlphanumericSort(debug.Modules))
ctx.ParsedFlags().VisitAll(func(f *flag.Flag) {
debug.Flags[f.Name] = f.Value.String()
})
}
// Debug returns the debug data.
func Debug() DebugInfo {
debugMu.Lock()
defer debugMu.Unlock()
if debug == nil {
return DebugInfo{}
}
return *debug
}
var (
debug *DebugInfo
debugMu sync.Mutex
)

View File

@@ -0,0 +1,72 @@
package gotenberg
import (
"reflect"
"runtime"
"testing"
flag "github.com/spf13/pflag"
)
func TestBuildDebug(t *testing.T) {
if !reflect.DeepEqual(Debug(), DebugInfo{}) {
t.Errorf("Debug() should return empty debug data")
}
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
fs.String("foo", "bar", "Set foo")
ctx := NewContext(ParsedFlags{
FlagSet: fs,
}, func() []ModuleDescriptor {
mod1 := &struct {
ModuleMock
}{}
mod1.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module { return mod1 }}
}
mod2 := &struct {
ModuleMock
DebuggableMock
}{}
mod2.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "bar", New: func() Module { return mod2 }}
}
mod2.DebugMock = func() map[string]interface{} {
return map[string]interface{}{
"foo": "bar",
}
}
return []ModuleDescriptor{mod1.Descriptor(), mod2.Descriptor()}
}())
// Load modules.
_, err := ctx.Modules(new(Module))
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
// Build debug data.
BuildDebug(ctx)
expect := DebugInfo{
Version: Version,
Architecture: runtime.GOARCH,
Modules: []string{
"bar",
"foo",
},
ModulesAdditionalData: map[string]map[string]interface{}{
"bar": {
"foo": "bar",
},
},
Flags: map[string]interface{}{
"foo": "bar",
},
}
if !reflect.DeepEqual(expect, Debug()) {
t.Errorf("expected '%+v', bug got '%+v'", expect, Debug())
}
}

35
pkg/gotenberg/env.go Normal file
View File

@@ -0,0 +1,35 @@
package gotenberg
import (
"fmt"
"os"
"strconv"
)
// StringEnv retrieves the value of the environment variable named by the key.
// If the variable is present in the environment and not empty, the value is
// returned.
func StringEnv(key string) (string, error) {
val, ok := os.LookupEnv(key)
if !ok {
return "", fmt.Errorf("environment variable '%s' does not exist", key)
}
if val == "" {
return "", fmt.Errorf("environment variable '%s' is empty", key)
}
return val, nil
}
// IntEnv relies on [StringEnv] and converts the values if it exists and is not
// empty.
func IntEnv(key string) (int, error) {
val, err := StringEnv(key)
if err != nil {
return 0, err
}
intVal, err := strconv.Atoi(val)
if err != nil {
return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err)
}
return intVal, nil
}

134
pkg/gotenberg/env_test.go Normal file
View File

@@ -0,0 +1,134 @@
package gotenberg
import (
"os"
"testing"
)
func TestStringEnv(t *testing.T) {
for _, tc := range []struct {
scenario string
key string
setEnv func()
expectVal string
expectError bool
}{
{
scenario: "non-existing environment variable",
key: "NON_EXISTING",
expectVal: "",
expectError: true,
},
{
scenario: "empty environment variable",
key: "EMPTY_STRING",
setEnv: func() {
err := os.Setenv("EMPTY_STRING", "")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: "",
expectError: true,
},
{
scenario: "success",
key: "EXISTING_STRING_VALUE",
setEnv: func() {
err := os.Setenv("EXISTING_STRING_VALUE", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: "foo",
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.setEnv != nil {
tc.setEnv()
}
val, err := StringEnv(tc.key)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectVal != val {
t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val)
}
})
}
}
func TestIntEnv(t *testing.T) {
for _, tc := range []struct {
scenario string
key string
setEnv func()
expectVal int
expectError bool
}{
{
scenario: "empty environment variable",
key: "EMPTY_INT",
setEnv: func() {
err := os.Setenv("EMPTY_INT", "")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: 0,
expectError: true,
},
{
scenario: "non-integer value",
key: "NON_INTEGER",
setEnv: func() {
err := os.Setenv("NON_INTEGER", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: 0,
expectError: true,
},
{
scenario: "success",
key: "EXISTING_INT_VALUE",
setEnv: func() {
err := os.Setenv("EXISTING_INT_VALUE", "123")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: 123,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.setEnv != nil {
tc.setEnv()
}
val, err := IntEnv(tc.key)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectVal != val {
t.Errorf("expected value %d but got %d", tc.expectVal, val)
}
})
}
}

53
pkg/gotenberg/filter.go Normal file
View File

@@ -0,0 +1,53 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"time"
"github.com/dlclark/regexp2"
)
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
var ErrFiltered = errors.New("value filtered")
// FilterDeadline checks if given value is allowed and not denied according to
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
// to process.
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
if allowed.String() != "" {
allow := regexp2.MustCompile(allowed.String(), 0)
allow.MatchTimeout = time.Until(deadline)
ok, err := allow.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
}
if !ok {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
}
}
if denied.String() != "" {
deny := regexp2.MustCompile(denied.String(), 0)
deny.MatchTimeout = time.Until(deadline)
ok, err := deny.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
}
}
return nil
}

View File

@@ -0,0 +1,83 @@
package gotenberg
import (
"context"
"errors"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func TestFilterDeadline(t *testing.T) {
for _, tc := range []struct {
scenario string
allowed *regexp2.Regexp
denied *regexp2.Regexp
s string
deadline time.Time
expectError bool
expectedError error
}{
{
scenario: "DeadlineExceeded (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
expectedError: context.DeadlineExceeded,
},
{
scenario: "ErrFiltered (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
s: "bar",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "DeadlineExceeded (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
expectedError: context.DeadlineExceeded,
},
{
scenario: "ErrFiltered (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "success",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
if tc.expectError && err == nil {
t.Fatal("expected an error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}

View File

@@ -1,14 +1,14 @@
package gotenberg
import (
"regexp"
"time"
"github.com/dlclark/regexp2"
"github.com/labstack/gommon/bytes"
flag "github.com/spf13/pflag"
)
// ParsedFlags wraps a flag.FlagSet so that retrieving the typed values is
// ParsedFlags wraps a [flag.FlagSet] so that retrieving the typed values is
// easier.
type ParsedFlags struct {
*flag.FlagSet
@@ -80,6 +80,28 @@ func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool
return f.MustBool(newName)
}
// MustInt64 returns the int64 value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustInt64(name string) int64 {
val, err := f.GetInt64(name)
if err != nil {
panic(err)
}
return val
}
// MustDeprecatedInt64 returns the int64 value of a deprecated flag if it was
// explicitly set or the int64 value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedInt64(deprecated string, newName string) int64 {
if f.Changed(deprecated) {
return f.MustInt64(deprecated)
}
return f.MustInt64(newName)
}
// MustInt returns the int value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustInt(name string) int {
@@ -146,50 +168,54 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
return f.MustDuration(newName)
}
// MustHumanReadableBytesString returns the human-readable bytes string of a
// flag given by name.
// MustHumanReadableBytes 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 {
func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
val, err := f.GetString(name)
if err != nil {
panic(err)
}
_, err = bytes.Parse(val)
if val == "" {
return 0
}
b, err := bytes.Parse(val)
if err != nil {
panic(err)
}
return val
return b
}
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
// string of a deprecated flag if it was explicitly set or the human-readable
// bytes string of the new flag.
// MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
// deprecated flag if it was explicitly set or the human-readable bytes string
// of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
if f.Changed(deprecated) {
return f.MustHumanReadableBytesString(deprecated)
return f.MustHumanReadableBytes(deprecated)
}
return f.MustHumanReadableBytesString(newName)
return f.MustHumanReadableBytes(newName)
}
// MustRegexp returns the regular expression of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
val, err := f.GetString(name)
if err != nil {
panic(err)
}
return regexp.MustCompile(val)
return regexp2.MustCompile(val, 0)
}
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
// it was explicitly set or the regular expression of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp2.Regexp {
if f.Changed(deprecated) {
return f.MustRegexp(deprecated)
}

View File

@@ -20,23 +20,27 @@ func TestParsedFlags_MustString(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -44,49 +48,55 @@ func TestParsedFlags_MustString(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustString(tc.name)
}()
})
}
}
func TestParsedFlags_MustDeprecatedString(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue string
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=foo"},
expectValue: "foo",
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=bar"},
expectValue: "bar",
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: "foo",
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedString("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
actual := parsedFlags.MustDeprecatedString("foo", "bar")
if actual != tc.expectValue {
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
}
})
}
}
@@ -101,23 +111,27 @@ func TestParsedFlags_MustStringSlice(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -125,49 +139,55 @@ func TestParsedFlags_MustStringSlice(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustStringSlice(tc.name)
}()
})
}
}
func TestParsedFlags_MustDeprecatedStringSlice(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue []string
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=foo"},
expectValue: []string{"foo"},
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=bar"},
expectValue: []string{"bar"},
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: []string{"foo"},
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", make([]string, 0), "")
fs.StringSlice("bar", make([]string, 0), "")
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", make([]string, 0), "")
fs.StringSlice("bar", make([]string, 0), "")
parsedFlags := ParsedFlags{FlagSet: fs}
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
if !reflect.DeepEqual(actual, tc.expectValue) {
t.Errorf("test %d: expected %+v but got %+v", i, tc.expectValue, actual)
}
actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
if !reflect.DeepEqual(actual, tc.expectValue) {
t.Errorf("expected %+v but got %+v", tc.expectValue, actual)
}
})
}
}
@@ -182,23 +202,27 @@ func TestParsedFlags_MustBool(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -206,48 +230,143 @@ func TestParsedFlags_MustBool(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustBool(tc.name)
}()
})
}
}
func TestParsedFlags_MustDeprecatedBool(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue bool
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=true"},
expectValue: true,
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=false"},
expectValue: false,
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=true", "--bar=false"},
expectValue: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Bool("foo", false, "")
fs.Bool("bar", true, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedBool("foo", "bar")
if actual != tc.expectValue {
t.Errorf("expected %v but got %v", tc.expectValue, actual)
}
})
}
}
func TestParsedFlags_MustInt64(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int64("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 _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic but got none")
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustInt64(tc.name)
})
}
}
func TestParsedFlags_MustDeprecatedInt64(t *testing.T) {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue int64
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1"},
expectValue: 1,
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2"},
expectValue: 2,
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1", "--bar=2"},
expectValue: 1,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Bool("foo", false, "")
fs.Bool("bar", true, "")
fs.Int64("foo", 0, "")
fs.Int64("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedBool("foo", "bar")
actual := parsedFlags.MustDeprecatedInt64("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %v but got %v", i, tc.expectValue, actual)
t.Errorf("expected %d but got %d", tc.expectValue, actual)
}
}
}
@@ -263,23 +382,27 @@ func TestParsedFlags_MustInt(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -287,49 +410,55 @@ func TestParsedFlags_MustInt(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustInt(tc.name)
}()
})
}
}
func TestParsedFlags_MustDeprecatedInt(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue int
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1"},
expectValue: 1,
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2"},
expectValue: 2,
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1", "--bar=2"},
expectValue: 1,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int("foo", 0, "")
fs.Int("bar", 0, "")
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int("foo", 0, "")
fs.Int("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedInt("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %d but got %d", i, tc.expectValue, actual)
}
actual := parsedFlags.MustDeprecatedInt("foo", "bar")
if actual != tc.expectValue {
t.Errorf("expected %d but got %d", tc.expectValue, actual)
}
})
}
}
@@ -344,23 +473,27 @@ func TestParsedFlags_MustFloat64(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -368,49 +501,55 @@ func TestParsedFlags_MustFloat64(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustFloat64(tc.name)
}()
})
}
}
func TestParsedFlags_MustDeprecatedFloat64(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue float64
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1.0"},
expectValue: 1.0,
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2.0"},
expectValue: 2.0,
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1.0", "--bar=2.0"},
expectValue: 1.0,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Float64("foo", 0, "")
fs.Float64("bar", 0, "")
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Float64("foo", 0, "")
fs.Float64("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %f but got %f", i, tc.expectValue, actual)
}
actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
if actual != tc.expectValue {
t.Errorf("expected %f but got %f", tc.expectValue, actual)
}
})
}
}
@@ -425,23 +564,27 @@ func TestParsedFlags_MustDuration(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -449,56 +592,63 @@ func TestParsedFlags_MustDuration(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustDuration(tc.name)
}()
})
}
}
func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue time.Duration
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1s"},
expectValue: time.Duration(1) * time.Second,
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2s"},
expectValue: time.Duration(2) * time.Second,
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1s", "--bar=2s"},
expectValue: time.Duration(1) * time.Second,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Duration("foo", 0, "")
fs.Duration("bar", 0, "")
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Duration("foo", 0, "")
fs.Duration("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
if actual != tc.expectValue {
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
}
})
}
}
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
func TestParsedFlags_MustHumanReadableBytes(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "1MB", "")
fs.String("bar", "1MB", "")
fs.String("qux", "", "")
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
if err != nil {
@@ -507,27 +657,32 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
{
name: "baz",
expectPanic: true,
scenario: "success: empty value",
name: "qux",
expectPanic: false,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -535,49 +690,55 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustHumanReadableBytesString(tc.name)
}()
parsedFlags.MustHumanReadableBytes(tc.name)
})
}
}
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
for i, tc := range []struct {
func TestParsedFlags_MustDeprecatedHumanReadableBytes(t *testing.T) {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue string
expectValue int64
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1MB"},
expectValue: "1MB",
expectValue: 1000000,
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2MB"},
expectValue: "2MB",
expectValue: 2000000,
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
expectValue: "1MB",
expectValue: 1000000,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
actual := parsedFlags.MustDeprecatedHumanReadableBytes("foo", "bar")
if actual != tc.expectValue {
t.Errorf("expected %d but got %d", tc.expectValue, actual)
}
})
}
}
@@ -593,27 +754,27 @@ func TestParsedFlags_MustRegexp(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
name string
expectPanic bool
}{
{
name: "foo",
scenario: "success",
name: "foo",
expectPanic: false,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
{
name: "baz",
expectPanic: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Fatal("expected panic but got none")
}
}()
}
@@ -621,48 +782,54 @@ func TestParsedFlags_MustRegexp(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
parsedFlags.MustRegexp(tc.name)
}()
})
}
}
func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
rawFlags []string
expectValue *regexp.Regexp
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=foo"},
expectValue: regexp.MustCompile("foo"),
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=bar"},
expectValue: regexp.MustCompile("bar"),
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: regexp.MustCompile("foo"),
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
if actual.String() != tc.expectValue.String() {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue.String(), actual.String())
}
actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
if actual.String() != tc.expectValue.String() {
t.Errorf("expected '%s' but got '%s'", tc.expectValue.String(), actual.String())
}
})
}
}

View File

@@ -7,27 +7,85 @@ import (
"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()
// MkdirAll defines the method signature for create a directory. Implement this
// interface if you don't want to rely on [os.MkdirAll], notably for testing
// purpose.
type MkdirAll interface {
// MkdirAll uses the same signature as [os.MkdirAll].
MkdirAll(path string, perm os.FileMode) error
}
// NewDirPath returns a random absolute path based on the temporary path.
func NewDirPath() string {
return fmt.Sprintf("%s/%s", TmpPath(), uuid.New())
// OsMkdirAll implements the [MkdirAll] interface with [os.MkdirAll].
type OsMkdirAll struct{}
// MkdirAll is a wrapper around [os.MkdirAll].
func (o *OsMkdirAll) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) }
// PathRename defines the method signature for renaming files. Implement this
// interface if you don't want to rely on [os.Rename], notably for testing
// purpose.
type PathRename interface {
// Rename uses the same signature as [os.Rename].
Rename(oldpath, newpath string) error
}
// MkdirAll creates a random directory based on the temporary path and
// returns its absolute path.
func MkdirAll() (string, error) {
path := NewDirPath()
// OsPathRename implements the [PathRename] interface with [os.Rename].
type OsPathRename struct{}
err := os.MkdirAll(path, 0755)
// Rename is a wrapper around [os.Rename].
func (o *OsPathRename) Rename(oldpath, newpath string) error {
return os.Rename(oldpath, newpath)
}
// FileSystem provides utilities for managing temporary directories. It creates
// unique directory names based on UUIDs to ensure isolation of temporary files
// for different modules.
type FileSystem struct {
workingDir string
mkdirAll MkdirAll
}
// NewFileSystem initializes a new [FileSystem] instance with a unique working
// directory.
func NewFileSystem(mkdirAll MkdirAll) *FileSystem {
return &FileSystem{
workingDir: uuid.NewString(),
mkdirAll: mkdirAll,
}
}
// WorkingDir returns the unique name of the working directory.
func (fs *FileSystem) WorkingDir() string {
return fs.workingDir
}
// WorkingDirPath constructs and returns the full path to the working directory
// inside the system's temporary directory.
func (fs *FileSystem) WorkingDirPath() string {
return fmt.Sprintf("%s/%s", os.TempDir(), fs.workingDir)
}
// NewDirPath generates a new unique path for a directory inside the working
// directory.
func (fs *FileSystem) NewDirPath() string {
return fmt.Sprintf("%s/%s", fs.WorkingDirPath(), uuid.NewString())
}
// MkdirAll creates a new unique directory inside the working directory and
// returns its path. If the directory creation fails, an error is returned.
func (fs *FileSystem) MkdirAll() (string, error) {
path := fs.NewDirPath()
err := fs.mkdirAll.MkdirAll(path, 0o755)
if err != nil {
return "", fmt.Errorf("create directory %s: %w", path, err)
}
return path, nil
}
// Interface guards.
var (
_ MkdirAll = (*OsMkdirAll)(nil)
_ PathRename = (*OsPathRename)(nil)
)

View File

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

54
pkg/gotenberg/gc.go Normal file
View File

@@ -0,0 +1,54 @@
package gotenberg
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
// GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings and before a given experiation time.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.Named("gc")
// To make sure that the next Walk method stays on
// the root level of the considered path, we have to
// return a filepath.SkipDir error if the current path
// is a directory.
skipDirOrNil := func(info os.FileInfo) error {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
return filepath.Walk(rootPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if path == rootPath {
return nil
}
for _, substr := range includeSubstr {
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
err := os.RemoveAll(path)
if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err)
}
logger.Debug(fmt.Sprintf("'%s' removed", path))
return skipDirOrNil(info)
}
}
return skipDirOrNil(info)
})
}

100
pkg/gotenberg/gc_test.go Normal file
View File

@@ -0,0 +1,100 @@
package gotenberg
import (
"fmt"
"os"
"path"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
func TestGarbageCollect(t *testing.T) {
for _, tc := range []struct {
scenario string
rootPath string
includeSubstr []string
expectError bool
expectNotExists []string
expectExists []string
}{
{
scenario: "root path does not exist",
rootPath: uuid.NewString(),
expectError: true,
},
{
scenario: "remove include substrings",
rootPath: func() string {
path := fmt.Sprintf("%s/a_directory", os.TempDir())
err := os.MkdirAll(path, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_bar_file", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_baz_file", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return path
}(),
includeSubstr: []string{"foo", path.Join(os.TempDir(), "/a_directory/a_bar_file")},
expectError: false,
expectExists: []string{"a_baz_file"},
expectNotExists: []string{"a_foo_file", "a_bar_file"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.rootPath)
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectError && err != nil {
return
}
for _, name := range tc.expectNotExists {
path := fmt.Sprintf("%s/%s", tc.rootPath, name)
_, err = os.Stat(path)
if !os.IsNotExist(err) {
t.Errorf("expected '%s' not to exist but it does: %v", path, err)
}
}
for _, name := range tc.expectExists {
path := fmt.Sprintf("%s/%s", tc.rootPath, name)
_, err = os.Stat(path)
if os.IsNotExist(err) {
t.Errorf("expected '%s' to exist but it does not: %v", path, err)
}
}
})
}
}

View File

@@ -1,9 +1,14 @@
package gotenberg
import "go.uber.org/zap"
import (
"fmt"
"github.com/hashicorp/go-retryablehttp"
"go.uber.org/zap"
)
// LoggerProvider is an interface for a module that supplies a method for
// creating a zap.Logger instance for use by other modules.
// creating a [zap.Logger] instance for use by other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
@@ -12,3 +17,41 @@ import "go.uber.org/zap"
type LoggerProvider interface {
Logger(mod Module) (*zap.Logger, error)
}
// LeveledLogger is wrapper around a [zap.Logger] so that it may be used by a
// [retryablehttp.Client].
type LeveledLogger struct {
logger *zap.Logger
}
// NewLeveledLogger instantiates a [LeveledLogger].
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
return &LeveledLogger{
logger: logger,
}
}
// Error logs a message at error level using the wrapped zap.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at warning level using the wrapped zap.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at info level using the wrapped zap.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at debug level using the wrapped zap.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
)

View File

@@ -15,7 +15,7 @@ type Metric struct {
Read func() float64
}
// MetricsProvider is a module interface which provides a list of Metric.
// MetricsProvider is a module interface which provides a list of [Metric].
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.MetricsProvider))

View File

@@ -2,65 +2,190 @@ package gotenberg
import (
"context"
"os"
"go.uber.org/zap"
)
// ModuleMock is a mock for the Module interface.
// ModuleMock is a mock for the [Module] interface.
type ModuleMock struct {
DescriptorMock func() ModuleDescriptor
}
func (mod ModuleMock) Descriptor() ModuleDescriptor {
func (mod *ModuleMock) Descriptor() ModuleDescriptor {
return mod.DescriptorMock()
}
// ValidatorMock is a mock for the Validator interface.
// ProvisionerMock is a mock for the [Provisioner] interface.
type ProvisionerMock struct {
ProvisionMock func(*Context) error
}
func (mod *ProvisionerMock) Provision(ctx *Context) error {
return mod.ProvisionMock(ctx)
}
// ValidatorMock is a mock for the [Validator] interface.
type ValidatorMock struct {
ValidateMock func() error
}
func (mod ValidatorMock) Validate() error {
func (mod *ValidatorMock) Validate() error {
return mod.ValidateMock()
}
// PDFEngineMock is a mock for the PDFEngine interface.
type PDFEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
type DebuggableMock struct {
DebugMock func() map[string]interface{}
}
func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (mod *DebuggableMock) Debug() map[string]interface{} {
return mod.DebugMock()
}
// PdfEngineMock is a mock for the [PdfEngine] interface.
//
//nolint:dupl
type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
}
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
}
func (engine PDFEngineMock) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, format, inputPath, outputPath)
func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
}
// PDFEngineProviderMock is a mock for the PDFEngineProvider interface.
type PDFEngineProviderMock struct {
PDFEngineMock func() (PDFEngine, error)
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return engine.FlattenMock(ctx, logger, inputPath)
}
func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) {
return provider.PDFEngineMock()
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
// LoggerProviderMock is a mock for the LoggerProvider interface.
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return engine.ReadMetadataMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
}
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
type PdfEngineProviderMock struct {
PdfEngineMock func() (PdfEngine, error)
}
func (provider *PdfEngineProviderMock) PdfEngine() (PdfEngine, error) {
return provider.PdfEngineMock()
}
// ProcessMock is a mock for the [Process] interface.
type ProcessMock struct {
StartMock func(logger *zap.Logger) error
StopMock func(logger *zap.Logger) error
HealthyMock func(logger *zap.Logger) bool
}
func (p *ProcessMock) Start(logger *zap.Logger) error {
return p.StartMock(logger)
}
func (p *ProcessMock) Stop(logger *zap.Logger) error {
return p.StopMock(logger)
}
func (p *ProcessMock) Healthy(logger *zap.Logger) bool {
return p.HealthyMock(logger)
}
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
type ProcessSupervisorMock struct {
LaunchMock func() error
ShutdownMock func() error
HealthyMock func() bool
RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error
ReqQueueSizeMock func() int64
RestartsCountMock func() int64
}
func (s *ProcessSupervisorMock) Launch() error {
return s.LaunchMock()
}
func (s *ProcessSupervisorMock) Shutdown() error {
return s.ShutdownMock()
}
func (s *ProcessSupervisorMock) Healthy() bool {
return s.HealthyMock()
}
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
return s.RunMock(ctx, logger, task)
}
func (s *ProcessSupervisorMock) ReqQueueSize() int64 {
return s.ReqQueueSizeMock()
}
func (s *ProcessSupervisorMock) RestartsCount() int64 {
return s.RestartsCountMock()
}
// LoggerProviderMock is a mock for the [LoggerProvider] interface.
type LoggerProviderMock struct {
LoggerMock func(mod Module) (*zap.Logger, error)
}
func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
return provider.LoggerMock(mod)
}
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
type MetricsProviderMock struct {
MetricsMock func() ([]Metric, error)
}
func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
return provider.MetricsMock()
}
// MkdirAllMock is a mock for the [MkdirAll] interface.
type MkdirAllMock struct {
MkdirAllMock func(path string, perm os.FileMode) error
}
func (mkdirAll *MkdirAllMock) MkdirAll(path string, perm os.FileMode) error {
return mkdirAll.MkdirAllMock(path, perm)
}
// PathRenameMock is a mock for the [PathRename] interface.
type PathRenameMock struct {
RenameMock func(oldpath, newpath string) error
}
func (rename *PathRenameMock) Rename(oldpath, newpath string) error {
return rename.RenameMock(oldpath, newpath)
}
// Interface guards.
var (
_ Module = (*ModuleMock)(nil)
_ Validator = (*ValidatorMock)(nil)
_ PDFEngine = (*PDFEngineMock)(nil)
_ PDFEngineProvider = (*PDFEngineProviderMock)(nil)
_ PdfEngine = (*PdfEngineMock)(nil)
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
_ Process = (*ProcessMock)(nil)
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil)
)

View File

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

View File

@@ -75,6 +75,12 @@ type SystemLogger interface {
SystemMessages() []string
}
// Debuggable is a module interface for modules which want to provide
// additional debug data.
type Debuggable interface {
Debug() map[string]interface{}
}
// MustRegisterModule registers a module.
//
// To register a module, create an init() method in the module main go file:
@@ -83,7 +89,7 @@ type SystemLogger interface {
// gotenberg.MustRegisterModule(YourModule{})
// }
//
// Then, in the main command (github.com/gotenberg/gotenberg/v7/cmd/gotenberg),
// Then, in the main command (github.com/gotenberg/gotenberg/v8/cmd/gotenberg),
// import the module:
//
// imports (

View File

@@ -5,32 +5,6 @@ import (
"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{
@@ -38,44 +12,51 @@ func TestMustRegisterModule(t *testing.T) {
}
descriptorsMu.RUnlock()
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
ID string
New func() Module
expectPanic bool
}{
{
scenario: "no ID",
ID: "",
New: func() Module { return new(ProtoModule) },
New: func() Module { return new(ModuleMock) },
expectPanic: true,
},
{
scenario: "nil New method",
ID: "b",
New: nil,
expectPanic: true,
},
{
scenario: "nil module",
ID: "b",
New: func() Module { return nil },
expectPanic: true,
},
{
scenario: "existing module",
ID: "a",
New: func() Module { return new(ProtoModule) },
New: func() Module { return new(ModuleMock) },
expectPanic: true,
},
{
ID: "b",
New: func() Module { return new(ProtoModule) },
scenario: "success",
ID: "b",
New: func() Module { return new(ModuleMock) },
expectPanic: false,
},
} {
func() {
mod := struct{ ProtoModule }{}
mod.descriptor = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
t.Run(tc.scenario, func(t *testing.T) {
mod := &struct{ ModuleMock }{}
mod.DescriptorMock = 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)
t.Error("expected panic but got none")
}
}()
}
@@ -83,13 +64,13 @@ func TestMustRegisterModule(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Errorf("expected no panic but got: %v", r)
}
}()
}
MustRegisterModule(mod)
}()
})
}
descriptorsMu.RLock()
@@ -124,12 +105,3 @@ func TestGetModuleDescriptors(t *testing.T) {
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

@@ -8,45 +8,123 @@ import (
)
var (
// ErrPDFEngineMethodNotAvailable happens if a PDFEngine method is not
// available in the implementation.
ErrPDFEngineMethodNotAvailable = errors.New("method not available")
// ErrPdfEngineMethodNotSupported is returned when a specific method of the
// PdfEngine interface is not supported by its current implementation.
ErrPdfEngineMethodNotSupported = errors.New("method not supported")
// ErrPDFFormatNotAvailable happens if a PDFEngine Convert's method does
// not handle a specific format.
ErrPDFFormatNotAvailable = errors.New("PDF format not available")
// ErrPdfSplitModeNotSupported is returned when the Split method of the
// PdfEngine interface does not sumport a requested PDF split mode.
ErrPdfSplitModeNotSupported = errors.New("split mode not supported")
// ErrPdfFormatNotSupported is returned when the Convert method of the
// PdfEngine interface does not support a requested PDF format conversion.
ErrPdfFormatNotSupported = errors.New("PDF format not supported")
// ErrPdfEngineMetadataValueNotSupported is returned when a metadata value
// is not supported.
ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported")
)
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"
// SplitModeIntervals represents a mode where a PDF is split at specific
// intervals.
SplitModeIntervals string = "intervals"
// SplitModePages represents a mode where a PDF is split at specific page
// ranges.
SplitModePages string = "pages"
)
// 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.
// SplitMode gathers the data required to split a PDF into multiple parts.
type SplitMode struct {
// Mode is either "intervals" or "pages".
Mode string
// Span is either the intervals or the page ranges to extract, depending on
// the selected mode.
Span string
// Unify specifies whether to put extracted pages into a single file or as
// many files as there are page ranges. Only works with "pages" mode.
Unify bool
}
const (
// PdfA1a represents the PDF/A-1a format.
PdfA1a string = "PDF/A-1a"
// PdfA1b represents the PDF/A-1b format.
PdfA1b string = "PDF/A-1b"
// PdfA2a represents the PDF/A-2a format.
PdfA2a string = "PDF/A-2a"
// PdfA2b represents the PDF/A-2b format.
PdfA2b string = "PDF/A-2b"
// PdfA2u represents the PDF/A-2u format.
PdfA2u string = "PDF/A-2u"
// PdfA3a represents the PDF/A-3a format.
PdfA3a string = "PDF/A-3a"
// PdfA3b represents the PDF/A-3b format.
PdfA3b string = "PDF/A-3b"
// PdfA3u represents the PDF/A-3u format.
PdfA3u string = "PDF/A-3u"
)
// PdfFormats specifies the target formats for a PDF conversion.
type PdfFormats struct {
// PdfA denotes the PDF/A standard format (e.g., PDF/A-1a).
PdfA string
// PdfUa indicates whether the PDF should comply
// with the PDF/UA (Universal Accessibility) standard.
PdfUa bool
}
// PdfEngine provides an interface for operations on PDFs. Implementations
// can utilize various tools like PDFtk, or implement functionality directly in
// Go.
//
//nolint:dupl
type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths.
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
// Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// Flatten merges existing annotation appearances with page content,
// effectively deleting the original annotations. This process can flatten
// forms as well, as forms share a relationship with annotations. Note that
// this operation is irreversible.
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
// Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
// ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
// WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
}
// PDFEngineProvider is a module interface which exposes a method for creating a
// PDFEngine for other modules.
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
// This is used to decouple the creation of a [PdfEngine] from its consumers.
//
// Example:
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.PDFEngineProvider))
// pdfengines, _ := provider.(gotenberg.PDFEngineProvider).PDFEngine()
// provider, _ := ctx.Module(new(gotenberg.PdfEngineProvider))
// engine, _ := provider.(gotenberg.PdfEngineProvider).PdfEngine()
// }
type PDFEngineProvider interface {
PDFEngine() (PDFEngine, error)
type PdfEngineProvider interface {
// PdfEngine returns an instance of the [PdfEngine] interface for PDF operations.
PdfEngine() (PdfEngine, error)
}

94
pkg/gotenberg/sort.go Normal file
View File

@@ -0,0 +1,94 @@
package gotenberg
import (
"regexp"
"sort"
"strconv"
)
// AlphanumericSort implements sort.Interface and helps to sort strings
// alphanumerically by either a numeric prefix or, if missing, a numeric
// suffix.
//
// See: https://github.com/gotenberg/gotenberg/issues/805.
type AlphanumericSort []string
func (s AlphanumericSort) Len() int {
return len(s)
}
func (s AlphanumericSort) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
func (s AlphanumericSort) Less(i, j int) bool {
numI, restI := extractNumber(s[i])
numJ, restJ := extractNumber(s[j])
// If both strings contain a number, compare them numerically.
if numI != -1 && numJ != -1 {
if numI != numJ {
return numI < numJ
}
// If the numbers are equal, compare the "rest" strings.
return restI < restJ
}
// If one contains a number and the other doesn't, the one with the number
// comes first.
if numI != -1 {
return true
}
if numJ != -1 {
return false
}
// Neither has a number; fall back to lexicographical order.
return s[i] < s[j]
}
// extractNumber attempts to extract a numeric portion from the filename.
// It first checks for a numeric prefix (digits at the beginning).
// If none is found, it next attempts to match a number immediately before the
// extension (for filenames such as "sample1_1.pdf").
// If that fails, it then attempts a trailing numeric pattern.
// If no number is found, it returns -1 and the original string.
func extractNumber(str string) (int, string) {
// Check for a numeric prefix.
if matches := prefixRegexp.FindStringSubmatch(str); len(matches) > 2 {
if num, err := strconv.Atoi(matches[1]); err == nil {
return num, matches[2]
}
}
// Check for a number immediately before an extension.
if matches := extensionSuffixRegexp.FindStringSubmatch(str); len(matches) > 3 {
if num, err := strconv.Atoi(matches[2]); err == nil {
// Remove the numeric block but keep the extension.
return num, matches[1] + matches[3]
}
}
// Check for a trailing number (with no extension following).
if matches := suffixRegexp.FindStringSubmatch(str); len(matches) > 2 {
if num, err := strconv.Atoi(matches[2]); err == nil {
return num, matches[1]
}
}
// No numeric portion found.
return -1, str
}
// Regular expressions used by extractNumber.
var (
// Matches a numeric prefix: one or more digits at the start.
prefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
// Matches a numeric block immediately before a file extension.
extensionSuffixRegexp = regexp.MustCompile(`^(.*?)(\d+)(\.[^.]+)$`)
// Matches a trailing numeric sequence when there is no extension.
suffixRegexp = regexp.MustCompile(`^(.*?)(\d+)$`)
)
// Interface guard.
var _ sort.Interface = (*AlphanumericSort)(nil)

View File

@@ -0,0 +1,44 @@
package gotenberg
import (
"reflect"
"sort"
"testing"
)
func TestAlphanumericSort(t *testing.T) {
for _, tc := range []struct {
scenario string
values []string
expectedSort []string
}{
{
scenario: "numeric and letters",
values: []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
},
{
scenario: "numeric suffixes with extensions",
values: []string{"sample1_10.pdf", "sample1_11.pdf", "sample1_4.pdf", "sample1_3.pdf", "sample1_1.pdf", "sample1_2.pdf"},
expectedSort: []string{"sample1_1.pdf", "sample1_2.pdf", "sample1_3.pdf", "sample1_4.pdf", "sample1_10.pdf", "sample1_11.pdf"},
},
{
scenario: "numeric suffixes",
values: []string{"sample1_10", "sample1_11", "sample1_4", "sample1_3", "sample1_1", "sample1_2"},
expectedSort: []string{"sample1_1", "sample1_2", "sample1_3", "sample1_4", "sample1_10", "sample1_11"},
},
{
scenario: "hrtime (PHP library)",
values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},
expectedSort: []string{"245654773394369", "245654773394919", "245654773395039", "245654773395149", "245654773395259"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
sort.Sort(AlphanumericSort(tc.values))
if !reflect.DeepEqual(tc.values, tc.expectedSort) {
t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values)
}
})
}
}

298
pkg/gotenberg/supervisor.go Normal file
View File

@@ -0,0 +1,298 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"sync/atomic"
"go.uber.org/zap"
)
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
// to restart an already restarting [Process].
var ErrProcessAlreadyRestarting = errors.New("process already restarting")
// ErrMaximumQueueSizeExceeded happens if Run() is called but the maximum queue
// size is already used.
var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
// Process is an interface that represents an abstract process
// and provides methods for starting, stopping, and checking the health of the
// process.
//
// Implementations of this interface should handle the actual logic for
// starting, stopping, and ensuring the process's health.
type Process interface {
// Start initiates the process and returns an error if the process cannot
// be started.
Start(logger *zap.Logger) error
// Stop terminates the process and returns an error if the process cannot
// be stopped.
Stop(logger *zap.Logger) error
// Healthy checks the health of the process. It returns true if the process
// is healthy; otherwise, it returns false.
Healthy(logger *zap.Logger) bool
}
// ProcessSupervisor provides methods to manage a [Process], including
// starting, stopping, and ensuring its health.
//
// Additionally, it allows for the execution of tasks while managing the
// process's state and provides functionality for limiting the number of
// requests that can be handled by the process, as well as managing a request
// queue.
type ProcessSupervisor interface {
// Launch starts the managed [Process].
Launch() error
// Shutdown stops the managed [Process].
Shutdown() error
// Healthy checks and returns the health status of the managed [Process].
//
// If the process has not been started or is restarting, it is considered
// healthy and true is returned. Otherwise, it returns the health status of
// the actual process.
Healthy() bool
// Run executes a provided task while managing the state of the [Process].
//
// Run manages the request queue and may restart the process if it is not
// healthy or if the number of handled requests exceeds the maximum limit.
//
// It returns an error if the task cannot be run or if the process state
// cannot be managed properly.
Run(ctx context.Context, logger *zap.Logger, task func() error) error
// ReqQueueSize returns the current size of the request queue.
ReqQueueSize() int64
// RestartsCount returns the current number of restart.
RestartsCount() int64
}
type processSupervisor struct {
logger *zap.Logger
process Process
maxReqLimit int64
maxQueueSize int64
mutexChan chan struct{}
firstStart atomic.Bool
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
}
// NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
b := &processSupervisor{
logger: logger,
process: process,
mutexChan: make(chan struct{}, 1),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
b.restartsCounter.Store(0)
b.isRestarting.Store(false)
return b
}
func (s *processSupervisor) Launch() error {
s.logger.Debug("start process")
err := s.process.Start(s.logger)
if err != nil {
return fmt.Errorf("start process: %w", err)
}
s.firstStart.Store(true)
s.logger.Debug("process successfully started")
return nil
}
func (s *processSupervisor) Shutdown() error {
s.logger.Debug("shutdown process")
err := s.process.Stop(s.logger)
if err != nil {
return fmt.Errorf("shutdown process: %w", err)
}
s.logger.Debug("process successfully shutdown")
return nil
}
func (s *processSupervisor) restart() error {
if s.isRestarting.Load() {
s.logger.Debug("process already restarting, skip restart")
return ErrProcessAlreadyRestarting
}
s.logger.Debug("restart process")
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)
err := s.Shutdown()
if err != nil {
// No big deal? Chances are it's already stopped.
s.logger.Debug(fmt.Sprintf("stop process before restart: %s", err))
}
err = s.Launch()
if err != nil {
return fmt.Errorf("restart process: %w", err)
}
s.reqCounter.Store(0)
s.restartsCounter.Add(1)
s.logger.Debug("process successfully restarted")
return nil
}
func (s *processSupervisor) Healthy() bool {
if !s.firstStart.Load() {
// A non-started process is always healthy.
return true
}
if s.isRestarting.Load() {
// A restarting process is always healthy.
return true
}
return s.process.Healthy(s.logger)
}
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
// A user reported a potential issue:
//
// "Although the counting operation is atomic, nothing prevent 2 concurrent
// goroutines to retrieve the same 'currentQueueSize' and to compare its
// value against the max limit. Then, resulting queue size would be 1 above
// the allowed limit."
//
// However, he was unable to actually trigger this issue, even when sending
// a lot of requests.
//
// For now, the best option is to consider this issue to be unlikely to
// happen, and keep the code as it is because it is more readable this way.
//
// See https://github.com/gotenberg/gotenberg/issues/951.
currentQueueSize := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
}
s.reqQueueSize.Add(1)
for {
err := func() error {
select {
case s.mutexChan <- struct{}{}:
logger.Debug("process lock acquired")
s.reqQueueSize.Add(-1)
s.reqCounter.Add(1)
releaseMutexChan := true
defer func() {
if releaseMutexChan {
logger.Debug("process lock released")
<-s.mutexChan
}
}()
if !s.firstStart.Load() {
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
}
}
if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
err := s.runWithDeadline(ctx, task)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseMutexChan = false
go func() {
err := s.runWithDeadline(context.Background(), func() error {
return s.restart()
})
if err != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
}
logger.Debug("process lock released")
<-s.mutexChan
}()
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
return err
case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1)
return fmt.Errorf("acquire process lock: %w", ctx.Err())
}
}()
if errors.Is(err, ErrProcessAlreadyRestarting) {
logger.Debug("process is already restarting, trying to acquire process lock again...")
s.reqQueueSize.Add(1)
continue
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
return err
}
}
func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error {
runChan := make(chan error, 1)
go func() {
runChan <- task()
}()
for {
select {
case err := <-runChan:
return err
case <-ctx.Done():
return ctx.Err()
}
}
}
func (s *processSupervisor) ReqQueueSize() int64 {
return s.reqQueueSize.Load()
}
func (s *processSupervisor) RestartsCount() int64 {
return s.restartsCounter.Load()
}
// Interface guards.
var (
_ ProcessSupervisor = (*processSupervisor)(nil)
)

View File

@@ -0,0 +1,639 @@
package gotenberg
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
func TestProcessSupervisor_Launch(t *testing.T) {
for _, tc := range []struct {
scenario string
startError error
expectError bool
firstStartSet bool
}{
{
scenario: "successful launch",
startError: nil,
expectError: false,
firstStartSet: true,
},
{
scenario: "failed launch",
startError: errors.New("start error"),
expectError: true,
firstStartSet: false,
},
{
scenario: "process already started",
startError: nil,
expectError: false,
firstStartSet: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return tc.startError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
if tc.firstStartSet {
ps.firstStart.Store(true)
}
err := ps.Launch()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.firstStartSet && !ps.firstStart.Load() {
t.Error("expected firstStart to be set but it was not")
}
})
}
}
func TestProcessSupervisor_Shutdown(t *testing.T) {
for _, tc := range []struct {
scenario string
stopError error
expectError bool
}{
{
scenario: "successful shutdown",
stopError: nil,
expectError: false,
},
{
scenario: "failed shutdown",
stopError: errors.New("stop error"),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
StopMock: func(logger *zap.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0)
err := ps.Shutdown()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestProcessSupervisor_restart(t *testing.T) {
for _, tc := range []struct {
scenario string
initiallyRestarting bool
startError error
stopError error
expectError bool
expectedError error
}{
{
scenario: "already restarting",
initiallyRestarting: true,
expectError: true,
expectedError: ErrProcessAlreadyRestarting,
},
{
scenario: "successful restart",
startError: nil,
stopError: nil,
expectError: false,
},
{
scenario: "failed to stop during restart",
startError: nil,
stopError: errors.New("stop error"),
expectError: false,
},
{
scenario: "failed to start during restart",
startError: errors.New("start error"),
stopError: nil,
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
if tc.initiallyRestarting {
ps.isRestarting.Store(true)
}
err := ps.restart()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}
func TestProcessSupervisor_Healthy(t *testing.T) {
for _, tc := range []struct {
scenario string
initiallyStarted bool
initiallyRestarting bool
processHealthy bool
expectHealthy bool
}{
{
scenario: "non-started process is always healthy",
initiallyStarted: false,
expectHealthy: true,
},
{
scenario: "restarting process is always healthy",
initiallyStarted: true,
initiallyRestarting: true,
expectHealthy: true,
},
{
scenario: "process reports as healthy",
initiallyStarted: true,
processHealthy: true,
expectHealthy: true,
},
{
scenario: "process reports as unhealthy",
initiallyStarted: true,
processHealthy: false,
expectHealthy: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
HealthyMock: func(logger *zap.Logger) bool {
return tc.processHealthy
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
if tc.initiallyRestarting {
ps.isRestarting.Store(true)
}
healthy := ps.Healthy()
if healthy != tc.expectHealthy {
t.Fatalf("expected healthy to be %v but got %v", tc.expectHealthy, healthy)
}
})
}
}
func TestProcessSupervisor_Run(t *testing.T) {
for _, tc := range []struct {
scenario string
initiallyStarted bool
isRestarting bool
startError error
processHealthy bool
maxReqLimit int64
tasksToRun int
taskError error
expectError bool
skipCallsCheck bool
expectedStartCalls int64
expectedHealthyCalls int64
expectedStopCalls int64
currentQueueSize int64
maxQueueSize int64
}{
{
scenario: "successfully run task on non-started process",
initiallyStarted: false,
isRestarting: false,
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 1,
expectError: false,
expectedStartCalls: 1,
expectedHealthyCalls: 1,
expectedStopCalls: 0,
},
{
scenario: "cannot launch non-started process",
initiallyStarted: false,
isRestarting: false,
startError: errors.New("launch error"),
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 1,
expectError: true,
expectedStartCalls: 1,
expectedHealthyCalls: 0,
expectedStopCalls: 0,
},
{
scenario: "run task with unhealthy process causing restart",
initiallyStarted: true,
isRestarting: false,
processHealthy: false,
maxReqLimit: 2,
tasksToRun: 1,
expectError: false,
expectedStartCalls: 1,
expectedHealthyCalls: 1,
expectedStopCalls: 1,
},
{
scenario: "cannot restart unhealthy process",
startError: errors.New("start error"),
initiallyStarted: true,
isRestarting: false,
processHealthy: false,
maxReqLimit: 2,
tasksToRun: 1,
expectError: true,
expectedStartCalls: 1,
expectedHealthyCalls: 1,
expectedStopCalls: 1,
},
{
scenario: "ErrProcessAlreadyRestarting",
initiallyStarted: true,
isRestarting: true,
processHealthy: false,
maxReqLimit: 1,
tasksToRun: 1,
expectError: true,
skipCallsCheck: true,
},
{
scenario: "run tasks reaching max request limit causing restart",
initiallyStarted: true,
isRestarting: false,
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 3,
expectError: false,
expectedStartCalls: 1,
expectedHealthyCalls: 3,
expectedStopCalls: 1,
},
{
scenario: "auto-restart after reaching max request limit",
startError: errors.New("start error"),
initiallyStarted: true,
isRestarting: false,
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 2,
expectError: true,
expectedStartCalls: 1,
expectedHealthyCalls: 2,
expectedStopCalls: 1,
},
{
scenario: "task error",
initiallyStarted: true,
isRestarting: false,
processHealthy: true,
maxReqLimit: 0,
tasksToRun: 1,
taskError: errors.New("task error"),
expectError: true,
expectedStartCalls: 0,
expectedHealthyCalls: 1,
expectedStopCalls: 0,
},
{
scenario: "queue size exceeded",
initiallyStarted: false,
isRestarting: false,
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 1,
expectError: true,
expectedStartCalls: 0,
expectedHealthyCalls: 0,
expectedStopCalls: 0,
currentQueueSize: 1,
maxQueueSize: 1,
},
{
scenario: "queue size not exceeded",
initiallyStarted: false,
isRestarting: false,
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 1,
expectError: true,
expectedStartCalls: 1,
expectedHealthyCalls: 1,
expectedStopCalls: 0,
currentQueueSize: 1,
maxQueueSize: 2,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
var startCalls, healthyCalls, stopCalls atomic.Int64
startCalls.Store(0)
healthyCalls.Store(0)
stopCalls.Store(0)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
startCalls.Add(1)
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
stopCalls.Add(1)
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
healthyCalls.Add(1)
return tc.processHealthy
},
}
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
if tc.isRestarting {
ps.isRestarting.Store(true)
}
if tc.currentQueueSize > 0 {
ps.reqQueueSize.Store(tc.currentQueueSize)
}
task := func() error {
return tc.taskError
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var wg sync.WaitGroup
errorChan := make(chan error, tc.tasksToRun)
for i := 0; i < tc.tasksToRun; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := ps.Run(ctx, logger, task)
if err != nil {
errorChan <- err
}
}()
}
wg.Wait()
close(errorChan)
for err := range errorChan {
if tc.expectError && err == nil {
t.Fatal("expected an error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}
if tc.skipCallsCheck {
return
}
// Making sure restarts are finished.
ps.mutexChan <- struct{}{}
<-ps.mutexChan
if startCalls.Load() != tc.expectedStartCalls {
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
}
if healthyCalls.Load() != tc.expectedHealthyCalls {
t.Errorf("expected %d process.Healthy calls, got %d", tc.expectedHealthyCalls, healthyCalls.Load())
}
if stopCalls.Load() != tc.expectedStopCalls {
t.Errorf("expected %d process.Stop calls, got %d", tc.expectedStopCalls, stopCalls.Load())
}
})
}
}
func TestProcessSupervisor_runWithDeadline(t *testing.T) {
for _, tc := range []struct {
scenario string
ctxDone bool
expectError bool
}{
{
scenario: "task finished",
ctxDone: false,
expectError: false,
},
{
scenario: "context expired",
ctxDone: true,
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone {
cancel()
} else {
defer cancel()
}
err := ps.runWithDeadline(ctx, func() error {
return nil
})
if tc.expectError && err == nil {
t.Fatal("expected an error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
// Simulating a lock.
ps.mutexChan <- struct{}{}
if ps.ReqQueueSize() != 0 {
t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wg sync.WaitGroup
errorChan := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
err := ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
errorChan <- err
}
}()
}
// We have to wait a little bit so that the request queue size may change.
time.Sleep(10 * time.Millisecond)
if ps.ReqQueueSize() != 10 {
t.Fatalf("expected queue size to be 10 but got %d", ps.ReqQueueSize())
}
wg.Wait()
close(errorChan)
for err := range errorChan {
if err == nil {
t.Error("expected a lock error but got none")
}
}
if ps.ReqQueueSize() != 0 {
t.Errorf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
}
}
func TestProcessSupervisor_RestartsCount(t *testing.T) {
for _, tc := range []struct {
scenario string
initialRestartsCount int64
restartAttempts int
startError error
stopError error
expectedRestartsCount int64
}{
{
scenario: "no restarts, counter remains 0",
initialRestartsCount: 0,
restartAttempts: 0,
expectedRestartsCount: 0,
},
{
scenario: "successful restart increases counter",
initialRestartsCount: 0,
restartAttempts: 1,
startError: nil,
stopError: nil,
expectedRestartsCount: 1,
},
{
scenario: "failed to stop during restart, no impact",
initialRestartsCount: 0,
restartAttempts: 1,
startError: nil,
stopError: errors.New("stop error"),
expectedRestartsCount: 1,
},
{
scenario: "multiple successful restarts",
initialRestartsCount: 0,
restartAttempts: 3,
startError: nil,
stopError: nil,
expectedRestartsCount: 3,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ {
_ = ps.restart()
}
actualRestartsCount := ps.RestartsCount()
if actualRestartsCount != tc.expectedRestartsCount {
t.Fatalf("expected restarts count to be %d, but got %d", tc.expectedRestartsCount, actualRestartsCount)
}
})
}
}

4
pkg/gotenberg/version.go Normal file
View File

@@ -0,0 +1,4 @@
package gotenberg
// Version is the... version of the Gotenberg application.
var Version = "snapshot"

View File

@@ -4,52 +4,68 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/gc"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func init() {
gotenberg.MustRegisterModule(API{})
gotenberg.MustRegisterModule(new(Api))
}
// API is a module which provides an HTTP server. Other modules may add routes,
// Api is a module which provides an HTTP server. Other modules may add routes,
// middlewares or health checks.
type API struct {
type Api struct {
port int
readTimeout time.Duration
writeTimeout time.Duration
bindIp string
tlsCertFile string
tlsKeyFile string
startTimeout time.Duration
bodyLimit int64
timeout time.Duration
rootPath string
traceHeader string
basicAuthUsername string
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool
enableDebugRoute bool
routes []Route
externalMiddlewares []Middleware
healthChecks []health.CheckerOption
gcGraceDuration time.Duration
readyFn []func() error
fs *gotenberg.FileSystem
logger *zap.Logger
srv *echo.Echo
}
// Router is a module interface which adds routes to the API.
type downloadFromConfig struct {
allowList *regexp2.Regexp
denyList *regexp2.Regexp
maxRetry int
disable bool
}
// Router is a module interface which adds routes to the [Api].
type Router interface {
Routes() ([]Route, error)
}
// Route represents a route from a Router.
// Route represents a route from a [Router].
type Route struct {
// Method is the HTTP method of the route (i.e., GET, POST, etc.).
// Required.
@@ -72,13 +88,13 @@ type Route struct {
Handler echo.HandlerFunc
}
// MiddlewareProvider is a module interface which adds middlewares to the API.
// MiddlewareProvider is a module interface which adds middlewares to the [Api].
type MiddlewareProvider interface {
Middlewares() ([]Middleware, error)
}
// MiddlewareStack is a type which helps to determine in which stack the
// middlewares provided by the MiddlewareProvider modules should be located.
// middlewares provided by the [MiddlewareProvider] modules should be located.
type MiddlewareStack uint32
const (
@@ -88,7 +104,7 @@ const (
)
// MiddlewarePriority is a type which helps to determine the execution order of
// middlewares provided by the MiddlewareProvider modules in a stack.
// middlewares provided by the [MiddlewareProvider] modules in a stack.
type MiddlewarePriority uint32
const (
@@ -99,7 +115,7 @@ const (
VeryHighPriority
)
// Middleware is a middleware which can be added to the API's middlewares
// Middleware is a middleware which can be added to the [Api]'s middlewares
// chain.
//
// middleware := Middleware{
@@ -126,13 +142,13 @@ const (
// }
type Middleware struct {
// Stack tells in which stack the middleware should be located.
// Default to DefaultStack.
// Default to [DefaultStack].
// Optional.
Stack MiddlewareStack
// Priority tells if the middleware should be positioned high or not in
// its stack.
// Default to VeryLowPriority.
// Default to [VeryLowPriority].
// Optional.
Priority MiddlewarePriority
@@ -147,77 +163,84 @@ type Middleware struct {
// See https://github.com/alexliesenfeld/health for more details.
type HealthChecker interface {
Checks() ([]health.CheckerOption, error)
Ready() error
}
// GarbageCollectorGraceDurationIncrementer is a module interface for
// increasing the grace duration provided by the API for the garbage collector.
type GarbageCollectorGraceDurationIncrementer interface {
AddGraceDuration() time.Duration
}
// Descriptor returns an API's module descriptor.
func (API) Descriptor() gotenberg.ModuleDescriptor {
// Descriptor returns an [Api]'s module descriptor.
func (a *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-bind-ip", "", "Set the IP address the API should bind to for incoming connections")
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
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-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
var err error
err = multierr.Append(err, fs.MarkDeprecated("api-read-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-process-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-write-timeout", "use api-timeout instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the api module: %v", err))
}
fs.Bool("api-enable-debug-route", false, "Enable the debug route")
return fs
}(),
New: func() gotenberg.Module { return new(API) },
New: func() gotenberg.Module { return new(Api) },
}
}
// Provision sets the module properties.
func (a *API) Provision(ctx *gotenberg.Context) error {
func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port")
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
a.bindIp = flags.MustString("api-bind-ip")
a.tlsCertFile = flags.MustString("api-tls-cert-file")
a.tlsKeyFile = flags.MustString("api-tls-key-file")
a.startTimeout = flags.MustDuration("api-start-timeout")
a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
// 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)
port, err := gotenberg.IntEnv(portEnvVar)
if err != nil {
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
return fmt.Errorf("get API port from env: %w", err)
}
a.port = port
}
// Enable basic auth?
enableBasicAuth := flags.MustBool("api-enable-basic-auth")
if enableBasicAuth {
basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME")
if err != nil {
return fmt.Errorf("get basic auth username from env: %w", err)
}
basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
if err != nil {
return fmt.Errorf("get basic auth password from env: %w", err)
}
a.basicAuthUsername = basicAuthUsername
a.basicAuthPassword = basicAuthPassword
}
// Get routes from modules.
mods, err := ctx.Modules(new(Router))
if err != nil {
@@ -281,20 +304,10 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
}
a.healthChecks = append(a.healthChecks, checks...)
a.readyFn = append(a.readyFn, healthChecker.Ready)
}
// Grace duration.
a.gcGraceDuration = a.timeout
mods, err = ctx.Modules(new(GarbageCollectorGraceDurationIncrementer))
if err != nil {
return fmt.Errorf("get garbage collector grace duration increments: %w", err)
}
for _, incrementer := range mods {
a.gcGraceDuration += incrementer.(GarbageCollectorGraceDurationIncrementer).AddGraceDuration()
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
@@ -307,11 +320,14 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
a.logger = logger
// File system.
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
return nil
}
// Validate validates the module properties.
func (a API) Validate() error {
func (a *Api) Validate() error {
var err error
if a.port < 1 || a.port > 65535 {
@@ -320,6 +336,16 @@ func (a API) Validate() error {
)
}
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = multierr.Append(err,
errors.New("both TLS certificate and key files must be set"),
)
}
if !strings.HasPrefix(a.rootPath, "/") {
err = multierr.Append(err,
errors.New("root path must start with /"),
@@ -342,8 +368,10 @@ func (a API) Validate() error {
return err
}
routesMap := make(map[string]string, len(a.routes)+1)
routesMap := make(map[string]string, len(a.routes)+3)
routesMap["/health"] = "/health"
routesMap["/version"] = "/version"
routesMap["/debug"] = "/debug"
for _, route := range a.routes {
if route.Path == "" {
@@ -383,14 +411,14 @@ func (a API) Validate() error {
}
// Start starts the HTTP server.
func (a *API) Start() error {
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.ReadTimeout = a.timeout
a.srv.Server.IdleTimeout = a.timeout
// See https://github.com/gotenberg/gotenberg/issues/396.
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
a.srv.Server.WriteTimeout = a.timeout + a.timeout
a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes.
@@ -425,19 +453,32 @@ func (a *API) Start() error {
a.srv.Pre(externalMiddleware.Handler)
case MultipartStack:
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
default:
case DefaultStack:
a.srv.Use(externalMiddleware.Handler)
}
}
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
// Basic auth?
var securityMiddleware echo.MiddlewareFunc
if a.basicAuthUsername != "" {
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
} else {
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}
// Add the modules' routes and their specific middlewares.
for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc
middlewares = append(middlewares, securityMiddleware)
if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.timeout))
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
@@ -454,22 +495,89 @@ func (a *API) Start() error {
)
}
// Let's not forget the health check route.
// Root route.
a.srv.GET(
a.rootPath,
func(c echo.Context) error {
return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the <a href="https://gotenberg.dev">documentation</a> to learn how to interact with it 🚀`)
},
securityMiddleware,
)
// Favicon route.
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"),
func(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
},
securityMiddleware,
)
// Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
healthCheckHandler := health.NewHandler(checker)
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker))
return echo.WrapHandler(healthCheckHandler)
}(),
hardTimeoutMiddleware(hardTimeout),
)
a.srv.HEAD(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
return echo.WrapHandler(healthCheckHandler)
}(),
hardTimeoutMiddleware(hardTimeout),
)
// ...the version route.
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "version"),
func(c echo.Context) error {
return c.String(http.StatusOK, gotenberg.Version)
},
securityMiddleware,
)
// ...and the debug route.
if a.enableDebugRoute {
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "debug"),
func(c echo.Context) error {
return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ")
},
securityMiddleware,
)
}
// Wait for all modules to be ready.
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
defer cancel()
eg, _ := errgroup.WithContext(ctx)
for _, f := range a.readyFn {
eg.Go(f)
}
err := eg.Wait()
if err != nil {
return fmt.Errorf("waiting for modules readiness: %w", err)
}
// As the following code is blocking, run it in a goroutine.
go func() {
server := &http2.Server{}
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
var err error
if a.tlsCertFile != "" && a.tlsKeyFile != "" {
// Start an HTTPS server (supports HTTP/2).
err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile)
} else {
// Start an HTTP/2 Cleartext (non-HTTPS) server.
server := &http2.Server{}
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
}
if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error())
}
@@ -479,26 +587,23 @@ func (a *API) Start() error {
}
// StartupMessage returns a custom startup message.
func (a API) StartupMessage() string {
return fmt.Sprintf("server listening on port %d", a.port)
func (a *Api) StartupMessage() string {
ip := a.bindIp
if a.bindIp == "" {
ip = "[::]"
}
return fmt.Sprintf("server started on %s:%d", ip, a.port)
}
// Stop stops the HTTP server.
func (a API) Stop(ctx context.Context) error {
func (a *Api) Stop(ctx context.Context) error {
return a.srv.Shutdown(ctx)
}
// GraceDuration updates the expiration time of files and directories parsed by
// the gc.GarbageCollector.
func (a API) GraceDuration() time.Duration {
return a.gcGraceDuration
}
// Interface guards.
var (
_ gotenberg.Module = (*API)(nil)
_ gotenberg.Provisioner = (*API)(nil)
_ gotenberg.Validator = (*API)(nil)
_ gotenberg.App = (*API)(nil)
_ gc.GarbageCollectorGraceDurationModifier = (*API)(nil)
_ gotenberg.Module = (*Api)(nil)
_ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil)
)

View File

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

View File

@@ -1,27 +1,29 @@
package api
import (
"compress/flate"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"unicode"
"github.com/google/uuid"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3"
"github.com/mholt/archives"
"go.uber.org/zap"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
var (
@@ -36,27 +38,76 @@ var (
// Context is the request context for a "multipart/form-data" requests.
type Context struct {
dirPath string
values map[string][]string
files map[string]string
dirPath string
values map[string][]string
files map[string]string
outputPaths []string
cancelled bool
cancelled bool
logger *zap.Logger
echoCtx echo.Context
logger *zap.Logger
echoCtx echo.Context
mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename
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) {
type trackingReader struct {
R io.Reader
AddReadBytes func(n int64) error
}
func (t *trackingReader) Read(p []byte) (int, error) {
n, err := t.R.Read(p)
if n > 0 {
errAddRead := t.AddReadBytes(int64(n))
if errAddRead != nil {
return n, fmt.Errorf("add read bytes: %w", errAddRead)
}
}
if err != nil {
// It's a common practice in Go to return io.EOF unwrapped to signal
// the end of a data stream. Wrapping it can lead to unexpected
// behavior in standard library functions.
return n, err
}
return n, nil
}
type downloadFrom struct {
// Url is the URL to download a file from.
Url string `json:"url"`
// ExtraHttpHeaders are the HTTP headers to send alongside.
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
}
// newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
// We want to make sure the multipart/form-data does not exceed a given
// limit. We consider: form fields (keys, values, files) and files
// downloaded remotely ("download from" feature).
var totalBytesRead atomic.Int64
addReadBytes := func(n int64) error {
newTotal := totalBytesRead.Add(n)
if bodyLimit != 0 && newTotal > bodyLimit {
return WrapError(
fmt.Errorf("body limit reached (> %d)", bodyLimit),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
)
}
return nil
}
ctx := &Context{
outputPaths: make([]string, 0),
cancelled: false,
logger: logger,
echoCtx: echoCtx,
mkdirAll: new(gotenberg.OsMkdirAll),
pathRename: new(gotenberg.OsPathRename),
Context: processCtx,
}
@@ -81,39 +132,51 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
return
}
ctx.logger.Debug(fmt.Sprintf("'%s' removed", ctx.dirPath))
ctx.logger.Debug(fmt.Sprintf("'%s' context's working directory 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'"),
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"),
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"),
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()
// This will ensure we do not exceed the body limit.
var formValuesSize int64
for key, valArray := range form.Value {
formValuesSize += int64(len(key))
for _, val := range valArray {
formValuesSize += int64(len(val))
}
}
err = addReadBytes(formValuesSize)
if err != nil {
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
}
dirPath, err := fs.MkdirAll()
if err != nil {
return nil, cancel, fmt.Errorf("create working directory: %w", err)
}
@@ -122,16 +185,151 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
ctx.values = form.Value
ctx.files = make(map[string]string)
copyToDisk := func(fh *multipart.FileHeader) error {
// Avoid directory traversal and normalize filename.
// See https://github.com/gotenberg/gotenberg/issues/104.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
// First, try to download files listed in the "downloadFrom" form field, if
// any.
raw, ok := ctx.values["downloadFrom"]
if !downloadFromCfg.disable && ok {
var dls []downloadFrom
err = json.Unmarshal([]byte(raw[0]), &dls)
if err != nil {
return fmt.Errorf("transform filename: %w", err)
return nil, cancel, WrapError(
fmt.Errorf("unmarshal json: %w", err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: %s", err)),
)
}
eg, _ := errgroup.WithContext(ctx)
for i, dl := range dls {
eg.Go(func() error {
deadline, ok := ctx.Deadline()
if !ok {
// Should not happen, as context is created with a timeout.
return errors.New("context has no deadline")
}
if strings.TrimSpace(dl.Url) == "" {
return WrapError(
errors.New("empty download from URL"),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field entry %d: URL must be set", i)),
)
}
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
if err != nil {
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
}
req.Header.Set("User-Agent", "Gotenberg")
for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(traceHeader, trace)
client := &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: time.Until(deadline),
},
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),
Logger: gotenberg.NewLeveledLogger(logger),
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
}
resp, err := client.Do(req)
if err != nil {
return WrapError(
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
)
}
defer func() {
err := resp.Body.Close()
if err != nil {
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
}
}()
if resp.StatusCode != http.StatusOK {
return WrapError(
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
)
}
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
return WrapError(
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
)
}
// FIXME: the implementation of this method might not be
// complete, as it fails to parse an empty mediatype.
// See: https://github.com/golang/go/issues/69551.
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
return WrapError(
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
)
}
filename, ok := params["filename"]
if !ok {
return WrapError(
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
)
}
// Avoid directory traversal and make sure filename characters are
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
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))
}
}()
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: resp.Body, AddReadBytes: addReadBytes}
_, err = io.Copy(out, reader)
if err != nil {
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
}
ctx.files[filename] = path
return nil
})
}
err = eg.Wait()
if err != nil {
return ctx, cancel, err
}
}
copyToDisk := func(fh *multipart.FileHeader) error {
in, err := fh.Open()
if err != nil {
return fmt.Errorf("open multipart file: %w", err)
@@ -144,13 +342,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
}
}()
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
// Avoid directory traversal and make sure filename characters are
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
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 {
@@ -158,7 +362,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
}
}()
_, err = io.Copy(out, in)
_, err = io.Copy(out, reader)
if err != nil {
return fmt.Errorf("copy multipart file to local file: %w", err)
}
@@ -168,29 +372,30 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
return nil
}
// Then, copy the form files, if any.
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))
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err
}
// Request returns the http.Request.
func (ctx Context) Request() *http.Request {
// Request returns the [http.Request].
func (ctx *Context) Request() *http.Request {
return ctx.echoCtx.Request()
}
// FormData return a FormData.
func (ctx Context) FormData() *FormData {
// FormData return a [FormData].
func (ctx *Context) FormData() *FormData {
return &FormData{
values: ctx.values,
files: ctx.files,
@@ -198,10 +403,32 @@ func (ctx Context) FormData() *FormData {
}
}
// 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)
// GeneratePath generates a path within the context's working directory.
// It generates a new UUID-based filename. It does not create a file.
func (ctx *Context) GeneratePath(extension string) string {
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
}
// CreateSubDirectory creates a subdirectory within the context's working
// directory.
func (ctx *Context) CreateSubDirectory(dirName string) (string, error) {
path := fmt.Sprintf("%s/%s", ctx.dirPath, dirName)
err := ctx.mkdirAll.MkdirAll(path, 0o755)
if err != nil {
return "", fmt.Errorf("create sub-directory %s: %w", path, err)
}
return path, nil
}
// Rename is just a wrapper around [os.Rename], as we need to mock this
// behavior in our tests.
func (ctx *Context) Rename(oldpath, newpath string) error {
ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath))
err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil {
return fmt.Errorf("rename path: %w", err)
}
return nil
}
// AddOutputPaths adds the given paths. Those paths will be used later to build
@@ -222,14 +449,14 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
return nil
}
// Log returns the context zap.Logger.
func (ctx Context) Log() *zap.Logger {
// 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) {
func (ctx *Context) BuildOutputFile() (string, error) {
if ctx.cancelled {
return "", ErrContextAlreadyClosed
}
@@ -240,22 +467,33 @@ func (ctx Context) BuildOutputFile() (string, error) {
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,
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
f := make(map[string]string)
for _, outputPath := range ctx.outputPaths {
f[outputPath] = ""
}
return f
}())
if err != nil {
return "", fmt.Errorf("create files info: %w", err)
}
archivePath := ctx.GeneratePath(".zip")
out, err := os.Create(archivePath)
if err != nil {
return "", fmt.Errorf("create zip file: %w", err)
}
defer func(out *os.File) {
err := out.Close()
if err != nil {
ctx.logger.Error(fmt.Sprintf("close zip file: %s", err))
}
}(out)
err := z.Archive(ctx.outputPaths, archivePath)
err = archives.Zip{}.Archive(ctx.Context, out, filesInfo)
if err != nil {
return "", fmt.Errorf("archive output files: %w", err)
}
@@ -267,7 +505,7 @@ func (ctx Context) BuildOutputFile() (string, error) {
// OutputFilename returns the filename based on the given output path or the
// "Gotenberg-Output-Filename" header's value.
func (ctx Context) OutputFilename(outputPath string) string {
func (ctx *Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename")
if filename == "" {

View File

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

View File

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

View File

@@ -7,9 +7,9 @@ import (
"testing"
)
func TestNewSentinelHTTPError(t *testing.T) {
actual := NewSentinelHTTPError(http.StatusInternalServerError, "foo")
expect := SentinelHTTPError{
func TestNewSentinelHttpError(t *testing.T) {
actual := NewSentinelHttpError(http.StatusInternalServerError, "foo")
expect := SentinelHttpError{
status: http.StatusInternalServerError,
message: "foo",
}
@@ -19,8 +19,8 @@ func TestNewSentinelHTTPError(t *testing.T) {
}
}
func TestSentinelHTTPError_Error(t *testing.T) {
err := SentinelHTTPError{
func TestSentinelHttpError_Error(t *testing.T) {
err := SentinelHttpError{
message: "foo",
}
@@ -32,11 +32,11 @@ func TestSentinelHTTPError_Error(t *testing.T) {
}
}
func TestSentinelHTTPError_HTTPError(t *testing.T) {
actualStatus, actualMessage := SentinelHTTPError{
func TestSentinelHttpError_HttpError(t *testing.T) {
actualStatus, actualMessage := SentinelHttpError{
status: http.StatusInternalServerError,
message: "foo",
}.HTTPError()
}.HttpError()
expectStatus := http.StatusInternalServerError
expectMessage := "foo"
@@ -51,7 +51,7 @@ func TestSentinelHTTPError_HTTPError(t *testing.T) {
}
func TestSentinelWrappedError_Is(t *testing.T) {
errSentinel := SentinelHTTPError{}
errSentinel := SentinelHttpError{}
err := sentinelWrappedError{
error: errors.New("foo"),
@@ -63,19 +63,19 @@ func TestSentinelWrappedError_Is(t *testing.T) {
}
}
func TestSentinelWrappedError_HTTPError(t *testing.T) {
expectStatus, expectMessage := SentinelHTTPError{
func TestSentinelWrappedError_HttpError(t *testing.T) {
expectStatus, expectMessage := SentinelHttpError{
status: http.StatusInternalServerError,
message: "foo",
}.HTTPError()
}.HttpError()
actualStatus, actualMessage := sentinelWrappedError{
error: errors.New("foo"),
sentinel: SentinelHTTPError{
sentinel: SentinelHttpError{
status: http.StatusInternalServerError,
message: "foo",
},
}.HTTPError()
}.HttpError()
if actualStatus != expectStatus {
t.Errorf("expected %d but got %d", expectStatus, actualStatus)
@@ -91,13 +91,13 @@ func TestWrapError(t *testing.T) {
expect := sentinelWrappedError{
error: errFoo,
sentinel: SentinelHTTPError{
sentinel: SentinelHttpError{
status: http.StatusInternalServerError,
message: "foo",
},
}
actual := WrapError(errFoo, SentinelHTTPError{
actual := WrapError(errFoo, SentinelHttpError{
status: http.StatusInternalServerError,
message: "foo",
})

View File

@@ -2,6 +2,7 @@ package api
import (
"fmt"
"math"
"net/http"
"os"
"path/filepath"
@@ -11,6 +12,8 @@ import (
"time"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// FormData is a helper for validating and hydrating values from a
@@ -23,8 +26,8 @@ type FormData struct {
errors error
}
// Validate returns nil or an error related to the FormData values, with a
// SentinelHTTPError (status code 400, errors' details as message) wrapped
// Validate returns nil or an error related to the [FormData] values, with a
// [SentinelHttpError] (status code 400, errors' details as message) wrapped
// inside.
//
// var foo string
@@ -32,18 +35,18 @@ type FormData struct {
// err := ctx.FormData().
// MandatoryString("foo", &foo, "bar").
// Validate()
func (form FormData) Validate() error {
func (form *FormData) Validate() error {
if form.errors == nil {
return nil
}
return WrapError(
form.errors,
NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
)
}
// String binds a form data value to a string variable.
// String binds a form field to a string variable.
//
// var foo string
//
@@ -52,17 +55,17 @@ func (form *FormData) String(key string, target *string, defaultValue string) *F
return form.mustValue(key, target, defaultValue)
}
// MandatoryString binds a form data value to a string variable. It populates
// MandatoryString binds a form field to a string variable. It populates
// an error if the value is empty or the "key" does not exist.
//
// var foo string
//
// ctx.FormData().MandatoryString("foo", &foo)
func (form *FormData) MandatoryString(key string, target *string) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Bool binds a form data value to a bool variable. It populates an error if
// Bool binds a form field to a bool variable. It populates an error if
// the value is not bool.
//
// var foo bool
@@ -72,17 +75,17 @@ func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormDat
return form.mustValue(key, target, defaultValue)
}
// MandatoryBool binds a form data value to a bool variable. It populates an
// MandatoryBool binds a form field to a bool variable. It populates an
// error if the value is not bool, is empty, or the "key" does not exist.
//
// var foo bool
//
// ctx.FormData().MandatoryBool("foo", &foo)
func (form *FormData) MandatoryBool(key string, target *bool) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Int binds a form data value to an int variable. It populates an error if the
// Int binds a form field to an int variable. It populates an error if the
// value is not int.
//
// var foo int
@@ -92,17 +95,17 @@ func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
return form.mustValue(key, target, defaultValue)
}
// MandatoryInt binds a form data value to an int variable. It populates an
// MandatoryInt binds a form field to an int variable. It populates an
// error if the value is not int, is empty, or the "key" does not exist.
//
// var foo int
//
// ctx.FormData().MandatoryInt("foo", &foo)
func (form *FormData) MandatoryInt(key string, target *int) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Float64 binds a form data value to a float64 variable. It populates an error
// Float64 binds a form field to a float64 variable. It populates an error
// if the value is not float64.
//
// var foo float64
@@ -112,18 +115,18 @@ func (form *FormData) Float64(key string, target *float64, defaultValue float64)
return form.mustValue(key, target, defaultValue)
}
// MandatoryFloat64 binds a form data value to a float64 variable. It populates
// MandatoryFloat64 binds a form field to a float64 variable. It populates
// an error if the is not float64, is empty, or the "key" does not exist.
//
// var foo float64
//
// ctx.FormData().MandatoryFloat64("foo", &foo)
func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Duration binds a form data value to a time.Duration variable. It populates
// an error if the form data value is not time.Duration.
// Duration binds a form field to a time.Duration variable. It populates
// an error if the form field is not time.Duration.
//
// var foo time.Duration
//
@@ -132,7 +135,7 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
return form.mustValue(key, target, defaultValue)
}
// MandatoryDuration binds a form data value to a time.Duration variable. It
// MandatoryDuration binds a form field to a time.Duration variable. It
// populates an error if the value is not time.Duration, is empty, or the "key"
// does not exist.
//
@@ -140,10 +143,95 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
//
// ctx.FormData().MandatoryDuration("foo", &foo)
func (form *FormData) MandatoryDuration(key string, target *time.Duration) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Custom helps to define a custom binding function for a form data value.
// Inches binds a form field to a float64 variable. It populates an error
// if the value cannot be computed back to inches.
//
// var foo float64
//
// ctx.FormData().Inches("foo", &foo, 2.0)
func (form *FormData) Inches(key string, target *float64, defaultValue float64) *FormData {
form.inches(key, target)
if *target == -math.MaxFloat64 {
*target = defaultValue
}
return form
}
// MandatoryInches binds a form field to a float64 variable. It populates
// an error if the value cannot be computed back to inches, is empty, or the
// "key" does not exist.
//
// var foo float64
//
// ctx.FormData().MandatoryInches("foo", &foo)
func (form *FormData) MandatoryInches(key string, target *float64) *FormData {
val, ok := form.values[key]
if !ok || val[0] == "" {
form.append(
fmt.Errorf("form field '%s' is required", key),
)
return form
}
return form.inches(key, target)
}
// inches tries to compute a string value to inches.
func (form *FormData) inches(key string, target *float64) *FormData {
var value string
form.mustValue(key, &value, "")
if value == "" {
*target = -math.MaxFloat64
return form
}
for _, unit := range []string{"pt", "px", "in", "mm", "cm", "pc"} {
if !strings.HasSuffix(value, unit) {
continue
}
val, err := strconv.ParseFloat(strings.TrimSuffix(value, unit), 64)
if err != nil {
form.append(
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
return form
}
switch unit {
case "pt":
*target = val * (1.0 / 72.0)
case "px":
*target = val * (1.0 / 96.0)
case "in":
*target = val
case "mm":
*target = val * (1.0 / 25.4)
case "cm":
*target = val * (1.0 / 2.54)
case "pc":
*target = val * (1.0 / 6.0)
}
return form
}
val, err := strconv.ParseFloat(value, 64)
if err != nil {
form.append(
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
return form
}
*target = val
return form
}
// Custom helps to define a custom binding function for a form field.
//
// var foo map[string]string
//
@@ -168,16 +256,15 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
err := assign(value)
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}
return form
}
// MandatoryCustom helps to define a custom binding function for a form data
// value. It populates an error if the value is empty or the "key" does not
// exist.
// MandatoryCustom helps to define a custom binding function for a form field.
// It populates an error if the value is empty or the "key" does not exist.
//
// var foo map[string]string
//
@@ -191,7 +278,7 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
// })
func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData {
var value string
form.mustMandatoryValue(key, &value)
form.mustMandatoryField(key, &value)
if value == "" {
return form
@@ -200,7 +287,7 @@ func (form *FormData) MandatoryCustom(key string, assign func(value string) erro
err := assign(value)
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}
@@ -305,7 +392,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
}
// See https://github.com/gotenberg/gotenberg/issues/139.
sort.Strings(*target)
sort.Sort(gotenberg.AlphanumericSort(*target))
return form
}
@@ -315,7 +402,7 @@ func (form *FormData) append(err error) {
form.errors = multierr.Append(form.errors, err)
}
// mustValue binds the target interface with a form data value. If the value is
// mustValue binds the target interface with a form field. If the value is
// empty or the "key" does not exist, it binds the default value. Currently,
// only the string, bool, int, float64 and time.Duration types are bindable.
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData {
@@ -343,16 +430,16 @@ func (form *FormData) mustValue(key string, target interface{}, defaultValue int
return form.mustAssign(key, val[0], target)
}
// mustMandatoryValue binds the target interface with a form data value. It
// mustMandatoryField binds the target interface with a form field. It
// populates an error if the value is empty or the "key" does not exist.
// Currently, only the string, bool, int, float64 and time.Duration types are
// bindable.
func (form *FormData) mustMandatoryValue(key string, target interface{}) *FormData {
func (form *FormData) mustMandatoryField(key string, target interface{}) *FormData {
val, ok := form.values[key]
if !ok || val[0] == "" {
form.append(
fmt.Errorf("form value '%s' is required", key),
fmt.Errorf("form field '%s' is required", key),
)
return form
@@ -386,7 +473,7 @@ func (form *FormData) mustAssign(key, value string, target interface{}) *FormDat
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"net/http"
@@ -10,17 +11,27 @@ import (
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// ErrAsyncProcess happens when a handler or middleware handles a request in an
// asynchronous fashion.
var ErrAsyncProcess = errors.New("async process")
var (
// ErrAsyncProcess happens when a handler or middleware handles a request
// in an asynchronous fashion.
ErrAsyncProcess = errors.New("async process")
// ErrNoOutputFile happens when a handler or middleware handles a request
// without sending any output file.
ErrNoOutputFile = errors.New("no output file")
)
// ParseError parses an error and returns the corresponding HTTP status and
// HTTP message.
func ParseError(err error) (int, string) {
echoErr, ok := err.(*echo.HTTPError)
var echoErr *echo.HTTPError
ok := errors.As(err, &echoErr)
if ok {
return echoErr.Code, http.StatusText(echoErr.Code)
}
@@ -29,9 +40,29 @@ func ParseError(err error) (int, string) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
}
var httpErr HTTPError
if errors.Is(err, gotenberg.ErrFiltered) {
return http.StatusForbidden, http.StatusText(http.StatusForbidden)
}
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
}
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
}
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
}
var httpErr HttpError
if errors.As(err, &httpErr) {
return httpErr.HTTPError()
return httpErr.HttpError()
}
// Default 500 status code.
@@ -54,8 +85,8 @@ func httpErrorHandler() echo.HTTPErrorHandler {
}
}
// latencyMiddleware sets the start time in the echo.Context under "startTime".
// Its value will be used later to calculate a request latency.
// latencyMiddleware sets the start time in the [echo.Context] under
// "startTime". Its value will be used later to calculate a request latency.
//
// startTime := c.Get("startTime").(time.Time)
func latencyMiddleware() echo.MiddlewareFunc {
@@ -71,9 +102,9 @@ func latencyMiddleware() echo.MiddlewareFunc {
}
}
// rootPathMiddleware sets the root path in the echo.Context under "rootPath".
// Its value may be used to skip a middleware execution based on a request
// URI.
// rootPathMiddleware sets the root path in the [echo.Context] under
// "rootPath". Its value may be used to skip a middleware execution based on a
// request URI.
//
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%s/health", rootPath)
@@ -87,14 +118,13 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("rootPath", rootPath)
// Call the next middleware in the chain.
return next(c)
}
}
}
// traceMiddleware sets the request identifier in the echo.Context under
// traceMiddleware sets the request identifier in the [echo.Context] under
// "trace". Its value is either retrieved from the trace header or generated if
// the header is not present / its value is empty.
//
@@ -120,8 +150,8 @@ func traceMiddleware(header string) echo.MiddlewareFunc {
}
}
// loggerMiddleware sets the logger in the echo.Context under "logger" and logs
// a synchronous request result.
// loggerMiddleware sets the logger in the [echo.Context] under "logger" and
// logs a synchronous request result.
//
// logger := c.Get("logger").(*zap.Logger)
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
@@ -131,9 +161,12 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string)
// Create the request logger and add it to our locals.
reqLogger := logger.With(zap.String("trace", trace))
c.Set("logger", reqLogger.Named(func() string {
// Create the application logger and add it to our locals.
appLogger := logger.
With(zap.String("log_type", "application")).
With(zap.String("trace", trace))
c.Set("logger", appLogger.Named(func() string {
return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
@@ -147,6 +180,11 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
c.Error(err)
}
// Create the access logger.
accessLogger := logger.
With(zap.String("log_type", "access")).
With(zap.String("trace", trace))
for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
@@ -182,9 +220,9 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
fields[11] = zap.Int64("bytes_out", c.Response().Size)
if err != nil {
reqLogger.Error(err.Error(), fields...)
accessLogger.Error(err.Error(), fields...)
} else {
reqLogger.Info("request handled", fields...)
accessLogger.Info("request handled", fields...)
}
return nil
@@ -192,21 +230,34 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
}
}
// basicAuthMiddleware manages basic authentication.
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
return true, nil
}
return false, nil
})
}
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
// Context and related context.CancelFunc in the echo.Context under "context"
// and "cancel". If the process is synchronous, it also handles the result of a
// "multipart/form-data" request.
// [Context] and related context.CancelFunc in the [echo.Context] under
// "context" and "cancel". If the process is synchronous, it also handles the
// result of a "multipart/form-data" request.
//
// ctx := c.Get("context").(*api.Context)
// cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
// We create a context with a timeout so that underlying processes are
// able to stop early and handle correctly a timeout scenario.
ctx, cancel, err := newContext(c, logger, timeout)
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
if err != nil {
cancel()
@@ -227,6 +278,13 @@ func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
defer cancel()
if errors.Is(err, ErrNoOutputFile) {
// A middleware/handler tells us that it's handling the process
// in an asynchronous fashion. Therefore, we must not cancel
// the context nor send an output file.
return nil
}
if err != nil {
return err
}

View File

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

View File

@@ -1,8 +1,11 @@
package api
import (
"github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// ContextMock is a helper for tests.
@@ -20,6 +23,15 @@ func (ctx *ContextMock) SetDirPath(path string) {
ctx.dirPath = path
}
// DirPath returns the context's working directory path.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetDirPath("/foo")
// dirPath := ctx.DirPath()
func (ctx *ContextMock) DirPath() string {
return ctx.dirPath
}
// SetValues sets the values.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
@@ -54,7 +66,7 @@ func (ctx *ContextMock) SetCancelled(cancelled bool) {
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// outputPaths := ctx.OutputPaths()
func (ctx ContextMock) OutputPaths() []string {
func (ctx *ContextMock) OutputPaths() []string {
return ctx.outputPaths
}
@@ -66,10 +78,65 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
ctx.logger = logger
}
// SetEchoContext sets the echo.Context.
// SetEchoContext sets the [echo.Context].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setEchoContext(c)
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c
ctx.echoCtx = c
}
// SetMkdirAll sets the [gotenberg.MkdirAll].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetMkdirAll(mkdirAll)
func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) {
ctx.mkdirAll = mkdirAll
}
// SetPathRename sets the [gotenberg.PathRename].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setPathRename(rename)
func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) {
ctx.pathRename = rename
}
// RouterMock is a mock for the [Router] interface.
type RouterMock struct {
RoutesMock func() ([]Route, error)
}
func (router *RouterMock) Routes() ([]Route, error) {
return router.RoutesMock()
}
// MiddlewareProviderMock is a mock for the [MiddlewareProvider] interface.
type MiddlewareProviderMock struct {
MiddlewaresMock func() ([]Middleware, error)
}
func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) {
return provider.MiddlewaresMock()
}
// HealthCheckerMock is mock for the [HealthChecker] interface.
type HealthCheckerMock struct {
ChecksMock func() ([]health.CheckerOption, error)
ReadyMock func() error
}
func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) {
return mod.ChecksMock()
}
func (mod *HealthCheckerMock) Ready() error {
return mod.ReadyMock()
}
// Interface guards.
var (
_ Router = (*RouterMock)(nil)
_ MiddlewareProvider = (*MiddlewareProviderMock)(nil)
_ HealthChecker = (*HealthCheckerMock)(nil)
)

View File

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

1
pkg/modules/api/testdata/sample.txt vendored Normal file
View File

@@ -0,0 +1 @@
This is a text from a text file.

View File

@@ -0,0 +1,459 @@
package chromium
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type browser interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
}
type browserArguments struct {
// Executor args.
binPath string
incognito bool
allowInsecureLocalhost bool
ignoreCertificateErrors bool
disableWebSecurity bool
allowFileAccessFromFiles bool
hostResolverRules string
proxyServer string
wsUrlReadTimeout time.Duration
// Tasks specific.
allowList *regexp2.Regexp
denyList *regexp2.Regexp
clearCache bool
clearCookies bool
disableJavaScript bool
}
type chromiumBrowser struct {
initialCtx context.Context
ctx context.Context
cancelFunc context.CancelFunc
userProfileDirPath string
ctxMu sync.RWMutex
isStarted atomic.Bool
arguments browserArguments
fs *gotenberg.FileSystem
}
func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
}
b.isStarted.Store(false)
return b
}
func (b *chromiumBrowser) Start(logger *zap.Logger) error {
if b.isStarted.Load() {
return errors.New("browser is already started")
}
debug := &debugLogger{logger: logger}
b.userProfileDirPath = b.fs.NewDirPath()
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.CombinedOutput(debug),
chromedp.ExecPath(b.arguments.binPath),
chromedp.NoSandbox,
// See:
// https://github.com/gotenberg/gotenberg/issues/327
// https://github.com/chromedp/chromedp/issues/904
chromedp.DisableGPU,
// See:
// https://github.com/puppeteer/puppeteer/issues/661
// https://github.com/puppeteer/puppeteer/issues/2410
chromedp.Flag("font-render-hinting", "none"),
chromedp.UserDataDir(b.userProfileDirPath),
// See https://github.com/gotenberg/gotenberg/issues/831.
chromedp.Flag("disable-pdf-tagging", true),
)
if b.arguments.incognito {
opts = append(opts, chromedp.Flag("incognito", b.arguments.incognito))
}
if b.arguments.allowInsecureLocalhost {
// See https://github.com/gotenberg/gotenberg/issues/488.
opts = append(opts, chromedp.Flag("allow-insecure-localhost", true))
}
if b.arguments.ignoreCertificateErrors {
opts = append(opts, chromedp.IgnoreCertErrors)
}
if b.arguments.disableWebSecurity {
opts = append(opts, chromedp.Flag("disable-web-security", true))
}
if b.arguments.allowFileAccessFromFiles {
// See https://github.com/gotenberg/gotenberg/issues/356.
opts = append(opts, chromedp.Flag("allow-file-access-from-files", true))
}
if b.arguments.hostResolverRules != "" {
// See https://github.com/gotenberg/gotenberg/issues/488.
opts = append(opts, chromedp.Flag("host-resolver-rules", b.arguments.hostResolverRules))
}
if b.arguments.proxyServer != "" {
// See https://github.com/gotenberg/gotenberg/issues/376.
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
}
// See https://github.com/gotenberg/gotenberg/issues/524.
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
allocatorCtx, allocatorCancel := chromedp.NewExecAllocator(b.initialCtx, opts...)
ctx, cancel := chromedp.NewContext(allocatorCtx, chromedp.WithDebugf(debug.Printf))
err := chromedp.Run(ctx)
if err != nil {
cancel()
allocatorCancel()
return fmt.Errorf("run exec allocator: %w", err)
}
b.ctxMu.Lock()
defer b.ctxMu.Unlock()
// We have to keep the context around, as we need it to create new tabs
// later.
b.ctx = ctx
b.cancelFunc = func() {
cancel()
allocatorCancel()
}
b.isStarted.Store(true)
return nil
}
func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
if !b.isStarted.Load() {
// No big deal? Like calling cancel twice.
return nil
}
// Always remove the user profile directory created by Chromium.
copyUserProfileDirPath := b.userProfileDirPath
expirationTime := time.Now()
defer func(userProfileDirPath string, expirationTime time.Time) {
// See:
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChrome/chrome-cleanup.sh
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChromium/chrome-cleanup.sh
// Clean up stuck processes.
ps, err := process.Processes()
if err != nil {
logger.Error(fmt.Sprintf("list processes: %v", err))
} else {
for _, p := range ps {
func() {
cmdline, err := p.Cmdline()
if err != nil {
return
}
if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") {
return
}
killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
err = p.KillWithContext(killCtx)
if err != nil {
logger.Error(fmt.Sprintf("kill process: %v", err))
} else {
logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid))
}
}()
}
}
go func() {
// FIXME: Chromium seems to recreate the user profile directory
// right after its deletion if we do not wait a certain amount
// of time before deleting it.
<-time.After(10 * time.Second)
err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
} else {
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
}
// Also remove Chromium specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
if err != nil {
logger.Error(err.Error())
}
}()
}(copyUserProfileDirPath, expirationTime)
b.ctxMu.Lock()
defer b.ctxMu.Unlock()
b.cancelFunc()
b.ctx = nil
b.userProfileDirPath = ""
b.isStarted.Store(false)
return nil
}
func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
// Good to know: the supervisor does not call this method if no first start
// or if the process is restarting.
if !b.isStarted.Load() {
// Non-started browser but not restarting?
return false
}
b.ctxMu.RLock()
defer b.ctxMu.RUnlock()
timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Duration(10)*time.Second)
defer timeoutCancel()
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
defer taskCancel()
err := chromedp.Run(taskCtx, chromedp.Navigate("about:blank"))
if err != nil {
logger.Error(fmt.Sprintf("browser health check failed: %s", err))
return false
}
return true
}
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
clearCacheActionFunc(logger, b.arguments.clearCache),
clearCookiesActionFunc(logger, b.arguments.clearCookies),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
// PDF specific.
printToPdfActionFunc(logger, outputPath, options),
})
}
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
clearCacheActionFunc(logger, b.arguments.clearCache),
clearCookiesActionFunc(logger, b.arguments.clearCookies),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
// Screenshot specific.
setDeviceMetricsOverride(logger, options.Width, options.Height),
captureScreenshotActionFunc(logger, outputPath, options),
})
}
func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error {
if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks")
}
deadline, ok := ctx.Deadline()
if !ok {
return errors.New("context has no deadline")
}
// We validate the "main" URL against our allow / deny lists.
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
b.ctxMu.RLock()
defer b.ctxMu.RUnlock()
timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Until(deadline))
defer timeoutCancel()
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
defer taskCancel()
// We validate all others requests against our allow / deny lists.
// If a request does not pass the validation, we make it fail. It also set
// the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
extraHttpHeaders: options.ExtraHttpHeaders,
})
var (
invalidHttpStatusCode error
invalidHttpStatusCodeMu sync.RWMutex
invalidResourceHttpStatusCode error
invalidResourceHttpStatusCodeMu sync.RWMutex
)
// See:
// https://github.com/gotenberg/gotenberg/issues/613.
// https://github.com/gotenberg/gotenberg/issues/1021.
if len(options.FailOnHttpStatusCodes) != 0 || len(options.FailOnResourceHttpStatusCodes) != 0 {
listenForEventResponseReceived(taskCtx, logger, eventResponseReceivedOptions{
mainPageUrl: url,
failOnHttpStatusCodes: options.FailOnHttpStatusCodes,
invalidHttpStatusCode: &invalidHttpStatusCode,
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
})
}
var (
consoleExceptions error
consoleExceptionsMu sync.RWMutex
)
// See https://github.com/gotenberg/gotenberg/issues/262.
if options.FailOnConsoleExceptions && !b.arguments.disableJavaScript {
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
}
var (
loadingFailed error
loadingFailedMu sync.RWMutex
resourceLoadingFailed error
resourceLoadingFailedMu sync.RWMutex
)
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021.
listenForEventLoadingFailed(taskCtx, logger, eventLoadingFailedOptions{
loadingFailed: &loadingFailed,
loadingFailedMu: &loadingFailedMu,
resourceLoadingFailed: &resourceLoadingFailed,
resourceLoadingFailedMu: &resourceLoadingFailedMu,
})
err = chromedp.Run(taskCtx, tasks...)
if err != nil {
errMessage := err.Error()
if strings.Contains(errMessage, "Show invalid printer settings error (-32000)") || strings.Contains(errMessage, "content area is empty (-32602)") {
return ErrInvalidPrinterSettings
}
if strings.Contains(errMessage, "Page range syntax error") {
return ErrPageRangesSyntaxError
}
if strings.Contains(errMessage, "rpcc: message too large") {
return ErrRpccMessageTooLarge
}
return fmt.Errorf("handle tasks: %w", err)
}
// See https://github.com/gotenberg/gotenberg/issues/613.
invalidHttpStatusCodeMu.RLock()
defer invalidHttpStatusCodeMu.RUnlock()
if invalidHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/262.
consoleExceptionsMu.RLock()
defer consoleExceptionsMu.RUnlock()
if consoleExceptions != nil {
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
if options.FailOnResourceLoadingFailed {
if resourceLoadingFailed != nil {
return fmt.Errorf("%v: %w", resourceLoadingFailed, ErrResourceLoadingFailed)
}
}
return nil
}
// Interface guards.
var (
_ gotenberg.Process = (*chromiumBrowser)(nil)
_ browser = (*chromiumBrowser)(nil)
)

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,8 @@ package chromium
import (
"context"
"fmt"
"regexp"
"net/http"
"slices"
"sync"
"github.com/chromedp/cdproto/cdp"
@@ -12,14 +13,30 @@ import (
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type eventRequestPausedOptions struct {
allowList, denyList *regexp2.Regexp
extraHttpHeaders []ExtraHttpHeader
}
// listenForEventRequestPaused listens for requests to check if they are
// allowed or not.
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) {
// allowed or not. It also set the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
if len(options.extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers")
} else {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
}
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) {
case *fetch.EventRequestPaused:
@@ -27,41 +44,229 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
allow := true
if !allowList.MatchString(e.Request.URL) {
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL))
allow = false
deadline, ok := ctx.Deadline()
if !ok {
logger.Error("context has no deadline, cannot filter URL")
return
}
if denyList.String() != "" && denyList.MatchString(e.Request.URL) {
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL))
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil {
logger.Warn(err.Error())
allow = false
}
cctx := chromedp.FromContext(ctx)
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
if allow {
req := fetch.ContinueRequest(e.RequestID)
err := req.Do(executorCtx)
if !allow {
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx)
if err != nil {
logger.Error(fmt.Sprintf("continue request: %s", err))
logger.Error(fmt.Sprintf("fail request: %s", err))
}
return
}
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err := req.Do(executorCtx)
req := fetch.ContinueRequest(e.RequestID)
var extraHttpHeadersToSet []ExtraHttpHeader
if len(options.extraHttpHeaders) > 0 {
// The user want to set extra HTTP headers.
// First, we have to check if at least one header has to be
// set for current request.
for _, header := range options.extraHttpHeaders {
if header.Scope == nil {
// Non-scoped header.
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
continue
}
ok, err := header.Scope.MatchString(e.Request.URL)
if err != nil {
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
} else if ok {
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
} else {
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
}
}
}
if len(extraHttpHeadersToSet) > 0 {
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
originalHeaders := e.Request.Headers
headers := make(map[string]string)
for key, value := range originalHeaders {
strValue, ok := value.(string)
if ok {
headers[key] = strValue
} else {
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
}
}
var headersEntries []*fetch.HeaderEntry
for key, value := range headers {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: key,
Value: value,
})
}
for _, header := range extraHttpHeadersToSet {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: header.Name,
Value: header.Value,
})
}
req.Headers = headersEntries
}
err = req.Do(executorCtx)
if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err))
logger.Error(fmt.Sprintf("continue request: %s", err))
}
}()
}
})
}
type eventResponseReceivedOptions struct {
mainPageUrl string
failOnHttpStatusCodes []int64
invalidHttpStatusCode *error
invalidHttpStatusCodeMu *sync.RWMutex
failOnResourceOnHttpStatusCode []int64
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
}
// listenForEventResponseReceived listens for an invalid HTTP status code that
// is returned by the main page or by one or more resources.
// See:
// https://github.com/gotenberg/gotenberg/issues/613.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventResponseReceived(
ctx context.Context,
logger *zap.Logger,
options eventResponseReceivedOptions,
) {
for _, code := range []int64{199, 299, 399, 499, 599} {
if slices.Contains(options.failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ {
options.failOnHttpStatusCodes = append(options.failOnHttpStatusCodes, i)
}
}
if slices.Contains(options.failOnResourceOnHttpStatusCode, code) {
for i := code - 99; i <= code; i++ {
options.failOnResourceOnHttpStatusCode = append(options.failOnResourceOnHttpStatusCode, i)
}
}
}
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventResponseReceived:
if ev.Response.URL == options.mainPageUrl {
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
options.invalidHttpStatusCodeMu.Lock()
defer options.invalidHttpStatusCodeMu.Unlock()
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
}
return
}
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
options.invalidResourceHttpStatusCodeMu.Lock()
defer options.invalidResourceHttpStatusCodeMu.Unlock()
*options.invalidResourceHttpStatusCode = multierr.Append(
*options.invalidResourceHttpStatusCode,
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
)
}
}
})
}
type eventLoadingFailedOptions struct {
loadingFailed *error
loadingFailedMu *sync.RWMutex
resourceLoadingFailed *error
resourceLoadingFailedMu *sync.RWMutex
}
// listenForEventLoadingFailed listens for an event indicating that the main
// page or one or more resources failed to load.
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
// We are looking for common errors.
// TODO: sufficient?
errors := []string{
"net::ERR_CONNECTION_CLOSED",
"net::ERR_CONNECTION_RESET",
"net::ERR_CONNECTION_REFUSED",
"net::ERR_CONNECTION_ABORTED",
"net::ERR_CONNECTION_FAILED",
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_INTERNET_DISCONNECTED",
"net::ERR_ADDRESS_UNREACHABLE",
"net::ERR_BLOCKED_BY_CLIENT",
"net::ERR_BLOCKED_BY_RESPONSE",
"net::ERR_FILE_NOT_FOUND",
}
if !slices.Contains(errors, ev.ErrorText) {
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
return
}
if ev.Type == network.ResourceTypeDocument {
// Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page.
logger.Debug("event EventLoadingFailed fired for main page")
options.loadingFailedMu.Lock()
defer options.loadingFailedMu.Unlock()
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
return
}
logger.Debug("event EventLoadingFailed fired for a resource")
options.resourceLoadingFailedMu.Lock()
defer options.resourceLoadingFailedMu.Unlock()
*options.resourceLoadingFailed = multierr.Append(
*options.resourceLoadingFailed,
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
)
}
})
}
// listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262.
@@ -181,7 +386,6 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
// completed or an error is encountered.
func runBatch(ctx context.Context, fn ...func() error) error {
eg, _ := errgroup.WithContext(ctx)
for _, f := range fn {
eg.Go(f)
}

View File

@@ -0,0 +1,44 @@
package chromium
import (
"context"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// ApiMock is a mock for the [Api] interface.
type ApiMock struct {
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
}
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return api.PdfMock(ctx, logger, url, outputPath, options)
}
func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return api.ScreenshotMock(ctx, logger, url, outputPath, options)
}
// browserMock is a mock for the [browser] interface.
type browserMock struct {
gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
}
func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return b.pdfMock(ctx, logger, url, outputPath, options)
}
func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return b.screenshotMock(ctx, logger, url, outputPath, options)
}
// Interface guards.
var (
_ Api = (*ApiMock)(nil)
_ browser = (*browserMock)(nil)
)

View File

@@ -9,102 +9,229 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
)
// FormDataChromiumPDFOptions creates Options form the form data. Fallback to
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
// default value if the considered key is not present.
func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
userAgent string
extraHTTPHeaders map[string]string
emulatedMediaType string
landscape, printBackground, omitBackground bool
scale, paperWidth, paperHeight float64
marginTop, marginBottom, marginLeft, marginRight float64
pageRanges string
headerTemplate, footerTemplate string
preferCSSPageSize bool
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
omitBackground bool
)
form := ctx.FormData().
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
String("userAgent", &userAgent, defaultOptions.UserAgent).
Custom("extraHttpHeaders", func(value string) error {
Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
Custom("failOnHttpStatusCodes", func(value string) error {
if value == "" {
extraHTTPHeaders = defaultOptions.ExtraHTTPHeaders
failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
return nil
}
err := json.Unmarshal([]byte(value), &extraHTTPHeaders)
err := json.Unmarshal([]byte(value), &failOnHttpStatusCodes)
if err != nil {
return fmt.Errorf("unmarshal extra HTTP headers: %w", err)
return fmt.Errorf("unmarshal failOnHttpStatusCodes: %w", err)
}
return nil
}).
Custom("failOnResourceHttpStatusCodes", func(value string) error {
if value == "" {
failOnResourceHttpStatusCodes = defaultOptions.FailOnResourceHttpStatusCodes
return nil
}
err := json.Unmarshal([]byte(value), &failOnResourceHttpStatusCodes)
if err != nil {
return fmt.Errorf("unmarshal failOnResourceHttpStatusCodes: %w", err)
}
return nil
}).
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
Custom("cookies", func(value string) error {
if value == "" {
cookies = defaultOptions.Cookies
return nil
}
err := json.Unmarshal([]byte(value), &cookies)
if err != nil {
return fmt.Errorf("unmarshal cookies: %w", err)
}
for i, cookie := range cookies {
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
}
}
return err
}).
String("userAgent", &userAgent, defaultOptions.UserAgent).
Custom("extraHttpHeaders", func(value string) error {
if value == "" {
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
return nil
}
var headers map[string]string
err := json.Unmarshal([]byte(value), &headers)
if err != nil {
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
}
for k, v := range headers {
var scope string
var valueTokens []string
var invalidScopeToken bool
tokens := strings.Split(v, ";")
for _, token := range tokens {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
tokenNoSpaces := strings.Join(strings.Fields(token), "")
parts := strings.SplitN(tokenNoSpaces, "=", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
scope = parts[1]
} else {
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
invalidScopeToken = true
break
}
} else {
if token != "" {
valueTokens = append(valueTokens, token)
}
}
}
if invalidScopeToken {
continue
}
var scopeRegexp *regexp2.Regexp
if len(scope) > 0 {
p, errCompile := regexp2.Compile(scope, 0)
if errCompile != nil {
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
continue
}
scopeRegexp = p
}
extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{
Name: k,
Value: strings.Join(valueTokens, "; "),
Scope: scopeRegexp,
})
}
return err
}).
Custom("emulatedMediaType", func(value string) error {
if value == "" {
emulatedMediaType = defaultOptions.EmulatedMediaType
return nil
}
if value != "screen" && value != "print" {
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
return errors.New("wrong value, expected either 'screen', 'print' or empty")
}
emulatedMediaType = value
return nil
}).
Bool("landscape", &landscape, defaultOptions.Landscape).
Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground).
Float64("scale", &scale, defaultOptions.Scale).
Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth).
Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight).
Float64("marginTop", &marginTop, defaultOptions.MarginTop).
Float64("marginBottom", &marginBottom, defaultOptions.MarginBottom).
Float64("marginLeft", &marginLeft, defaultOptions.MarginLeft).
Float64("marginRight", &marginRight, defaultOptions.MarginRight).
String("nativePageRanges", &pageRanges, defaultOptions.PageRanges).
Content("header.html", &headerTemplate, defaultOptions.HeaderTemplate).
Content("footer.html", &footerTemplate, defaultOptions.FooterTemplate).
Bool("preferCssPageSize", &preferCSSPageSize, defaultOptions.PreferCSSPageSize)
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
UserAgent: userAgent,
ExtraHTTPHeaders: extraHTTPHeaders,
ExtraLinkTags: defaultOptions.ExtraLinkTags,
EmulatedMediaType: emulatedMediaType,
ExtraScriptTags: defaultOptions.ExtraScriptTags,
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
}
return form, options
}
// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to
// default value if the considered key is not present.
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
form, options := FormDataChromiumOptions(ctx)
defaultPdfOptions := DefaultPdfOptions()
var (
landscape, printBackground, singlePage bool
scale, paperWidth, paperHeight float64
marginTop, marginBottom, marginLeft, marginRight float64
pageRanges string
headerTemplate, footerTemplate string
preferCssPageSize bool
generateDocumentOutline bool
)
form.
Bool("landscape", &landscape, defaultPdfOptions.Landscape).
Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
Float64("scale", &scale, defaultPdfOptions.Scale).
Bool("singlePage", &singlePage, defaultPdfOptions.SinglePage).
Inches("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
Inches("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
Inches("marginTop", &marginTop, defaultPdfOptions.MarginTop).
Inches("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
Inches("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
Inches("marginRight", &marginRight, defaultPdfOptions.MarginRight).
String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize).
Bool("generateDocumentOutline", &generateDocumentOutline, defaultPdfOptions.GenerateDocumentOutline)
pdfOptions := PdfOptions{
Options: options,
Landscape: landscape,
PrintBackground: printBackground,
OmitBackground: omitBackground,
Scale: scale,
SinglePage: singlePage,
PaperWidth: paperWidth,
PaperHeight: paperHeight,
MarginTop: marginTop,
@@ -114,61 +241,104 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
PageRanges: pageRanges,
HeaderTemplate: headerTemplate,
FooterTemplate: footerTemplate,
PreferCSSPageSize: preferCSSPageSize,
PreferCssPageSize: preferCssPageSize,
GenerateDocumentOutline: generateDocumentOutline,
}
return form, options
return form, pdfOptions
}
// convertURLRoute returns an api.Route which can convert a URL to PDF.
func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form
// data. Fallback to default value if the considered key is not present.
func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) {
form, options := FormDataChromiumOptions(ctx)
defaultScreenshotOptions := DefaultScreenshotOptions()
var (
width, height int
clip bool
format string
quality int
optimizeForSpeed bool
)
form.
Int("width", &width, defaultScreenshotOptions.Width).
Int("height", &height, defaultScreenshotOptions.Height).
Bool("clip", &clip, defaultScreenshotOptions.Clip).
Custom("format", func(value string) error {
if value == "" {
format = defaultScreenshotOptions.Format
return nil
}
if value != "png" && value != "jpeg" && value != "webp" {
return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'")
}
format = value
return nil
}).
Custom("quality", func(value string) error {
if value == "" {
quality = defaultScreenshotOptions.Quality
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 0 {
return errors.New("value is negative")
}
if intValue > 100 {
return errors.New("value is superior to 100")
}
quality = intValue
return nil
}).
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
screenshotOptions := ScreenshotOptions{
Options: options,
Width: width,
Height: height,
Clip: clip,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
}
return form, screenshotOptions
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/url",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
var (
URL string
PDFformat string
)
form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var url string
err := form.
MandatoryString("url", &URL).
String("pdfFormat", &PDFformat, "").
Custom("extraLinkTags", func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options.ExtraLinkTags)
if err != nil {
return fmt.Errorf("unmarshal extra link tags: %w", err)
}
return nil
}).
Custom("extraScriptTags", func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options.ExtraScriptTags)
if err != nil {
return fmt.Errorf("unmarshal extra script tags: %w", err)
}
return nil
}).
MandatoryString("url", &url).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -178,33 +348,59 @@ func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
}
}
// convertHTMLRoute returns an api.Route which can convert an HTML file to PDF.
func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
// screenshotUrlRoute returns an [api.Route] which can take a screenshot from a
// URL.
func screenshotUrlRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/url",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var url string
err := form.
MandatoryString("url", &url).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("URL screenshot: %w", err)
}
return nil
},
}
}
// convertHtmlRoute returns an [api.Route] which can convert an HTML file to
// PDF.
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/html",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
var (
inputPath string
PDFformat string
)
form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var inputPath string
err := form.
MandatoryPath("index.html", &inputPath).
String("pdfFormat", &PDFformat, "").
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
URL := fmt.Sprintf("file://%s", inputPath)
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -214,108 +410,69 @@ func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
}
}
// convertMarkdownRoute returns an api.Route which can convert markdown files
// screenshotHtmlRoute returns an [api.Route] which can take a screenshot from
// an HTML file.
func screenshotHtmlRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/html",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var inputPath string
err := form.
MandatoryPath("index.html", &inputPath).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
url := fmt.Sprintf("file://%s", inputPath)
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("HTML screenshot: %w", err)
}
return nil
},
}
}
// convertMarkdownRoute returns an [api.Route] which can convert markdown files
// to PDF.
func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/markdown",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var (
inputPath string
markdownPaths []string
PDFformat string
)
err := form.
MandatoryPath("index.html", &inputPath).
MandatoryPaths([]string{".md"}, &markdownPaths).
String("pdfFormat", &PDFformat, "").
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
// We have to convert each markdown file referenced in the HTML
// file to... HTML. Thanks to the "html/template" package, we are
// able to provide the "toHTML" function which the user may call
// directly inside the HTML file.
var markdownFilesNotFoundErr error
tmpl, err := template.
New(filepath.Base(inputPath)).
Funcs(template.FuncMap{
"toHTML": func(filename string) (template.HTML, error) {
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
if filename == markdownFilename {
path = markdownPath
break
}
}
if path == "" {
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)
return "", nil
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
}
unsafe := blackfriday.Run(b)
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
// #nosec
return template.HTML(sanitized), nil
},
}).ParseFiles(inputPath)
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
return fmt.Errorf("parse template file: %w", err)
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, &struct{}{})
if err != nil {
return fmt.Errorf("execute template: %w", err)
}
if markdownFilesNotFoundErr != nil {
return api.WrapError(
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
api.NewSentinelHTTPError(
http.StatusBadRequest,
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
),
)
}
inputPath = ctx.GeneratePath(".html")
err = os.WriteFile(inputPath, buffer.Bytes(), 0600)
if err != nil {
return fmt.Errorf("write template result: %w", err)
}
URL := fmt.Sprintf("file://%s", inputPath)
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -325,54 +482,140 @@ func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
}
}
// convertURL is a stub which is called by the other methods of this file.
func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL, PDFformat string, options Options) error {
// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
// from markdown files.
func screenshotMarkdownRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/markdown",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var (
inputPath string
markdownPaths []string
)
err := form.
MandatoryPath("index.html", &inputPath).
MandatoryPaths([]string{".md"}, &markdownPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("markdown screenshot: %w", err)
}
return nil
},
}
}
func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) {
// We have to convert each markdown file referenced in the HTML
// file to... HTML. Thanks to the "html/template" package, we are
// able to provide the "toHTML" function which the user may call
// directly inside the HTML file.
var markdownFilesNotFoundErr error
tmpl, err := template.
New(filepath.Base(inputPath)).
Funcs(template.FuncMap{
"toHTML": func(filename string) (template.HTML, error) {
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
if filename == markdownFilename {
path = markdownPath
break
}
}
if path == "" {
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)
return "", nil
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
}
unsafe := blackfriday.Run(b)
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
// #nosec
return template.HTML(sanitized), nil
},
}).ParseFiles(inputPath)
if err != nil {
return "", fmt.Errorf("parse template file: %w", err)
}
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, &struct{}{})
if err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
if markdownFilesNotFoundErr != nil {
return "", api.WrapError(
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
),
)
}
inputPath = ctx.GeneratePath(".html")
err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
if err != nil {
return "", fmt.Errorf("write template result: %w", err)
}
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
outputPath := ctx.GeneratePath(".pdf")
err := chromium.PDF(ctx, ctx.Log(), URL, outputPath, options)
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, options.Options)
if err != nil {
if errors.Is(err, ErrURLNotAuthorized) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusForbidden,
fmt.Sprintf("'%s' does not match the authorized URLs", URL),
),
)
}
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusBadRequest,
"omitBackground requires printBackground set to true",
),
)
}
if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return
// an ErrInvalidEvaluationExpression error. In such a scenario,
// we return a 500.
return fmt.Errorf("convert to PDF: %w", err)
}
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusBadRequest,
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
),
)
}
if errors.Is(err, ErrInvalidPrinterSettings) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusBadRequest,
"Chromium does not handle the provided settings; please check for aberrant form values",
),
@@ -382,52 +625,62 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
if errors.Is(err, ErrPageRangesSyntaxError) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Chromium does not handle the page ranges '%s' (nativePageRanges)", options.PageRanges),
),
)
}
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
return fmt.Errorf("convert to PDF: %w", err)
}
// So far so good, the URL has been converted to PDF.
// Now, let's check if the client want to convert this result PDF
// to a specific PDF format.
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
if err != nil {
return fmt.Errorf("split PDF: %w", err)
}
if PDFformat != "" {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF(s): %w", err)
}
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPath)
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
if err != nil {
if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat),
),
)
zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
// The PDF has been split and split parts have been converted to a
// specific format. We want to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
return fmt.Errorf("convert PDF: %w", err)
}
} else {
outputPaths = convertOutputPaths
}
// Important: the output path is now the converted file.
outputPath = convertOutputPath
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
}
func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error {
ext := fmt.Sprintf(".%s", options.Format)
outputPath := ctx.GeneratePath(ext)
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, options.Options)
if err != nil {
return fmt.Errorf("screenshot: %w", err)
}
err = ctx.AddOutputPaths(outputPath)
@@ -437,3 +690,78 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
return nil
}
func handleChromiumError(err error, options Options) error {
if err == nil {
return nil
}
if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return
// an ErrInvalidEvaluationExpression error. In such a scenario,
// we return a 500.
return err
}
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
),
)
}
if errors.Is(err, ErrInvalidHttpStatusCode) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
),
)
}
if errors.Is(err, ErrInvalidResourceHttpStatusCode) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Invalid HTTP status code from resources:\n%s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidResourceHttpStatusCode.Error()), "")),
),
)
}
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n%s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
if errors.Is(err, ErrLoadingFailed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Chromium returned %v", err),
),
)
}
if errors.Is(err, ErrResourceLoadingFailed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Chromium failed to load resources: %v", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrResourceLoadingFailed.Error()), "")),
),
)
}
return err
}

View File

@@ -1,905 +0,0 @@
package chromium
import (
"context"
"errors"
"net/http"
"os"
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestFormDataChromiumPDFOptions(t *testing.T) {
for i, tc := range []struct {
ctx *api.ContextMock
options Options
}{
{
ctx: &api.ContextMock{Context: &api.Context{}},
options: DefaultOptions(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
"foo",
},
})
return ctx
}(),
options: DefaultOptions(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
`{"foo":"bar"}`,
},
})
return ctx
}(),
options: func() Options {
options := DefaultOptions()
options.ExtraHTTPHeaders = map[string]string{
"foo": "bar",
}
return options
}(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
"foo",
},
})
return ctx
}(),
options: DefaultOptions(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"emulatedMediaType": {
"foo",
},
})
return ctx
}(),
options: DefaultOptions(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"emulatedMediaType": {
"screen",
},
})
return ctx
}(),
options: func() Options {
options := DefaultOptions()
options.EmulatedMediaType = "screen"
return options
}(),
},
} {
_, actual := FormDataChromiumPDFOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.options) {
t.Errorf("test %d: expected %+v but got: %+v", i, tc.options, actual)
}
}
}
func TestConvertURLHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.ContextMock
api API
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
expectOutputPathsCount int
}{
{
ctx: &api.ContextMock{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"",
},
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return errors.New("foo")
}
return chromiumAPI
}(),
expectErr: true,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectOutputPathsCount: 1,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
"extraLinkTags": {
"foo",
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
"extraScriptTags": {
"foo",
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
"extraLinkTags": {
`[{"href":"https://cdn.foo/foo.css"},{"href":"https://cdn.bar/bar.css"}]`,
},
"extraScriptTags": {
`[{"src":"https://cdn.foo/foo.js"},{"src":"https://cdn.bar/bar.js"}]`,
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectOutputPathsCount: 1,
},
} {
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertURLRoute(tc.api, nil).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 api.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)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
}
}
func TestConvertHTMLHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.ContextMock
api API
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
expectOutputPathsCount int
}{
{
ctx: &api.ContextMock{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.html": "/foo/foo.html",
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return errors.New("foo")
}
return chromiumAPI
}(),
expectErr: true,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectOutputPathsCount: 1,
},
} {
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertHTMLRoute(tc.api, nil).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 api.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)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
}
}
func TestConvertMarkdownHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.ContextMock
api API
outputDir string
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
expectOutputPathsCount int
}{
{
ctx: &api.ContextMock{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.html": "/foo/foo.html",
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
"markdown.md": "/foo/markdown.md",
})
return ctx
}(),
expectErr: true,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample2/index.html",
"markdown1.md": "/foo/markdown1.md",
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample1/index.html",
"markdown1.md": "/foo/markdown1.md",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return errors.New("foo")
}
return chromiumAPI
}(),
expectErr: true,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample1/index.html",
"markdown1.md": "/tests/test/testdata/chromium/markdown/sample1/markdown1.md",
"markdown2.md": "/tests/test/testdata/chromium/markdown/sample1/markdown2.md",
"markdown3.md": "/tests/test/testdata/chromium/markdown/sample1/markdown3.md",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return errors.New("foo")
}
return chromiumAPI
}(),
expectErr: true,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetDirPath("/tmp/foo")
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample1/index.html",
"markdown1.md": "/tests/test/testdata/chromium/markdown/sample1/markdown1.md",
"markdown2.md": "/tests/test/testdata/chromium/markdown/sample1/markdown2.md",
"markdown3.md": "/tests/test/testdata/chromium/markdown/sample1/markdown3.md",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return errors.New("foo")
}
return chromiumAPI
}(),
outputDir: "/tmp/foo",
expectErr: true,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetDirPath("/tmp/foo")
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample1/index.html",
"markdown1.md": "/tests/test/testdata/chromium/markdown/sample1/markdown1.md",
"markdown2.md": "/tests/test/testdata/chromium/markdown/sample1/markdown2.md",
"markdown3.md": "/tests/test/testdata/chromium/markdown/sample1/markdown3.md",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
outputDir: "/tmp/foo",
expectOutputPathsCount: 1,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetDirPath("/tmp/foo")
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample3/index.html",
"markdown1.md": "/tests/test/testdata/chromium/markdown/sample3/markdown.md",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
outputDir: "/tmp/foo",
expectOutputPathsCount: 1,
},
} {
func() {
if tc.outputDir != "" {
err := os.MkdirAll(tc.outputDir, 0755)
if err != nil {
t.Fatalf("test %d: expected error but got: %v", i, err)
}
defer func() {
err := os.RemoveAll(tc.outputDir)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
}()
}
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertMarkdownRoute(tc.api, nil).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 api.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)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
}()
}
}
func TestConvertURL(t *testing.T) {
for i, tc := range []struct {
ctx *api.ContextMock
api API
engine gotenberg.PDFEngine
PDFformat string
options Options
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
expectOutputPathsCount int
}{
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrURLNotAuthorized
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrOmitBackgroundWithoutPrintBackground
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrInvalidEvaluationExpression
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrInvalidEvaluationExpression
}
return chromiumAPI
}(),
options: func() Options {
options := DefaultOptions()
options.WaitForExpression = "foo"
return options
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrInvalidPrinterSettings
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrPageRangesSyntaxError
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrConsoleExceptions
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusConflict,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return errors.New("foo")
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return gotenberg.ErrPDFFormatNotAvailable
},
}
}(),
PDFformat: "foo",
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return errors.New("foo")
},
}
}(),
PDFformat: "foo",
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
}
}(),
PDFformat: "foo",
options: DefaultOptions(),
expectOutputPathsCount: 1,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetCancelled(true)
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectOutputPathsCount: 1,
},
} {
err := convertURL(tc.ctx.Context, tc.api, tc.engine, "", tc.PDFformat, tc.options)
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 api.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)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
}
}

View File

@@ -0,0 +1,511 @@
package chromium
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"go.uber.org/zap"
)
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
paperHeight := options.PaperHeight
pageRanges := options.PageRanges
if options.SinglePage {
logger.Debug("single page PDF")
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
if err != nil {
return fmt.Errorf("get layout metrics: %w", err)
}
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
paperHeight = cssContentSize.Height / 96
pageRanges = "1" // little dirty hack to avoid leftovers.
}
printToPdf := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// Does not seem to work.
// See https://github.com/gotenberg/gotenberg/issues/831.
WithGenerateTaggedPDF(false)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.Debug("no custom header nor footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
} else {
logger.Debug("with custom header and/or footer")
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf))
_, stream, err := printToPdf.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err = reader.Close()
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}
}
func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
captureScreenshot := page.CaptureScreenshot().
WithCaptureBeyondViewport(true).
WithFromSurface(true).
WithOptimizeForSpeed(options.OptimizeForSpeed).
WithFormat(page.CaptureScreenshotFormat(options.Format))
if options.Clip {
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
Width: float64(options.Width),
Height: float64(options.Height),
Scale: 1,
})
}
if options.Format == "jpeg" {
captureScreenshot = captureScreenshot.
WithQuality(int64(options.Quality))
}
logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
buffer, err := captureScreenshot.Do(ctx)
if err != nil {
return fmt.Errorf("capture screenshot: %w", err)
}
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
}()
_, err = file.Write(buffer)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}
}
func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.Debug("set device metrics override")
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("set device metrics override: %w", err)
}
}
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
if !clear {
logger.Debug("cache not cleared")
return nil
}
logger.Debug("clear cache")
err := network.ClearBrowserCache().Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("clear cache: %w", err)
}
}
func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
if !clear {
logger.Debug("cookies not cleared")
return nil
}
logger.Debug("clear cookies")
err := network.ClearBrowserCookies().Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("clear cookies: %w", err)
}
}
func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/175.
if !disable {
logger.Debug("JavaScript not disabled")
return nil
}
logger.Debug("disable JavaScript")
err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("disable JavaScript: %w", err)
}
}
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(cookies) == 0 {
logger.Debug("no cookies to set")
return nil
}
deadline, ok := ctx.Deadline()
if !ok {
return errors.New("context has no deadline, cannot set cookies")
}
epochTime := cdp.TimeSinceEpoch(deadline)
cookiePretty := func(c *network.SetCookieParams) string {
return fmt.Sprintf(
"Name: '%s', Value: '%s', Domain: '%s', Path: '%s', Secure: %t, HTTPOnly: %t, SameSite: '%s', Expires: %s",
c.Name,
c.Value,
c.Domain,
c.Path,
c.Secure,
c.HTTPOnly,
c.SameSite.String(),
c.Expires.Time().String(),
)
}
for _, cookie := range cookies {
cookieParams := network.
SetCookie(cookie.Name, cookie.Value).
WithDomain(cookie.Domain).
WithPath(cookie.Path).
WithSecure(cookie.Secure).
WithHTTPOnly(cookie.HttpOnly).
WithSameSite(cookie.SameSite).
WithExpires(&epochTime)
err := cookieParams.Do(ctx)
if err != nil {
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
}
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
}
return nil
}
}
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(userAgent) == 0 {
logger.Debug("no user agent override")
return nil
}
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("set user agent override: %w", err)
}
}
// This code has been replaced with the listenForEventRequestPaused function.
// Indeed, the user may want to scope the headers per domain, but using
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
// See https://github.com/gotenberg/gotenberg/issues/1011.
//
//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
// if len(extraHttpHeaders) == 0 {
// logger.Debug("no extra HTTP headers")
// return nil
// }
//
// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
//
// headers := make(network.Headers, len(extraHttpHeaders))
// for key, value := range extraHttpHeaders {
// headers[key] = value
// }
//
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
// if err == nil {
// return nil
// }
//
// return fmt.Errorf("set extra HTTP headers: %w", err)
// }
//}
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
_, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", url, err)
}
waitFunc := []func() error{
waitForEventDomContentEventFired(ctx, logger),
waitForEventLoadEventFired(ctx, logger),
waitForEventLoadingFinished(ctx, logger),
}
if !skipNetworkIdleEvent {
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
} else {
logger.Debug("skipping network idle event")
}
err = runBatch(
ctx,
waitFunc...,
)
if err == nil {
return nil
}
return fmt.Errorf("wait for events: %w", err)
}
}
func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, printBackground bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/226.
if !omitBackground {
logger.Debug("default white background not hidden")
return nil
}
if !printBackground {
// See https://github.com/chromedp/chromedp/issues/1179#issuecomment-1284794416.
return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground)
}
logger.Debug("hide default white background")
err := emulation.SetDefaultBackgroundColorOverride().WithColor(
&cdp.RGBA{
R: 0,
G: 0,
B: 0,
A: 0,
}).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("hide default white background: %w", err)
}
}
func forceExactColorsActionFunc() chromedp.ActionFunc {
return func(ctx context.Context) error {
// See:
// https://github.com/gotenberg/gotenberg/issues/354
// https://github.com/puppeteer/puppeteer/issues/2685
// https://github.com/chromedp/chromedp/issues/520
script := `
(() => {
const css = 'html { -webkit-print-color-adjust: exact !important; }';
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
})();
`
evaluate := chromedp.Evaluate(script, nil)
err := evaluate.Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("add CSS for exact colors: %w", err)
}
}
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if mediaType == "" {
logger.Debug("no emulated media type")
return nil
}
if mediaType != "screen" && mediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
}
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia := emulation.SetEmulatedMedia()
err := emulatedMedia.WithMedia(mediaType).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("emulate media type '%s': %w", mediaType, err)
}
}
func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc {
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait delay")
return nil
}
if delay <= 0 {
logger.Debug("no wait delay")
return nil
}
// We wait for a given amount of time so that JavaScript
// scripts have a chance to finish before printing the page.
logger.Debug(fmt.Sprintf("wait '%s' before print", delay))
select {
case <-ctx.Done():
return fmt.Errorf("wait delay: %w", ctx.Err())
case <-time.After(delay):
return nil
}
}
}
func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait expression")
return nil
}
if expression == "" {
logger.Debug("no wait expression")
return nil
}
// We wait until the evaluation of the expression is true or
// until the context is done.
logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression))
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
case <-ticker.C:
var ok bool
evaluate := chromedp.Evaluate(expression, &ok)
err := evaluate.Do(ctx)
if err != nil {
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
}
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
}

View File

@@ -0,0 +1,11 @@
// Package exiftool provides an implementation of the gotenberg.PdfEngine
// interface using the ExifTool command-line tool. This package allows for:
//
// 1. The reading of metadata.
// 2. The writing of metadata.
//
// The path to the exiftool binary must be specified using the
// EXIFTOOL_BIN_PATH environment variable.
//
// See: https://exiftool.org.
package exiftool

View File

@@ -0,0 +1,186 @@
package exiftool
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"reflect"
"strings"
"syscall"
"github.com/barasher/go-exiftool"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func init() {
gotenberg.MustRegisterModule(new(ExifTool))
}
// ExifTool abstracts the CLI tool ExifTool and implements the
// [gotenberg.PdfEngine] interface.
type ExifTool struct {
binPath string
}
// Descriptor returns [ExifTool]'s module descriptor.
func (engine *ExifTool) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "exiftool",
New: func() gotenberg.Module { return new(ExifTool) },
}
}
// Provision sets the module properties.
func (engine *ExifTool) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("EXIFTOOL_BIN_PATH")
if !ok {
return errors.New("EXIFTOOL_BIN_PATH environment variable is not set")
}
engine.binPath = binPath
return nil
}
// Validate validates the module properties.
func (engine *ExifTool) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("ExifTool binary path does not exist: %w", err)
}
return nil
}
// Debug returns additional debug data.
func (engine *ExifTool) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = strings.TrimSpace(string(output))
return debug
}
// Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Split is not available in this implementation.
func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Flatten is not available in this implementation.
func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadMetadata extracts the metadata of a given PDF file.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil {
return nil, fmt.Errorf("new ExifTool: %w", err)
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
}
return fileMetadata[0].Fields, nil
}
// WriteMetadata writes the metadata into a given PDF file.
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil {
return fmt.Errorf("new ExifTool: %w", err)
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
}
for key, value := range metadata {
switch val := value.(type) {
case string:
fileMetadata[0].SetString(key, val)
case []string:
fileMetadata[0].SetStrings(key, val)
case []interface{}:
// See https://github.com/gotenberg/gotenberg/issues/1048.
strings := make([]string, len(val))
for i, entry := range val {
if str, ok := entry.(string); ok {
strings[i] = str
continue
}
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
fileMetadata[0].SetStrings(key, strings)
case bool:
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
case int:
fileMetadata[0].SetInt(key, int64(val))
case int64:
fileMetadata[0].SetInt(key, val)
case float32:
fileMetadata[0].SetFloat(key, float64(val))
case float64:
fileMetadata[0].SetFloat(key, val)
// TODO: support more complex cases, e.g., arrays and nested objects
// (limitations in underlying library).
default:
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
}
exifTool.WriteMetadata(fileMetadata)
if fileMetadata[0].Err != nil {
return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
}
return nil
}
// Interface guards.
var (
_ gotenberg.Module = (*ExifTool)(nil)
_ gotenberg.Provisioner = (*ExifTool)(nil)
_ gotenberg.Validator = (*ExifTool)(nil)
_ gotenberg.Debuggable = (*ExifTool)(nil)
_ gotenberg.PdfEngine = (*ExifTool)(nil)
)

View File

@@ -1,3 +0,0 @@
// Package gc provides a module for removing files and directories that have
// expired.
package gc

View File

@@ -1,225 +0,0 @@
package gc
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
)
func init() {
gotenberg.MustRegisterModule(GarbageCollector{})
}
// GarbageCollector is a module for removing files and directories that have
// expired. It allows us to make sure that the application does not leak files
// or directories when running.
type GarbageCollector struct {
rootPath string
graceDuration time.Duration
excludeSubstr []string
ticker *time.Ticker
done chan bool
logger *zap.Logger
}
// GarbageCollectorGraceDurationModifier is a module interface which allows to
// update the expiration time of files and directories parsed by the garbage
// collector. For instance, if the grace duration is 30s, the garbage collector
// will remove paths that have a modification time older than 30s. If there are
// many GarbageCollectorGraceDurationModifier, only the longest grace duration
// is selected.
type GarbageCollectorGraceDurationModifier interface {
GraceDuration() time.Duration
}
// GarbageCollectorExcludeSubstrModifier is a module interface which adds the
// given substrings to the exclude list of the garbage collector. If a path
// contains one of those substrings, the garbage collector ignores it.
type GarbageCollectorExcludeSubstrModifier interface {
ExcludeSubstr() []string
}
// Descriptor returns a GarbageCollector's module descriptor.
func (gc GarbageCollector) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "gc",
New: func() gotenberg.Module { return new(GarbageCollector) },
}
}
// Provision sets the module properties.
func (gc *GarbageCollector) Provision(ctx *gotenberg.Context) error {
gc.rootPath = gotenberg.TmpPath()
graceDurationModifiers, err := ctx.Modules(new(GarbageCollectorGraceDurationModifier))
if err != nil {
return fmt.Errorf("get grace duration modifiers: %w", err)
}
for _, graceDurationModifier := range graceDurationModifiers {
modifier := graceDurationModifier.(GarbageCollectorGraceDurationModifier)
if gc.graceDuration < modifier.GraceDuration() {
gc.graceDuration = modifier.GraceDuration()
}
}
excludeSubstrModifiers, err := ctx.Modules(new(GarbageCollectorExcludeSubstrModifier))
if err != nil {
return fmt.Errorf("get exclude substr modifiers: %w", err)
}
gc.excludeSubstr = strings.Split(os.Getenv("GC_EXCLUDE_SUBSTR"), ",")
for _, excludeSubstrModifier := range excludeSubstrModifiers {
modifier := excludeSubstrModifier.(GarbageCollectorExcludeSubstrModifier)
gc.excludeSubstr = append(gc.excludeSubstr, modifier.ExcludeSubstr()...)
}
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(gc)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
gc.logger = logger
return nil
}
// Start starts the garbage collector.
func (gc *GarbageCollector) Start() error {
gc.ticker = time.NewTicker(gc.graceDuration + time.Duration(1)*time.Second)
gc.done = make(chan bool, 1)
go func() {
for {
func() {
gcMu.RLock()
defer gcMu.RUnlock()
select {
case <-gc.done:
return
case <-gc.ticker.C:
gc.collect(false)
}
}()
}
}()
return nil
}
// collect parses the root path of the garbage collector and removes files or
// directories that have expired. It ignores the expiration date if the "force"
// argument is set to true.
func (gc GarbageCollector) collect(force bool) {
expirationTime := time.Now().Add(-gc.graceDuration)
// To make sure that the next Walk method stays on
// the root level of the considered path, we have to
// return a filepath.SkipDir error if the current path
// is a directory.
skipDirOrNil := func(info os.FileInfo) error {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
removePath := func(path string) {
err := os.RemoveAll(path)
if err != nil {
gc.logger.Error(fmt.Sprintf("remove '%s': %s", path, err))
}
gc.logger.Debug(fmt.Sprintf("'%s' removed", path))
}
err := filepath.Walk(gc.rootPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
// For whatever reasons, the Walk method failed
// to process the current path.
return pathErr
}
if path == gc.rootPath {
return nil
}
for _, substr := range gc.excludeSubstr {
if strings.Contains(info.Name(), substr) {
return skipDirOrNil(info)
}
}
if force {
removePath(path)
return skipDirOrNil(info)
}
if info.ModTime().Before(expirationTime) {
removePath(path)
}
return skipDirOrNil(info)
})
if err != nil {
gc.logger.Error(err.Error())
}
}
// StartupMessage returns an empty string.
func (gc GarbageCollector) StartupMessage() string {
return ""
}
// Stop stops the garbage collector.
func (gc *GarbageCollector) Stop(ctx context.Context) error {
_, ok := ctx.Deadline()
if !ok {
return errors.New("no context dead line")
}
// Block until the context is done so that other module may gracefully stop
// before we do a shutdown cleanup.
gc.logger.Debug("wait for the end of grace duration")
<-ctx.Done()
gc.ticker.Stop()
gc.done <- true
gc.logger.Debug("shutdown cleanup...")
gc.collect(true)
return nil
}
var gcMu sync.RWMutex
// Interface guards.
var (
_ gotenberg.Module = (*GarbageCollector)(nil)
_ gotenberg.Provisioner = (*GarbageCollector)(nil)
_ gotenberg.App = (*GarbageCollector)(nil)
)

View File

@@ -1,468 +0,0 @@
package gc
import (
"context"
"errors"
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
)
type ProtoModule struct {
descriptor func() gotenberg.ModuleDescriptor
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
type ProtoValidator struct {
ProtoModule
validate func() error
}
func (mod ProtoValidator) Validate() error {
return mod.validate()
}
type ProtoGarbageCollectorGraceDurationModifier struct {
ProtoValidator
graceDuration func() time.Duration
}
func (mod ProtoGarbageCollectorGraceDurationModifier) GraceDuration() time.Duration {
return mod.graceDuration()
}
type ProtoGarbageCollectorExcludeSubstrModifier struct {
ProtoValidator
excludeSubstr func() []string
}
func (mod ProtoGarbageCollectorExcludeSubstrModifier) ExcludeSubstr() []string {
return mod.excludeSubstr()
}
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 TestGarbageCollector_Descriptor(t *testing.T) {
descriptor := GarbageCollector{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(GarbageCollector))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestGarbageCollector_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectGraceDuration time.Duration
expectExcludeSubstr []string
expectErr bool
}{
{
ctx: func() *gotenberg.Context {
mod := struct {
ProtoGarbageCollectorGraceDurationModifier
}{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error { return errors.New("foo") }
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
mod.Descriptor(),
})
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct {
ProtoGarbageCollectorExcludeSubstrModifier
}{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error { return errors.New("foo") }
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
mod.Descriptor(),
})
}(),
expectErr: true,
},
{
ctx: gotenberg.NewContext(gotenberg.ParsedFlags{}, make([]gotenberg.ModuleDescriptor, 0)),
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(mod gotenberg.Module) (*zap.Logger, error) { return nil, errors.New("foo") }
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
mod.Descriptor(),
})
}(),
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(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil }
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
mod.Descriptor(),
})
}(),
},
{
ctx: func() *gotenberg.Context {
mod1 := struct {
ProtoGarbageCollectorGraceDurationModifier
}{}
mod1.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
}
mod1.graceDuration = func() time.Duration { return time.Duration(10) * time.Second }
mod1.validate = func() error { return nil }
mod2 := struct {
ProtoGarbageCollectorGraceDurationModifier
}{}
mod2.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
}
mod2.graceDuration = func() time.Duration { return time.Duration(20) * time.Second }
mod2.validate = func() error { return nil }
mod3 := struct {
ProtoLoggerProvider
}{}
mod3.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }}
}
mod3.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil }
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
mod1.Descriptor(),
mod2.Descriptor(),
mod3.Descriptor(),
})
}(),
expectGraceDuration: time.Duration(20) * time.Second,
},
{
ctx: func() *gotenberg.Context {
mod1 := struct {
ProtoGarbageCollectorExcludeSubstrModifier
}{}
mod1.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
}
mod1.excludeSubstr = func() []string { return []string{"foo"} }
mod1.validate = func() error { return nil }
mod2 := struct {
ProtoGarbageCollectorExcludeSubstrModifier
}{}
mod2.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
}
mod2.excludeSubstr = func() []string { return []string{"bar"} }
mod2.validate = func() error { return nil }
mod3 := struct {
ProtoLoggerProvider
}{}
mod3.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return mod3 }}
}
mod3.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil }
return gotenberg.NewContext(gotenberg.ParsedFlags{}, []gotenberg.ModuleDescriptor{
mod1.Descriptor(),
mod2.Descriptor(),
mod3.Descriptor(),
})
}(),
expectExcludeSubstr: func() []string {
expect := strings.Split(os.Getenv("GC_EXCLUDE_SUBSTR"), ",")
return append(expect, "foo", "bar")
}(),
},
} {
mod := new(GarbageCollector)
err := mod.Provision(tc.ctx)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectGraceDuration != 0 && tc.expectGraceDuration != mod.graceDuration {
t.Errorf("test %d: expected grace duration of '%s' but got '%s'", i, tc.expectGraceDuration, mod.graceDuration)
}
if tc.expectExcludeSubstr != nil && !reflect.DeepEqual(tc.expectExcludeSubstr, mod.excludeSubstr) {
t.Errorf("test %d: expected exclude substr '%s' but got '%s'", i, tc.expectExcludeSubstr, mod.excludeSubstr)
}
}
}
func TestGarbageCollector_Start(t *testing.T) {
mod := new(GarbageCollector)
mod.logger = zap.NewNop()
path, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
mod.rootPath = path
err = mod.Start()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
time.Sleep(time.Duration(2) * time.Second)
mod.ticker.Stop()
mod.done <- true
}
func TestGarbageCollector_collect(t *testing.T) {
for i, tc := range []struct {
gc *GarbageCollector
expectNotExists []string
expectExists []string
force bool
}{
{
gc: func() *GarbageCollector {
mod := new(GarbageCollector)
mod.logger = zap.NewNop()
path, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
mod.rootPath = path
err = os.WriteFile(path+"/foo", []byte{1}, 0755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
mod.excludeSubstr = []string{
"foo",
}
return mod
}(),
expectExists: []string{
"/foo",
},
},
{
gc: func() *GarbageCollector {
mod := new(GarbageCollector)
mod.logger = zap.NewNop()
path, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
mod.rootPath = path
err = os.WriteFile(path+"/foo", []byte{1}, 0755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.MkdirAll(path+"/bar", 0755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return mod
}(),
expectNotExists: []string{
"/foo",
"/bar",
},
force: true,
},
{
gc: func() *GarbageCollector {
mod := new(GarbageCollector)
mod.logger = zap.NewNop()
mod.graceDuration = time.Duration(10) * time.Second
path, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
mod.rootPath = path
err = os.WriteFile(path+"/foo", []byte{1}, 0755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
newTime := time.Now().Add(-time.Duration(20) * time.Second)
err = os.Chtimes(path+"/foo", newTime, newTime)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(path+"/bar", []byte{1}, 0755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
newTime = time.Now().Add(time.Duration(10) * time.Second)
err = os.Chtimes(path+"/bar", newTime, newTime)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return mod
}(),
expectNotExists: []string{
"/foo",
},
expectExists: []string{
"/bar",
},
},
} {
tc.gc.collect(tc.force)
for _, name := range tc.expectNotExists {
path := tc.gc.rootPath + name
_, err := os.Stat(path)
if !os.IsNotExist(err) {
t.Errorf("test %d: expected '%s' not to exist but got: %v", i, path, err)
}
}
for _, name := range tc.expectExists {
path := tc.gc.rootPath + name
_, err := os.Stat(path)
if os.IsNotExist(err) {
t.Errorf("test %d: expected '%s' to exist but got: %v", i, path, err)
}
}
err := os.RemoveAll(tc.gc.rootPath)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestGarbageCollector_StartupMessage(t *testing.T) {
actual := new(GarbageCollector).StartupMessage()
expect := ""
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestGarbageCollector_Stop(t *testing.T) {
for i, tc := range []struct {
timeout time.Duration
expectErr bool
}{
{
expectErr: true,
},
{
timeout: time.Duration(1) * time.Nanosecond,
},
} {
func() {
mod := new(GarbageCollector)
mod.logger = zap.NewNop()
path, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
mod.rootPath = path
err = mod.Start()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
if tc.timeout == 0 {
err = mod.Stop(context.TODO())
} else {
ctx, cancel := context.WithTimeout(context.Background(), tc.timeout)
defer cancel()
err = mod.Stop(ctx)
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}()
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ GarbageCollectorGraceDurationModifier = (*ProtoGarbageCollectorGraceDurationModifier)(nil)
_ gotenberg.Module = (*ProtoGarbageCollectorGraceDurationModifier)(nil)
_ gotenberg.Validator = (*ProtoGarbageCollectorGraceDurationModifier)(nil)
_ GarbageCollectorExcludeSubstrModifier = (*ProtoGarbageCollectorExcludeSubstrModifier)(nil)
_ gotenberg.Module = (*ProtoGarbageCollectorExcludeSubstrModifier)(nil)
_ gotenberg.Validator = (*ProtoGarbageCollectorExcludeSubstrModifier)(nil)
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
)

View File

@@ -0,0 +1,570 @@
package api
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/alexliesenfeld/health"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func init() {
gotenberg.MustRegisterModule(new(Api))
}
var (
// ErrInvalidPdfFormats happens if the PDF formats option cannot be handled
// by LibreOffice.
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
// ErrUnoException happens when unoconverter returns an exit code 5.
ErrUnoException = errors.New("uno exception")
// ErrRuntimeException happens when unoconverter returns an exit code 6.
ErrRuntimeException = errors.New("uno exception")
// ErrCoreDumped happens randomly; sometime a conversion will work as
// expected, and some other time the same conversion will fail.
// See https://github.com/gotenberg/gotenberg/issues/639.
ErrCoreDumped = errors.New("core dumped")
)
// Api is a module which provides a [Uno] to interact with LibreOffice.
type Api struct {
autoStart bool
args libreOfficeArguments
logger *zap.Logger
libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor
}
// Options gathers available options when converting a document to PDF.
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
type Options struct {
// Password specifies the password for opening the source file.
Password string
// Landscape allows to change the orientation of the resulting PDF.
Landscape bool
// PageRanges allows to select the pages to convert.
PageRanges string
// UpdateIndexes specifies whether to update the indexes before conversion,
// keeping in mind that doing so might result in missing links in the final
// PDF.
UpdateIndexes bool
// ExportFormFields specifies whether form fields are exported as widgets
// or only their fixed print representation is exported.
ExportFormFields bool
// AllowDuplicateFieldNames specifies whether multiple form fields exported
// are allowed to have the same field name.
AllowDuplicateFieldNames bool
// ExportBookmarks specifies if bookmarks are exported to PDF.
ExportBookmarks bool
// ExportBookmarksToPdfDestination specifies that the bookmarks contained
// in the source LibreOffice file should be exported to the PDF file as
// Named Destination.
ExportBookmarksToPdfDestination bool
// ExportPlaceholders exports the placeholders fields visual markings only.
// The exported placeholder is ineffective.
ExportPlaceholders bool
// ExportNotes specifies if notes are exported to PDF.
ExportNotes bool
// ExportNotesPages specifies if notes pages are exported to PDF.
// Notes pages are available in Impress documents only.
ExportNotesPages bool
// ExportOnlyNotesPages specifies, if the property ExportNotesPages is set
// to true, if only notes pages are exported to PDF.
ExportOnlyNotesPages bool
// ExportNotesInMargin specifies if notes in margin are exported to PDF.
ExportNotesInMargin bool
// ConvertOooTargetToPdfTarget specifies that the target documents with
// .od[tpgs] extension, will have that extension changed to .pdf when the
// link is exported to PDF. The source document remains untouched.
ConvertOooTargetToPdfTarget bool
// ExportLinksRelativeFsys specifies that the file system related
// hyperlinks (file:// protocol) present in the document will be exported
// as relative to the source document location.
ExportLinksRelativeFsys bool
// ExportHiddenSlides exports, for LibreOffice Impress, slides that are not
// included in slide shows.
ExportHiddenSlides bool
// SkipEmptyPages specifies that automatically inserted empty pages are
// suppressed. This option is active only if storing Writer documents.
SkipEmptyPages bool
// AddOriginalDocumentAsStream specifies that a stream is inserted to the
// PDF file which contains the original document for archiving purposes.
AddOriginalDocumentAsStream bool
// SinglePageSheets ignores each sheets paper size, print ranges and
// shown/hidden status and puts every sheet (even hidden sheets) on exactly
// one page.
SinglePageSheets bool
// LosslessImageCompression specifies if images are exported to PDF using
// a lossless compression format like PNG or compressed using the JPEG
// format.
LosslessImageCompression bool
// Quality specifies the quality of the JPG export. A higher value produces
// a higher-quality image and a larger file. Between 1 and 100.
Quality int
// ReduceImageResolution specifies if the resolution of each image is
// reduced to the resolution specified by the property MaxImageResolution.
ReduceImageResolution bool
// MaxImageResolution, if the property ReduceImageResolution is set to
// true, tells if all images will be reduced to the given value in DPI.
// Possible values are: 75, 150, 300, 600 and 1200.
MaxImageResolution int
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA.
PdfFormats gotenberg.PdfFormats
}
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
Password: "",
Landscape: false,
PageRanges: "",
UpdateIndexes: true,
ExportFormFields: true,
AllowDuplicateFieldNames: false,
ExportBookmarks: true,
ExportBookmarksToPdfDestination: false,
ExportPlaceholders: false,
ExportNotes: false,
ExportNotesPages: false,
ExportOnlyNotesPages: false,
ExportNotesInMargin: false,
ConvertOooTargetToPdfTarget: false,
ExportLinksRelativeFsys: false,
ExportHiddenSlides: false,
SkipEmptyPages: false,
AddOriginalDocumentAsStream: false,
SinglePageSheets: false,
LosslessImageCompression: false,
Quality: 90,
ReduceImageResolution: false,
MaxImageResolution: 300,
PdfFormats: gotenberg.PdfFormats{
PdfA: "",
PdfUa: false,
},
}
}
// Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface {
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
Extensions() []string
}
// Provider is a module interface which exposes a method for creating a
// [Uno] for other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(libreofficeapi.Provider))
// libreOffice, _ := provider.(api.Provider).LibreOffice()
// }
type Provider interface {
LibreOffice() (Uno, error)
}
// Descriptor returns a [Api]'s module descriptor.
func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "libreoffice-api",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
fs.Int64("libreoffice-max-queue-size", 0, "Maximum request queue size for LibreOffice. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
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.autoStart = flags.MustBool("libreoffice-auto-start")
libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH")
if !ok {
return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set")
}
unoBinPath, ok := os.LookupEnv("UNOCONVERTER_BIN_PATH")
if !ok {
return errors.New("UNOCONVERTER_BIN_PATH environment variable is not set")
}
a.args = libreOfficeArguments{
binPath: libreOfficeBinPath,
unoBinPath: unoBinPath,
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
}
// Logger.
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.Named("libreoffice")
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
return nil
}
// Validate validates the module properties.
func (a *Api) Validate() error {
var err error
_, statErr := os.Stat(a.args.binPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
}
_, statErr = os.Stat(a.args.unoBinPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
}
return err
}
// Start does nothing if auto-start is not enabled. Otherwise, it starts a
// LibreOffice instance.
func (a *Api) Start() error {
if !a.autoStart {
return nil
}
err := a.supervisor.Launch()
if err != nil {
return fmt.Errorf("launch supervisor: %w", err)
}
return nil
}
// StartupMessage returns a custom startup message.
func (a *Api) StartupMessage() string {
if !a.autoStart {
return "LibreOffice ready to start"
}
return "LibreOffice automatically started"
}
// Stop stops the current browser instance.
func (a *Api) Stop(ctx context.Context) error {
// Block until the context is done so that other module may gracefully stop
// before we do a shutdown.
a.logger.Debug("wait for the end of grace duration")
<-ctx.Done()
err := a.supervisor.Shutdown()
if err == nil {
return nil
}
return fmt.Errorf("stop LibreOffice: %w", err)
}
// Debug returns additional debug data.
func (a *Api) Debug() map[string]interface{} {
debug := make(map[string]interface{})
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
debug["version"] = err.Error()
return debug
}
debug["version"] = strings.TrimSpace(string(output))
return debug
}
// Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "libreoffice_requests_queue_size",
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
Read: func() float64 {
return float64(a.supervisor.ReqQueueSize())
},
},
{
Name: "libreoffice_restarts_count",
Description: "Current number of LibreOffice restarts.",
Read: func() float64 {
return float64(a.supervisor.RestartsCount())
},
},
}, nil
}
// Checks adds a health check that verifies if LibreOffice is healthy.
func (a *Api) Checks() ([]health.CheckerOption, error) {
return []health.CheckerOption{
health.WithCheck(health.Check{
Name: "libreoffice",
Check: func(_ context.Context) error {
if a.supervisor.Healthy() {
return nil
}
return errors.New("LibreOffice is unhealthy")
},
}),
}, nil
}
// Ready returns no error if the module is ready.
func (a *Api) Ready() error {
if !a.autoStart {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), a.args.startTimeout)
defer cancel()
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while waiting for LibreOffice to be ready: %w", ctx.Err())
case <-ticker.C:
ok := a.libreOffice.Healthy(a.logger)
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
// LibreOffice returns a [Uno] for interacting with LibreOffice.
func (a *Api) LibreOffice() (Uno, error) {
return a, nil
}
// Pdf converts a document to PDF.
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
err := a.supervisor.Run(ctx, logger, func() error {
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
if err == nil {
return nil
}
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options)
}
return fmt.Errorf("supervisor run task: %w", err)
}
// Extensions returns the file extensions available for conversions.
// FIXME: don't care, take all on the route level?
func (a *Api) Extensions() []string {
return []string{
".123",
".602",
".abw",
".bib",
".bmp",
".cdr",
".cgm",
".cmx",
".csv",
".cwk",
".dbf",
".dif",
".doc",
".docm",
".docx",
".dot",
".dotm",
".dotx",
".dxf",
".emf",
".eps",
".epub",
".fodg",
".fodp",
".fods",
".fodt",
".fopd",
".gif",
".htm",
".html",
".hwp",
".jpeg",
".jpg",
".key",
".ltx",
".lwp",
".mcw",
".met",
".mml",
".mw",
".numbers",
".odd",
".odg",
".odm",
".odp",
".ods",
".odt",
".otg",
".oth",
".otp",
".ots",
".ott",
".pages",
".pbm",
".pcd",
".pct",
".pcx",
".pdb",
".pdf",
".pgm",
".png",
".pot",
".potm",
".potx",
".ppm",
".pps",
".ppt",
".pptm",
".pptx",
".psd",
".psw",
".pub",
".pwp",
".pxl",
".ras",
".rtf",
".sda",
".sdc",
".sdd",
".sdp",
".sdw",
".sgl",
".slk",
".smf",
".stc",
".std",
".sti",
".stw",
".svg",
".svm",
".swf",
".sxc",
".sxd",
".sxg",
".sxi",
".sxm",
".sxw",
".tga",
".tif",
".tiff",
".txt",
".uof",
".uop",
".uos",
".uot",
".vdx",
".vor",
".vsd",
".vsdm",
".vsdx",
".wb2",
".wk1",
".wks",
".wmf",
".wpd",
".wpg",
".wps",
".xbm",
".xhtml",
".xls",
".xlsb",
".xlsm",
".xlsx",
".xlt",
".xltm",
".xltx",
".xlw",
".xml",
".xpm",
".zabw",
}
}
// Interface guards.
var (
_ gotenberg.Module = (*Api)(nil)
_ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil)
_ gotenberg.Debuggable = (*Api)(nil)
_ gotenberg.MetricsProvider = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil)
_ Provider = (*Api)(nil)
)

View File

@@ -0,0 +1,3 @@
// Package api provides a module which manages a LibreOffice instance and
// interacts with it via the UNO (Universal Network Objects) API.
package api

View File

@@ -1,4 +1,4 @@
package uno
package api
import (
"fmt"

View File

@@ -0,0 +1,440 @@
package api
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type libreOffice interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
}
type libreOfficeArguments struct {
binPath string
unoBinPath string
startTimeout time.Duration
}
type libreOfficeProcess struct {
socketPort int
userProfileDirPath string
cmd *gotenberg.Cmd
cfgMu sync.RWMutex
isStarted atomic.Bool
arguments libreOfficeArguments
fs *gotenberg.FileSystem
}
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
p := &libreOfficeProcess{
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
}
p.isStarted.Store(false)
return p
}
func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
if p.isStarted.Load() {
return errors.New("LibreOffice is already started")
}
port, err := freePort(logger)
if err != nil {
return fmt.Errorf("get free port: %w", err)
}
userProfileDirPath := p.fs.NewDirPath()
args := []string{
"--headless",
"--invisible",
"--nocrashreport",
"--nodefault",
"--nologo",
"--nofirststartwizard",
"--norestore",
fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath),
fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port),
}
ctx, cancel := context.WithTimeout(context.Background(), p.arguments.startTimeout)
defer cancel()
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
if err != nil {
return fmt.Errorf("create LibreOffice command: %w", err)
}
// For whatever reason, LibreOffice requires a first start before being
// able to run as a daemon.
exitCode, err := cmd.Exec()
if err != nil && exitCode != 81 {
return fmt.Errorf("execute LibreOffice: %w", err)
}
logger.Debug("got exit code 81, e.g., LibreOffice first start")
// Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
err = cmd.Start()
if err != nil {
return fmt.Errorf("start LibreOffice: %w", err)
}
waitChan := make(chan error, 1)
go func() {
// By waiting the process, we avoid the creation of a zombie process
// and make sure we catch an early exit if any.
waitChan <- cmd.Wait()
}()
connChan := make(chan error, 1)
go func() {
// As the LibreOffice socket may take some time to be available, we
// have to ensure that it is indeed accepting connections.
for {
if ctx.Err() != nil {
connChan <- ctx.Err()
break
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second)
if err != nil {
continue
}
connChan <- nil
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
}
break
}
}()
var success bool
defer func() {
if success {
p.cfgMu.Lock()
defer p.cfgMu.Unlock()
p.socketPort = port
p.userProfileDirPath = userProfileDirPath
p.cmd = cmd
p.isStarted.Store(true)
return
}
// Let's make sure the process is killed.
err = cmd.Kill()
if err != nil {
logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err))
}
// And the user profile directory is deleted.
err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
}
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}()
logger.Debug("waiting for the LibreOffice socket to be available...")
for {
select {
case err = <-connChan:
if err != nil {
return fmt.Errorf("LibreOffice socket not available: %w", err)
}
logger.Debug("LibreOffice socket available")
success = true
return nil
case err = <-waitChan:
return fmt.Errorf("LibreOffice process exited: %w", err)
}
}
}
func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
if !p.isStarted.Load() {
// No big deal? Like calling cancel twice.
return nil
}
// Always remove the user profile directory created by LibreOffice.
copyUserProfileDirPath := p.userProfileDirPath
expirationTime := time.Now()
defer func(userProfileDirPath string, expirationTime time.Time) {
go func() {
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
} else {
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}
// Also remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil {
logger.Error(err.Error())
}
}()
}(copyUserProfileDirPath, expirationTime)
p.cfgMu.Lock()
defer p.cfgMu.Unlock()
err := p.cmd.Kill()
if err != nil {
return fmt.Errorf("kill LibreOffice process: %w", err)
}
p.socketPort = 0
p.userProfileDirPath = ""
p.cmd = nil
p.isStarted.Store(false)
return nil
}
func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
// Good to know: the supervisor does not call this method if no first start
// or if the process is restarting.
if !p.isStarted.Load() {
// Non-started browser but not restarting?
return false
}
p.cfgMu.RLock()
defer p.cfgMu.RUnlock()
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.socketPort), time.Duration(10)*time.Second)
if err == nil {
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
}
return true
}
return false
}
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
if !p.isStarted.Load() {
return errors.New("LibreOffice not started, cannot handle PDF conversion")
}
args := []string{
"--no-launch",
"--format",
"pdf",
}
args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
if checkedEntry != nil {
args = append(args, "-vvv")
}
if options.Password != "" {
args = append(args, "--password", options.Password)
}
if options.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape")
}
// See: https://github.com/gotenberg/gotenberg/issues/1149.
if options.PageRanges != "" {
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
}
if !options.UpdateIndexes {
args = append(args, "--disable-update-indexes")
}
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
args = append(args, "--export", fmt.Sprintf("ExportBookmarksToPDFDestination=%t", options.ExportBookmarksToPdfDestination))
args = append(args, "--export", fmt.Sprintf("ExportPlaceholders=%t", options.ExportPlaceholders))
args = append(args, "--export", fmt.Sprintf("ExportNotes=%t", options.ExportNotes))
args = append(args, "--export", fmt.Sprintf("ExportNotesPages=%t", options.ExportNotesPages))
args = append(args, "--export", fmt.Sprintf("ExportOnlyNotesPages=%t", options.ExportOnlyNotesPages))
args = append(args, "--export", fmt.Sprintf("ExportNotesInMargin=%t", options.ExportNotesInMargin))
args = append(args, "--export", fmt.Sprintf("ConvertOOoTargetToPDFTarget=%t", options.ConvertOooTargetToPdfTarget))
args = append(args, "--export", fmt.Sprintf("ExportLinksRelativeFsys=%t", options.ExportLinksRelativeFsys))
args = append(args, "--export", fmt.Sprintf("ExportHiddenSlides=%t", options.ExportHiddenSlides))
args = append(args, "--export", fmt.Sprintf("IsSkipEmptyPages=%t", options.SkipEmptyPages))
args = append(args, "--export", fmt.Sprintf("IsAddStream=%t", options.AddOriginalDocumentAsStream))
args = append(args, "--export", fmt.Sprintf("SinglePageSheets=%t", options.SinglePageSheets))
args = append(args, "--export", fmt.Sprintf("UseLosslessCompression=%t", options.LosslessImageCompression))
args = append(args, "--export", fmt.Sprintf("Quality=%d", options.Quality))
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
switch options.PdfFormats.PdfA {
case "":
case gotenberg.PdfA1b:
args = append(args, "--export", "SelectPdfVersion=1")
case gotenberg.PdfA2b:
args = append(args, "--export", "SelectPdfVersion=2")
case gotenberg.PdfA3b:
args = append(args, "--export", "SelectPdfVersion=3")
default:
return ErrInvalidPdfFormats
}
if options.PdfFormats.PdfUa {
args = append(
args,
"--export", "PDFUACompliance=true",
"--export", "UseTaggedPDF=true",
"--export", "EnableTextAccessForAccessibilityTools=true",
)
} else {
args = append(
args,
"--export", "PDFUACompliance=false",
"--export", "UseTaggedPDF=false",
"--export", "EnableTextAccessForAccessibilityTools=false",
)
}
inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath)
if err != nil {
return fmt.Errorf("non-basic latin characters guard: %w", err)
}
args = append(args, "--output", outputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...)
if err != nil {
return fmt.Errorf("create uno command: %w", err)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
exitCode, err := cmd.Exec()
if err == nil {
return nil
}
// LibreOffice's errors are not explicit.
// For instance, an exit code 5 may be explained by a malformed page
// ranges, but also by a not required password.
// We may want to retry in case of a core dumped event.
// See https://github.com/gotenberg/gotenberg/issues/639.
if strings.Contains(err.Error(), "core dumped") {
return ErrCoreDumped
}
if exitCode == 5 {
// Potentially malformed page ranges or password not required.
return ErrUnoException
}
if exitCode == 6 {
// Password potentially required or invalid.
return ErrRuntimeException
}
return fmt.Errorf("convert to PDF: %w", err)
}
// LibreOffice cannot convert a file with a name containing non-basic Latin
// characters.
// See:
// https://github.com/gotenberg/gotenberg/issues/104
// https://github.com/gotenberg/gotenberg/issues/730
func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) {
hasNonBasicLatinChars := func(str string) bool {
for _, r := range str {
// Check if the character is outside basic Latin.
if r != '.' && (r < ' ' || r > '~') {
return true
}
}
return false
}
filename := filepath.Base(inputPath)
if !hasNonBasicLatinChars(filename) {
logger.Debug("no non-basic latin characters in filename, skip copy")
return inputPath, nil
}
logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename")
basePath := filepath.Dir(inputPath)
ext := filepath.Ext(inputPath)
newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext))
in, err := os.Open(inputPath)
if err != nil {
return "", fmt.Errorf("open file: %w", err)
}
defer func() {
err := in.Close()
if err != nil {
logger.Error(fmt.Sprintf("close file: %s", err))
}
}()
out, err := os.Create(newInputPath)
if err != nil {
return "", fmt.Errorf("create new file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close new file: %s", err))
}
}()
_, err = io.Copy(out, in)
if err != nil {
return "", fmt.Errorf("copy file to new file: %w", err)
}
return newInputPath, nil
}
// Interface guards.
var (
_ gotenberg.Process = (*libreOfficeProcess)(nil)
_ libreOffice = (*libreOfficeProcess)(nil)
)

View File

@@ -0,0 +1,59 @@
package api
import (
"context"
"errors"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// ApiMock is a mock for the [Uno] interface.
type ApiMock struct {
PdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
ExtensionsMock func() []string
}
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return api.PdfMock(ctx, logger, inputPath, outputPath, options)
}
func (api *ApiMock) Extensions() []string {
return api.ExtensionsMock()
}
// ProviderMock is a mock for the [Provider] interface.
type ProviderMock struct {
LibreOfficeMock func() (Uno, error)
}
func (provider *ProviderMock) LibreOffice() (Uno, error) {
return provider.LibreOfficeMock()
}
// libreOfficeMock is a mock for the [libreOffice] interface.
type libreOfficeMock struct {
errCoreDumpedCount int
gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
}
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
if errors.Is(err, ErrCoreDumped) {
b.errCoreDumpedCount += 1
}
if b.errCoreDumpedCount > 1 {
return nil
}
return err
}
// Interface guards.
var (
_ Uno = (*ApiMock)(nil)
_ Provider = (*ProviderMock)(nil)
_ libreOffice = (*libreOfficeMock)(nil)
)

View File

@@ -3,26 +3,27 @@ package libreoffice
import (
"fmt"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
flag "github.com/spf13/pflag"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libeofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
func init() {
gotenberg.MustRegisterModule(LibreOffice{})
gotenberg.MustRegisterModule(new(LibreOffice))
}
// LibreOffice is a module which provides a route for converting documents to
// PDF with LibreOffice.
type LibreOffice struct {
unoAPI uno.API
engine gotenberg.PDFEngine
api libeofficeapi.Uno
engine gotenberg.PdfEngine
disableRoutes bool
}
// Descriptor returns a LibreOffice's module descriptor.
func (LibreOffice) Descriptor() gotenberg.ModuleDescriptor {
// Descriptor returns a [LibreOffice]'s module descriptor.
func (mod *LibreOffice) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "libreoffice",
FlagSet: func() *flag.FlagSet {
@@ -40,24 +41,24 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.disableRoutes = flags.MustBool("libreoffice-disable-routes")
provider, err := ctx.Module(new(uno.Provider))
provider, err := ctx.Module(new(libeofficeapi.Provider))
if err != nil {
return fmt.Errorf("get unoAPI provider: %w", err)
return fmt.Errorf("get LibreOffice Uno provider: %w", err)
}
unoAPI, err := provider.(uno.Provider).UNO()
libreOfficeApi, err := provider.(libeofficeapi.Provider).LibreOffice()
if err != nil {
return fmt.Errorf("get unoAPI API: %w", err)
return fmt.Errorf("get LibreOffice Uno: %w", err)
}
mod.unoAPI = unoAPI
mod.api = libreOfficeApi
provider, err = ctx.Module(new(gotenberg.PDFEngineProvider))
provider, err = ctx.Module(new(gotenberg.PdfEngineProvider))
if err != nil {
return fmt.Errorf("get PDF engine provider: %w", err)
}
engine, err := provider.(gotenberg.PDFEngineProvider).PDFEngine()
engine, err := provider.(gotenberg.PdfEngineProvider).PdfEngine()
if err != nil {
return fmt.Errorf("get PDF engine: %w", err)
}
@@ -68,13 +69,13 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error {
}
// Routes returns the HTTP routes.
func (mod LibreOffice) Routes() ([]api.Route, error) {
func (mod *LibreOffice) Routes() ([]api.Route, error) {
if mod.disableRoutes {
return nil, nil
}
return []api.Route{
convertRoute(mod.unoAPI, mod.engine),
convertRoute(mod.api, mod.engine),
}, nil
}

View File

@@ -1,223 +0,0 @@
package libreoffice
import (
"errors"
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
)
func TestLibreOffice_Descriptor(t *testing.T) {
descriptor := LibreOffice{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(LibreOffice))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestLibreOffice_Provision(t *testing.T) {
tests := []struct {
name string
ctx *gotenberg.Context
expectProvisionErr bool
}{
{
name: "nominal behavior",
ctx: func() *gotenberg.Context {
provider1 := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider1
}}
}
provider1.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
provider2 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineProviderMock
}{}
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
return provider2
}}
}
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
return gotenberg.PDFEngineMock{}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider1.Descriptor(),
provider2.Descriptor(),
},
)
}(),
},
{
name: "no UNO API provider",
ctx: gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
),
expectProvisionErr: true,
},
{
name: "no API from UNO API provider",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
{
name: "no PDF engine provider",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
{
name: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context {
provider1 := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider1
}}
}
provider1.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
provider2 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineProviderMock
}{}
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
return provider2
}}
}
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
return gotenberg.PDFEngineMock{}, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider1.Descriptor(),
provider2.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := new(LibreOffice)
err := mod.Provision(tc.ctx)
if tc.expectProvisionErr && err == nil {
t.Error("expected mod.Provision() error, but got none")
}
if !tc.expectProvisionErr && err != nil {
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
}
})
}
}
func TestLibreOffice_Routes(t *testing.T) {
tests := []struct {
name string
mod LibreOffice
expectRoutesCount int
}{
{
name: "route not disabled",
mod: LibreOffice{},
expectRoutesCount: 1,
},
{
name: "route disabled",
mod: LibreOffice{
disableRoutes: true,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
routes, err := tc.mod.Routes()
if err != nil {
t.Fatalf("expected no error from mod.Routes(), but got: %v", err)
}
if tc.expectRoutesCount != len(routes) {
t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes))
}
})
}
}

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