Compare commits

...

188 Commits

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

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

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

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

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

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

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

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

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

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

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

I noted that the versions of `actions/checkout` in use were out of date. Didn't want to leave it unautomated.
2023-03-17 13:23:52 +01:00
Pieter Oliver
fd0ff633e6 chore: add 503 to every endpoint as this is a known error
As detailed on the website documentation.
2023-03-08 15:06:05 +01:00
Julien Neuhart
02923fe9ce chore: update Golang to 1.20 & update Go dependencies & update Golangci-lint to 1.51.2 2023-02-22 15:35:30 +01:00
Julien Neuhart
875f97813e fix: tests 2023-01-13 10:21:36 +01:00
Julien Neuhart
6a29385762 chore: update Go dependencies 2023-01-12 15:53:08 +01:00
Julien Neuhart
fbc9f9dec9 feat(libreoffice): add .odg, .dotx, .xltx support - close #567, close #573 2023-01-12 15:48:53 +01:00
Julien Neuhart
4607ade8e9 chore: update LICENSE 2023-01-04 17:01:56 +01:00
Julien Neuhart
e4a553c022 fix(libreoffice): downgrade LibreOffice version (7.0.4 at the time of writing) - fix #442 & fix #516 & fix #529 & fix #537 & fix #568 2023-01-04 15:29:02 +01:00
Julien Neuhart
bc733bab0c chore: update Go dependencies 2023-01-02 17:41:59 +01:00
Julien Neuhart
1a522ddf48 Merge branch 'main' of github.com:gotenberg/gotenberg 2022-12-03 16:12:29 +01:00
Dorian de Koning
1de0f4c4fa fix(webhook/client): error on http status codes >= 400 2022-12-03 16:10:39 +01:00
Julien Neuhart
71eb5c25b5 chore: upgrade golangci-lint to version 1.50.1 2022-12-03 15:51:00 +01:00
Julien Neuhart
ea34979a29 chore: update Go dependencies 2022-12-03 15:49:07 +01:00
Julien Neuhart
1f784ad44c chore(chromium): add test without custom header/footer templates 2022-10-21 12:10:16 +02:00
Julien Neuhart
f89726752f feat(chromium): add new omitBackground form field (closes #226) 2022-10-21 11:51:06 +02:00
Julien Neuhart
921b6b7ba4 chore: upgrade golangci-lint from 1.49 to 1.50 2022-10-21 11:51:06 +02:00
Julien Neuhart
6c5cc7a0ce chore: update Go dependencies 2022-10-21 11:51:06 +02:00
Yohann Tilotti
f851e2f2cf fix(libreoffice): Typo error on mkdir 2022-10-17 22:16:44 +02:00
Julien Neuhart
b73207f29a fix: websocket timeout reached (fixes #524) 2022-10-17 10:50:55 +02:00
Julien Neuhart
8d8f0b55ea chore: update Go dependencies 2022-10-17 10:50:55 +02:00
Julien Neuhart
0e1e9b9cf3 fix: only linux/amd64 for Cloud Run 2022-10-03 10:25:32 +02:00
Julien Neuhart
ab4eed78af chore: update PDFtk from v3.3.2 to v3.3.3 2022-10-03 10:23:18 +02:00
Julien Neuhart
8a36c15d5c chore: update Noto emoji from v2.034 to v2.038 2022-10-03 10:22:31 +02:00
Julien Neuhart
bd895286bc chore: update Go dependencies 2022-10-03 10:21:49 +02:00
Julien Neuhart
b76990ec16 feat(chromium): new flags chromium-allow-insecure-localhost and chromium-host-resolver-rules 2022-09-01 18:04:00 +02:00
Julien Neuhart
9bc93c5cf5 chore: remove ioutil package 2022-09-01 18:04:00 +02:00
Julien Neuhart
1d8ae79f84 feat(chromium): add ReturnAsStream support 2022-09-01 18:04:00 +02:00
Julien Neuhart
4269aa2422 chore: update Go dependencies 2022-09-01 18:04:00 +02:00
Julien Neuhart
c887dc1ec8 chore: upgrade golangci-lint to version from version 1.48 to 1.49 2022-09-01 18:04:00 +02:00
Julien Neuhart
8bbacd691d chore: update Golang versionin go.mod file 2022-08-19 15:24:09 +02:00
Julien Neuhart
d8f3f85fb7 fix: replace depracted io/ioutil package with os package 2022-08-19 15:01:31 +02:00
Julien Neuhart
159c0b3350 fix: Golang Docker image for tests version 2022-08-19 14:52:36 +02:00
Julien Neuhart
45788f3235 chore: upgrade Golang dependencies 2022-08-19 14:11:36 +02:00
Julien Neuhart
4c85c6ccdf chore: upgrade from Golang 1.18 to 1.19, golangci-lint from 1.46 to 1.48 2022-08-19 14:03:32 +02:00
Julien Neuhart
08408ae005 chore: update Go dependencies 2022-06-07 15:08:04 +02:00
Julien Neuhart
d470279fb7 chore: update golangci-lint version 2022-06-07 14:43:33 +02:00
Julien Neuhart
8260458f8f Revert "fix(chromium): regex only accepts tmp folder by default (#429)"
This reverts commit 27018fa8e2.
2022-03-22 10:48:01 +01:00
Julien Neuhart
0512dd1e86 fix(tests): add -buildvcs=false as a temporary fix for go 1.18 bug (see https://github.com/golang/go/issues/51723) 2022-03-21 17:13:35 +01:00
Julien Neuhart
4972019cb9 chore: update golang-ci-lint 2022-03-21 16:46:46 +01:00
Julien Neuhart
5e615cab52 chore: upgrade go to 1.18 and update Go dependencies 2022-03-21 16:46:15 +01:00
Julien Neuhart
64ea2b6d3b fix(libreoffice): start the long-running listener on the first conversion (fixes #420) 2022-03-21 16:39:52 +01:00
Gabor Szabad
27018fa8e2 fix(chromium): regex only accepts tmp folder by default (#429) 2022-02-25 13:52:27 +01:00
Julien Neuhart
263d634801 chore: rename MockContext to ContextMock and move it to a dedicated go file 2022-02-09 16:09:48 +01:00
Julien Neuhart
c61c33f71c chore: update LICENSE 2022-02-09 10:41:50 +01:00
Julien Neuhart
bfb30b8fed chore: remove Go Report Card from README (not working) 2022-02-08 19:46:53 +01:00
Julien Neuhart
ca3e27937e chore(uno): update uno-listener-restart-threshold flag description 2022-02-08 19:46:31 +01:00
Julien Neuhart
50c34a4186 fix(uno): repare not working test 2022-02-08 19:45:39 +01:00
Julien Neuhart
84875aaf82 fix(chromium): remove local files from extra ling tags & extra script tags (closes #418) 2022-02-08 19:45:10 +01:00
Julien Neuhart
b7a8b95820 fix(uno): close tcp connexions to the LibreOffice listener, better health check that take the restart into account 2022-02-08 16:12:41 +01:00
Julien Neuhart
5cad5ca11a fix: PDFtk new binary URL 2022-02-08 11:33:55 +01:00
Julien Neuhart
7598de29dd chore: upgrade golangci-lint ot v1.44.0 2022-02-08 11:33:55 +01:00
Julien Neuhart
af6ffe67c8 chore: upgrade PDFtk to v3.3.2 2022-02-08 11:33:55 +01:00
Julien Neuhart
6944c4b3bf chore: upgrade Noto Color Emoji font to v2.034 2022-02-08 11:33:55 +01:00
Julien Neuhart
24817d5707 feat: add more PDF formats, drastically improve LibreOffice long-running instance management 2022-02-08 11:33:55 +01:00
Julien Neuhart
3d855712ee chore: update Go dependencies 2022-02-08 11:33:55 +01:00
Julien Neuhart
872263d253 feat: switch from top to htop 2022-01-05 15:46:20 +01:00
167 changed files with 17098 additions and 59387 deletions

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

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

2
.github/stale.yml vendored
View File

@@ -1,5 +1,5 @@
# Number of days of inactivity before an issue becomes stale # Number of days of inactivity before an issue becomes stale
daysUntilStale: 60 daysUntilStale: 15
# Number of days of inactivity before a stale issue is closed # Number of days of inactivity before a stale issue is closed
daysUntilClose: 7 daysUntilClose: 7
# Issues with these labels will never be considered stale # Issues with these labels will never be considered stale

View File

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

View File

@@ -14,12 +14,17 @@ jobs:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Setup Go
uses: actions/checkout@v2 uses: actions/setup-go@v5
- name: Run linters
uses: golangci/golangci-lint-action@v2
with: with:
version: v1.43 go-version: '1.22'
cache: false
- name: Checkout source code
uses: actions/checkout@v4
- name: Run linters
uses: golangci/golangci-lint-action@v4
with:
version: v1.56.2
tests: tests:
needs: needs:
@@ -30,14 +35,37 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v3
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Build testing environment - name: Build testing environment
run: make build build-tests run: make build build-tests
- name: Run tests - name: Run tests
run: | run: |
make tests-once make tests-once
bash <(curl -s https://codecov.io/bash) bash <(curl -s https://codecov.io/bash)
multiarch_build:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- Tests
name: Multi-arch build
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Log in to Docker Hub Container Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image for main branch
run: |
make release GOTENBERG_VERSION=edge
make release GOTENBERG_VERSION=edge DOCKER_REPOSITORY=thecodingmachine

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/coverage.html /coverage.html
/coverage.txt /coverage.txt
/TODO.txt

View File

@@ -1,30 +1,59 @@
linters-settings:
gci:
sections:
- standard
- default
- prefix(github.com/gotenberg/gotenberg/v8)
skip-generated: true
custom-order: true
linters: linters:
disable-all: true disable-all: true
enable: enable:
- asasalint
- asciicheck
- bidichk
- bodyclose - bodyclose
- deadcode - decorder
- dogsled
- dupl
- dupword
- durationcheck
- errcheck - errcheck
- errname
- exhaustive
- exportloopref
- gci
- gofmt - gofmt
- goimports - goimports
- gofumpt
- gosec - gosec
- gosimple - gosimple
- govet - govet
- ineffassign - ineffassign
- importas
- misspell - misspell
- prealloc - prealloc
- promlinter
- sloglint
- sqlclosecheck
- staticcheck - staticcheck
- structcheck - tenv
- testableexamples
- testifylint
- tparallel
- typecheck - typecheck
- unconvert - unconvert
- unused - unused
- varcheck - wastedassign
- whitespace
- zerologlint
run: run:
deadline: 5m timeout: 5m
issues-exit-code: 1 issues-exit-code: 1
tests: false tests: false
output: output:
format: 'colored-line-number'
print-issued-lines: true print-issued-lines: true
print-linter-name: true print-linter-name: true

View File

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

View File

@@ -5,14 +5,14 @@ help: ## Show the help
.PHONY: it .PHONY: it
it: build build-tests ## Initialize the development environment it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.17 GOLANG_VERSION=1.22
DOCKER_REPOSITORY=gotenberg DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001 GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001 GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.028 # See https://github.com/googlefonts/noto-emoji/releases. NOTO_COLOR_EMOJI_VERSION=v2.042 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=1527259628 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package. PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.43.0 # See https://github.com/golangci/golangci-lint/releases. GOLANGCI_LINT_VERSION=v1.56.2 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build .PHONY: build
build: ## Build the Gotenberg's Docker image build: ## Build the Gotenberg's Docker image
@@ -29,29 +29,42 @@ build: ## Build the Gotenberg's Docker image
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_ROOT_PATH=/ API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace API_TRACE_HEADER=Gotenberg-Trace
API_DISABLE_HEALTH_CHECK_LOGGING=false API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
CHROMIUM_INCOGNITO=false CHROMIUM_INCOGNITO=false
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
CHROMIUM_DISABLE_WEB_SECURITY=false CHROMIUM_DISABLE_WEB_SECURITY=false
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER= CHROMIUM_PROXY_SERVER=
CHROMIUM_ALLOW_LIST= 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_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=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_LEVEL=info
LOG_FORMAT=auto LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES= PDFENGINES_ENGINES=
PDFENGINES_DISABLE_ROUTES=false PDFENGINES_DISABLE_ROUTES=false
PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_COLLECT=false PROMETHEUS_DISABLE_COLLECT=false
UNOCONV_DISABLE_LISTENER=false
WEBHOOK_ALLOW_LIST= WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST= WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST= WEBHOOK_ERROR_ALLOW_LIST=
@@ -71,31 +84,44 @@ run: ## Start a Gotenberg container
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \ --api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \ --api-port-from-env=$(API_PORT_FROM_ENV) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \ --api-timeout=$(API_TIMEOUT) \
--api-root-path=$(API_ROOT_PATH) \ --api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \ --api-trace-header=$(API_TRACE_HEADER) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--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-incognito=$(CHROMIUM_INCOGNITO) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \ --chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \ --chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \ --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-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \ --chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
--chromium-deny-list=$(CHROMIUM_DENY_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-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \ --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-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \ --log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \ --pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \ --pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \ --prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \ --prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--unoconv-disable-listener=$(UNOCONV_DISABLE_LISTENER) \ --webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \ --webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \ --webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \ --webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \ --webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
@@ -128,14 +154,18 @@ tests-once: ## Run the tests once (prefer the "tests" command while developing)
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \ $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
gotest gotest
# go install mvdan.cc/gofumpt@latest
# go install github.com/daixiang0/gci@latest
.PHONY: fmt .PHONY: fmt
fmt: ## Format the code and "optimize" the dependencies fmt: ## Format the code and "optimize" the dependencies
go fmt ./... gofumpt -l -w .
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order .
go mod tidy go mod tidy
# go install golang.org/x/tools/cmd/godoc@latest
.PHONY: godoc .PHONY: godoc
godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/godoc) godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7) $(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060 godoc -http=:6060
.PHONY: release .PHONY: release

View File

@@ -2,7 +2,7 @@
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" /> <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> <h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p> <p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a> &#183; 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</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> </p>
--- ---
@@ -15,18 +15,18 @@ numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, an
Open a terminal and run the following command: 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): 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. 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 ## Sponsors
@@ -41,6 +41,5 @@ Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to in
[![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/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) [![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) [![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) [![Go Reference](https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg)](https://pkg.go.dev/github.com/gotenberg/gotenberg/v8)
[![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg) [![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg)
[![Go Report Card](https://goreportcard.com/badge/github.com/gotenberg/gotenberg)](https://goreportcard.com/report/gotenberg/gotenberg)

41
SECURITY.md Normal file
View File

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

View File

@@ -1,11 +1,14 @@
# Note: ARG instructions do not create additional layers. # ARG instructions do not create additional layers. Instead, next layers will
# Instead, next layers will concatenate them. # concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION ARG GOLANG_VERSION
# Note: we have to repeat ARG instructions in each build stage that uses them.
# ----------------------------------------------
# Gotenberg binary build stage
# ----------------------------------------------
FROM golang:$GOLANG_VERSION AS binary-stage
ARG GOTENBERG_VERSION ARG GOTENBERG_VERSION
FROM golang:$GOLANG_VERSION AS builder
ENV CGO_ENABLED 0 ENV CGO_ENABLED 0
# Define the working directory outside of $GOPATH (we're using go modules). # Define the working directory outside of $GOPATH (we're using go modules).
@@ -21,40 +24,26 @@ RUN go mod download &&\
COPY cmd ./cmd COPY cmd ./cmd
COPY pkg ./pkg COPY pkg ./pkg
# Build the binary. RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
ARG GOTENBERG_VERSION
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go # ----------------------------------------------
# Final stage
FROM debian:11-slim # ----------------------------------------------
FROM debian:12-slim
ARG GOTENBERG_VERSION ARG GOTENBERG_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"
# Improve fonts subpixel hinting and smoothing.
# Credits:
# https://github.com/arachnys/athenapdf/issues/69.
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Simple wrapper around Java and PDFtk.
COPY build/pdftk.sh /usr/bin/pdftk
# Setup the Docker image.
ARG GOTENBERG_USER_GID ARG GOTENBERG_USER_GID
ARG GOTENBERG_USER_UID ARG GOTENBERG_USER_UID
ARG NOTO_COLOR_EMOJI_VERSION ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION ARG PDFTK_VERSION
ARG TMP_CHOMIUM_VERSION_ARMHF="116.0.5845.180-1~deb12u1"
# Script for installing either Google Chrome stable on amd64 architecture or LABEL org.opencontainers.image.title="Gotenberg" \
# Chromium on other architectures. org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \
# See https://github.com/gotenberg/gotenberg/issues/328. org.opencontainers.image.version="$GOTENBERG_VERSION" \
COPY build/install-chromium.sh /tmp/install-chromium.sh 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 \ RUN \
# Create a non-root user. # Create a non-root user.
@@ -62,19 +51,28 @@ RUN \
groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\ groupadd --gid "$GOTENBERG_USER_GID" gotenberg &&\
useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\ useradd --uid "$GOTENBERG_USER_UID" --gid gotenberg --shell /bin/bash --home /home/gotenberg --no-create-home gotenberg &&\
mkdir /home/gotenberg &&\ mkdir /home/gotenberg &&\
chown gotenberg: /home/gotenberg &&\ chown gotenberg: /home/gotenberg
# Install dependencies required for the next instructions or debugging.
# Note: procps for "top" command (useful when debugging processes). RUN \
# Install system dependencies required for the next instructions or debugging.
# Note: tini is a helper for reaping zombie processes. # Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\ apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg procps tini python3 default-jre-headless &&\ 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.
# See https://stackoverflow.com/a/24417119/3248473.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install fonts. # Install fonts.
# Credits: # Credits:
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile. # 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. # 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 &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ 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 \ culmus \
fonts-beng \ fonts-beng \
fonts-hosny-amiri \ fonts-hosny-amiri \
@@ -109,56 +107,96 @@ RUN \
fonts-noto-ui-core \ fonts-noto-ui-core \
fonts-sil-gentium \ fonts-sil-gentium \
fonts-sil-gentium-basic &&\ 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. # Add Color and Black-and-White Noto emoji font.
# Credits: # Credits:
# https://github.com/gotenberg/gotenberg/pull/325. # https://github.com/gotenberg/gotenberg/pull/325.
# https://github.com/googlefonts/noto-emoji. # https://github.com/googlefonts/noto-emoji.
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\ curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
# Install Google Chrome / Chromium.
/tmp/install-chromium.sh &&\
# Install LibreOffice.
# Note: we use the bullseye-backports distribution to get the latest LibreOffice version.
# See:
# https://github.com/gotenberg/gotenberg/pull/322.
# https://github.com/gotenberg/gotenberg/issues/403.
echo "deb https://httpredir.debian.org/debian/ bullseye-backports main contrib non-free" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bullseye-backports libreoffice &&\
# Download unoconv (Python script).
curl -Ls https://raw.githubusercontent.com/dagwieers/unoconv/master/unoconv -o /usr/bin/unoconv &&\
chmod +x /usr/bin/unoconv &&\
# unoconv will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\
# Download PDFtk.
# See https://github.com/gotenberg/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/pdftk-java/pdftk/-/jobs/$PDFTK_VERSION/artifacts/raw/build/libs/pdftk-all.jar" &&\
chmod a+x /usr/bin/pdftk-all.jar &&\
# Download QPDF.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
# See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\
# Cleanup. # Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook. rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Therefore, there is no need for apt-get clean.
# See https://stackoverflow.com/a/24417119/3248473.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* &&\
# Print versions of main dependencies.
chromium --version &&\
libreoffice --version &&\
unoconv --version &&\
pdftk --version &&\
qpdf --version
# Copy the Gotenberg binary from the builder stage. RUN \
COPY --from=builder /home/gotenberg /usr/bin/ # Install either Google Chrome stable on amd64 architecture or
# Chromium on other architectures.
# See https://github.com/gotenberg/gotenberg/issues/328.
# FIXME:
# armhf is currently not working with the latest version of Chromium.
# See: https://github.com/gotenberg/gotenberg/issues/709.
/bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" "./binary-chromium/chromium_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' &&\
# Verify installation.
chromium --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install LibreOffice & 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 -t bookworm-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.0.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 &&\
unoconverter --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# 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 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/*
# Improve fonts subpixel hinting and smoothing.
# Credits:
# https://github.com/arachnys/athenapdf/issues/69.
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy the Gotenberg binary from the binary stage.
COPY --from=binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else. # Environment variables required by modules or else.
ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg"
ENV CHROMIUM_BIN_PATH /usr/bin/chromium 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 UNOCONVERTER_BIN_PATH /usr/bin/unoconverter
ENV PDFTK_BIN_PATH /usr/bin/pdftk ENV PDFTK_BIN_PATH /usr/bin/pdftk
ENV QPDF_BIN_PATH /usr/bin/qpdf ENV QPDF_BIN_PATH /usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH /usr/bin/exiftool
USER gotenberg USER gotenberg
WORKDIR /home/gotenberg WORKDIR /home/gotenberg

View File

@@ -1,5 +1,3 @@
# Note: ARG instructions do not create additional layers.
# Instead, next layers will concatenate them.
ARG DOCKER_REPOSITORY ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION ARG GOTENBERG_VERSION

View File

@@ -1,15 +0,0 @@
#!/bin/bash
set -e
ARCH=$(dpkg --print-architecture)
if [[ "$ARCH" == "amd64" ]]; then
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable
mv /usr/bin/google-chrome-stable /usr/bin/chromium
else
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium
fi

View File

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

View File

@@ -5,11 +5,13 @@ import (
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"syscall"
"time" "time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"golang.org/x/sync/errgroup" "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. // See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
@@ -73,7 +75,6 @@ func Run() {
go func(app gotenberg.App) { go func(app gotenberg.App) {
id := app.(gotenberg.Module).Descriptor().ID id := app.(gotenberg.Module).Descriptor().ID
err = app.Start() err = app.Start()
if err != nil { if err != nil {
fmt.Printf("[FATAL] starting %s: %s\n", id, err) fmt.Printf("[FATAL] starting %s: %s\n", id, err)
os.Exit(1) os.Exit(1)
@@ -82,7 +83,6 @@ func Run() {
startupMessage := app.StartupMessage() startupMessage := app.StartupMessage()
if startupMessage == "" { if startupMessage == "" {
fmt.Printf("[SYSTEM] %s: application started\n", id) fmt.Printf("[SYSTEM] %s: application started\n", id)
return return
} }
@@ -109,8 +109,8 @@ func Run() {
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C). // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes).
signal.Notify(quit, os.Interrupt) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Block until we receive our signal. // Block until we receive our signal.
<-quit <-quit
@@ -119,7 +119,7 @@ func Run() {
defer cancel() defer cancel()
forceQuit := make(chan os.Signal, 1) forceQuit := make(chan os.Signal, 1)
signal.Notify(forceQuit, os.Interrupt) signal.Notify(forceQuit, syscall.SIGINT)
go func() { go func() {
// In case of force quit, cancel the context. // In case of force quit, cancel the context.
@@ -142,7 +142,6 @@ func Run() {
} }
fmt.Printf("[SYSTEM] %s: application stopped\n", id) fmt.Printf("[SYSTEM] %s: application stopped\n", id)
return nil return nil
} }
}(a.(gotenberg.App))) }(a.(gotenberg.App)))

View File

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

View File

@@ -1,730 +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
/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
/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
/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
/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
/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
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

86
go.mod
View File

@@ -1,66 +1,68 @@
module github.com/gotenberg/gotenberg/v7 module github.com/gotenberg/gotenberg/v8
go 1.17 go 1.21
require ( require (
github.com/alexliesenfeld/health v0.6.0 github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.0.4 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/chromedp/cdproto v0.0.0-20211226214557-5a9a0564ad26 github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/chromedp v0.7.6 github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154
github.com/chromedp/chromedp v0.9.5
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.0 github.com/hashicorp/go-retryablehttp v0.7.5
github.com/klauspost/compress v1.13.6 // indirect github.com/klauspost/compress v1.17.7 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.6.1 github.com/labstack/echo/v4 v4.11.4
github.com/labstack/gommon v0.3.1 github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1 github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.16 github.com/microcosm-cc/bluemonday v1.0.26
github.com/nwaples/rardecode v1.1.2 // indirect github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.3.12 github.com/pdfcpu/pdfcpu v0.7.0
github.com/pierrec/lz4/v4 v4.1.12 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_golang v1.19.0
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.10 // indirect github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.11.0
go.uber.org/multierr v1.7.0 go.uber.org/zap v1.27.0
go.uber.org/zap v1.19.1 golang.org/x/crypto v0.21.0 // indirect
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect golang.org/x/image v0.15.0 // indirect
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect golang.org/x/net v0.22.0
golang.org/x/net v0.0.0-20210913180222-943fd674d43e golang.org/x/sync v0.6.0
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sys v0.18.0 // indirect
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 // indirect golang.org/x/term v0.18.0
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b golang.org/x/text v0.14.0
golang.org/x/text v0.3.7
) )
require github.com/dlclark/regexp2 v1.11.0
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.1.0 // indirect github.com/gobwas/ws v1.3.2 // indirect
github.com/golang/protobuf v1.5.2 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 // indirect github.com/hhrutter/tiff v1.0.1 // indirect
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/common v0.51.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect github.com/prometheus/procfs v0.13.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
google.golang.org/protobuf v1.27.1 // indirect google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
) )

669
go.sum
View File

@@ -1,617 +1,168 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
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.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
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/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
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/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
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=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
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/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexliesenfeld/health v0.6.0 h1:HRBTCgybNSe4lqGEk7nU82c3bjwh9W+3b46W6UvD4CQ=
github.com/alexliesenfeld/health v0.6.0/go.mod h1:N4NDIeQtlWumG+6z1ne1v62eQxktz5ylEgGgH9emdMw=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 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/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/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154 h1:jeAmkzyOAQBPRmZMhX+i/CJv0VViLkHk1nF0qx8s0Mk=
github.com/chromedp/cdproto v0.0.0-20211126220118-81fa0469ad77/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20211226214557-5a9a0564ad26 h1:sj/nGJwGadNGvxuA/dKFC+3o9hHi0PTGb+iIMhlCX6A= github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
github.com/chromedp/cdproto v0.0.0-20211226214557-5a9a0564ad26/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U= github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
github.com/chromedp/chromedp v0.7.6 h1:2juGaktzjwULlsn+DnvIZXFUckEp5xs+GOBroaea+jA=
github.com/chromedp/chromedp v0.7.6/go.mod h1:ayT4YU/MGAALNfOg9gNrpGSAdnU51PMx+FCeuT1iXzo=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
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/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
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-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 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/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 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
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/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
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/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
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.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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/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.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 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 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4= github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/lzw v0.0.0-20190827003112-58b82c5a41cc/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk= github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 h1:1yY/RQWNSBjJe2GDCIYoLmpWVidrooriUr4QS/zaATQ= github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 h1:o1wMw7uTNyA58IlEdDpxIrtFHTgnvYzA8sCQz8luv94=
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7/go.mod h1:WkUxfS2JUu3qPo6tRld7ISb8HiC0gVSU91kooBMDVok=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
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/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
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.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/echo/v4 v4.6.1 h1:OMVsrnNFzYlGSdaiYGHbgWQnr+JM7NG+B9suCPie14M= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/echo/v4 v4.6.1/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= 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/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.2 h1:Cj0yZY6T1Zx1R7AhTbyGSALm44/Mmq+BAPc4B/p/d3M= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.2/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5 h1:1SoBaSPudixRecmlHXb/GxmaD3fLMtHIDN13QujwQuc= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.3.12 h1:B+MdKisilWNSk5OCO58Z9U6H93usH73xqk6hMOaZCls= github.com/pdfcpu/pdfcpu v0.7.0 h1:cd7/z7hAyyDuzdciKfNZyQ3TYreJza2DsuPdIHYURcA=
github.com/pdfcpu/pdfcpu v0.3.12/go.mod h1:8XVBtVxuuIuSZL4Ez15Q4QoC+H8zeAaGnuiOEwAk8jA= github.com/pdfcpu/pdfcpu v0.7.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.12 h1:44l88ehTZAUGW4VlO1QC4zkilL99M6Y9MXNwEs0uzP8= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.12/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ= github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/common v0.51.0 h1:vT5R9NAlW4V6k8Wruk7ikrHaHRsrPbduM/cKTOdQM/k=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/common v0.51.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 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.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
go.uber.org/zap v1.19.1 h1:ue41HOKd1vGURxrmeKIgELGb3jPW9DMUDGtsinblHwI= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
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-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
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/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
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/lint v0.0.0-20200302205851-738671d3881b/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.1.1-0.20191107180719-034126e5016b/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.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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-20181114220301-adae6a3d119a/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-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/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-20200114155413-6afb5195e5aa/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-20200222125558-5a598a2470a0/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-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210913180222-943fd674d43e h1:+b/22bPvDYt4NPDcy4xAGCmON713ONAWFeY3Z7I3tR8=
golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
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-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20190222072716-a9d3bda3a223/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-20190422165155-953cdadca894/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-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/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-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/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-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-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.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-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-20191130070609-6e064ea0cf2d/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-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/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-20200204074204-1cc6d1ef6c74/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-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
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/appengine v1.6.6/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-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
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.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
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.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
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=

View File

@@ -13,43 +13,43 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
// Cmd wraps an exec.Cmd. // Cmd wraps an [exec.Cmd].
type Cmd struct { type Cmd struct {
ctx context.Context ctx context.Context
logger *zap.Logger logger *zap.Logger
process *exec.Cmd process *exec.Cmd
} }
// Command creates a Cmd without a context. It configures the internal // 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 // [exec.Cmd] of [Cmd] so that we may kill its unix process and all its
// without creating orphans. // children without creating orphans.
// //
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. // 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 := exec.Command(binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return Cmd{ return &Cmd{
ctx: nil, ctx: nil,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd, process: cmd,
} }
} }
// CommandContext creates a Cmd with a context. It configures the internal // 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 // [exec.Cmd] of [Cmd] so that we may kill its unix process and all its
// without creating orphans. // children without creating orphans.
// //
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. // 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 { if ctx == nil {
return Cmd{}, errors.New("nil context") return nil, errors.New("nil context")
} }
cmd := exec.CommandContext(ctx, binPath, args...) cmd := exec.CommandContext(ctx, binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return Cmd{ return &Cmd{
ctx: ctx, ctx: ctx,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd, 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. // Start starts the command but does not wait for its completion.
func (cmd Cmd) Start() error { func (cmd *Cmd) Start() error {
err := cmd.pipeOutput() err := cmd.pipeOutput()
if err != nil { if err != nil {
return fmt.Errorf("pipe unix process output: %w", err) return fmt.Errorf("pipe unix process output: %w", err)
@@ -73,22 +73,37 @@ func (cmd Cmd) Start() error {
return nil return nil
} }
// Wait waits for the command to complete. It should be called when using the
// Start method, so that the command does not leak zombies.
func (cmd *Cmd) Wait() error {
err := cmd.process.Wait()
if err != nil {
return fmt.Errorf("wait for unix process: %w", err)
}
return nil
}
// Exec executes the command and wait for its completion or until the context // 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. // is done. In any case, it kills the unix process and all its children.
func (cmd Cmd) Exec() error { func (cmd *Cmd) Exec() (int, error) {
if cmd.ctx == nil { if cmd.ctx == nil {
return errors.New("nil context") return 10, errors.New("nil context")
} }
err := cmd.Start() err := cmd.Start()
if err != nil { if err != nil {
return fmt.Errorf("start command: %w", err) if cmd.process.ProcessState == nil {
return 131, fmt.Errorf("start command: %w", err)
}
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("start command: %w", err)
} }
errChan := make(chan error, 1) errChan := make(chan error, 1)
go func() { go func() {
errChan <- cmd.process.Wait() errChan <- cmd.Wait()
}() }()
select { select {
@@ -99,23 +114,27 @@ func (cmd Cmd) Exec() error {
} }
if err == nil { if err == nil {
return nil return 0, nil
} }
return fmt.Errorf("unix process error: %w", err) if cmd.process.ProcessState == nil {
return 131, fmt.Errorf("unix process error: %w", err)
}
return cmd.process.ProcessState.ExitCode(), fmt.Errorf("unix process error: %w", err)
case <-cmd.ctx.Done(): case <-cmd.ctx.Done():
errProc := cmd.Kill() errProc := cmd.Kill()
if errProc != nil { if errProc != nil {
cmd.logger.Error(errProc.Error()) cmd.logger.Error(errProc.Error())
} }
return fmt.Errorf("context done: %w", cmd.ctx.Err()) return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
} }
} }
// pipeOutput creates logs entries according to the process stdout and stderr. // pipeOutput creates logs entries according to the process stdout and stderr.
// It does nothing if the logging level is not debug. // 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") checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output")
if checkedEntry == nil { if checkedEntry == nil {
return nil return nil
@@ -135,11 +154,15 @@ func (cmd Cmd) pipeOutput() error {
// (either stdout or stderr). // (either stdout or stderr).
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) { logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) {
r := bufio.NewReader(reader) r := bufio.NewReader(reader)
defer reader.Close() defer func(reader io.ReadCloser) {
err := reader.Close()
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
}(reader)
for { for {
line, _, err := r.ReadLine() line, _, err := r.ReadLine()
if err != nil { if err != nil {
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("pipe unix process output error: %s", err)) logger.Error(fmt.Sprintf("pipe unix process output error: %s", err))
@@ -163,7 +186,7 @@ func (cmd Cmd) pipeOutput() error {
// Kill kills the unix process and all its children without creating orphans. // 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. // 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 { if cmd.process == nil {
// We cannot use the logger here, because for whatever reason using it // We cannot use the logger here, because for whatever reason using it
// result to a panic. // result to a panic.

View File

@@ -10,211 +10,308 @@ import (
func TestCommand(t *testing.T) { func TestCommand(t *testing.T) {
cmd := Command(zap.NewNop(), "foo") cmd := Command(zap.NewNop(), "foo")
if !cmd.process.SysProcAttr.Setpgid { if !cmd.process.SysProcAttr.Setpgid {
t.Error("expected Setpgid to be true") t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
} }
} }
func TestCommandContext(t *testing.T) { func TestCommandContext(t *testing.T) {
for i, tc := range []struct { tests := []struct {
ctx context.Context scenario string
expectErr bool ctx context.Context
expectCommandContextError bool
}{ }{
{ {
ctx: nil, scenario: "nominal behavior",
expectErr: true, ctx: context.Background(),
expectCommandContextError: false,
}, },
{ {
ctx: context.TODO(), scenario: "nil context",
ctx: nil,
expectCommandContextError: true,
}, },
} { }
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
if err == nil && !cmd.process.SysProcAttr.Setpgid { for _, tc := range tests {
t.Fatalf("test %d: expected Setpgid to be true", i) t.Run(tc.scenario, func(t *testing.T) {
} cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
if tc.expectErr && err == nil { if err == nil && !cmd.process.SysProcAttr.Setpgid {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
} }
if !tc.expectErr && err != nil { if !tc.expectCommandContextError && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if tc.expectCommandContextError && err == nil {
t.Fatal("expected error but got none")
}
})
} }
} }
func TestCmd_Start(t *testing.T) { func TestCmd_Start(t *testing.T) {
for i, tc := range []struct { tests := []struct {
cmd Cmd scenario string
expectErr bool cmd *Cmd
expectStartError bool
}{ }{
{ {
cmd: Command(zap.NewNop(), "foo"), scenario: "nominal behavior",
expectErr: true, cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectStartError: false,
}, },
{ {
cmd: Command(zap.NewNop(), "echo", "Hello", "World"), scenario: "start error",
cmd: Command(zap.NewNop(), "foo"),
expectStartError: true,
}, },
} { }
err := tc.cmd.Start()
if tc.expectErr && err == nil { for _, tc := range tests {
t.Errorf("test %d: expected error but got: %v", i, err) t.Run(tc.scenario, func(t *testing.T) {
} err := tc.cmd.Start()
if !tc.expectErr && err != nil { if !tc.expectStartError && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if tc.expectStartError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestCmd_Wait(t *testing.T) {
tests := []struct {
scenario string
cmd *Cmd
expectWaitError bool
}{
{
scenario: "nominal behavior",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectWaitError: false,
},
{
scenario: "wait error",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectWaitError: true,
},
}
for _, tc := range tests {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.Wait()
if !tc.expectWaitError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectWaitError && err == nil {
t.Fatal("expected error but got none")
}
})
} }
} }
func TestCmd_Exec(t *testing.T) { func TestCmd_Exec(t *testing.T) {
for i, tc := range []struct { tests := []struct {
cmd Cmd scenario string
timeout time.Duration cmd *Cmd
expectErr bool timeout time.Duration
expectExecError bool
}{ }{
{ {
cmd: Command(zap.NewNop(), "foo"), scenario: "nominal behavior",
expectErr: true, 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
}(),
expectExecError: false,
}, },
{ {
cmd: Command(zap.NewNop(), "foo"), scenario: "nil context",
timeout: time.Duration(5) * time.Second, cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectErr: true, expectExecError: true,
}, },
{ {
cmd: Command(zap.NewNop(), "echo", "Hello", "World"), scenario: "start error",
timeout: time.Duration(5) * time.Second, cmd: func() *Cmd {
cmd, err := CommandContext(context.Background(), zap.NewNop(), "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectExecError: true,
}, },
{ {
cmd: Command(zap.NewNop(), "sleep", "3"), scenario: "context done",
timeout: time.Duration(2) * time.Second, cmd: Command(zap.NewNop(), "sleep", "2"),
expectErr: true, timeout: time.Duration(1) * time.Second,
expectExecError: true,
}, },
} { }
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
tc.cmd.ctx = ctx for _, tc := range tests {
} t.Run(tc.scenario, func(t *testing.T) {
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
err := tc.cmd.Exec() tc.cmd.ctx = ctx
}
if tc.expectErr && err == nil { _, err := tc.cmd.Exec()
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil { if !tc.expectExecError && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if tc.expectExecError && err == nil {
t.Fatal("expected error but got none")
}
})
} }
} }
func TestCmd_pipeOutput(t *testing.T) { func TestCmd_pipeOutput(t *testing.T) {
for i, tc := range []struct { tests := []struct {
cmd Cmd scenario string
run bool cmd *Cmd
expectErr bool run bool
expectPipeOutputError bool
}{ }{
{ {
cmd: Command(zap.NewNop(), "echo", "Hello", "World"), scenario: "nominal behavior",
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
expectPipeOutputError: false,
}, },
{ {
cmd: func() Cmd { scenario: "no debug, no pipe",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
run: false,
expectPipeOutputError: false,
},
{
scenario: "stdout already piped",
cmd: func() *Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World") cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StdoutPipe() _, err := cmd.process.StdoutPipe()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
return cmd return cmd
}(), }(),
expectErr: true, run: false,
expectPipeOutputError: true,
}, },
{ {
cmd: func() Cmd { scenario: "stderr already piped",
cmd: func() *Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World") cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StderrPipe() _, err := cmd.process.StderrPipe()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
return cmd return cmd
}(), }(),
expectErr: true, run: false,
expectPipeOutputError: true,
}, },
{ }
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
},
} {
err := tc.cmd.pipeOutput()
if tc.run { for _, tc := range tests {
errStart := tc.cmd.process.Start() t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.pipeOutput()
if errStart != nil { if tc.run {
t.Fatalf("test %d: expected no error but got: %v", i, err) errStart := tc.cmd.process.Start()
if errStart != nil {
t.Fatalf("expected no error but got: %v", err)
}
} }
}
if tc.expectErr && err == nil { if !tc.expectPipeOutputError && err != nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if !tc.expectErr && err != nil { if tc.expectPipeOutputError && err == nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatal("expected error but got none")
} }
})
} }
} }
func TestCmd_Kill(t *testing.T) { func TestCmd_Kill(t *testing.T) {
for i, tc := range []struct { tests := []struct {
cmd Cmd scenario string
expectErr bool cmd *Cmd
}{ }{
{ {
cmd: Cmd{logger: zap.NewNop()}, scenario: "nominal behavior",
}, cmd: func() *Cmd {
{
cmd: func() Cmd {
cmd := Command(zap.NewNop(), "sleep", "60") cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start() err := cmd.process.Start()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
return cmd return cmd
}(), }(),
}, },
{ {
cmd: func() Cmd { scenario: "no process",
cmd := Command(zap.NewNop(), "echo", "Hello", "World") cmd: &Cmd{logger: zap.NewNop()},
err := cmd.process.Run() },
{
scenario: "process already killed",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = cmd.Kill()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
return cmd return cmd
}(), }(),
}, },
} { }
err := tc.cmd.Kill()
if tc.expectErr && err == nil { for _, tc := range tests {
t.Errorf("test %d: expected error but got: %v", i, err) t.Run(tc.scenario, func(t *testing.T) {
} err := tc.cmd.Kill()
if err != nil {
if !tc.expectErr && err != nil { t.Fatalf("expected no error but got: %v", err)
t.Errorf("test %d: expected no error but got: %v", i, err) }
} })
} }
} }

View File

@@ -5,7 +5,7 @@ import (
"reflect" "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. // module may use the context to get other modules that it needs internally.
type Context struct { type Context struct {
flags ParsedFlags flags ParsedFlags
@@ -13,8 +13,8 @@ type Context struct {
moduleInstances map[string]interface{} moduleInstances map[string]interface{}
} }
// NewContext creates a Context. // NewContext creates a [Context].
// In a module, prefer the Provisioner interface to get a Context. // In a module, prefer the [Provisioner] interface to get a [Context].
func NewContext( func NewContext(
flags ParsedFlags, flags ParsedFlags,
descriptors []ModuleDescriptor, descriptors []ModuleDescriptor,
@@ -32,7 +32,7 @@ func NewContext(
// flags := ctx.ParsedFlags() // flags := ctx.ParsedFlags()
// m.foo = flags.RequiredString("foo") // m.foo = flags.RequiredString("foo")
// } // }
func (ctx Context) ParsedFlags() ParsedFlags { func (ctx *Context) ParsedFlags() ParsedFlags {
return ctx.flags return ctx.flags
} }
@@ -47,7 +47,6 @@ func (ctx Context) ParsedFlags() ParsedFlags {
// initializes it. Otherwise, returns the already initialized instance. // initializes it. Otherwise, returns the already initialized instance.
func (ctx *Context) Module(kind interface{}) (interface{}, error) { func (ctx *Context) Module(kind interface{}) (interface{}, error) {
mods, err := ctx.Modules(kind) mods, err := ctx.Modules(kind)
if err != nil { if err != nil {
return nil, fmt.Errorf("get module: %w", err) 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 // 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 { func (ctx *Context) loadModule(id string, instance interface{}) error {
if prov, ok := instance.(Provisioner); ok { if prov, ok := instance.(Provisioner); ok {
// The instance can be provisioned. // The instance can be provisioned.

View File

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

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 package gotenberg
import ( import (
"regexp"
"time" "time"
"github.com/dlclark/regexp2"
"github.com/labstack/gommon/bytes" "github.com/labstack/gommon/bytes"
flag "github.com/spf13/pflag" 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. // easier.
type ParsedFlags struct { type ParsedFlags struct {
*flag.FlagSet *flag.FlagSet
@@ -80,6 +80,28 @@ func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool
return f.MustBool(newName) 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. // MustInt returns the int value of a flag given by name.
// It panics if an error occurs. // It panics if an error occurs.
func (f *ParsedFlags) MustInt(name string) int { func (f *ParsedFlags) MustInt(name string) int {
@@ -177,19 +199,19 @@ func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string,
// MustRegexp returns the regular expression of a flag given by name. // MustRegexp returns the regular expression of a flag given by name.
// It panics if an error occurs. // 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) val, err := f.GetString(name)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return regexp.MustCompile(val) return regexp2.MustCompile(val, 0)
} }
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if // MustDeprecatedRegexp returns the regular expression of a deprecated flag if
// it was explicitly set or the regular expression of the new flag. // it was explicitly set or the regular expression of the new flag.
// It panics if an error occurs. // 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) { if f.Changed(deprecated) {
return f.MustRegexp(deprecated) return f.MustRegexp(deprecated)
} }

View File

@@ -20,23 +20,27 @@ func TestParsedFlags_MustString(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { 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 { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) parsedFlags.MustString(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedString(t *testing.T) { func TestParsedFlags_MustDeprecatedString(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue string expectValue string
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=foo"}, rawFlags: []string{"--foo=foo"},
expectValue: "foo", expectValue: "foo",
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=bar"}, rawFlags: []string{"--bar=bar"},
expectValue: "bar", expectValue: "bar",
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=foo", "--bar=bar"}, rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: "foo", expectValue: "foo",
}, },
} { } {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) t.Run(tc.scenario, func(t *testing.T) {
fs.String("foo", "", "") fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("bar", "", "") fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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.MustDeprecatedString("foo", "bar") actual := parsedFlags.MustDeprecatedString("foo", "bar")
if actual != tc.expectValue { if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual) t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
} }
})
} }
} }
@@ -101,23 +111,27 @@ func TestParsedFlags_MustStringSlice(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { 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 { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) parsedFlags.MustStringSlice(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedStringSlice(t *testing.T) { func TestParsedFlags_MustDeprecatedStringSlice(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue []string expectValue []string
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=foo"}, rawFlags: []string{"--foo=foo"},
expectValue: []string{"foo"}, expectValue: []string{"foo"},
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=bar"}, rawFlags: []string{"--bar=bar"},
expectValue: []string{"bar"}, expectValue: []string{"bar"},
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=foo", "--bar=bar"}, rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: []string{"foo"}, expectValue: []string{"foo"},
}, },
} { } {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) t.Run(tc.scenario, func(t *testing.T) {
fs.StringSlice("foo", make([]string, 0), "") fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("bar", make([]string, 0), "") 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) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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.MustDeprecatedStringSlice("foo", "bar") actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
if !reflect.DeepEqual(actual, tc.expectValue) { if !reflect.DeepEqual(actual, tc.expectValue) {
t.Errorf("test %d: expected %+v but got %+v", i, tc.expectValue, actual) t.Errorf("expected %+v but got %+v", tc.expectValue, actual)
} }
})
} }
} }
@@ -182,23 +202,27 @@ func TestParsedFlags_MustBool(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { 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 { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) parsedFlags.MustBool(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedBool(t *testing.T) { func TestParsedFlags_MustDeprecatedBool(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue bool expectValue bool
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=true"}, rawFlags: []string{"--foo=true"},
expectValue: true, expectValue: true,
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=false"}, rawFlags: []string{"--bar=false"},
expectValue: false, expectValue: false,
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=true", "--bar=false"}, rawFlags: []string{"--foo=true", "--bar=false"},
expectValue: true, 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 := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Bool("foo", false, "") fs.Int64("foo", 0, "")
fs.Bool("bar", true, "") fs.Int64("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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 { 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} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { 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 { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) parsedFlags.MustInt(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedInt(t *testing.T) { func TestParsedFlags_MustDeprecatedInt(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue int expectValue int
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1"}, rawFlags: []string{"--foo=1"},
expectValue: 1, expectValue: 1,
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2"}, rawFlags: []string{"--bar=2"},
expectValue: 2, expectValue: 2,
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1", "--bar=2"}, rawFlags: []string{"--foo=1", "--bar=2"},
expectValue: 1, expectValue: 1,
}, },
} { } {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) t.Run(tc.scenario, func(t *testing.T) {
fs.Int("foo", 0, "") fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int("bar", 0, "") fs.Int("foo", 0, "")
fs.Int("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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.MustDeprecatedInt("foo", "bar") actual := parsedFlags.MustDeprecatedInt("foo", "bar")
if actual != tc.expectValue { if actual != tc.expectValue {
t.Errorf("test %d: expected %d but got %d", i, tc.expectValue, actual) t.Errorf("expected %d but got %d", tc.expectValue, actual)
} }
})
} }
} }
@@ -344,23 +473,27 @@ func TestParsedFlags_MustFloat64(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { 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 { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) parsedFlags.MustFloat64(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedFloat64(t *testing.T) { func TestParsedFlags_MustDeprecatedFloat64(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue float64 expectValue float64
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1.0"}, rawFlags: []string{"--foo=1.0"},
expectValue: 1.0, expectValue: 1.0,
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2.0"}, rawFlags: []string{"--bar=2.0"},
expectValue: 2.0, expectValue: 2.0,
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1.0", "--bar=2.0"}, rawFlags: []string{"--foo=1.0", "--bar=2.0"},
expectValue: 1.0, expectValue: 1.0,
}, },
} { } {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) t.Run(tc.scenario, func(t *testing.T) {
fs.Float64("foo", 0, "") fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Float64("bar", 0, "") fs.Float64("foo", 0, "")
fs.Float64("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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.MustDeprecatedFloat64("foo", "bar") actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
if actual != tc.expectValue { if actual != tc.expectValue {
t.Errorf("test %d: expected %f but got %f", i, tc.expectValue, actual) t.Errorf("expected %f but got %f", tc.expectValue, actual)
} }
})
} }
} }
@@ -425,23 +564,27 @@ func TestParsedFlags_MustDuration(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i) t.Fatal("expected panic but got none")
} }
}() }()
} }
@@ -449,49 +592,55 @@ func TestParsedFlags_MustDuration(t *testing.T) {
if !tc.expectPanic { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) parsedFlags.MustDuration(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedDuration(t *testing.T) { func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue time.Duration expectValue time.Duration
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1s"}, rawFlags: []string{"--foo=1s"},
expectValue: time.Duration(1) * time.Second, expectValue: time.Duration(1) * time.Second,
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2s"}, rawFlags: []string{"--bar=2s"},
expectValue: time.Duration(2) * time.Second, expectValue: time.Duration(2) * time.Second,
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1s", "--bar=2s"}, rawFlags: []string{"--foo=1s", "--bar=2s"},
expectValue: time.Duration(1) * time.Second, expectValue: time.Duration(1) * time.Second,
}, },
} { } {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) t.Run(tc.scenario, func(t *testing.T) {
fs.Duration("foo", 0, "") fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Duration("bar", 0, "") fs.Duration("foo", 0, "")
fs.Duration("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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.MustDeprecatedDuration("foo", "bar") actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
if actual != tc.expectValue { if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual) t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
} }
})
} }
} }
@@ -507,27 +656,27 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
{
name: "baz",
expectPanic: true,
},
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i) t.Fatal("expected panic but got none")
} }
}() }()
} }
@@ -535,49 +684,55 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
if !tc.expectPanic { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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.MustHumanReadableBytesString(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) { func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue string expectValue string
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=1MB"}, rawFlags: []string{"--foo=1MB"},
expectValue: "1MB", expectValue: "1MB",
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2MB"}, rawFlags: []string{"--bar=2MB"},
expectValue: "2MB", expectValue: "2MB",
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1MB", "--bar=2MB"}, rawFlags: []string{"--foo=1MB", "--bar=2MB"},
expectValue: "1MB", expectValue: "1MB",
}, },
} { } {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) t.Run(tc.scenario, func(t *testing.T) {
fs.String("foo", "", "") fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("bar", "", "") fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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.MustDeprecatedHumanReadableBytesString("foo", "bar") actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
if actual != tc.expectValue { if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual) t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
} }
})
} }
} }
@@ -593,27 +748,27 @@ func TestParsedFlags_MustRegexp(t *testing.T) {
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
for i, tc := range []struct { for _, tc := range []struct {
scenario string
name string name string
expectPanic bool expectPanic bool
}{ }{
{ {
name: "foo", scenario: "success",
name: "foo",
expectPanic: false,
}, },
{ {
scenario: "non-existing flag",
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
{
name: "baz",
expectPanic: true,
},
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i) t.Fatal("expected panic but got none")
} }
}() }()
} }
@@ -621,48 +776,54 @@ func TestParsedFlags_MustRegexp(t *testing.T) {
if !tc.expectPanic { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) parsedFlags.MustRegexp(tc.name)
}() })
} }
} }
func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) { func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
rawFlags []string rawFlags []string
expectValue *regexp.Regexp expectValue *regexp.Regexp
}{ }{
{ {
scenario: "deprecated flag value",
rawFlags: []string{"--foo=foo"}, rawFlags: []string{"--foo=foo"},
expectValue: regexp.MustCompile("foo"), expectValue: regexp.MustCompile("foo"),
}, },
{ {
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=bar"}, rawFlags: []string{"--bar=bar"},
expectValue: regexp.MustCompile("bar"), expectValue: regexp.MustCompile("bar"),
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=foo", "--bar=bar"}, rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: regexp.MustCompile("foo"), expectValue: regexp.MustCompile("foo"),
}, },
} { } {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) t.Run(tc.scenario, func(t *testing.T) {
fs.String("foo", "", "") fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("bar", "", "") fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs} parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags) err := parsedFlags.Parse(tc.rawFlags)
if err != nil { 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.MustDeprecatedRegexp("foo", "bar") actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
if actual.String() != tc.expectValue.String() { if actual.String() != tc.expectValue.String() {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue.String(), actual.String()) t.Errorf("expected '%s' but got '%s'", tc.expectValue.String(), actual.String())
} }
})
} }
} }

View File

@@ -7,27 +7,55 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// TmpPath returns the default directory to use for temporary files and // FileSystem provides utilities for managing temporary directories. It creates
// directories. Most if not all files and directories created by the // unique directory names based on UUIDs to ensure isolation of temporary files
// application and its dependencies must be based on this default directory. // for different modules.
func TmpPath() string { type FileSystem struct {
return os.TempDir() workingDir string
} }
// NewDirPath returns a random absolute path based on the temporary path. // NewFileSystem initializes a new [FileSystem] instance with a unique working
func NewDirPath() string { // directory.
return fmt.Sprintf("%s/%s", TmpPath(), uuid.New()) func NewFileSystem() *FileSystem {
return &FileSystem{
workingDir: uuid.NewString(),
}
} }
// MkdirAll creates a random directory based on the temporary path and // WorkingDir returns the unique name of the working directory.
// returns its absolute path. func (fs *FileSystem) WorkingDir() string {
func MkdirAll() (string, error) { return fs.workingDir
path := NewDirPath() }
err := os.MkdirAll(path, 0755) // 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 := os.MkdirAll(path, 0o755)
if err != nil { if err != nil {
return "", fmt.Errorf("create directory %s: %w", path, err) return "", fmt.Errorf("create directory %s: %w", path, err)
} }
return path, nil return path, nil
} }
// 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
}

View File

@@ -1,59 +1,55 @@
package gotenberg package gotenberg
import ( import (
"fmt"
"os" "os"
"strings" "strings"
"testing" "testing"
) )
func TestTmpPath(t *testing.T) { func TestFileSystem_WorkingDir(t *testing.T) {
osTempDir := os.TempDir() fs := NewFileSystem()
tmpPath := TmpPath() dirName := fs.WorkingDir()
if tmpPath != osTempDir { if dirName == "" {
t.Errorf("expected path '%s' but got '%s'", osTempDir, tmpPath) t.Error("expected directory name but got empty string")
} }
} }
func TestNewDirPath(t *testing.T) { func TestFileSystem_WorkingDirPath(t *testing.T) {
newDirPath := NewDirPath() fs := NewFileSystem()
tmpPath := TmpPath() expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
if !strings.HasPrefix(newDirPath, tmpPath) { if fs.WorkingDirPath() != expectedPath {
t.Fatalf("expected path '%s' to start with '%s'", newDirPath, tmpPath) t.Errorf("expected path '%s' but got '%s'", expectedPath, fs.WorkingDirPath())
}
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) { func TestFileSystem_NewDirPath(t *testing.T) {
path, err := MkdirAll() fs := NewFileSystem()
newDir := fs.NewDirPath()
expectedPrefix := fs.WorkingDirPath()
if !strings.HasPrefix(newDir, expectedPrefix) {
t.Errorf("expected new directory to start with '%s' but got '%s'", expectedPrefix, newDir)
}
}
func TestFileSystem_MkdirAll(t *testing.T) {
fs := NewFileSystem()
newPath, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
tmpPath := TmpPath() _, err = os.Stat(newPath)
if !strings.HasPrefix(path, tmpPath) { if os.IsNotExist(err) {
t.Fatalf("expected path '%s' to start with '%s'", path, tmpPath) t.Errorf("expected directory '%s' to exist but it doesn't", newPath)
} }
_, err = os.Stat(path) err = os.RemoveAll(fs.WorkingDirPath())
if os.IsNotExist(err) { if err != nil {
t.Errorf("expected path '%s' to exist but got: %v", path, err) t.Fatalf("expected no error while cleaning up but got: %v", err)
} }
} }

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

@@ -0,0 +1,53 @@
package gotenberg
import (
"fmt"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
)
// GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) 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 {
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)
})
}

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

@@ -0,0 +1,98 @@
package gotenberg
import (
"fmt"
"os"
"testing"
"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(fmt.Sprintf("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", fmt.Sprintf("%s/a_directory/a_bar_file", os.TempDir())},
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)
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

@@ -2,8 +2,8 @@ package gotenberg
import "go.uber.org/zap" import "go.uber.org/zap"
// LoggerProvider is a module interface which exposes a method for creating a // LoggerProvider is an interface for a module that supplies a method for
// zap.Logger for other modules. // creating a [zap.Logger] instance for use by other modules.
// //
// func (m *YourModule) Provision(ctx *gotenberg.Context) error { // func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider)) // provider, _ := ctx.Module(new(gotenberg.LoggerProvider))

View File

@@ -15,7 +15,7 @@ type Metric struct {
Read func() float64 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 { // func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.MetricsProvider)) // provider, _ := ctx.Module(new(gotenberg.MetricsProvider))

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

@@ -0,0 +1,159 @@
package gotenberg
import (
"context"
"go.uber.org/zap"
)
// ModuleMock is a mock for the [Module] interface.
type ModuleMock struct {
DescriptorMock func() ModuleDescriptor
}
func (mod *ModuleMock) Descriptor() ModuleDescriptor {
return mod.DescriptorMock()
}
// 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 {
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, 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, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
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) {
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()
}
// 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)
_ Process = (*ProcessMock)(nil)
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil)
)

219
pkg/gotenberg/mocks_test.go Normal file
View File

@@ -0,0 +1,219 @@
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 ModuleMock.Descriptor, but got '%s'", "foo", mock.Descriptor().ID)
}
}
func TestProvisionerMock(t *testing.T) {
mock := &ProvisionerMock{
ProvisionMock: func(*Context) error {
return nil
},
}
err := mock.Provision(&Context{})
if err != nil {
t.Errorf("expected no error from ProvisionerMock.Provision, but got: %v", err)
}
}
func TestValidatorMock(t *testing.T) {
mock := &ValidatorMock{
ValidateMock: func() error {
return nil
},
}
err := mock.Validate()
if err != nil {
t.Errorf("expected no error from ValidatorMock.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, formats PdfFormats, inputPath, outputPath string) error {
return nil
},
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
}
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
}
_, err = mock.ReadMetadata(context.Background(), zap.NewNop(), "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.ReadMetadata, but got: %v", err)
}
err = mock.WriteMetadata(context.Background(), zap.NewNop(), map[string]interface{}{}, "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.WriteMetadata but got: %v", err)
}
}
func TestPDFEngineProviderMock(t *testing.T) {
mock := &PdfEngineProviderMock{
PdfEngineMock: func() (PdfEngine, error) {
return new(PdfEngineMock), nil
},
}
_, err := mock.PdfEngine()
if err != nil {
t.Errorf("expected no error from PdfEngineProviderMock.PdfEngine, but got: %v", err)
}
}
func TestProcessMock(t *testing.T) {
mock := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return nil
},
StopMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
err := mock.Start(zap.NewNop())
if err != nil {
t.Errorf("expected no error from ProcessMock.Start, but got: %v", err)
}
err = mock.Stop(zap.NewNop())
if err != nil {
t.Errorf("expected no error from ProcessMock.Stop, but got: %v", err)
}
healthy := mock.Healthy(zap.NewNop())
if !healthy {
t.Error("expected true from ProcessMock.Healthy, but got false")
}
}
func TestProcessSupervisorMock(t *testing.T) {
mock := &ProcessSupervisorMock{
LaunchMock: func() error {
return nil
},
ShutdownMock: func() error {
return nil
},
HealthyMock: func() bool {
return true
},
RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return nil
},
ReqQueueSizeMock: func() int64 {
return 0
},
RestartsCountMock: func() int64 {
return 0
},
}
err := mock.Launch()
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Launch, but got: %v", err)
}
err = mock.Shutdown()
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Shutdown, but got: %v", err)
}
healthy := mock.Healthy()
if !healthy {
t.Error("expected true from ProcessSupervisorMock.Healthy, but got false")
}
err = mock.Run(context.TODO(), zap.NewNop(), nil)
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Run, but got: %v", err)
}
size := mock.ReqQueueSize()
if size != 0 {
t.Errorf("expected 0 from ProcessSupervisorMock.ReqQueueSize, but got: %d", size)
}
restarts := mock.RestartsCount()
if restarts != 0 {
t.Errorf("expected 0 from ProcessSupervisorMock.RestartsCount, but got: %d", restarts)
}
}
func TestLoggerProviderMock(t *testing.T) {
mock := &LoggerProviderMock{
LoggerMock: func(mod Module) (*zap.Logger, error) {
return nil, nil
},
}
_, err := mock.Logger(new(ModuleMock))
if err != nil {
t.Errorf("expected no error from LoggerProviderMock.Logger, but got: %v", err)
}
}
func TestMetricsProviderMock(t *testing.T) {
mock := &MetricsProviderMock{
MetricsMock: func() ([]Metric, error) {
return nil, nil
},
}
_, err := mock.Metrics()
if err != nil {
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
}
}
func TestPathRenameMock(t *testing.T) {
mock := &PathRenameMock{
RenameMock: func(oldpath, newpath string) error {
return nil
},
}
err := mock.Rename("", "")
if err != nil {
t.Errorf("expected no error from PathRenameMock.Rename, but got: %v", err)
}
}

View File

@@ -83,7 +83,7 @@ type SystemLogger interface {
// gotenberg.MustRegisterModule(YourModule{}) // 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: // import the module:
// //
// imports ( // imports (

View File

@@ -5,32 +5,6 @@ import (
"testing" "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) { func TestMustRegisterModule(t *testing.T) {
descriptorsMu.RLock() descriptorsMu.RLock()
descriptors = map[string]ModuleDescriptor{ descriptors = map[string]ModuleDescriptor{
@@ -38,44 +12,51 @@ func TestMustRegisterModule(t *testing.T) {
} }
descriptorsMu.RUnlock() descriptorsMu.RUnlock()
for i, tc := range []struct { for _, tc := range []struct {
scenario string
ID string ID string
New func() Module New func() Module
expectPanic bool expectPanic bool
}{ }{
{ {
scenario: "no ID",
ID: "", ID: "",
New: func() Module { return new(ProtoModule) }, New: func() Module { return new(ModuleMock) },
expectPanic: true, expectPanic: true,
}, },
{ {
scenario: "nil New method",
ID: "b", ID: "b",
New: nil, New: nil,
expectPanic: true, expectPanic: true,
}, },
{ {
scenario: "nil module",
ID: "b", ID: "b",
New: func() Module { return nil }, New: func() Module { return nil },
expectPanic: true, expectPanic: true,
}, },
{ {
scenario: "existing module",
ID: "a", ID: "a",
New: func() Module { return new(ProtoModule) }, New: func() Module { return new(ModuleMock) },
expectPanic: true, expectPanic: true,
}, },
{ {
ID: "b", scenario: "success",
New: func() Module { return new(ProtoModule) }, ID: "b",
New: func() Module { return new(ModuleMock) },
expectPanic: false,
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
mod := struct{ ProtoModule }{} mod := &struct{ ModuleMock }{}
mod.descriptor = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} } mod.DescriptorMock = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
if tc.expectPanic { if tc.expectPanic {
defer func() { defer func() {
if r := recover(); r == nil { 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 { if !tc.expectPanic {
defer func() { defer func() {
if r := recover(); r != nil { 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) MustRegisterModule(mod)
}() })
} }
descriptorsMu.RLock() descriptorsMu.RLock()
@@ -124,12 +105,3 @@ func TestGetModuleDescriptors(t *testing.T) {
descriptors = make(map[string]ModuleDescriptor) descriptors = make(map[string]ModuleDescriptor)
descriptorsMu.RUnlock() 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,84 @@ import (
) )
var ( var (
// ErrPDFEngineMethodNotAvailable happens if a PDFEngine method is not // ErrPdfEngineMethodNotSupported is returned when a specific method of the
// available in the implementation. // PdfEngine interface is not supported by its current implementation.
ErrPDFEngineMethodNotAvailable = errors.New("method not available") ErrPdfEngineMethodNotSupported = errors.New("method not supported")
// ErrPDFFormatNotAvailable happens if a PDFEngine Convert's method does // ErrPdfFormatNotSupported is returned when the Convert method of the
// not handle a specific format. // PdfEngine interface does not support a requested PDF format conversion.
ErrPDFFormatNotAvailable = errors.New("PDF format not available") 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 ( const (
FormatPDFA1a string = "PDF/A-1a" // PdfA1a represents the PDF/A-1a format.
FormatPDFA1b string = "PDF/A-1b" PdfA1a string = "PDF/A-1a"
FormatPDFA2a string = "PDF/A-2a"
FormatPDFA2b string = "PDF/A-2b" // PdfA1b represents the PDF/A-1b format.
FormatPDFA2u string = "PDF/A-2u" PdfA1b string = "PDF/A-1b"
FormatPDFA3a string = "PDF/A-3a"
FormatPDFA3b string = "PDF/A-3b" // PdfA2a represents the PDF/A-2a format.
FormatPDFA3u string = "PDF/A-3u" 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"
) )
// PDFEngine is a module interface which exposes methods for manipulating one // PdfFormats specifies the target formats for a PDF conversion.
// or more PDFs. Implementations may abstract powerful tools like PDFtk, or type PdfFormats struct {
// fulfill those methods contracts in Golang directly. // PdfA denotes the PDF/A standard format (e.g., PDF/A-1a).
type PDFEngine interface { PdfA string
// Merge merges the given PDFs into a unique PDF. The pages' order reflects
// order of the given files. // 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.
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 Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
// Convert converts the given PDF to a specific PDF format. // Convert transforms a given PDF to the specified formats defined in
Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error // 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 // PdfEngineProvider offers an interface to instantiate a [PdfEngine].
// PDFEngine for other modules. // This is used to decouple the creation of a [PdfEngine] from its consumers.
//
// Example:
// //
// func (m *YourModule) Provision(ctx *gotenberg.Context) error { // func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.PDFEngineProvider)) // provider, _ := ctx.Module(new(gotenberg.PdfEngineProvider))
// pdfengines, _ := provider.(gotenberg.PDFEngineProvider).PDFEngine() // engine, _ := provider.(gotenberg.PdfEngineProvider).PdfEngine()
// } // }
type PDFEngineProvider interface { type PdfEngineProvider interface {
PDFEngine() (PDFEngine, error) // PdfEngine returns an instance of the [PdfEngine] interface for PDF operations.
PdfEngine() (PdfEngine, error)
} }

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

@@ -0,0 +1,69 @@
package gotenberg
import (
"regexp"
"sort"
"strconv"
)
// AlphanumericSort implements sort.Interface and helps to sort strings
// alphanumerically.
//
// 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 := extractPrefix(s[i])
numJ, restJ := extractPrefix(s[j])
// Compares numerical prefixes if they exist.
if numI != -1 && numJ != -1 {
if numI != numJ {
return numI < numJ
}
// If numbers are equal, falls back to string comparison of the rest.
return restI < restJ
}
// If one has a numerical prefix and the other doesn't, the one with the
// number comes first.
if numI != -1 {
return true
}
if numJ != -1 {
return false
}
// If neither has a numerical prefix, compare as strings
return s[i] < s[j]
}
// extractPrefix attempts to extract a numerical prefix and the rest of the filename
func extractPrefix(filename string) (int, string) {
matches := numPrefixRegexp.FindStringSubmatch(filename)
if len(matches) > 2 {
prefix, err := strconv.Atoi(matches[1])
if err == nil {
return prefix, matches[2]
}
}
// Returns -1 if no numerical prefix is found, indicating to just compare
// as strings.
return -1, filename
}
var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
// Interface guard.
var (
_ sort.Interface = (*AlphanumericSort)(nil)
)

View File

@@ -0,0 +1,34 @@
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: "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)
}
})
}
}

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

@@ -0,0 +1,273 @@
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 {
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)
defer func() {
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)
}
}
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting...")
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
return s.runWithDeadline(ctx, task)
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,635 @@
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: "cannot 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
}
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)
}
})
}
}

View File

@@ -12,25 +12,25 @@ import (
"time" "time"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/gc"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func init() { 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. // middlewares or health checks.
type API struct { type Api struct {
port int port int
readTimeout time.Duration startTimeout time.Duration
writeTimeout time.Duration
timeout time.Duration timeout time.Duration
rootPath string rootPath string
traceHeader string traceHeader string
@@ -39,17 +39,18 @@ type API struct {
routes []Route routes []Route
externalMiddlewares []Middleware externalMiddlewares []Middleware
healthChecks []health.CheckerOption healthChecks []health.CheckerOption
gcGraceDuration time.Duration readyFn []func() error
fs *gotenberg.FileSystem
logger *zap.Logger logger *zap.Logger
srv *echo.Echo srv *echo.Echo
} }
// Router is a module interface which adds routes to the API. // Router is a module interface which adds routes to the [Api].
type Router interface { type Router interface {
Routes() ([]Route, error) Routes() ([]Route, error)
} }
// Route represents a route from a Router. // Route represents a route from a [Router].
type Route struct { type Route struct {
// Method is the HTTP method of the route (i.e., GET, POST, etc.). // Method is the HTTP method of the route (i.e., GET, POST, etc.).
// Required. // Required.
@@ -72,13 +73,13 @@ type Route struct {
Handler echo.HandlerFunc 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 { type MiddlewareProvider interface {
Middlewares() ([]Middleware, error) Middlewares() ([]Middleware, error)
} }
// MiddlewareStack is a type which helps to determine in which stack the // 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 type MiddlewareStack uint32
const ( const (
@@ -88,7 +89,7 @@ const (
) )
// MiddlewarePriority is a type which helps to determine the execution order of // 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 type MiddlewarePriority uint32
const ( const (
@@ -99,40 +100,40 @@ const (
VeryHighPriority 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. // chain.
// //
// middleware := Middleware{ // middleware := Middleware{
// Handler: func() echo.MiddlewareFunc { // Handler: func() echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc { // return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error { // return func(c echo.Context) error {
// rootPath := c.Get("rootPath").(string) // rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath) // healthURI := fmt.Sprintf("%shealth", rootPath)
// //
// // Skip the middleware if health check URI. // // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI { // if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain. // // Call the next middleware in the chain.
// return next(c) // return next(c)
// } // }
// //
// // Your middleware process. // // Your middleware process.
// // ... // // ...
// //
// // Call the next middleware in the chain. // // Call the next middleware in the chain.
// return next(c) // return next(c)
// } // }
// } // }
// }(), // }(),
// } // }
type Middleware struct { type Middleware struct {
// Stack tells in which stack the middleware should be located. // Stack tells in which stack the middleware should be located.
// Default to DefaultStack. // Default to [DefaultStack].
// Optional. // Optional.
Stack MiddlewareStack Stack MiddlewareStack
// Priority tells if the middleware should be positioned high or not in // Priority tells if the middleware should be positioned high or not in
// its stack. // its stack.
// Default to VeryLowPriority. // Default to [VeryLowPriority].
// Optional. // Optional.
Priority MiddlewarePriority Priority MiddlewarePriority
@@ -147,52 +148,35 @@ type Middleware struct {
// See https://github.com/alexliesenfeld/health for more details. // See https://github.com/alexliesenfeld/health for more details.
type HealthChecker interface { type HealthChecker interface {
Checks() ([]health.CheckerOption, error) Checks() ([]health.CheckerOption, error)
Ready() error
} }
// GarbageCollectorGraceDurationIncrementer is a module interface for // Descriptor returns an [Api]'s module descriptor.
// increasing the grace duration provided by the API for the garbage collector. func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
type GarbageCollectorGraceDurationIncrementer interface {
AddGraceDuration() time.Duration
}
// Descriptor returns an API's module descriptor.
func (API) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ return gotenberg.ModuleDescriptor{
ID: "api", ID: "api",
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("api", flag.ExitOnError) fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int("api-port", 3000, "Set the port on which the API should listen") 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.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-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request")
fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests") fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-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.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
var err error
err = multierr.Append(err, fs.MarkDeprecated("api-read-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-process-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-write-timeout", "use api-timeout instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the api module: %v", err))
}
return fs return fs
}(), }(),
New: func() gotenberg.Module { return new(API) }, New: func() gotenberg.Module { return new(Api) },
} }
} }
// Provision sets the module properties. // Provision sets the module properties.
func (a *API) Provision(ctx *gotenberg.Context) error { func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port") a.port = flags.MustInt("api-port")
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout") a.startTimeout = flags.MustDuration("api-start-timeout")
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout") a.timeout = flags.MustDuration("api-timeout")
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
a.rootPath = flags.MustString("api-root-path") a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header") a.traceHeader = flags.MustString("api-trace-header")
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging") a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
@@ -281,20 +265,10 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
} }
a.healthChecks = append(a.healthChecks, checks...) a.healthChecks = append(a.healthChecks, checks...)
a.readyFn = append(a.readyFn, healthChecker.Ready)
} }
// Grace duration. // Logger.
a.gcGraceDuration = a.timeout
mods, err = ctx.Modules(new(GarbageCollectorGraceDurationIncrementer))
if err != nil {
return fmt.Errorf("get garbage collector grace duration increments: %w", err)
}
for _, incrementer := range mods {
a.gcGraceDuration += incrementer.(GarbageCollectorGraceDurationIncrementer).AddGraceDuration()
}
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil { if err != nil {
return fmt.Errorf("get logger provider: %w", err) return fmt.Errorf("get logger provider: %w", err)
@@ -307,11 +281,14 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
a.logger = logger a.logger = logger
// File system.
a.fs = gotenberg.NewFileSystem()
return nil return nil
} }
// Validate validates the module properties. // Validate validates the module properties.
func (a API) Validate() error { func (a *Api) Validate() error {
var err error var err error
if a.port < 1 || a.port > 65535 { if a.port < 1 || a.port > 65535 {
@@ -383,14 +360,14 @@ func (a API) Validate() error {
} }
// Start starts the HTTP server. // Start starts the HTTP server.
func (a *API) Start() error { func (a *Api) Start() error {
a.srv = echo.New() a.srv = echo.New()
a.srv.HideBanner = true a.srv.HideBanner = true
a.srv.HidePort = true a.srv.HidePort = true
a.srv.Server.ReadTimeout = a.readTimeout a.srv.Server.ReadTimeout = a.timeout
a.srv.Server.IdleTimeout = a.timeout a.srv.Server.IdleTimeout = a.timeout
// See https://github.com/gotenberg/gotenberg/issues/396. // 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() a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes. // Let's prepare the modules' routes.
@@ -425,7 +402,7 @@ func (a *API) Start() error {
a.srv.Pre(externalMiddleware.Handler) a.srv.Pre(externalMiddleware.Handler)
case MultipartStack: case MultipartStack:
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware) externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
default: case DefaultStack:
a.srv.Use(externalMiddleware.Handler) a.srv.Use(externalMiddleware.Handler)
} }
} }
@@ -437,7 +414,7 @@ func (a *API) Start() error {
var middlewares []echo.MiddlewareFunc var middlewares []echo.MiddlewareFunc
if route.IsMultipart { if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.timeout)) middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout))
for _, externalMultipartMiddleware := range externalMultipartMiddlewares { for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler) middlewares = append(middlewares, externalMultipartMiddleware.Handler)
@@ -460,12 +437,25 @@ func (a *API) Start() error {
func() echo.HandlerFunc { func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout)) checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...) checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker)) return echo.WrapHandler(health.NewHandler(checker))
}(), }(),
hardTimeoutMiddleware(hardTimeout), hardTimeoutMiddleware(hardTimeout),
) )
// 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. // As the following code is blocking, run it in a goroutine.
go func() { go func() {
server := &http2.Server{} server := &http2.Server{}
@@ -479,26 +469,19 @@ func (a *API) Start() error {
} }
// StartupMessage returns a custom startup message. // StartupMessage returns a custom startup message.
func (a API) StartupMessage() string { func (a *Api) StartupMessage() string {
return fmt.Sprintf("server listening on port %d", a.port) return fmt.Sprintf("server listening on port %d", a.port)
} }
// Stop stops the HTTP server. // 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) 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. // Interface guards.
var ( var (
_ gotenberg.Module = (*API)(nil) _ gotenberg.Module = (*Api)(nil)
_ gotenberg.Provisioner = (*API)(nil) _ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*API)(nil) _ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*API)(nil) _ gotenberg.App = (*Api)(nil)
_ gc.GarbageCollectorGraceDurationModifier = (*API)(nil)
) )

File diff suppressed because it is too large Load Diff

View File

@@ -12,16 +12,14 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"unicode"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3" "github.com/mholt/archiver/v3"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
var ( var (
@@ -36,20 +34,26 @@ var (
// Context is the request context for a "multipart/form-data" requests. // Context is the request context for a "multipart/form-data" requests.
type Context struct { type Context struct {
dirPath string dirPath string
values map[string][]string values map[string][]string
files map[string]string files map[string]string
outputPaths []string outputPaths []string
cancelled bool
cancelled bool logger *zap.Logger
logger *zap.Logger echoCtx echo.Context
echoCtx echo.Context pathRename gotenberg.PathRename
context.Context context.Context
} }
// newContext returns a Context by parsing a "multipart/form-data" request. type osPathRename struct{}
func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration) (*Context, context.CancelFunc, error) {
func (o *osPathRename) Rename(oldpath, newpath string) error {
return os.Rename(oldpath, newpath)
}
// 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) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout) processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
ctx := &Context{ ctx := &Context{
@@ -57,6 +61,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
cancelled: false, cancelled: false,
logger: logger, logger: logger,
echoCtx: echoCtx, echoCtx: echoCtx,
pathRename: new(osPathRename),
Context: processCtx, Context: processCtx,
} }
@@ -81,39 +86,38 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
return 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 ctx.cancelled = true
} }
}() }()
form, err := echoCtx.MultipartForm() form, err := echoCtx.MultipartForm()
if err != nil { if err != nil {
if errors.Is(err, http.ErrNotMultipart) { if errors.Is(err, http.ErrNotMultipart) {
return nil, cancel, WrapError( return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err), 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) { if errors.Is(err, http.ErrMissingBoundary) {
return nil, cancel, WrapError( return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err), 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()) { if strings.Contains(err.Error(), io.EOF.Error()) {
return nil, cancel, WrapError( return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err), 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) return nil, cancel, fmt.Errorf("get multipart form: %w", err)
} }
dirPath, err := gotenberg.MkdirAll() dirPath, err := fs.MkdirAll()
if err != nil { if err != nil {
return nil, cancel, fmt.Errorf("create working directory: %w", err) return nil, cancel, fmt.Errorf("create working directory: %w", err)
} }
@@ -123,15 +127,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
ctx.files = make(map[string]string) ctx.files = make(map[string]string)
copyToDisk := func(fh *multipart.FileHeader) error { copyToDisk := func(fh *multipart.FileHeader) error {
// Avoid directory traversal and normalize filename.
// See https://github.com/gotenberg/gotenberg/issues/104.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
if err != nil {
return fmt.Errorf("transform filename: %w", err)
}
in, err := fh.Open() in, err := fh.Open()
if err != nil { if err != nil {
return fmt.Errorf("open multipart file: %w", err) return fmt.Errorf("open multipart file: %w", err)
@@ -144,6 +139,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
} }
}() }()
// 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) path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
out, err := os.Create(path) out, err := os.Create(path)
@@ -171,26 +170,25 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
for _, files := range form.File { for _, files := range form.File {
for _, fh := range files { for _, fh := range files {
err = copyToDisk(fh) err = copyToDisk(fh)
if err != nil { if err != nil {
return ctx, cancel, fmt.Errorf("copy to disk: %w", err) 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 fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form data files: %+v", ctx.files)) ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
return ctx, cancel, err return ctx, cancel, err
} }
// Request returns the http.Request. // Request returns the [http.Request].
func (ctx Context) Request() *http.Request { func (ctx *Context) Request() *http.Request {
return ctx.echoCtx.Request() return ctx.echoCtx.Request()
} }
// FormData return a FormData. // FormData return a [FormData].
func (ctx Context) FormData() *FormData { func (ctx *Context) FormData() *FormData {
return &FormData{ return &FormData{
values: ctx.values, values: ctx.values,
files: ctx.files, files: ctx.files,
@@ -198,10 +196,20 @@ func (ctx Context) FormData() *FormData {
} }
} }
// GeneratePath generates a path within the context's working directory. It // GeneratePath generates a path within the context's working directory.
// does not create a file. // It generates a new UUID-based filename. It does not create a file.
func (ctx Context) GeneratePath(extension string) string { func (ctx *Context) GeneratePath(extension string) string {
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension) return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
}
// 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 {
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 // AddOutputPaths adds the given paths. Those paths will be used later to build
@@ -222,14 +230,14 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
return nil return nil
} }
// Log returns the context zap.Logger. // Log returns the context [zap.Logger].
func (ctx Context) Log() *zap.Logger { func (ctx *Context) Log() *zap.Logger {
return ctx.logger return ctx.logger
} }
// BuildOutputFile builds the output file according to the output paths // BuildOutputFile builds the output file according to the output paths
// registered in the context. If many output paths, an archive is created. // 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 { if ctx.cancelled {
return "", ErrContextAlreadyClosed return "", ErrContextAlreadyClosed
} }
@@ -267,7 +275,7 @@ func (ctx Context) BuildOutputFile() (string, error) {
// OutputFilename returns the filename based on the given output path or the // OutputFilename returns the filename based on the given output path or the
// "Gotenberg-Output-Filename" header's value. // "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") filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename")
if filename == "" { if filename == "" {
@@ -277,70 +285,7 @@ func (ctx Context) OutputFilename(outputPath string) string {
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath)) return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
} }
// MockContext is a helper for tests. // Interface guard.
// var (
// ctx := &api.MockContext{Context: &api.Context{}} _ gotenberg.PathRename = (*osPathRename)(nil)
type MockContext struct { )
*Context
}
// SetDirPath sets the context's working directory path.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetDirPath("/foo")
func (ctx *MockContext) SetDirPath(path string) {
ctx.dirPath = path
}
// SetValues sets the values.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetValues(map[string][]string{
// "url": {
// "foo",
// },
// })
func (ctx *MockContext) SetValues(values map[string][]string) {
ctx.values = values
}
// SetFiles sets the files.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetFiles(map[string]string{
// "foo": "/foo",
// })
func (ctx *MockContext) SetFiles(files map[string]string) {
ctx.files = files
}
// SetCancelled sets if the context is cancelled or not.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetCancelled(true)
func (ctx *MockContext) SetCancelled(cancelled bool) {
ctx.cancelled = cancelled
}
// OutputPaths returns the registered output paths.
// ctx := &api.MockContext{Context: &api.Context{}}
// outputPaths := ctx.OutputPaths()
func (ctx MockContext) OutputPaths() []string {
return ctx.outputPaths
}
// SetLogger sets the logger.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.SetLogger(zap.NewNop())
func (ctx *MockContext) SetLogger(logger *zap.Logger) {
ctx.logger = logger
}
// SetEchoContext sets the echo.Context.
//
// ctx := &api.MockContext{Context: &api.Context{}}
// ctx.setEchoContext(c)
func (ctx *MockContext) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c
}

View File

@@ -3,153 +3,206 @@ package api
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestOsPathRename_Rename(t *testing.T) {
dirPath, err := gotenberg.NewFileSystem().MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
path := "/tests/test/testdata/api/sample1.txt"
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
in, err := os.Open(path)
if err != nil {
t.Fatalf("open file: %v", err)
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}()
out, err := os.Create(copyPath)
if err != nil {
t.Fatalf("create new file: %v", err)
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf("close new file: %v", err)
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf("copy file to new file: %v", err)
}
rename := new(osPathRename)
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
err = rename.Rename(copyPath, newPath)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestNewContext(t *testing.T) { func TestNewContext(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
request *http.Request request *http.Request
expectErr bool expectError bool
expectHTTPErr bool expectHttpError bool
expectHTTPStatus int expectHttpStatus int
}{ }{
{ {
scenario: "http.ErrNotMultipart",
request: httptest.NewRequest(http.MethodPost, "/", nil), request: httptest.NewRequest(http.MethodPost, "/", nil),
expectErr: true, expectError: true,
expectHTTPErr: true, expectHttpError: true,
expectHTTPStatus: http.StatusUnsupportedMediaType, expectHttpStatus: http.StatusUnsupportedMediaType,
}, },
{ {
scenario: "http.ErrMissingBoundary",
request: func() *http.Request { request: func() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil) req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm) req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm)
return req return req
}(), }(),
expectErr: true, expectError: true,
expectHTTPErr: true, expectHttpError: true,
expectHTTPStatus: http.StatusUnsupportedMediaType, expectHttpStatus: http.StatusUnsupportedMediaType,
}, },
{ {
scenario: "malformed body",
request: func() *http.Request { request: func() *http.Request {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
defer func() { defer func() {
err := writer.Close() err := writer.Close()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
}() }()
err := writer.WriteField("foo", "foo") err := writer.WriteField("foo", "foo")
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
req := httptest.NewRequest(http.MethodPost, "/", nil) req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req return req
}(), }(),
expectErr: true, expectError: true,
expectHTTPErr: true, expectHttpError: true,
expectHTTPStatus: http.StatusBadRequest, expectHttpStatus: http.StatusBadRequest,
}, },
{ {
scenario: "success",
request: func() *http.Request { request: func() *http.Request {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
defer func() { defer func() {
err := writer.Close() err := writer.Close()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
}() }()
err := writer.WriteField("foo", "foo") err := writer.WriteField("foo", "foo")
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
part, err := writer.CreateFormFile("foo.txt", "foo.txt") part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
_, err = part.Write([]byte("foo")) _, err = part.Write([]byte("foo"))
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
req := httptest.NewRequest(http.MethodPost, "/", body) req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req return req
}(), }(),
expectError: false,
expectHttpError: false,
}, },
} { } {
handler := func(c echo.Context) error { t.Run(tc.scenario, func(t *testing.T) {
_, cancel, err := newContext(c, zap.NewNop(), time.Duration(10)*time.Second) handler := func(c echo.Context) error {
defer cancel() _, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second)
// Context already cancelled. defer cancel()
defer cancel() // Context already cancelled.
defer cancel()
if err != nil { if err != nil {
return err return err
}
return nil
} }
return nil recorder := httptest.NewRecorder()
}
recorder := httptest.NewRecorder() srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
srv := echo.New() c := srv.NewContext(tc.request, recorder)
srv.HideBanner = true err := handler(c)
srv.HidePort = true
c := srv.NewContext(tc.request, recorder) if tc.expectError && err == nil {
err := handler(c) t.Fatal("expected error but got none", 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)
}
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)
} }
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var httpErr HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
})
} }
} }
@@ -158,7 +211,7 @@ func TestContext_Request(t *testing.T) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
c := echo.New().NewContext(request, recorder) c := echo.New().NewContext(request, recorder)
ctx := Context{ ctx := &Context{
echoCtx: c, echoCtx: c,
} }
@@ -168,7 +221,7 @@ func TestContext_Request(t *testing.T) {
} }
func TestContext_FormData(t *testing.T) { func TestContext_FormData(t *testing.T) {
ctx := Context{ ctx := &Context{
values: map[string][]string{ values: map[string][]string{
"foo": {"foo"}, "foo": {"foo"},
}, },
@@ -189,52 +242,95 @@ func TestContext_FormData(t *testing.T) {
} }
func TestContext_GeneratePath(t *testing.T) { func TestContext_GeneratePath(t *testing.T) {
ctx := Context{ ctx := &Context{
dirPath: "/foo", dirPath: "/foo",
} }
path := ctx.GeneratePath(".pdf") path := ctx.GeneratePath(".pdf")
if !strings.HasPrefix(path, ctx.dirPath) { if !strings.HasPrefix(path, ctx.dirPath) {
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath) t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
} }
} }
func TestContext_Rename(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "failure",
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return errors.New("cannot rename")
}}},
expectError: true,
},
{
scenario: "success",
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.ctx.Rename("", "")
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_AddOutputPaths(t *testing.T) { func TestContext_AddOutputPaths(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
ctx *Context ctx *Context
path string path string
expectCount int expectCount int
expectErr bool expectError bool
}{ }{
{ {
ctx: &Context{cancelled: true}, scenario: "ErrContextAlreadyClosed",
expectErr: true, ctx: &Context{cancelled: true},
expectCount: 0,
expectError: true,
}, },
{ {
ctx: &Context{dirPath: "/foo"}, scenario: "ErrOutOfBoundsOutputPath",
path: "/bar/foo.txt", ctx: &Context{dirPath: "/foo"},
expectErr: true, path: "/bar/foo.txt",
expectCount: 0,
expectError: true,
}, },
{ {
scenario: "success",
ctx: &Context{dirPath: "/foo"}, ctx: &Context{dirPath: "/foo"},
path: "/foo/foo.txt", path: "/foo/foo.txt",
expectCount: 1, expectCount: 1,
expectError: false,
}, },
} { } {
err := tc.ctx.AddOutputPaths(tc.path) t.Run(tc.scenario, func(t *testing.T) {
err := tc.ctx.AddOutputPaths(tc.path)
if tc.expectErr && err == nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatal("expected error but got none", err)
} }
if !tc.expectErr && err != nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if len(tc.ctx.outputPaths) != tc.expectCount { 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)) t.Errorf("expected %d output paths but got %d", tc.expectCount, len(tc.ctx.outputPaths))
} }
})
} }
} }
@@ -249,76 +345,91 @@ func TestContext_Log(t *testing.T) {
} }
func TestContext_BuildOutputFile(t *testing.T) { func TestContext_BuildOutputFile(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
ctx *Context scenario string
expectErr bool ctx *Context
expectError bool
}{ }{
{ {
ctx: &Context{cancelled: true}, scenario: "ErrContextAlreadyClosed",
expectErr: true, ctx: &Context{cancelled: true},
expectError: true,
}, },
{ {
ctx: &Context{}, scenario: "no output path",
expectErr: true, ctx: &Context{},
expectError: true,
}, },
{ {
ctx: &Context{outputPaths: []string{"foo.txt"}}, scenario: "success: one output path",
ctx: &Context{outputPaths: []string{"foo.txt"}},
expectError: false,
}, },
{ {
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}}, scenario: "cannot archive: invalid output paths",
expectErr: true, ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
expectError: true,
}, },
{ {
scenario: "success: many output paths",
ctx: &Context{ ctx: &Context{
outputPaths: []string{ outputPaths: []string{
"/tests/test/testdata/api/sample1.txt", "/tests/test/testdata/api/sample1.txt",
"/tests/test/testdata/api/sample1.txt", "/tests/test/testdata/api/sample1.txt",
}, },
}, },
expectError: false,
}, },
} { } {
dirPath, err := gotenberg.MkdirAll() t.Run(tc.scenario, func(t *testing.T) {
if err != nil { fs := gotenberg.NewFileSystem()
t.Fatalf("%d: expected no erro but got: %v", i, err) dirPath, err := fs.MkdirAll()
} if err != nil {
t.Fatalf("expected no erro but got: %v", err)
}
tc.ctx.dirPath = dirPath defer func() {
tc.ctx.logger = zap.NewNop() err := os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
_, err = tc.ctx.BuildOutputFile() tc.ctx.dirPath = dirPath
tc.ctx.logger = zap.NewNop()
if tc.expectErr && err == nil { _, err = tc.ctx.BuildOutputFile()
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatal("expected error but got none", err)
} }
err = os.RemoveAll(dirPath) if !tc.expectError && err != nil {
if err != nil { t.Fatalf("expected no error but got: %v", err)
t.Fatalf("%d: expected no erro but got: %v", i, err) }
} })
} }
} }
func TestContext_OutputFilename(t *testing.T) { func TestContext_OutputFilename(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
ctx *Context ctx *Context
outputPath string outputPath string
expectOutputFilename string expectOutputFilename string
}{ }{
{ {
scenario: "with Gotenberg-Output-Filename header",
ctx: func() *Context { ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil) c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
c.Request().Header.Set("Gotenberg-Output-Filename", "foo") c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
return &Context{echoCtx: c} return &Context{echoCtx: c}
}(), }(),
outputPath: "/foo/bar.txt", outputPath: "/foo/bar.txt",
expectOutputFilename: "foo.txt", expectOutputFilename: "foo.txt",
}, },
{ {
scenario: "without custom filename",
ctx: func() *Context { ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil) c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
return &Context{echoCtx: c} return &Context{echoCtx: c}
@@ -327,106 +438,12 @@ func TestContext_OutputFilename(t *testing.T) {
expectOutputFilename: "foo.txt", expectOutputFilename: "foo.txt",
}, },
} { } {
actual := tc.ctx.OutputFilename(tc.outputPath) t.Run(tc.scenario, func(t *testing.T) {
actual := tc.ctx.OutputFilename(tc.outputPath)
if actual != tc.expectOutputFilename { if actual != tc.expectOutputFilename {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectOutputFilename, actual) t.Errorf("expected '%s' but got '%s'", tc.expectOutputFilename, actual)
} }
} })
}
func TestMockContext_SetDirPath(t *testing.T) {
mock := &MockContext{&Context{}}
mock.SetDirPath("/foo")
actual := mock.dirPath
expect := "/foo"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestMockContext_SetValues(t *testing.T) {
mock := &MockContext{&Context{}}
mock.SetValues(map[string][]string{
"foo": {"foo"},
})
actual := mock.values
expect := map[string][]string{
"foo": {"foo"},
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestMockContext_SetFiles(t *testing.T) {
mock := &MockContext{&Context{}}
mock.SetFiles(map[string]string{
"foo": "/foo",
})
actual := mock.files
expect := map[string]string{
"foo": "/foo",
}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestMockContext_SetCancelled(t *testing.T) {
mock := &MockContext{&Context{}}
mock.SetCancelled(true)
actual := mock.cancelled
if !actual {
t.Errorf("expected %t but got %t", true, actual)
}
}
func TestMockContext_OutputPaths(t *testing.T) {
mock := MockContext{
&Context{
outputPaths: []string{"/foo"},
},
}
actual := mock.OutputPaths()
expect := []string{"/foo"}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
}
}
func TestMockContext_SetLogger(t *testing.T) {
mock := MockContext{&Context{}}
expect := zap.NewNop()
mock.SetLogger(expect)
actual := mock.logger
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestMockContext_SetEchoContext(t *testing.T) {
mock := MockContext{&Context{}}
expect := echo.New().NewContext(nil, nil)
mock.SetEchoContext(expect)
actual := mock.echoCtx
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
} }
} }

View File

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

View File

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

View File

@@ -2,8 +2,9 @@ package api
import ( import (
"fmt" "fmt"
"io/ioutil" "math"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
@@ -11,156 +12,243 @@ import (
"time" "time"
"go.uber.org/multierr" "go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// FormData is a helper for validating and hydrating values from a // FormData is a helper for validating and hydrating values from a
// "multipart/form-data" request. // "multipart/form-data" request.
// //
// form := ctx.FormData() // form := ctx.FormData()
type FormData struct { type FormData struct {
values map[string][]string values map[string][]string
files map[string]string files map[string]string
errors error errors error
} }
// Validate returns nil or an error related to the FormData values, with a // Validate returns nil or an error related to the [FormData] values, with a
// SentinelHTTPError (status code 400, errors' details as message) wrapped // [SentinelHttpError] (status code 400, errors' details as message) wrapped
// inside. // inside.
// //
// var foo string // var foo string
// //
// err := ctx.FormData(). // err := ctx.FormData().
// MandatoryString("foo", &foo, "bar"). // MandatoryString("foo", &foo, "bar").
// Validate() // Validate()
func (form FormData) Validate() error { func (form *FormData) Validate() error {
if form.errors == nil { if form.errors == nil {
return nil return nil
} }
return WrapError( return WrapError(
form.errors, 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 // var foo string
// //
// ctx.FormData().String("foo", &foo, "bar") // ctx.FormData().String("foo", &foo, "bar")
func (form *FormData) String(key string, target *string, defaultValue string) *FormData { func (form *FormData) String(key string, target *string, defaultValue string) *FormData {
return form.mustValue(key, target, defaultValue) 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. // an error if the value is empty or the "key" does not exist.
// //
// var foo string // var foo string
// //
// ctx.FormData().MandatoryString("foo", &foo) // ctx.FormData().MandatoryString("foo", &foo)
func (form *FormData) MandatoryString(key string, target *string) *FormData { 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. // the value is not bool.
// //
// var foo bool // var foo bool
// //
// ctx.FormData().Bool("foo", &foo, true) // ctx.FormData().Bool("foo", &foo, true)
func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormData { func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormData {
return form.mustValue(key, target, defaultValue) 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. // error if the value is not bool, is empty, or the "key" does not exist.
// //
// var foo bool // var foo bool
// //
// ctx.FormData().MandatoryBool("foo", &foo) // ctx.FormData().MandatoryBool("foo", &foo)
func (form *FormData) MandatoryBool(key string, target *bool) *FormData { 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. // value is not int.
// //
// var foo int // var foo int
// //
// ctx.FormData().Int("foo", &foo, 2) // ctx.FormData().Int("foo", &foo, 2)
func (form *FormData) Int(key string, target *int, defaultValue int) *FormData { func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
return form.mustValue(key, target, defaultValue) 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. // error if the value is not int, is empty, or the "key" does not exist.
// //
// var foo int // var foo int
// //
// ctx.FormData().MandatoryInt("foo", &foo) // ctx.FormData().MandatoryInt("foo", &foo)
func (form *FormData) MandatoryInt(key string, target *int) *FormData { 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. // if the value is not float64.
// //
// var foo float64 // var foo float64
// //
// ctx.FormData().Float64("foo", &foo, 2.0) // ctx.FormData().Float64("foo", &foo, 2.0)
func (form *FormData) Float64(key string, target *float64, defaultValue float64) *FormData { func (form *FormData) Float64(key string, target *float64, defaultValue float64) *FormData {
return form.mustValue(key, target, defaultValue) 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. // an error if the is not float64, is empty, or the "key" does not exist.
// //
// var foo float64 // var foo float64
// //
// ctx.FormData().MandatoryFloat64("foo", &foo) // ctx.FormData().MandatoryFloat64("foo", &foo)
func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData { 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 // Duration binds a form field to a time.Duration variable. It populates
// an error if the form data value is not time.Duration. // an error if the form field is not time.Duration.
// //
// var foo time.Duration // var foo time.Duration
// //
// ctx.FormData().Duration("foo", &foo, time.Duration(2) * time.Second) // ctx.FormData().Duration("foo", &foo, time.Duration(2) * time.Second)
func (form *FormData) Duration(key string, target *time.Duration, defaultValue time.Duration) *FormData { func (form *FormData) Duration(key string, target *time.Duration, defaultValue time.Duration) *FormData {
return form.mustValue(key, target, defaultValue) 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" // populates an error if the value is not time.Duration, is empty, or the "key"
// does not exist. // does not exist.
// //
// var foo time.Duration // var foo time.Duration
// //
// ctx.FormData().MandatoryDuration("foo", &foo) // ctx.FormData().MandatoryDuration("foo", &foo)
func (form *FormData) MandatoryDuration(key string, target *time.Duration) *FormData { 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 map[string]string // var foo float64
// //
// ctx.FormData().Custom("foo", func(value string) error { // ctx.FormData().Inches("foo", &foo, 2.0)
// if value == "" { func (form *FormData) Inches(key string, target *float64, defaultValue float64) *FormData {
// foo = "bar" 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.
// //
// return nil // var foo float64
// }
// //
// err := json.Unmarshal([]byte(value), &foo) // ctx.FormData().MandatoryInches("foo", &foo)
// if err != nil { func (form *FormData) MandatoryInches(key string, target *float64) *FormData {
// return fmt.Errorf("unmarshal foo: %w", err) 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.
// //
// return nil // var foo map[string]string
// }) //
// ctx.FormData().Custom("foo", func(value string) error {
// if value == "" {
// foo = "bar"
//
// return nil
// }
//
// err := json.Unmarshal([]byte(value), &foo)
// if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err)
// }
//
// return nil
// })
func (form *FormData) Custom(key string, assign func(value string) error) *FormData { func (form *FormData) Custom(key string, assign func(value string) error) *FormData {
var value string var value string
form.mustValue(key, &value, "") form.mustValue(key, &value, "")
@@ -168,30 +256,29 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
err := assign(value) err := assign(value)
if err != nil { if err != nil {
form.append( 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 return form
} }
// MandatoryCustom helps to define a custom binding function for a form data // MandatoryCustom helps to define a custom binding function for a form field.
// value. It populates an error if the value is empty or the "key" does not // It populates an error if the value is empty or the "key" does not exist.
// exist.
// //
// var foo map[string]string // var foo map[string]string
// //
// ctx.FormData().MandatoryCustom("foo", func(value string) error { // ctx.FormData().MandatoryCustom("foo", func(value string) error {
// err := json.Unmarshal([]byte(value), &foo) // err := json.Unmarshal([]byte(value), &foo)
// if err != nil { // if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err) // return fmt.Errorf("unmarshal foo: %w", err)
// } // }
// //
// return nil // return nil
// }) // })
func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData { func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData {
var value string var value string
form.mustMandatoryValue(key, &value) form.mustMandatoryField(key, &value)
if value == "" { if value == "" {
return form return form
@@ -200,7 +287,7 @@ func (form *FormData) MandatoryCustom(key string, assign func(value string) erro
err := assign(value) err := assign(value)
if err != nil { if err != nil {
form.append( 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),
) )
} }
@@ -209,9 +296,9 @@ func (form *FormData) MandatoryCustom(key string, assign func(value string) erro
// Path binds the absolute path of a form data file to a string variable. // Path binds the absolute path of a form data file to a string variable.
// //
// var path string // var path string
// //
// ctx.FormData().Path("foo.txt", &path) // ctx.FormData().Path("foo.txt", &path)
func (form *FormData) Path(filename string, target *string) *FormData { func (form *FormData) Path(filename string, target *string) *FormData {
return form.path(filename, target) return form.path(filename, target)
} }
@@ -219,18 +306,18 @@ func (form *FormData) Path(filename string, target *string) *FormData {
// MandatoryPath binds the absolute path ofa form data file to a string // MandatoryPath binds the absolute path ofa form data file to a string
// variable. It populates an error if the file does not exist. // variable. It populates an error if the file does not exist.
// //
// var path string // var path string
// //
// ctx.FormData().MandatoryPath("foo.txt", &path) // ctx.FormData().MandatoryPath("foo.txt", &path)
func (form *FormData) MandatoryPath(filename string, target *string) *FormData { func (form *FormData) MandatoryPath(filename string, target *string) *FormData {
return form.mandatoryPath(filename, target) return form.mandatoryPath(filename, target)
} }
// Content binds the content of a form data file to a string variable. // Content binds the content of a form data file to a string variable.
// //
// var content string // var content string
// //
// ctx.FormData().Content("foo.txt", &content, "bar") // ctx.FormData().Content("foo.txt", &content, "bar")
func (form *FormData) Content(filename string, target *string, defaultValue string) *FormData { func (form *FormData) Content(filename string, target *string, defaultValue string) *FormData {
var path string var path string
form.path(filename, &path) form.path(filename, &path)
@@ -247,9 +334,9 @@ func (form *FormData) Content(filename string, target *string, defaultValue stri
// MandatoryContent binds the content of a form data file to a string variable. // MandatoryContent binds the content of a form data file to a string variable.
// It populates an error if the file does not exist. // It populates an error if the file does not exist.
// //
// var content string // var content string
// //
// ctx.FormData().MandatoryContent("foo.txt", &content) // ctx.FormData().MandatoryContent("foo.txt", &content)
func (form *FormData) MandatoryContent(filename string, target *string) *FormData { func (form *FormData) MandatoryContent(filename string, target *string) *FormData {
var path string var path string
form.mandatoryPath(filename, &path) form.mandatoryPath(filename, &path)
@@ -264,9 +351,9 @@ func (form *FormData) MandatoryContent(filename string, target *string) *FormDat
// Paths binds the absolute paths of form data files, according to a list of // Paths binds the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable. // file extensions, to a string slice variable.
// //
// var paths []string // var paths []string
// //
// ctx.FormData().Paths([]string{".txt"}, &paths) // ctx.FormData().Paths([]string{".txt"}, &paths)
func (form *FormData) Paths(extensions []string, target *[]string) *FormData { func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
return form.paths(extensions, target) return form.paths(extensions, target)
} }
@@ -275,9 +362,9 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
// list of file extensions, to a string slice variable. It populates an error // list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions. // if there is no file for given file extensions.
// //
// var paths []string // var paths []string
// //
// ctx.FormData().MandatoryPaths([]string{".txt"}, &paths) // ctx.FormData().MandatoryPaths([]string{".txt"}, &paths)
func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *FormData { func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *FormData {
form.paths(extensions, target) form.paths(extensions, target)
@@ -305,7 +392,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
} }
// See https://github.com/gotenberg/gotenberg/issues/139. // See https://github.com/gotenberg/gotenberg/issues/139.
sort.Strings(*target) sort.Sort(gotenberg.AlphanumericSort(*target))
return form return form
} }
@@ -315,7 +402,7 @@ func (form *FormData) append(err error) {
form.errors = multierr.Append(form.errors, err) 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, // 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. // only the string, bool, int, float64 and time.Duration types are bindable.
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData { 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) 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. // 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 // Currently, only the string, bool, int, float64 and time.Duration types are
// bindable. // bindable.
func (form *FormData) mustMandatoryValue(key string, target interface{}) *FormData { func (form *FormData) mustMandatoryField(key string, target interface{}) *FormData {
val, ok := form.values[key] val, ok := form.values[key]
if !ok || val[0] == "" { if !ok || val[0] == "" {
form.append( form.append(
fmt.Errorf("form value '%s' is required", key), fmt.Errorf("form field '%s' is required", key),
) )
return form return form
@@ -386,7 +473,7 @@ func (form *FormData) mustAssign(key, value string, target interface{}) *FormDat
if err != nil { if err != nil {
form.append( 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),
) )
} }
@@ -426,7 +513,7 @@ func (form *FormData) mandatoryPath(filename string, target *string) *FormData {
// readFile binds the content of a file to a string variable. It populates an // readFile binds the content of a file to a string variable. It populates an
// error if it fails to read the file content. // error if it fails to read the file content.
func (form *FormData) readFile(path, filename string, target *string) *FormData { func (form *FormData) readFile(path, filename string, target *string) *FormData {
b, err := ioutil.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {
form.append( form.append(
fmt.Errorf("form file '%s' is invalid (%w)", filename, err), fmt.Errorf("form file '%s' is invalid (%w)", filename, err),

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,25 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// ErrAsyncProcess happens when a handler or middleware handles a request in an var (
// asynchronous fashion. // ErrAsyncProcess happens when a handler or middleware handles a request
var ErrAsyncProcess = errors.New("async process") // 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 // ParseError parses an error and returns the corresponding HTTP status and
// HTTP message. // HTTP message.
func ParseError(err error) (int, string) { func ParseError(err error) (int, string) {
echoErr, ok := err.(*echo.HTTPError) var echoErr *echo.HTTPError
ok := errors.As(err, &echoErr)
if ok { if ok {
return echoErr.Code, http.StatusText(echoErr.Code) return echoErr.Code, http.StatusText(echoErr.Code)
} }
@@ -29,9 +38,25 @@ func ParseError(err error) (int, string) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable) 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.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) { if errors.As(err, &httpErr) {
return httpErr.HTTPError() return httpErr.HttpError()
} }
// Default 500 status code. // Default 500 status code.
@@ -54,10 +79,10 @@ func httpErrorHandler() echo.HTTPErrorHandler {
} }
} }
// latencyMiddleware sets the start time in the echo.Context under "startTime". // latencyMiddleware sets the start time in the [echo.Context] under
// Its value will be used later to calculate a request latency. // "startTime". Its value will be used later to calculate a request latency.
// //
// startTime := c.Get("startTime").(time.Time) // startTime := c.Get("startTime").(time.Time)
func latencyMiddleware() echo.MiddlewareFunc { func latencyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@@ -71,18 +96,18 @@ func latencyMiddleware() echo.MiddlewareFunc {
} }
} }
// rootPathMiddleware sets the root path in the echo.Context under "rootPath". // rootPathMiddleware sets the root path in the [echo.Context] under
// Its value may be used to skip a middleware execution based on a request // "rootPath". Its value may be used to skip a middleware execution based on a
// URI. // request URI.
// //
// rootPath := c.Get("rootPath").(string) // rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%s/health", rootPath) // healthURI := fmt.Sprintf("%s/health", rootPath)
// //
// // Skip the middleware if health check URI. // // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI { // if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain. // // Call the next middleware in the chain.
// return next(c) // return next(c)
// } // }
func rootPathMiddleware(rootPath string) echo.MiddlewareFunc { func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@@ -94,12 +119,12 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
} }
} }
// 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 // "trace". Its value is either retrieved from the trace header or generated if
// the header is not present / its value is empty. // the header is not present / its value is empty.
// //
// trace := c.Get("trace").(string) // trace := c.Get("trace").(string)
// traceHeader := c.Get("traceHeader").(string). // traceHeader := c.Get("traceHeader").(string).
func traceMiddleware(header string) echo.MiddlewareFunc { func traceMiddleware(header string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@@ -120,10 +145,10 @@ func traceMiddleware(header string) echo.MiddlewareFunc {
} }
} }
// loggerMiddleware sets the logger in the echo.Context under "logger" and logs // loggerMiddleware sets the logger in the [echo.Context] under "logger" and
// a synchronous request result. // logs a synchronous request result.
// //
// logger := c.Get("logger").(*zap.Logger) // logger := c.Get("logger").(*zap.Logger)
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc { func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
@@ -193,20 +218,20 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
} }
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the // contextMiddleware, a middleware for "multipart/form-data" requests, sets the
// Context and related context.CancelFunc in the echo.Context under "context" // [Context] and related context.CancelFunc in the [echo.Context] under
// and "cancel". If the process is synchronous, it also handles the result of a // "context" and "cancel". If the process is synchronous, it also handles the
// "multipart/form-data" request. // result of a "multipart/form-data" request.
// //
// ctx := c.Get("context").(*api.Context) // ctx := c.Get("context").(*api.Context)
// cancel := c.Get("cancel").(context.CancelFunc) // cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc { func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger) logger := c.Get("logger").(*zap.Logger)
// We create a context with a timeout so that underlying processes are // We create a context with a timeout so that underlying processes are
// able to stop early and handle correctly a timeout scenario. // 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)
if err != nil { if err != nil {
cancel() cancel()
@@ -227,6 +252,13 @@ func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
defer cancel() 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 { if err != nil {
return err return err
} }

View File

@@ -13,6 +13,8 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestParseError(t *testing.T) { func TestParseError(t *testing.T) {
@@ -27,14 +29,29 @@ func TestParseError(t *testing.T) {
expectMessage: http.StatusText(http.StatusInternalServerError), expectMessage: http.StatusText(http.StatusInternalServerError),
}, },
{ {
err: context.DeadlineExceeded, err: gotenberg.ErrFiltered,
expectStatus: http.StatusServiceUnavailable, expectStatus: http.StatusForbidden,
expectMessage: http.StatusText(http.StatusServiceUnavailable), expectMessage: http.StatusText(http.StatusForbidden),
},
{
err: gotenberg.ErrMaximumQueueSizeExceeded,
expectStatus: http.StatusTooManyRequests,
expectMessage: http.StatusText(http.StatusTooManyRequests),
},
{
err: gotenberg.ErrPdfFormatNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues",
},
{
err: gotenberg.ErrPdfEngineMetadataValueNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues",
}, },
{ {
err: WrapError( err: WrapError(
errors.New("foo"), errors.New("foo"),
NewSentinelHTTPError(http.StatusBadRequest, "foo"), NewSentinelHttpError(http.StatusBadRequest, "foo"),
), ),
expectStatus: http.StatusBadRequest, expectStatus: http.StatusBadRequest,
expectMessage: "foo", expectMessage: "foo",
@@ -71,7 +88,7 @@ func TestHttpErrorHandler(t *testing.T) {
{ {
err: WrapError( err: WrapError(
errors.New("foo"), errors.New("foo"),
NewSentinelHTTPError(http.StatusBadRequest, "foo"), NewSentinelHttpError(http.StatusBadRequest, "foo"),
), ),
expectStatus: http.StatusBadRequest, expectStatus: http.StatusBadRequest,
expectMessage: "foo", expectMessage: "foo",
@@ -122,7 +139,6 @@ func TestLatencyMiddleware(t *testing.T) {
return nil return nil
}, },
)(c) )(c)
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
@@ -150,7 +166,6 @@ func TestRootPathMiddleware(t *testing.T) {
return nil return nil
}, },
)(c) )(c)
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
@@ -191,7 +206,6 @@ func TestTraceMiddleware(t *testing.T) {
return nil return nil
}, },
)(c) )(c)
if err != nil { if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err) t.Fatalf("test %d: expected no error but got: %v", i, err)
} }
@@ -271,7 +285,6 @@ func TestLoggerMiddleware(t *testing.T) {
} }
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c) err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
if err != nil { if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Errorf("test %d: expected no error but got: %v", i, err)
} }
@@ -322,6 +335,15 @@ func TestContextMiddleware(t *testing.T) {
}(), }(),
expectStatus: http.StatusNoContent, expectStatus: http.StatusNoContent,
}, },
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return ErrNoOutputFile
}
}(),
expectStatus: http.StatusOK,
},
{ {
request: buildMultipartFormDataRequest(), request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc { next: func() echo.HandlerFunc {
@@ -389,7 +411,7 @@ func TestContextMiddleware(t *testing.T) {
c.Set("trace", "foo") c.Set("trace", "foo")
c.Set("startTime", time.Now()) c.Set("startTime", time.Now())
err := contextMiddleware(time.Duration(10) * time.Second)(tc.next)(c) err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second)(tc.next)(c)
if tc.expectErr && err == nil { if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Errorf("test %d: expected error but got: %v", i, err)

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

@@ -0,0 +1,134 @@
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.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
type ContextMock struct {
*Context
}
// SetDirPath sets the context's working directory path.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetDirPath("/foo")
func (ctx *ContextMock) SetDirPath(path string) {
ctx.dirPath = path
}
// 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{}}
// ctx.SetValues(map[string][]string{
// "url": {
// "foo",
// },
// })
func (ctx *ContextMock) SetValues(values map[string][]string) {
ctx.values = values
}
// SetFiles sets the files.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetFiles(map[string]string{
// "foo": "/foo",
// })
func (ctx *ContextMock) SetFiles(files map[string]string) {
ctx.files = files
}
// SetCancelled sets if the context is cancelled or not.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetCancelled(true)
func (ctx *ContextMock) SetCancelled(cancelled bool) {
ctx.cancelled = cancelled
}
// OutputPaths returns the registered output paths.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// outputPaths := ctx.OutputPaths()
func (ctx *ContextMock) OutputPaths() []string {
return ctx.outputPaths
}
// SetLogger sets the logger.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetLogger(zap.NewNop())
func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
ctx.logger = logger
}
// SetEchoContext sets the [echo.Context].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setEchoContext(c)
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c
}
// SetPathRename sets the [gotenberg.PathRename].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setPathRename(rename)
func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) {
ctx.Context.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

@@ -0,0 +1,178 @@
package api
import (
"reflect"
"testing"
"github.com/alexliesenfeld/health"
"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_DirPath(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)
}
}
func TestContextMock_SetPathRename(t *testing.T) {
mock := ContextMock{&Context{}}
expect := new(osPathRename)
mock.SetPathRename(expect)
actual := mock.pathRename
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestRouterMock(t *testing.T) {
mock := &RouterMock{
RoutesMock: func() ([]Route, error) {
return nil, nil
},
}
_, err := mock.Routes()
if err != nil {
t.Errorf("expected no error from RouterMock.Routes, but got: %v", err)
}
}
func TestMiddlewareProviderMock(t *testing.T) {
mock := &MiddlewareProviderMock{
MiddlewaresMock: func() ([]Middleware, error) {
return nil, nil
},
}
_, err := mock.Middlewares()
if err != nil {
t.Errorf("expected no error from MiddlewareProviderMock.Middlewares, but got: %v", err)
}
}
func TestHealthCheckerMock(t *testing.T) {
mock := &HealthCheckerMock{
ChecksMock: func() ([]health.CheckerOption, error) {
return nil, nil
},
ReadyMock: func() error {
return nil
},
}
_, err := mock.Checks()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
}
err = mock.Ready()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
}
}

View File

@@ -0,0 +1,354 @@
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"
"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(),
}
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
defer func(userProfileDirPath string) {
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))
}
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
}()
}(copyUserProfileDirPath)
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),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
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),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
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.
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.
listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList)
var (
invalidHttpStatusCode error
invalidHttpStatusCodeMu sync.RWMutex
)
// See https://github.com/gotenberg/gotenberg/issues/613.
if len(options.FailOnHttpStatusCodes) != 0 {
listenForEventResponseReceived(taskCtx, logger, url, options.FailOnHttpStatusCodes, &invalidHttpStatusCode, &invalidHttpStatusCodeMu)
}
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)
}
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/262.
consoleExceptionsMu.RLock()
defer consoleExceptionsMu.RUnlock()
if consoleExceptions != nil {
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
}
return nil
}
// Interface guards.
var (
_ gotenberg.Process = (*chromiumBrowser)(nil)
_ browser = (*chromiumBrowser)(nil)
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,58 +3,20 @@ package chromium
import ( import (
"context" "context"
"errors" "errors"
"io/ioutil"
"os" "os"
"reflect" "reflect"
"regexp"
"testing" "testing"
"time" "time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/alexliesenfeld/health"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
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) { func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions() actual := DefaultPdfOptions()
notExpect := Options{} notExpect := PdfOptions{}
if reflect.DeepEqual(actual, notExpect) { if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect) t.Errorf("expected %v and got identical %v", actual, notExpect)
@@ -62,7 +24,7 @@ func TestDefaultOptions(t *testing.T) {
} }
func TestChromium_Descriptor(t *testing.T) { func TestChromium_Descriptor(t *testing.T) {
descriptor := Chromium{}.Descriptor() descriptor := new(Chromium).Descriptor()
actual := reflect.TypeOf(descriptor.New()) actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Chromium)) expect := reflect.TypeOf(new(Chromium))
@@ -73,11 +35,13 @@ func TestChromium_Descriptor(t *testing.T) {
} }
func TestChromium_Provision(t *testing.T) { func TestChromium_Provision(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
ctx *gotenberg.Context scenario string
expectErr bool ctx *gotenberg.Context
expectError bool
}{ }{
{ {
scenario: "no logger provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
return gotenberg.NewContext( return gotenberg.NewContext(
gotenberg.ParsedFlags{ gotenberg.ParsedFlags{
@@ -86,15 +50,19 @@ func TestChromium_Provision(t *testing.T) {
[]gotenberg.ModuleDescriptor{}, []gotenberg.ModuleDescriptor{},
) )
}(), }(),
expectErr: true, expectError: true,
}, },
{ {
scenario: "no logger from logger provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod := struct{ ProtoPDFEngineProvider }{} mod := &struct {
mod.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
} }
mod.pdfEngine = func() (gotenberg.PDFEngine, error) { mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
} }
@@ -107,16 +75,78 @@ func TestChromium_Provision(t *testing.T) {
}, },
) )
}(), }(),
expectErr: true, expectError: true,
}, },
{ {
scenario: "no PDF engine provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod := struct{ ProtoPDFEngineProvider }{} mod := &struct {
mod.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }} return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
} }
mod.pdfEngine = func() (gotenberg.PDFEngine, error) { mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return struct{ ProtoPDFEngine }{}, nil return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return new(gotenberg.PdfEngineMock), nil
} }
return gotenberg.NewContext( return gotenberg.NewContext(
@@ -130,62 +160,287 @@ func TestChromium_Provision(t *testing.T) {
}(), }(),
}, },
} { } {
mod := new(Chromium) t.Run(tc.scenario, func(t *testing.T) {
err := mod.Provision(tc.ctx) mod := new(Chromium)
err := mod.Provision(tc.ctx)
if tc.expectErr && err == nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if !tc.expectErr && err != nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatal("expected error but got none")
} }
})
} }
} }
func TestChromium_Validate(t *testing.T) { func TestChromium_Validate(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
binPath string scenario string
expectErr bool binPath string
expectError bool
}{ }{
{ {
expectErr: true, scenario: "empty bin path",
binPath: "",
expectError: true,
}, },
{ {
binPath: "/foo", scenario: "bin path does not exist",
expectErr: true, binPath: "/foo",
expectError: true,
}, },
{ {
binPath: os.Getenv("CHROMIUM_BIN_PATH"), scenario: "validate success",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
expectError: false,
}, },
} { } {
mod := new(Chromium) t.Run(tc.scenario, func(t *testing.T) {
mod.binPath = tc.binPath mod := new(Chromium)
err := mod.Validate() mod.args = browserArguments{
binPath: tc.binPath,
}
err := mod.Validate()
if tc.expectErr && err == nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if !tc.expectErr && err != nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatal("expected error but got none")
} }
})
}
}
func TestChromium_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
expectError: false,
},
{
scenario: "auto-start success",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "auto-start failed",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.supervisor = tc.supervisor
err := mod.Start()
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 TestChromium_StartupMessage(t *testing.T) {
mod := new(Chromium)
mod.autoStart = true
autoStartMsg := mod.StartupMessage()
mod.autoStart = false
noAutoStartMsg := mod.StartupMessage()
if autoStartMsg == noAutoStartMsg {
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
}
}
func TestChromium_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "stop success",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "stop failed",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.logger = zap.NewNop()
mod.supervisor = tc.supervisor
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
cancel()
err := mod.Stop(ctx)
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 TestChromium_Metrics(t *testing.T) { func TestChromium_Metrics(t *testing.T) {
metrics, err := new(Chromium).Metrics() mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{
ReqQueueSizeMock: func() int64 {
return 10
},
RestartsCountMock: func() int64 {
return 0
},
}
metrics, err := mod.Metrics()
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
if len(metrics) != 1 { if len(metrics) != 2 {
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics)) t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
} }
actual := metrics[0].Read() actual := metrics[0].Read()
if actual != 0 { if actual != float64(10) {
t.Errorf("expected %d Chromium instances, but got %f", 0, actual) t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual)
}
actual = metrics[1].Read()
if actual != float64(0) {
t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual)
}
}
func TestChromium_Checks(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
expectAvailabilityStatus health.AvailabilityStatus
}{
{
scenario: "healthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return true
}},
expectAvailabilityStatus: health.StatusUp,
},
{
scenario: "unhealthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return false
}},
expectAvailabilityStatus: health.StatusDown,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = tc.supervisor
checks, err := mod.Checks()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
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)
}
})
}
}
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
browser browser
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout}
mod.browser = tc.browser
err := mod.Ready()
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")
}
})
} }
} }
@@ -199,293 +454,118 @@ func TestChromium_Chromium(t *testing.T) {
} }
func TestChromium_Routes(t *testing.T) { func TestChromium_Routes(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
expectRoutes int expectRoutes int
disableRoutes bool disableRoutes bool
}{ }{
{ {
expectRoutes: 3, scenario: "routes not disabled",
expectRoutes: 6,
disableRoutes: false,
}, },
{ {
scenario: "routes disabled",
expectRoutes: 0,
disableRoutes: true, disableRoutes: true,
}, },
} { } {
mod := new(Chromium) t.Run(tc.scenario, func(t *testing.T) {
mod.disableRoutes = tc.disableRoutes mod := new(Chromium)
mod.disableRoutes = tc.disableRoutes
routes, err := mod.Routes() routes, err := mod.Routes()
if err != nil { if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if tc.expectRoutes != len(routes) { if tc.expectRoutes != len(routes) {
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes)) t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
} }
})
} }
} }
func TestChromium_PDF(t *testing.T) { func TestChromium_Pdf(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
timeout time.Duration scenario string
cancel context.CancelFunc supervisor gotenberg.ProcessSupervisor
URL string browser browser
options Options expectError bool
userAgent string
incognito bool
ignoreCertificateErrors bool
disableWebSecurity bool
allowFileAccessFromFiles bool
proxyServer string
allowList *regexp.Regexp
denyList *regexp.Regexp
disableJavaScript bool
expectErr bool
}{ }{
{ {
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html", scenario: "PDF task success",
allowList: regexp.MustCompile("file:///tmp/*"), browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
expectErr: true, return nil
}},
expectError: false,
}, },
{ {
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html", scenario: "PDF task error",
denyList: regexp.MustCompile("file:///tests/*"), browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
expectErr: true, return errors.New("PDF task error")
}, }},
{ expectError: true,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
UserAgent: "foo",
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample10/index.html",
options: Options{
FailOnConsoleExceptions: true,
},
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample9/index.html",
disableJavaScript: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
ExtraHTTPHeaders: map[string]string{
"foo": "bar",
},
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraLinkTags: []LinkTag{
{
Href: "font.woff",
},
{
Href: "style.css",
},
},
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "foo",
},
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "screen",
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "print",
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraScriptTags: []ScriptTag{
{
Src: "script.js",
},
},
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitDelay: time.Duration(1) * time.Nanosecond,
},
},
{
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitWindowStatus: "foo",
},
expectErr: true,
},
{
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitWindowStatus: "ready",
},
},
{
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'foo'",
},
expectErr: true,
},
{
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'ready'",
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitForExpression: "return undefined",
},
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
MarginBottom: 100,
},
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
PageRanges: "foo",
},
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
userAgent: "foo",
incognito: true,
ignoreCertificateErrors: true,
disableWebSecurity: true,
allowFileAccessFromFiles: true,
proxyServer: "foo",
},
{
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
},
{
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
allowList: regexp.MustCompile("file:///tests/*"),
},
{
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
denyList: regexp.MustCompile("file:///etc/*"),
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: func() string {
b, err := ioutil.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 := ioutil.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample5/index.html",
},
{
URL: "file:///tests/test/testdata/chromium/html/sample6/index.html",
allowFileAccessFromFiles: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample7/index.html",
}, },
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium) mod := new(Chromium)
mod.binPath = os.Getenv("CHROMIUM_BIN_PATH") mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
mod.userAgent = tc.userAgent return task()
mod.incognito = tc.incognito }}
mod.ignoreCertificateErrors = tc.ignoreCertificateErrors mod.browser = tc.browser
mod.disableWebSecurity = tc.disableWebSecurity
mod.allowFileAccessFromFiles = tc.allowFileAccessFromFiles
mod.proxyServer = tc.proxyServer
if tc.allowList == nil { err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
tc.allowList = regexp.MustCompile("")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
} }
if tc.denyList == nil { if tc.expectError && err == nil {
tc.denyList = regexp.MustCompile("") t.Fatal("expected error but got none")
} }
})
mod.allowList = tc.allowList
mod.denyList = tc.denyList
mod.disableJavaScript = tc.disableJavaScript
outputDir, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("test %d: expected error but got: %v", i, err)
}
defer func() {
err := os.RemoveAll(outputDir)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, 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 %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. func TestChromium_Screenshot(t *testing.T) {
var ( for _, tc := range []struct {
_ gotenberg.Module = (*ProtoModule)(nil) scenario string
_ API = (*ProtoAPI)(nil) supervisor gotenberg.ProcessSupervisor
_ gotenberg.PDFEngineProvider = (*ProtoPDFEngineProvider)(nil) browser browser
_ gotenberg.Module = (*ProtoPDFEngineProvider)(nil) expectError bool
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil) }{
) {
scenario: "Screenshot task success",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
}},
expectError: false,
},
{
scenario: "Screenshot task error",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return errors.New("screenshot task error")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
mod.browser = tc.browser
err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
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")
}
})
}
}

View File

@@ -7,21 +7,21 @@ import (
"go.uber.org/zap" "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. // Chromium.
type debugLogger struct { type debugLogger struct {
logger *zap.Logger logger *zap.Logger
} }
// Write logs the bytes in a debug message. // 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)) debug.logger.Debug(string(p))
return len(p), nil return len(p), nil
} }
// Printf logs a debug message. // 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...)) debug.logger.Debug(fmt.Sprintf(format, v...))
} }

View File

@@ -7,7 +7,7 @@ import (
) )
func TestDebugLogger_Write(t *testing.T) { func TestDebugLogger_Write(t *testing.T) {
actual, err := debugLogger{logger: zap.NewNop()}.Write([]byte("foo")) actual, err := (&debugLogger{logger: zap.NewNop()}).Write([]byte("foo"))
expected := len([]byte("foo")) expected := len([]byte("foo"))
if actual != expected { if actual != expected {
@@ -20,5 +20,5 @@ func TestDebugLogger_Write(t *testing.T) {
} }
func TestDebugLogger_Printf(t *testing.T) { func TestDebugLogger_Printf(t *testing.T) {
debugLogger{logger: zap.NewNop()}.Printf("%s", "foo") (&debugLogger{logger: zap.NewNop()}).Printf("%s", "foo")
} }

View File

@@ -1,4 +1,4 @@
// Package chromium provides a module which adds routes for converting HTML // 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. // module.
package chromium package chromium

View File

@@ -3,7 +3,7 @@ package chromium
import ( import (
"context" "context"
"fmt" "fmt"
"regexp" "slices"
"sync" "sync"
"github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/cdp"
@@ -12,14 +12,17 @@ import (
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// listenForEventRequestPaused listens for requests to check if they are // listenForEventRequestPaused listens for requests to check if they are
// allowed or not. // allowed or not.
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) { func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) {
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) { switch e := ev.(type) {
case *fetch.EventRequestPaused: case *fetch.EventRequestPaused:
@@ -27,13 +30,15 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL)) logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
allow := true allow := true
if !allowList.MatchString(e.Request.URL) { deadline, ok := ctx.Deadline()
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL)) if !ok {
allow = false logger.Error("context has no deadline, cannot filter URL")
return
} }
if denyList.String() != "" && denyList.MatchString(e.Request.URL) { err := gotenberg.FilterDeadline(allowList, denyList, e.Request.URL, deadline)
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL)) if err != nil {
logger.Warn(err.Error())
allow = false allow = false
} }
@@ -42,18 +47,15 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
if allow { if allow {
req := fetch.ContinueRequest(e.RequestID) req := fetch.ContinueRequest(e.RequestID)
err := req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("continue request: %s", err)) logger.Error(fmt.Sprintf("continue request: %s", err))
} }
return return
} }
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err := req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err)) logger.Error(fmt.Sprintf("fail request: %s", err))
} }
@@ -62,6 +64,37 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
}) })
} }
// listenForEventResponseReceived listens for an invalid HTTP status code is
// returned by the main page.
// See https://github.com/gotenberg/gotenberg/issues/613.
func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url string, failOnHttpStatusCodes []int64, invalidHttpStatusCode *error, invalidHttpStatusCodeMu *sync.RWMutex) {
for _, code := range []int64{199, 299, 399, 499, 599} {
if slices.Contains(failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ {
failOnHttpStatusCodes = append(failOnHttpStatusCodes, i)
}
}
}
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventResponseReceived:
if ev.Response.URL != url {
return
}
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
if slices.Contains(failOnHttpStatusCodes, ev.Response.Status) {
invalidHttpStatusCodeMu.Lock()
defer invalidHttpStatusCodeMu.Unlock()
*invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
}
}
})
}
// listenForEventExceptionThrown listens for exceptions in the console and // listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer. // appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262. // See https://github.com/gotenberg/gotenberg/issues/262.
@@ -181,7 +214,6 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
// completed or an error is encountered. // completed or an error is encountered.
func runBatch(ctx context.Context, fn ...func() error) error { func runBatch(ctx context.Context, fn ...func() error) error {
eg, _ := errgroup.WithContext(ctx) eg, _ := errgroup.WithContext(ctx)
for _, f := range fn { for _, f := range fn {
eg.Go(f) 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

@@ -0,0 +1,50 @@
package chromium
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestApiMock(t *testing.T) {
mock := &ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
},
ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
},
}
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if err != nil {
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
}
err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if err != nil {
t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err)
}
}
func TestBrowserMock(t *testing.T) {
mock := &browserMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
},
screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
},
}
err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if err != nil {
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
}
err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if err != nil {
t.Errorf("expected no error from browserMock.screenshot, but got: %v", err)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
package chromium
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"strings"
"github.com/chromedp/cdproto/cdp"
cdprotoio "github.com/chromedp/cdproto/io"
)
// Credits: https://raw.githubusercontent.com/mafredri/cdp/3c5eab7ffc5cbee667b0a813ce470ac423792811/protocol/io/stream_reader.go.
type streamReader struct {
ctx context.Context
handle cdprotoio.StreamHandle
r io.Reader
pos int
eof bool
}
// Read a chunk of the stream.
func (reader *streamReader) Read(p []byte) (n int, err error) {
if reader.r != nil {
// Continue reading from buffer.
return reader.read(p)
}
if reader.eof {
return 0, io.EOF
}
if len(p) == 0 {
return 0, nil
}
// Chromium might have an off-by-one when deciding the maximum size (at
// least for base64 encoded data), usually it will overflow. We subtract
// one to make sure it fits into p.
size := len(p) - 1
if size < 1 {
// Safety-check to avoid crashing Chrome (e.g. via SetSize(-1)).
size = 1
}
reply, err := reader.next(reader.pos, size)
if err != nil {
return 0, err
}
reader.eof = reply.EOF
switch {
case reply.Base64encoded:
b := []byte(reply.Data)
size := base64.StdEncoding.DecodedLen(len(b))
// Safety-check for fast-path to avoid panics.
if len(p) >= size {
n, err = base64.StdEncoding.Decode(p, b)
reader.pos += n
return n, err
}
reader.r = base64.NewDecoder(base64.StdEncoding, bytes.NewReader(b))
default:
reader.r = strings.NewReader(reply.Data)
}
return reader.read(p)
}
// Close closes the stream, discard any temporary backing storage.
func (reader *streamReader) Close() error {
err := cdprotoio.Close(reader.handle).Do(reader.ctx)
if err == nil {
return nil
}
return fmt.Errorf("close Chromium stream: %w", err)
}
func (reader *streamReader) next(pos, size int) (cdprotoio.ReadReturns, error) {
params := cdprotoio.
Read(reader.handle).
WithOffset(int64(pos)).
WithSize(int64(size))
var res cdprotoio.ReadReturns
err := cdp.Execute(reader.ctx, cdprotoio.CommandRead, params, &res)
if err == nil {
return res, nil
}
return res, fmt.Errorf("execute IO.read command: %w", err)
}
func (reader *streamReader) read(p []byte) (n int, err error) {
n, err = reader.r.Read(p)
reader.pos += n
if !reader.eof && err == io.EOF {
reader.r = nil
err = nil
}
return n, err
}
// Interface guards.
var (
_ io.Reader = (*streamReader)(nil)
)

View File

@@ -0,0 +1,417 @@
package chromium
import (
"bufio"
"context"
"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).
// 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.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 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 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,143 @@
package exiftool
import (
"context"
"errors"
"fmt"
"os"
"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
}
// 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)
}
// 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 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: %w", 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.PdfEngine = (*ExifTool)(nil)
)

View File

@@ -0,0 +1,322 @@
package exiftool
import (
"context"
"errors"
"fmt"
"io"
"os"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestExifTool_Descriptor(t *testing.T) {
descriptor := new(ExifTool).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(ExifTool))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestExifTool_Provision(t *testing.T) {
engine := new(ExifTool)
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
err := engine.Provision(ctx)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestExifTool_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("EXIFTOOL_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(ExifTool)
engine.binPath = tc.binPath
err := engine.Validate()
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 TestExiftool_Merge(t *testing.T) {
engine := new(ExifTool)
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Convert(t *testing.T) {
engine := new(ExifTool)
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_ReadMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
inputPath string
expectMetadata map[string]interface{}
expectError bool
}{
{
scenario: "invalid input path",
inputPath: "foo",
expectMetadata: nil,
expectError: true,
},
{
scenario: "success",
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectMetadata: map[string]interface{}{
"FileName": "sample1.pdf",
"FileTypeExtension": "pdf",
"MIMEType": "application/pdf",
"PDFVersion": 1.4,
"PageCount": float64(3),
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(ExifTool)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), tc.inputPath)
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.expectMetadata != nil && err == nil {
for k, v := range tc.expectMetadata {
if v2, ok := metadata[k]; !ok || v != v2 {
t.Errorf("expected entry %s with value %v to exists", k, v)
}
}
}
})
}
}
func TestExiftool_WriteMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
createCopy bool
inputPath string
metadata map[string]interface{}
expectMetadata map[string]interface{}
expectError bool
expectedError error
}{
{
scenario: "invalid input path",
createCopy: false,
inputPath: "foo",
expectError: true,
},
{
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Unsupported": map[string]interface{}{},
},
expectError: true,
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
},
{
scenario: "success",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreationDate": "2006-09-18T16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": []string{
"first",
"second",
},
"Marked": true,
"ModDate": "2006-09-18T16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown",
// Those are not valid PDF metadata.
"int": 1,
"int64": int64(2),
"float32": float32(2.2),
"float64": 3.3,
},
expectMetadata: map[string]interface{}{
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreationDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": []interface{}{
"first",
"second",
},
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
"Title": "Sample",
"Trapped": "Unknown",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(ExifTool)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var destinationPath string
if tc.createCopy {
fs := gotenberg.NewFileSystem()
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error no but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
source, err := os.Open(tc.inputPath)
if err != nil {
t.Fatalf("open source file: %v", err)
}
defer func(source *os.File) {
err := source.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(source)
destination, err := os.Create(destinationPath)
if err != nil {
t.Fatalf("create destination file: %v", err)
}
defer func(destination *os.File) {
err := destination.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(destination)
_, err = io.Copy(destination, source)
if err != nil {
t.Fatalf("copy source into destination: %v", err)
}
} else {
destinationPath = tc.inputPath
}
err = engine.WriteMetadata(context.Background(), zap.NewNop(), tc.metadata, destinationPath)
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)
}
if tc.expectError {
return
}
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectMetadata != nil && err == nil {
for k, v := range tc.expectMetadata {
v2, ok := metadata[k]
if !ok {
t.Errorf("expected entry %s with value %v to exists, but got none", k, v)
continue
}
switch v2.(type) {
case []interface{}:
for i, entry := range v.([]interface{}) {
if entry != v2.([]interface{})[i] {
t.Errorf("expected entry %s to contain value %v, but got %v", k, entry, v2.([]interface{})[i])
}
}
default:
if v != v2 {
t.Errorf("expected entry %s with value %v to exists, but got %v", k, v, v2)
}
}
}
}
})
}
}

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,416 @@
package api
import (
"context"
"errors"
"fmt"
"os"
"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")
// ErrMalformedPageRanges happens if the page ranges option cannot be
// interpreted by LibreOffice.
ErrMalformedPageRanges = errors.New("page ranges are malformed")
)
// 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.
type Options struct {
// Landscape allows to change the orientation of the resulting PDF.
// Optional.
Landscape bool
// PageRanges allows to select the pages to convert.
// Optional.
PageRanges string
// ExportFormFields allows to... export form fields in the resulting PDF.
// Optional.
ExportFormFields bool
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA.
// Optional.
PdfFormats gotenberg.PdfFormats
}
// 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)
}
// 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 {
return a.supervisor.Run(ctx, logger, func() error {
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
}
// 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.MetricsProvider = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil)
_ Provider = (*Api)(nil)
)

View File

@@ -0,0 +1,458 @@
package api
import (
"context"
"errors"
"os"
"reflect"
"testing"
"time"
"github.com/alexliesenfeld/health"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestApi_Descriptor(t *testing.T) {
descriptor := new(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 _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
scenario: "no logger provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectError: true,
},
{
scenario: "no logger from logger provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
err := a.Provision(tc.ctx)
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 TestApi_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
unoBinPath string
expectError bool
}{
{
scenario: "empty LibreOffice bin path",
binPath: "",
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
expectError: true,
},
{
scenario: "LibreOffice bin path does not exist",
binPath: "/foo",
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
expectError: true,
},
{
scenario: "empty uno bin path",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
unoBinPath: "",
expectError: true,
},
{
scenario: "uno bin path does not exist",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
unoBinPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.args = libreOfficeArguments{
binPath: tc.binPath,
unoBinPath: tc.unoBinPath,
}
err := a.Validate()
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 TestApi_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
expectError: false,
},
{
scenario: "auto-start success",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "auto-start failed",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.autoStart = tc.autoStart
a.supervisor = tc.supervisor
err := a.Start()
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 TestApi_StartupMessage(t *testing.T) {
a := new(Api)
a.autoStart = true
autoStartMsg := a.StartupMessage()
a.autoStart = false
noAutoStartMsg := a.StartupMessage()
if autoStartMsg == noAutoStartMsg {
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
}
}
func TestApi_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "stop success",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "stop failed",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.logger = zap.NewNop()
a.supervisor = tc.supervisor
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
cancel()
err := a.Stop(ctx)
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 TestApi_Metrics(t *testing.T) {
a := new(Api)
a.supervisor = &gotenberg.ProcessSupervisorMock{
ReqQueueSizeMock: func() int64 {
return 10
},
RestartsCountMock: func() int64 {
return 0
},
}
metrics, err := a.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 != float64(10) {
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
}
actual = metrics[1].Read()
if actual != float64(0) {
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
}
}
func TestApi_Checks(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
expectAvailabilityStatus health.AvailabilityStatus
}{
{
scenario: "healthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return true
}},
expectAvailabilityStatus: health.StatusUp,
},
{
scenario: "unhealthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return false
}},
expectAvailabilityStatus: health.StatusDown,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.supervisor = tc.supervisor
checks, err := a.Checks()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
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)
}
})
}
}
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
libreOffice libreOffice
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.autoStart = tc.autoStart
a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
a.libreOffice = tc.libreOffice
err := a.Ready()
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 TestApi_LibreOffice(t *testing.T) {
a := new(Api)
_, err := a.LibreOffice()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestApi_Pdf(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
libreOffice libreOffice
expectError bool
}{
{
scenario: "PDF task success",
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return nil
}},
expectError: false,
},
{
scenario: "PDF task error",
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return errors.New("PDF task error")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
a.libreOffice = tc.libreOffice
err := a.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
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 TestApi_Extensions(t *testing.T) {
a := new(Api)
extensions := a.Extensions()
actual := len(extensions)
expect := 130
if actual != expect {
t.Errorf("expected %d extensions, but got %d", expect, actual)
}
}

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 unoconv package api
import ( import (
"fmt" "fmt"
@@ -9,18 +9,18 @@ import (
) )
func freePort(logger *zap.Logger) (int, error) { func freePort(logger *zap.Logger) (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0") netListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
return 0, fmt.Errorf("listen on the local network address: %w", err) return 0, fmt.Errorf("listen on the local network address: %w", err)
} }
defer func() { defer func() {
err := listener.Close() err := netListener.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close listener: %s", err.Error())) logger.Error(fmt.Sprintf("close network listener: %s", err.Error()))
} }
}() }()
addr := listener.Addr().String() addr := netListener.Addr().String()
_, portStr, err := net.SplitHostPort(addr) _, portStr, err := net.SplitHostPort(addr)
if err != nil { if err != nil {

View File

@@ -0,0 +1,405 @@
package api
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"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(),
}
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
defer func(userProfileDirPath string) {
go func() {
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))
// Also remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
if err != nil {
logger.Error(err.Error())
}
}()
}(copyUserProfileDirPath)
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.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape")
}
if options.PageRanges != "" {
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
}
if !options.ExportFormFields {
args = append(args, "--export", "ExportFormFields=false")
}
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", "UseTaggedPDF=true",
"--export", "EnableTextAccessForAccessibilityTools=true",
)
} else {
args = append(
args,
"--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.
// That's why we have to make an educated guess according to the exit code
// and given inputs.
if exitCode == 5 && options.PageRanges != "" {
return ErrMalformedPageRanges
}
// Possible errors:
// 1. LibreOffice failed for some reason.
// 2. Context done.
//
// On the second scenario, LibreOffice might not have time to remove some
// of its temporary files, as it has been killed without warning. The
// garbage collector will delete them for us (if the module is loaded).
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,679 @@
package api
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestLibreOfficeProcess_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
expectError bool
cleanup bool
}{
{
scenario: "successful start",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
expectError: false,
cleanup: true,
},
{
scenario: "LibreOffice already started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(true)
return p
}(),
expectError: true,
cleanup: false,
},
{
scenario: "non-exit code 81 on first start",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: "foo",
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
expectError: true,
cleanup: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
err := tc.libreOffice.Start(logger)
if tc.cleanup {
defer func(p libreOffice, logger *zap.Logger) {
err = p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
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 TestLibreOfficeProcess_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
setup func(libreOffice libreOffice, logger *zap.Logger) error
expectError bool
}{
{
scenario: "successful stop",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
setup: func(p libreOffice, logger *zap.Logger) error {
return p.Start(logger)
},
expectError: false,
},
{
scenario: "LibreOffice already stopped",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
if tc.setup != nil {
err := tc.setup(tc.libreOffice, logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
}
err := tc.libreOffice.Stop(logger)
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 TestLibreOfficeProcess_Healthy(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
setup func(libreOffice libreOffice, logger *zap.Logger) error
expectHealthy bool
cleanup bool
}{
{
scenario: "healthy LibreOffice",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
setup: func(p libreOffice, logger *zap.Logger) error {
return p.Start(logger)
},
expectHealthy: true,
cleanup: true,
},
{
scenario: "LibreOffice not started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
expectHealthy: false,
cleanup: false,
},
{
scenario: "unhealthy LibreOffice",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(true)
p.socketPort = 12345
return p
}(),
expectHealthy: false,
cleanup: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
if tc.setup != nil {
err := tc.setup(tc.libreOffice, logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
}
if tc.cleanup {
defer func(p libreOffice, logger *zap.Logger) {
err := p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
healthy := tc.libreOffice.Healthy(logger)
if !tc.expectHealthy && healthy {
t.Fatal("expected unhealthy LibreOffice but got an healthy one")
}
if tc.expectHealthy && !healthy {
t.Fatal("expected a healthy LibreOffice but got an unhealthy one")
}
})
}
}
func TestLibreOfficeProcess_pdf(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
fs *gotenberg.FileSystem
options Options
cancelledCtx bool
start bool
expectError bool
expectedError error
}{
{
scenario: "LibreOffice not started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
fs: gotenberg.NewFileSystem(),
cancelledCtx: false,
start: false,
expectError: true,
},
{
scenario: "ErrInvalidPdfFormats",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.socketPort = 12345
p.isStarted.Store(true)
return p
}(),
fs: gotenberg.NewFileSystem(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
cancelledCtx: false,
start: false,
expectError: true,
expectedError: ErrInvalidPdfFormats,
},
{
scenario: "ErrMalformedPageRanges",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
options: Options{PageRanges: "foo"},
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: true,
expectedError: ErrMalformedPageRanges,
},
{
scenario: "context done",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: true,
start: true,
expectError: true,
},
{
scenario: "success (default options)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (landscape)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{Landscape: true},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (disable form fields)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("DisableFormFields"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{ExportFormFields: false},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (page ranges)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PageRanges: "1-1"},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-1b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-2b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA2b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-3b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA3b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/UA)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfUa: true}},
cancelledCtx: false,
start: true,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
// Force the debug level.
logger := zap.NewExample()
defer func() {
err := os.RemoveAll(tc.fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}()
if tc.start {
err := tc.libreOffice.Start(logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
defer func(p libreOffice, logger *zap.Logger) {
err = p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
defer cancel()
if tc.cancelledCtx {
cancel()
}
err := tc.libreOffice.pdf(
ctx,
logger,
fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options,
)
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 TestNonBasicLatinCharactersGuard(t *testing.T) {
for _, tc := range []struct {
scenario string
fs *gotenberg.FileSystem
filename string
expectSameInputPath bool
expectError bool
}{
{
scenario: "basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "document.txt",
expectSameInputPath: true,
expectError: false,
},
{
scenario: "non-basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "éèßàùä.txt",
expectSameInputPath: false,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}()
inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
newInputPath, err := nonBasicLatinCharactersGuard(
zap.NewNop(),
inputPath,
)
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.expectSameInputPath && newInputPath != inputPath {
t.Fatalf("expected same input path, but got '%s'", newInputPath)
}
if !tc.expectSameInputPath && newInputPath == inputPath {
t.Fatalf("expected different input path, but got same '%s'", newInputPath)
}
})
}
}

View File

@@ -0,0 +1,49 @@
package api
import (
"context"
"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 {
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 {
return b.pdfMock(ctx, logger, inputPath, outputPath, options)
}
// Interface guards.
var (
_ Uno = (*ApiMock)(nil)
_ Provider = (*ProviderMock)(nil)
_ libreOffice = (*libreOfficeMock)(nil)
)

View File

@@ -0,0 +1,55 @@
package api
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestApiMock(t *testing.T) {
mock := &ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return nil
},
ExtensionsMock: func() []string {
return nil
},
}
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
if err != nil {
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
}
ext := mock.Extensions()
if ext != nil {
t.Errorf("expected nil result from ApiMock.Extensions, but got: %v", ext)
}
}
func TestProviderMock(t *testing.T) {
mock := &ProviderMock{
LibreOfficeMock: func() (Uno, error) {
return nil, nil
},
}
_, err := mock.LibreOffice()
if err != nil {
t.Errorf("expected no error from ProviderMock.LibreOffice, but got: %v", err)
}
}
func TestLibreOfficeMock(t *testing.T) {
mock := &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return nil
},
}
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
if err != nil {
t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err)
}
}

View File

@@ -1,3 +1,3 @@
// Package libreoffice provides a module which adds a route for converting // Package libreoffice provides a module which adds a route for converting
// document to PDF with LibreOffice. // documents to PDF with LibreOffice.
package libreoffice package libreoffice

View File

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

View File

@@ -1,70 +1,16 @@
package libreoffice package libreoffice
import ( import (
"context"
"errors" "errors"
"reflect" "reflect"
"testing" "testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv" libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"go.uber.org/zap"
) )
type ProtoModule struct {
descriptor func() gotenberg.ModuleDescriptor
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
type ProtoUnoconvProvider struct {
ProtoModule
unoconv func() (unoconv.API, error)
}
func (mod ProtoUnoconvProvider) Unoconv() (unoconv.API, error) {
return mod.unoconv()
}
type ProtoUnoconvAPI struct {
pdf func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error
extensions func() []string
}
func (mod ProtoUnoconvAPI) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error {
return mod.pdf(ctx, logger, inputPath, outputPath, options)
}
func (mod ProtoUnoconvAPI) Extensions() []string {
return mod.extensions()
}
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 TestLibreOffice_Descriptor(t *testing.T) { func TestLibreOffice_Descriptor(t *testing.T) {
descriptor := LibreOffice{}.Descriptor() descriptor := new(LibreOffice).Descriptor()
actual := reflect.TypeOf(descriptor.New()) actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(LibreOffice)) expect := reflect.TypeOf(new(LibreOffice))
@@ -75,35 +21,34 @@ func TestLibreOffice_Descriptor(t *testing.T) {
} }
func TestLibreOffice_Provision(t *testing.T) { func TestLibreOffice_Provision(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
ctx *gotenberg.Context scenario string
expectErr bool ctx *gotenberg.Context
expectError bool
}{ }{
{ {
scenario: "no LibreOffice API provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod := struct{ ProtoModule }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
return gotenberg.NewContext( return gotenberg.NewContext(
gotenberg.ParsedFlags{ gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet, FlagSet: new(LibreOffice).Descriptor().FlagSet,
}, },
[]gotenberg.ModuleDescriptor{ []gotenberg.ModuleDescriptor{},
mod.Descriptor(),
},
) )
}(), }(),
expectErr: true, expectError: true,
}, },
{ {
scenario: "no LibreOffice API from LibreOffice API provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{} mod := &struct {
mod.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} libreofficeapi.ProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
} }
mod.unoconv = func() (unoconv.API, error) { mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
} }
@@ -116,16 +61,20 @@ func TestLibreOffice_Provision(t *testing.T) {
}, },
) )
}(), }(),
expectErr: true, expectError: true,
}, },
{ {
scenario: "no PDF engine provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{} mod := &struct {
mod.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} libreofficeapi.ProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
} }
mod.unoconv = func() (unoconv.API, error) { mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return struct{ ProtoUnoconvAPI }{}, nil return new(libreofficeapi.ApiMock), nil
} }
return gotenberg.NewContext( return gotenberg.NewContext(
@@ -137,23 +86,23 @@ func TestLibreOffice_Provision(t *testing.T) {
}, },
) )
}(), }(),
expectErr: true, expectError: true,
}, },
{ {
scenario: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod1 := struct{ ProtoUnoconvProvider }{} mod := &struct {
mod1.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }} libreofficeapi.ProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
} }
mod1.unoconv = func() (unoconv.API, error) { mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return struct{ ProtoUnoconvAPI }{}, nil return new(libreofficeapi.ApiMock), nil
} }
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
mod2 := struct{ ProtoPDFEngineProvider }{}
mod2.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
}
mod2.pdfEngine = func() (gotenberg.PDFEngine, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
} }
@@ -162,29 +111,28 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet, FlagSet: new(LibreOffice).Descriptor().FlagSet,
}, },
[]gotenberg.ModuleDescriptor{ []gotenberg.ModuleDescriptor{
mod1.Descriptor(), mod.Descriptor(),
mod2.Descriptor(),
}, },
) )
}(), }(),
expectErr: true, expectError: true,
}, },
{ {
scenario: "provision success",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod1 := struct{ ProtoUnoconvProvider }{} mod := &struct {
mod1.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }} libreofficeapi.ProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
} }
mod1.unoconv = func() (unoconv.API, error) { mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return struct{ ProtoUnoconvAPI }{}, nil return new(libreofficeapi.ApiMock), nil
} }
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
mod2 := struct{ ProtoPDFEngineProvider }{} return new(gotenberg.PdfEngineMock), nil
mod2.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
}
mod2.pdfEngine = func() (gotenberg.PDFEngine, error) {
return struct{ ProtoPDFEngine }{}, nil
} }
return gotenberg.NewContext( return gotenberg.NewContext(
@@ -192,59 +140,57 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet, FlagSet: new(LibreOffice).Descriptor().FlagSet,
}, },
[]gotenberg.ModuleDescriptor{ []gotenberg.ModuleDescriptor{
mod1.Descriptor(), mod.Descriptor(),
mod2.Descriptor(),
}, },
) )
}(), }(),
expectError: false,
}, },
} { } {
mod := new(LibreOffice) t.Run(tc.scenario, func(t *testing.T) {
err := mod.Provision(tc.ctx) mod := new(LibreOffice)
err := mod.Provision(tc.ctx)
if tc.expectErr && err == nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if !tc.expectErr && err != nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatal("expected error but got none")
} }
})
} }
} }
func TestLibreOffice_Routes(t *testing.T) { func TestLibreOffice_Routes(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
scenario string
expectRoutes int expectRoutes int
disableRoutes bool disableRoutes bool
}{ }{
{ {
expectRoutes: 1, scenario: "routes not disabled",
expectRoutes: 1,
disableRoutes: false,
}, },
{ {
scenario: "routes disabled",
expectRoutes: 0,
disableRoutes: true, disableRoutes: true,
}, },
} { } {
mod := new(LibreOffice) t.Run(tc.scenario, func(t *testing.T) {
mod.disableRoutes = tc.disableRoutes mod := new(LibreOffice)
mod.disableRoutes = tc.disableRoutes
routes, err := mod.Routes() routes, err := mod.Routes()
if err != nil { if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if tc.expectRoutes != len(routes) { if tc.expectRoutes != len(routes) {
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes)) t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
} }
})
} }
} }
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ unoconv.Provider = (*ProtoUnoconvProvider)(nil)
_ gotenberg.Module = (*ProtoUnoconvProvider)(nil)
_ unoconv.API = (*ProtoUnoconvAPI)(nil)
_ gotenberg.PDFEngineProvider = (*ProtoPDFEngineProvider)(nil)
_ gotenberg.Module = (*ProtoPDFEngineProvider)(nil)
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil)
)

View File

@@ -1,3 +1,6 @@
// Package pdfengine provides a module which abstracts the CLI tool unoconv and // Package pdfengine provides a module which interacts with LibreOffice via the
// implements the gotenberg.PDFEngine interface. // UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
// interface. This package allows for:
//
// 1. The conversion to specific PDF formats.
package pdfengine package pdfengine

View File

@@ -2,75 +2,88 @@ package pdfengine
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
) )
func init() { func init() {
gotenberg.MustRegisterModule(UnoconvPDFEngine{}) gotenberg.MustRegisterModule(new(LibreOfficePdfEngine))
} }
// UnoconvPDFEngine abstracts the CLI tool unoconv and implements the // LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API
// gotenberg.PDFEngine interface. // and implements the [gotenberg.PdfEngine] interface.
type UnoconvPDFEngine struct { type LibreOfficePdfEngine struct {
unoconv unoconv.API unoApi api.Uno
} }
// Descriptor returns a UnoconvPDFEngine's module descriptor. // Descriptor returns a [LibreOfficePdfEngine]'s module descriptor.
func (UnoconvPDFEngine) Descriptor() gotenberg.ModuleDescriptor { func (engine *LibreOfficePdfEngine) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ return gotenberg.ModuleDescriptor{
ID: "unoconv-pdfengine", ID: "libreoffice-pdfengine",
New: func() gotenberg.Module { return new(UnoconvPDFEngine) }, New: func() gotenberg.Module { return new(LibreOfficePdfEngine) },
} }
} }
// Provision sets the module properties. // Provision sets the module properties.
func (engine *UnoconvPDFEngine) Provision(ctx *gotenberg.Context) error { func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error {
provider, err := ctx.Module(new(unoconv.Provider)) provider, err := ctx.Module(new(api.Provider))
if err != nil { if err != nil {
return fmt.Errorf("get unoconv provider: %w", err) return fmt.Errorf("get LibreOffice Uno provider: %w", err)
} }
uno, err := provider.(unoconv.Provider).Unoconv() unoApi, err := provider.(api.Provider).LibreOffice()
if err != nil { if err != nil {
return fmt.Errorf("get unoconv API: %w", err) return fmt.Errorf("get LibreOffice Uno: %w", err)
} }
engine.unoconv = uno engine.unoApi = unoApi
return nil return nil
} }
// Merge is not available for this PDF engine. // Merge is not available in this implementation.
func (engine UnoconvPDFEngine) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error { func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return fmt.Errorf("merge PDFs with unoconv: %w", gotenberg.ErrPDFEngineMethodNotAvailable) return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Convert converts the given PDF to a specific PDF format. Currently, only the // Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1 format is available. If another PDF format is requested, it returns // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// a gotenberg.ErrPDFFormatNotAvailable error. // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
func (engine UnoconvPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error { // error.
if format != gotenberg.FormatPDFA1a { func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable) err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, api.Options{
} PdfFormats: formats,
err := engine.unoconv.PDF(ctx, logger, inputPath, outputPath, unoconv.Options{
PDFArchive: true,
}) })
if err == nil { if err == nil {
return nil return nil
} }
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, err) if errors.Is(err, api.ErrInvalidPdfFormats) {
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported)
}
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err)
}
// ReadMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*UnoconvPDFEngine)(nil) _ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
_ gotenberg.Provisioner = (*UnoconvPDFEngine)(nil) _ gotenberg.Provisioner = (*LibreOfficePdfEngine)(nil)
_ gotenberg.PDFEngine = (*UnoconvPDFEngine)(nil) _ gotenberg.PdfEngine = (*LibreOfficePdfEngine)(nil)
) )

View File

@@ -6,192 +6,181 @@ import (
"reflect" "reflect"
"testing" "testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
flag "github.com/spf13/pflag"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
) )
type ProtoModule struct { func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
descriptor func() gotenberg.ModuleDescriptor descriptor := new(LibreOfficePdfEngine).Descriptor()
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
type ProtoUnoconvProvider struct {
ProtoModule
unoconv func() (unoconv.API, error)
}
func (mod ProtoUnoconvProvider) Unoconv() (unoconv.API, error) {
return mod.unoconv()
}
type ProtoUnoconvAPI struct {
pdf func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error
}
func (mod ProtoUnoconvAPI) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options unoconv.Options) error {
return mod.pdf(ctx, logger, inputPath, outputPath, options)
}
func (mod ProtoUnoconvAPI) Extensions() []string {
return nil
}
func TestUnoconvPDFEngine_Descriptor(t *testing.T) {
descriptor := UnoconvPDFEngine{}.Descriptor()
actual := reflect.TypeOf(descriptor.New()) actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(UnoconvPDFEngine)) expect := reflect.TypeOf(new(LibreOfficePdfEngine))
if actual != expect { if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual) t.Errorf("expected '%s' but got '%s'", expect, actual)
} }
} }
func TestUnoconvPDFEngine_Provision(t *testing.T) { func TestLibreOfficePdfEngine_Provider(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
ctx *gotenberg.Context scenario string
expectErr bool ctx *gotenberg.Context
expectError bool
}{ }{
{ {
ctx: func() *gotenberg.Context { scenario: "no LibreOffice API provider",
mod := struct{ ProtoModule }{} ctx: gotenberg.NewContext(
mod.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ParsedFlags{
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
} },
[]gotenberg.ModuleDescriptor{},
return gotenberg.NewContext( ),
gotenberg.ParsedFlags{ expectError: true,
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError),
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
}, },
{ {
scenario: "no API from LibreOffice API provider",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{} provider := &struct {
mod.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} api.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
} }
mod.unoconv = func() (unoconv.API, error) { provider.LibreOfficeMock = func() (api.Uno, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
} }
return gotenberg.NewContext( return gotenberg.NewContext(
gotenberg.ParsedFlags{ gotenberg.ParsedFlags{
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError), FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
}, },
[]gotenberg.ModuleDescriptor{ []gotenberg.ModuleDescriptor{
mod.Descriptor(), provider.Descriptor(),
}, },
) )
}(), }(),
expectErr: true, expectError: true,
}, },
{ {
scenario: "provision success",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{} provider := &struct {
mod.descriptor = func() gotenberg.ModuleDescriptor { gotenberg.ModuleMock
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }} api.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
} }
mod.unoconv = func() (unoconv.API, error) { provider.LibreOfficeMock = func() (api.Uno, error) {
return struct{ ProtoUnoconvAPI }{}, nil return new(api.ApiMock), nil
} }
return gotenberg.NewContext( return gotenberg.NewContext(
gotenberg.ParsedFlags{ gotenberg.ParsedFlags{
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError), FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
}, },
[]gotenberg.ModuleDescriptor{ []gotenberg.ModuleDescriptor{
mod.Descriptor(), provider.Descriptor(),
}, },
) )
}(), }(),
expectError: false,
}, },
} { } {
mod := new(UnoconvPDFEngine) t.Run(tc.scenario, func(t *testing.T) {
err := mod.Provision(tc.ctx) engine := new(LibreOfficePdfEngine)
err := engine.Provision(tc.ctx)
if tc.expectErr && err == nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if !tc.expectErr && err != nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatal("expected error but got none")
} }
})
} }
} }
func TestUnoconvPDFEngine_Merge(t *testing.T) { func TestLibreOfficePdfEngine_Merge(t *testing.T) {
mod := new(UnoconvPDFEngine) engine := new(LibreOfficePdfEngine)
err := mod.Merge(context.TODO(), zap.NewNop(), nil, "") err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) { if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err) t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
} }
} }
func TestUnoconvPDFEngine_Convert(t *testing.T) { func TestLibreOfficePdfEngine_Convert(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
api unoconv.API scenario string
format string api api.Uno
expectErr bool expectError bool
}{ }{
{ {
format: "", scenario: "convert success",
expectErr: true, api: &api.ApiMock{
}, PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
{
api: func() unoconv.API {
unoconvAPI := struct{ ProtoUnoconvAPI }{}
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, __ string, _ unoconv.Options) error {
return errors.New("foo")
}
return unoconvAPI
}(),
format: gotenberg.FormatPDFA1a,
expectErr: true,
},
{
api: func() unoconv.API {
unoconvAPI := struct{ ProtoUnoconvAPI }{}
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, __ string, _ unoconv.Options) error {
return nil return nil
} },
},
return unoconvAPI expectError: false,
}(), },
format: gotenberg.FormatPDFA1a, {
scenario: "invalid PDF format",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return api.ErrInvalidPdfFormats
},
},
expectError: true,
},
{
scenario: "convert fail",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return errors.New("foo")
},
},
expectError: true,
}, },
} { } {
mod := new(UnoconvPDFEngine) t.Run(tc.scenario, func(t *testing.T) {
mod.unoconv = tc.api engine := &LibreOfficePdfEngine{unoApi: tc.api}
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
err := mod.Convert(context.TODO(), zap.NewNop(), tc.format, "", "") if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectErr && err == nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatal("expected error but got none")
} }
})
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
} }
} }
// Interface guards. func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
var ( engine := new(LibreOfficePdfEngine)
_ gotenberg.Module = (*ProtoModule)(nil) _, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
_ unoconv.Provider = (*ProtoUnoconvProvider)(nil)
_ gotenberg.Module = (*ProtoUnoconvProvider)(nil) if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
_ unoconv.API = (*ProtoUnoconvAPI)(nil) t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
) }
}
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}

View File

@@ -1,19 +1,21 @@
package libreoffice package libreoffice
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
) )
// convertRoute returns an api.Route which can convert LibreOffice documents // convertRoute returns an [api.Route] which can convert LibreOffice documents
// to PDF. // to PDF.
func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route { func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/libreoffice/convert", Path: "/forms/libreoffice/convert",
@@ -23,54 +25,75 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
// Let's get the data from the form and validate them. // Let's get the data from the form and validate them.
var ( var (
inputPaths []string inputPaths []string
landscape bool landscape bool
nativePageRanges string nativePageRanges string
nativePDFA1aFormat bool exportFormFields bool
PDFformat string pdfa string
merge bool pdfua bool
nativePdfFormats bool
merge bool
metadata map[string]interface{}
) )
err := ctx.FormData(). err := ctx.FormData().
MandatoryPaths(uno.Extensions(), &inputPaths). MandatoryPaths(libreOffice.Extensions(), &inputPaths).
Bool("landscape", &landscape, false). Bool("landscape", &landscape, false).
String("nativePageRanges", &nativePageRanges, ""). String("nativePageRanges", &nativePageRanges, "").
Bool("nativePdfA1aFormat", &nativePDFA1aFormat, false). Bool("exportFormFields", &exportFormFields, true).
String("pdfFormat", &PDFformat, ""). String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false). Bool("merge", &merge, false).
Custom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
}).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
if nativePDFA1aFormat && PDFformat != "" { pdfFormats := gotenberg.PdfFormats{
return api.WrapError( PdfA: pdfa,
errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form values"), PdfUa: pdfua,
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided"),
)
} }
// Alright, let's convert each document to PDF. // Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths)) outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf") outputPaths[i] = ctx.GeneratePath(".pdf")
options := libreofficeapi.Options{
options := unoconv.Options{ Landscape: landscape,
Landscape: landscape, PageRanges: nativePageRanges,
PageRanges: nativePageRanges, ExportFormFields: exportFormFields,
PDFArchive: nativePDFA1aFormat,
} }
err = uno.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options) if nativePdfFormats {
options.PdfFormats = pdfFormats
}
err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options)
if err != nil { if err != nil {
if errors.Is(err, unoconv.ErrMalformedPageRanges) { if errors.Is(err, libreofficeapi.ErrInvalidPdfFormats) {
return api.WrapError( return api.WrapError(
fmt.Errorf("convert to PDF: %w", err), fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)), api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
),
)
}
if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
) )
} }
@@ -78,9 +101,7 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
} }
} }
// So far so good, let's check if we have to merge the PDFs. Quick // So far so good, let's check if we have to merge the PDFs.
// win: if there is only one PDF, skip this step.
if len(outputPaths) > 1 && merge { if len(outputPaths) > 1 && merge {
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
@@ -89,88 +110,56 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.Route {
return fmt.Errorf("merge PDFs: %w", err) return fmt.Errorf("merge PDFs: %w", err)
} }
// Now, let's check if the client want to convert this result // Only one output path.
// PDF to a specific PDF format. outputPaths = []string{outputPath}
// Note: nativePdfA1aFormat has not been specified if we reach
// this part of the code. Indeed, the handler returns early on
// an error if both nativePdfA1aFormat and pdfFormat are
// present.
if PDFformat != "" {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPath)
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),
),
)
}
return fmt.Errorf("convert PDF: %w", err)
}
// Important: the output path is now the converted file.
outputPath = convertOutputPath
}
// Last but not least, add the output path to the context so that
// the API is able to send it as a response to the client.
err = ctx.AddOutputPaths(outputPath)
if err != nil {
return fmt.Errorf("add output path: %w", err)
}
return nil
} }
// Ok, we don't have to merge the PDFs. Let's check if the client // Let's check if the client want to convert each PDF to a specific
// want to convert each PDF to a specific PDF format. // PDF format.
zeroValued := gotenberg.PdfFormats{}
// Note: nativePdfA1aFormat has not been specified if we reach this if !nativePdfFormats && pdfFormats != zeroValued {
// part of the code. Indeed, the handler returns early on an error
// if both nativePdfA1aFormat and pdfFormat are present.
if PDFformat != "" {
convertOutputPaths := make([]string, len(outputPaths)) convertOutputPaths := make([]string, len(outputPaths))
for i, outputPath := range outputPaths { for i, outputPath := range outputPaths {
convertInputPath := outputPath convertInputPath := outputPath
convertOutputPaths[i] = ctx.GeneratePath(".pdf") convertOutputPaths[i] = ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPaths[i]) err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
if err != nil { 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),
),
)
}
return fmt.Errorf("convert PDF: %w", err) return fmt.Errorf("convert PDF: %w", err)
} }
} }
// Important: the output paths are now the converted files. // Important: the output paths are now the converted files.
outputPaths = convertOutputPaths outputPaths = convertOutputPaths
} }
// Writes and potentially overrides metadata entries, if any.
if len(metadata) > 0 {
for _, outputPath := range outputPaths {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
}
}
if len(outputPaths) > 1 {
// If .zip archive, document.docx -> document.docx.pdf.
for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
err = ctx.Rename(outputPaths[i], outputPath)
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
outputPaths[i] = outputPath
}
}
// Last but not least, add the output paths to the context so that // Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client. // the API is able to send them as a response to the client.
err = ctx.AddOutputPaths(outputPaths...) err = ctx.AddOutputPaths(outputPaths...)
if err != nil { if err != nil {
return fmt.Errorf("add output paths: %w", err) return fmt.Errorf("add output paths: %w", err)

View File

@@ -4,542 +4,452 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"slices"
"testing" "testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
) )
func TestConvertHandler(t *testing.T) { func TestConvertRoute(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
ctx *api.MockContext scenario string
api unoconv.API ctx *api.ContextMock
engine gotenberg.PDFEngine libreOffice libreofficeapi.Uno
expectErr bool engine gotenberg.PdfEngine
expectHTTPErr bool expectOptions libreofficeapi.Options
expectHTTPStatus int expectError bool
expectHttpError bool
expectHttpStatus int
expectOutputPathsCount int expectOutputPathsCount int
expectOutputPaths []string
}{ }{
{ {
ctx: &api.MockContext{Context: &api.Context{}}, scenario: "missing at least one mandatory file",
api: func() unoconv.API { ctx: &api.ContextMock{Context: new(api.Context)},
unoconvAPI := struct{ ProtoUnoconvAPI }{} libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
unoconvAPI.extensions = func() []string { return []string{".docx"}
return []string{ }},
".foo", expectError: true,
} expectHttpError: true,
} expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
return unoconvAPI
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
}, },
{ {
ctx: func() *api.MockContext { scenario: "invalid metadata form field",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"nativePdfA1aFormat": { "metadata": {
"true",
},
"pdfFormat": {
"foo", "foo",
}, },
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
unoconvAPI := struct{ ProtoUnoconvAPI }{} return []string{".docx"}
unoconvAPI.extensions = func() []string { }},
return []string{ expectError: true,
".docx", expectHttpError: true,
} expectHttpStatus: http.StatusBadRequest,
} expectOutputPathsCount: 0,
return unoconvAPI
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
}, },
{ {
ctx: func() *api.MockContext { scenario: "ErrPdfFormatNotSupported (nativePdfFormats)",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { return libreofficeapi.ErrInvalidPdfFormats
return unoconv.ErrMalformedPageRanges },
} ExtensionsMock: func() []string {
unoconvAPI.extensions = func() []string { return []string{".docx"}
return []string{ },
".docx", },
} expectError: true,
} expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
return unoconvAPI expectOutputPathsCount: 0,
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
}, },
{ {
ctx: func() *api.MockContext { scenario: "ErrMalformedPageRanges",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"pdfa": {
"foo",
},
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error { return libreofficeapi.ErrMalformedPageRanges
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "error from LibreOffice",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return errors.New("foo") return errors.New("foo")
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx"}
".docx", },
} },
} expectError: true,
expectHttpError: false,
return unoconvAPI expectOutputPathsCount: 0,
}(),
expectErr: true,
}, },
{ {
ctx: func() *api.MockContext { scenario: "PDF engine merge error",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
"bar.docx": "/foo/bar.docx", "document2.docx": "/document2.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"merge": { "merge": {
"true", "true",
}, },
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil return nil
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx"}
".docx", },
} },
} engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return unoconvAPI return errors.New("foo")
}(), },
engine: func() gotenberg.PDFEngine { },
return &ProtoPDFEngine{ expectError: true,
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { expectHttpError: false,
return errors.New("foo") expectOutputPathsCount: 0,
},
}
}(),
expectErr: true,
}, },
{ {
ctx: func() *api.MockContext { scenario: "PDF engine convert error",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
"bar.docx": "/foo/bar.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"merge": { "pdfa": {
"true", gotenberg.PdfA1b,
}, },
"pdfFormat": { "nativePdfFormats": {
"foo", "false",
}, },
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil return nil
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx"}
".docx", },
} },
} engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return unoconvAPI return errors.New("foo")
}(), },
engine: func() gotenberg.PDFEngine { },
return &ProtoPDFEngine{ expectError: true,
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { expectHttpError: false,
return nil expectOutputPathsCount: 0,
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return gotenberg.ErrPDFFormatNotAvailable
},
}
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
}, },
{ {
ctx: func() *api.MockContext { scenario: "PDF engine write metadata error",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
"bar.docx": "/foo/bar.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"merge": { "metadata": {
"true", "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
"pdfFormat": {
"foo",
}, },
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil return nil
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx"}
".docx", },
} },
} engine: &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return unoconvAPI return errors.New("foo")
}(), },
engine: func() gotenberg.PDFEngine { },
return &ProtoPDFEngine{ expectError: true,
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { expectHttpError: false,
return nil expectOutputPathsCount: 0,
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return errors.New("foo")
},
}
}(),
expectErr: true,
}, },
{ {
ctx: func() *api.MockContext { scenario: "cannot rename many files",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
"document2.doc": "/document2.doc",
})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return errors.New("cannot rename")
}})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx", ".doc"}
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "cannot add output paths",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetCancelled(true) ctx.SetCancelled(true)
ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx",
"bar.docx": "/foo/bar.docx",
})
ctx.SetValues(map[string][]string{
"merge": {
"true",
},
"pdfFormat": {
"foo",
},
})
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil return nil
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx"}
".docx", },
} },
} expectError: true,
expectHttpError: false,
return unoconvAPI expectOutputPathsCount: 0,
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
}
}(),
expectErr: true,
}, },
{ {
ctx: func() *api.MockContext { scenario: "success (single file)",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
"bar.docx": "/foo/bar.docx", "document2.docx": "/document2.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"merge": { "merge": {
"true", "true",
}, },
"pdfFormat": { "pdfa": {
"foo", gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
"nativePdfFormats": {
"false",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
}, },
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil return nil
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx"}
".docx", },
} },
} engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return unoconvAPI return nil
}(), },
engine: func() gotenberg.PDFEngine { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return &ProtoPDFEngine{ return nil
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { },
return nil WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
}, return nil
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { },
return nil },
}, expectError: false,
} expectHttpError: false,
}(),
expectOutputPathsCount: 1, expectOutputPathsCount: 1,
}, },
{ {
ctx: func() *api.MockContext { scenario: "success (many files)",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
"document2.docx": "/document2.docx",
"document2.doc": "/document2.doc",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfFormat": { "pdfa": {
"foo", gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
"nativePdfFormats": {
"false",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
}, },
}) })
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil return nil
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx", ".doc"}
".docx", },
} },
} engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return unoconvAPI return nil
}(), },
engine: func() gotenberg.PDFEngine { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return &ProtoPDFEngine{ return nil
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error { },
return nil WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
}, return nil
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error { },
return gotenberg.ErrPDFFormatNotAvailable },
}, expectError: false,
} expectHttpError: false,
}(), expectOutputPathsCount: 3,
expectErr: true, expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"},
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
}, },
{ {
ctx: func() *api.MockContext { scenario: "success with native PDF/A & PDF/UA",
ctx := &api.MockContext{Context: &api.Context{}} ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx", "document.docx": "/document.docx",
"bar.docx": "/foo/bar.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfFormat": { "pdfa": {
"foo", gotenberg.PdfA1b,
},
"pdfua": {
"true",
}, },
}) })
return ctx return ctx
}(), }(),
api: func() unoconv.API { libreOffice: &libreofficeapi.ApiMock{
unoconvAPI := struct{ ProtoUnoconvAPI }{} PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil return nil
} },
unoconvAPI.extensions = func() []string { ExtensionsMock: func() []string {
return []string{ return []string{".docx"}
".docx", },
} },
} expectError: false,
expectHttpError: false,
return unoconvAPI expectOutputPathsCount: 1,
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return errors.New("foo")
},
}
}(),
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx.SetCancelled(true)
ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx",
"bar.docx": "/foo/bar.docx",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
},
})
return ctx
}(),
api: func() unoconv.API {
unoconvAPI := struct{ ProtoUnoconvAPI }{}
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil
}
unoconvAPI.extensions = func() []string {
return []string{
".docx",
}
}
return unoconvAPI
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
}
}(),
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.docx": "/foo/foo.docx",
"bar.docx": "/foo/bar.docx",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
},
})
return ctx
}(),
api: func() unoconv.API {
unoconvAPI := struct{ ProtoUnoconvAPI }{}
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ unoconv.Options) error {
return nil
}
unoconvAPI.extensions = func() []string {
return []string{
".docx",
}
}
return unoconvAPI
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
}
}(),
expectOutputPathsCount: 2,
}, },
} { } {
c := echo.New().NewContext(nil, nil) t.Run(tc.scenario, func(t *testing.T) {
c.Set("context", tc.ctx.Context) tc.ctx.SetLogger(zap.NewNop())
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertRoute(tc.api, tc.engine).Handler(c) err := convertRoute(tc.libreOffice, tc.engine).Handler(c)
if tc.expectErr && err == nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatal("expected error but got none", 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()) { if !tc.expectError && err != nil {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths())) t.Fatalf("expected no error but got: %v", err)
} }
var httpErr api.HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
for _, path := range tc.expectOutputPaths {
if !slices.Contains(tc.ctx.OutputPaths(), path) {
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
}
}
})
} }
} }

View File

@@ -1,2 +0,0 @@
// Package unoconv provides a module which abstracts the CLI tool unoconv.
package unoconv

View File

@@ -1,482 +0,0 @@
package unoconv
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag"
"go.uber.org/zap"
)
func init() {
gotenberg.MustRegisterModule(Unoconv{})
}
// ErrMalformedPageRanges happens if the page ranges option cannot be
// interpreted by LibreOffice.
var ErrMalformedPageRanges = errors.New("page ranges are malformed")
// Unoconv is a module which provides an API to interact with unoconv.
type Unoconv struct {
binPath string
disableListener bool
listenerCmd gotenberg.Cmd
listenerPort int
logger *zap.Logger
}
// Options gathers available options when converting a document to PDF.
type Options struct {
// Landscape allows to change the orientation of the resulting PDF.
// Optional.
Landscape bool
// PageRanges allows to select the pages to convert.
// TODO: should prefer a method form PDFEngine.
// Optional.
PageRanges string
// PDFArchive allows to convert the resulting PDF to PDF/A-1a.
// In a module, prefer the Convert method from the gotenberg.PDFEngine
// interface.
// Optional.
PDFArchive bool
}
// API is an abstraction on top of unoconv.
//
// See https://github.com/unoconv/unoconv.
type API 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 an API
// for other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(unoconv.Provider))
// uno, _ := provider.(unoconv.Provider).Unoconv()
// }
type Provider interface {
Unoconv() (API, error)
}
// Descriptor returns a Unoconv's module descriptor.
func (Unoconv) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "unoconv",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("unoconv", flag.ExitOnError)
fs.Bool("unoconv-disable-listener", false, "Do not start a unoconv listener - save resources in detriment of performance")
return fs
}(),
New: func() gotenberg.Module { return new(Unoconv) },
}
}
// Provision sets the module properties. It returns an error if the environment
// variable UNOCONV_BIN_PATH is not set.
func (mod *Unoconv) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.disableListener = flags.MustBool("unoconv-disable-listener")
binPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
if !ok {
return errors.New("UNOCONV_BIN_PATH environment variable is not set")
}
mod.binPath = binPath
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
mod.logger = logger
return nil
}
// Validate validates the module properties.
func (mod Unoconv) Validate() error {
_, err := os.Stat(mod.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("unoconv binary path does not exist: %w", err)
}
return nil
}
func (mod *Unoconv) Start() error {
if mod.disableListener {
return nil
}
port, err := freePort(mod.logger)
if err != nil {
return fmt.Errorf("get free port: %w", err)
}
mod.listenerPort = port
args := []string{
"--listener",
"--user-profile",
// Just to make sure LibreOffice does not leak files in an unknown
// directory. The directory will be removed anyway by the garbage
// collector.
fmt.Sprintf("//%s", gotenberg.NewDirPath()),
"--port",
fmt.Sprintf("%d", mod.listenerPort),
}
checkedEntry := mod.logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
if checkedEntry != nil {
args = append(args, "-vvv")
}
mod.listenerCmd = gotenberg.Command(mod.logger, mod.binPath, args...)
err = mod.listenerCmd.Start()
if err != nil {
return fmt.Errorf("start unoconv listener: %w", err)
}
listenerActiveInstancesCountMu.Lock()
listenerActiveInstancesCount += 1
listenerActiveInstancesCountMu.Unlock()
return nil
}
// StartupMessage returns a custom startup message.
func (mod Unoconv) StartupMessage() string {
if mod.disableListener {
return "listener disabled"
}
return fmt.Sprintf("listener started on port %d", mod.listenerPort)
}
// Stop stops the HTTP server.
func (mod *Unoconv) Stop(ctx context.Context) error {
if mod.disableListener {
return nil
}
_, 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.
mod.logger.Debug("wait for the end of grace duration")
<-ctx.Done()
err := mod.listenerCmd.Kill()
if err != nil {
return fmt.Errorf("kill unoconv listener: %w", err)
}
listenerActiveInstancesCountMu.Lock()
listenerActiveInstancesCount -= 1
listenerActiveInstancesCountMu.Unlock()
return nil
}
// Metrics returns the metrics.
func (mod Unoconv) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "unoconv_active_instances_count",
Description: "Current number of active unoconv instances.",
Read: func() float64 {
activeInstancesCountMu.RLock()
defer activeInstancesCountMu.RUnlock()
return activeInstancesCount
},
},
{
Name: "unoconv_listener_active_instances_count",
Description: "Current number of active unoconv listener instances.",
Read: func() float64 {
listenerActiveInstancesCountMu.RLock()
defer listenerActiveInstancesCountMu.RUnlock()
return listenerActiveInstancesCount
},
},
{
Name: "unoconv_listener_queue_length",
Description: "Current number of processes in the queue.",
Read: func() float64 {
listenerQueueLengthMu.RLock()
defer listenerQueueLengthMu.RUnlock()
return listenerQueueLength
},
},
}, nil
}
// Unoconv returns an API for interacting with unoconv.
func (mod *Unoconv) Unoconv() (API, error) {
return mod, nil
}
// PDF converts a document to PDF.
//
// In stateless mode, it creates a dedicated LibreOffice instance thanks to a
// custom user profile directory and a free port. Substantial calls to this
// method may increase CPU and memory usage drastically. In such a scenario,
// the given context may also be done before the end of the conversion.
//
// In listener mode, it calls the unoconv listener to interact with
// LibreOffice, improving substantially the performance. However, it cannot
// perform parallel operations and have to wait for the lock to be available.
func (mod Unoconv) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
args := []string{
"--format",
"pdf",
}
var userProfileDirPath string
if mod.disableListener {
port, err := freePort(logger)
if err != nil {
return fmt.Errorf("get free port: %w", err)
}
userProfileDirPath = gotenberg.NewDirPath()
args = append(args,
"--port",
fmt.Sprintf("%d", port),
"--user-profile",
fmt.Sprintf("//%s", userProfileDirPath),
)
} else {
args = append(args, "--port", fmt.Sprintf("%d", mod.listenerPort))
}
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
if checkedEntry != nil {
args = append(args, "-vvv")
}
if options.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape")
}
if options.PageRanges != "" {
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
}
if options.PDFArchive {
args = append(args, "--export", "SelectPdfVersion=1")
}
args = append(args, "--output", outputPath, inputPath)
if !mod.disableListener {
listenerQueueLengthMu.Lock()
listenerQueueLength += 1
listenerQueueLengthMu.Unlock()
select {
case listenerLock <- struct{}{}:
logger.Debug("unoconv lock acquired")
listenerQueueLengthMu.Lock()
listenerQueueLength -= 1
listenerQueueLengthMu.Unlock()
break
case <-ctx.Done():
logger.Debug("failed to acquire the unoconv lock before deadline")
listenerQueueLengthMu.Lock()
listenerQueueLength -= 1
listenerQueueLengthMu.Unlock()
return fmt.Errorf("acquire unoconv lock: %w", ctx.Err())
}
defer func() {
<-listenerLock
logger.Debug("unoconv lock released")
}()
}
cmd, err := gotenberg.CommandContext(ctx, logger, mod.binPath, args...)
if err != nil {
return fmt.Errorf("create unoconv command: %w", err)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
activeInstancesCountMu.Lock()
activeInstancesCount += 1
activeInstancesCountMu.Unlock()
err = cmd.Exec()
activeInstancesCountMu.Lock()
activeInstancesCount -= 1
activeInstancesCountMu.Unlock()
if mod.disableListener {
// Always remove the user profile directory created by LibreOffice.
// See https://github.com/gotenberg/gotenberg/issues/192.
go func() {
logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath))
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove user profile directory: %s", err))
}
}()
}
if err == nil {
return nil
}
// Unoconv/LibreOffice errors are not explicit.
// That's why we have to make an educated guess according to the exit code
// and given inputs.
if strings.Contains(err.Error(), "exit status 5") && options.PageRanges != "" {
return ErrMalformedPageRanges
}
// Possible errors:
// 1. Unoconv/LibreOffice failed for some reason.
// 2. Context done.
//
// On the second scenario, LibreOffice might not had time to remove some of
// its temporary files, as it has been killed without warning. The garbage
// collector will delete them for us (if the module is loaded).
return fmt.Errorf("unoconv PDF: %w", err)
}
// Extensions returns the file extensions available with unoconv.
func (mod Unoconv) Extensions() []string {
return []string{
".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",
".jpeg",
".met",
".odd",
".otg",
".pbm",
".pct",
".pgm",
".ppm",
".ras",
".std",
".svg",
".svm",
".swf",
".sxd",
".sxw",
".tif",
".tiff",
".xhtml",
".xpm",
".odp",
".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",
}
}
var (
listenerLock = make(chan struct{}, 1)
listenerQueueLength float64
listenerQueueLengthMu sync.RWMutex
listenerActiveInstancesCount float64
listenerActiveInstancesCountMu sync.RWMutex
activeInstancesCount float64
activeInstancesCountMu sync.RWMutex
)
// Interface guards.
var (
_ gotenberg.Module = (*Unoconv)(nil)
_ gotenberg.Provisioner = (*Unoconv)(nil)
_ gotenberg.Validator = (*Unoconv)(nil)
_ gotenberg.App = (*Unoconv)(nil)
_ gotenberg.MetricsProvider = (*Unoconv)(nil)
_ API = (*Unoconv)(nil)
_ Provider = (*Unoconv)(nil)
)

View File

@@ -1,453 +0,0 @@
package unoconv
import (
"context"
"errors"
"os"
"reflect"
"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 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 TestUnoconv_Descriptor(t *testing.T) {
descriptor := Unoconv{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Unoconv))
if actual != expect {
t.Errorf("expected '%'s' but got '%s'", expect, actual)
}
}
func TestUnoconv_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectErr bool
}{
{
ctx: gotenberg.NewContext(
gotenberg.ParsedFlags{FlagSet: new(Unoconv).Descriptor().FlagSet},
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{
FlagSet: new(Unoconv).Descriptor().FlagSet,
},
[]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{
FlagSet: new(Unoconv).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
},
} {
mod := new(Unoconv)
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 TestUnoconv_Validate(t *testing.T) {
for i, tc := range []struct {
binPath string
expectErr bool
}{
{
expectErr: true,
},
{
binPath: "/foo",
expectErr: true,
},
{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
},
} {
mod := Unoconv{
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 TestUnoconv_Start(t *testing.T) {
for i, tc := range []struct {
mod *Unoconv
expectErr bool
}{
{
mod: &Unoconv{
disableListener: true,
logger: zap.NewNop(),
},
},
{
mod: &Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
disableListener: false,
logger: zap.NewExample(),
},
},
} {
func() {
err := tc.mod.Start()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(1)*time.Nanosecond)
defer cancel()
err = tc.mod.Stop(ctx)
if err != nil {
t.Errorf("test %d: expected not error but got: %v", i, err)
}
}()
}
}
func TestUnoconv_StartupMessage(t *testing.T) {
for i, tc := range []struct {
disableListener bool
expectMessage string
}{
{
disableListener: true,
expectMessage: "listener disabled",
},
{
expectMessage: "listener started on port 0",
},
} {
mod := Unoconv{
disableListener: tc.disableListener,
}
actual := mod.StartupMessage()
if actual != tc.expectMessage {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectMessage, actual)
}
}
}
func TestUnoconv_Stop(t *testing.T) {
for i, tc := range []struct {
start bool
disableListener bool
timeout time.Duration
expectErr bool
}{
{
disableListener: true,
},
{
expectErr: true,
},
{
start: true,
timeout: time.Duration(1) * time.Nanosecond,
},
} {
func() {
mod := &Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
disableListener: tc.disableListener,
logger: zap.NewNop(),
}
if tc.start {
err := mod.Start()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
}
var err error
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)
}
}()
}
}
func TestUnoconv_Metrics(t *testing.T) {
metrics, err := new(Unoconv).Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 3 {
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
}
actual := metrics[0].Read()
if actual != 0 {
t.Errorf("expected %d unoconv instances, but got %f", 0, actual)
}
actual = metrics[1].Read()
if actual != 0 {
t.Errorf("expected %d unoconv listener instances, but got %f", 0, actual)
}
actual = metrics[2].Read()
if actual != 0 {
t.Errorf("expected %d processes in the queue, but got %f", 0, actual)
}
}
func TestUnoconv_Unoconv(t *testing.T) {
mod := new(Unoconv)
_, err := mod.Unoconv()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestUnoconv_PDF(t *testing.T) {
for i, tc := range []struct {
ctx context.Context
mod Unoconv
logger *zap.Logger
inputPath string
options Options
expectErr bool
}{
{
mod: Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
disableListener: true,
},
logger: zap.NewNop(),
expectErr: true,
},
{
ctx: context.Background(),
mod: Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
disableListener: true,
},
logger: zap.NewExample(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
Landscape: true,
PageRanges: "1-2",
PDFArchive: true,
},
},
{
ctx: context.Background(),
mod: Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
disableListener: true,
},
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PageRanges: "foo",
},
expectErr: true,
},
{
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.TODO())
defer cancel()
return ctx
}(),
mod: Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
disableListener: true,
},
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
expectErr: true,
},
{
ctx: context.Background(),
mod: Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
logger: zap.NewNop(),
},
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
},
{
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.TODO())
defer cancel()
return ctx
}(),
mod: Unoconv{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
logger: zap.NewNop(),
},
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
expectErr: true,
},
} {
func() {
outputDir, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("test %d: expected error but got: %v", i, err)
}
defer func() {
err := os.RemoveAll(outputDir)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
}()
if !tc.mod.disableListener {
err = tc.mod.Start()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
// Let's give it some room to start.
time.Sleep(time.Duration(1) * time.Second)
}
err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", 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)
}
if !tc.mod.disableListener {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(1)*time.Nanosecond)
defer cancel()
err = tc.mod.Stop(ctx)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
}
}()
}
}
func TestUnoconv_Extensions(t *testing.T) {
mod := new(Unoconv)
extensions := mod.Extensions()
actual := len(extensions)
expect := 76
if actual != expect {
t.Errorf("expected %d extensions but got %d", expect, actual)
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
)

View File

@@ -1,3 +1,3 @@
// Package logging provides a module which creates a zap.Logger for other // Package logging provides a module which creates a zap.Logger instance for
// modules. // other modules.
package logging package logging

View File

@@ -5,16 +5,17 @@ import (
"os" "os"
"time" "time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"golang.org/x/term" "golang.org/x/term"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func init() { func init() {
gotenberg.MustRegisterModule(Logging{}) gotenberg.MustRegisterModule(new(Logging))
} }
const ( const (
@@ -30,20 +31,23 @@ const (
textLoggingFormat = "text" textLoggingFormat = "text"
) )
// Logging is a module which implements the gotenberg.LoggerProvider interface. // Logging is a module which implements the [gotenberg.LoggerProvider]
// interface.
type Logging struct { type Logging struct {
level string level string
format string format string
fieldsPrefix string
} }
// Descriptor returns a Logging's module descriptor. // Descriptor returns a [Logging]'s module descriptor.
func (Logging) Descriptor() gotenberg.ModuleDescriptor { func (log *Logging) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ return gotenberg.ModuleDescriptor{
ID: "logging", ID: "logging",
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("logging", flag.ExitOnError) fs := flag.NewFlagSet("logging", flag.ExitOnError)
fs.String("log-level", infoLoggingLevel, fmt.Sprintf("Set the log level - %s, %s, %s, or %s", errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel)) fs.String("log-level", infoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel))
fs.String("log-format", autoLoggingFormat, fmt.Sprintf("Set log format - %s, %s, or %s", autoLoggingFormat, jsonLoggingFormat, textLoggingFormat)) fs.String("log-format", autoLoggingFormat, fmt.Sprintf("Specify the format of logging. Options include %s, %s, or %s", autoLoggingFormat, jsonLoggingFormat, textLoggingFormat))
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs")
return fs return fs
}(), }(),
@@ -57,12 +61,13 @@ func (log *Logging) Provision(ctx *gotenberg.Context) error {
log.level = flags.MustString("log-level") log.level = flags.MustString("log-level")
log.format = flags.MustString("log-format") log.format = flags.MustString("log-format")
log.fieldsPrefix = flags.MustString("log-fields-prefix")
return nil return nil
} }
// Validate validates the log level and format. // Validate validates the log level and format.
func (log Logging) Validate() error { func (log *Logging) Validate() error {
var err error var err error
switch log.level { switch log.level {
@@ -88,8 +93,8 @@ func (log Logging) Validate() error {
return err return err
} }
// Logger returns a zap.Logger. // Logger returns a [zap.Logger].
func (log Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) { func (log *Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) {
if logger == nil { if logger == nil {
lvl, err := newLogLevel(log.level) lvl, err := newLogLevel(log.level)
if err != nil { if err != nil {
@@ -101,29 +106,64 @@ func (log Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) {
return nil, fmt.Errorf("get log encoder: %w", err) return nil, fmt.Errorf("get log encoder: %w", err)
} }
core := zapcore.NewCore(encoder, os.Stderr, lvl) logger = zap.New(customCore{
logger = zap.New(core) Core: zapcore.NewCore(encoder, os.Stderr, lvl),
fieldsPrefix: log.fieldsPrefix,
// nolint })
defer logger.Sync()
} }
return logger.Named(mod.Descriptor().ID), nil return logger.Named(mod.Descriptor().ID), nil
} }
func newLogLevel(level string) (zapcore.Level, error) { // See https://github.com/gotenberg/gotenberg/issues/659.
switch level { type customCore struct {
case errorLoggingLevel: zapcore.Core
return zap.ErrorLevel, nil fieldsPrefix string
case warnLoggingLevel: }
return zap.WarnLevel, nil
case infoLoggingLevel: func (c customCore) With(fields []zapcore.Field) zapcore.Core {
return zap.InfoLevel, nil if c.fieldsPrefix != "" {
case debugLoggingLevel: for i := range fields {
return zap.DebugLevel, nil fields[i].Key = c.fieldsPrefix + "_" + fields[i].Key
default: }
return -2, fmt.Errorf("%s is not a recognized log level", level)
} }
return customCore{
Core: c.Core.With(fields),
fieldsPrefix: c.fieldsPrefix,
}
}
func (c customCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
// This is a copy from the zapcore.ioCore implementation. Indeed, by doing
// so, we are able to prefix the fields given to the logger methods like
// Debug, Info, Warn, Error, etc.
if c.Enabled(ent.Level) {
return ce.AddCore(ent, c)
}
return ce
}
func (c customCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
if c.fieldsPrefix != "" {
for i := range fields {
fields[i].Key = c.fieldsPrefix + "_" + fields[i].Key
}
}
return c.Core.Write(entry, fields)
}
func newLogLevel(level string) (zapcore.Level, error) {
lvl := zapcore.InvalidLevel
err := lvl.UnmarshalText([]byte(level))
if err != nil {
return lvl, fmt.Errorf("%q is not a recognized log level: %w", level, err)
}
return lvl, nil
} }
func newLogEncoder(format string) (zapcore.Encoder, error) { func newLogEncoder(format string) (zapcore.Encoder, error) {
@@ -158,6 +198,7 @@ func newLogEncoder(format string) (zapcore.Encoder, error) {
} }
} }
// Singleton so that we instantiate our logger only once.
var logger *zap.Logger = nil var logger *zap.Logger = nil
// Interface guards. // Interface guards.
@@ -166,4 +207,5 @@ var (
_ gotenberg.Provisioner = (*Logging)(nil) _ gotenberg.Provisioner = (*Logging)(nil)
_ gotenberg.Validator = (*Logging)(nil) _ gotenberg.Validator = (*Logging)(nil)
_ gotenberg.LoggerProvider = (*Logging)(nil) _ gotenberg.LoggerProvider = (*Logging)(nil)
_ zapcore.Core = (*customCore)(nil)
) )

View File

@@ -1,23 +1,19 @@
package logging package logging
import ( import (
"fmt"
"reflect" "reflect"
"testing" "testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type ProtoModule struct {
descriptor func() gotenberg.ModuleDescriptor
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
func TestLogging_Descriptor(t *testing.T) { func TestLogging_Descriptor(t *testing.T) {
descriptor := Logging{}.Descriptor() descriptor := new(Logging).Descriptor()
actual := reflect.TypeOf(descriptor.New()) actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Logging)) expect := reflect.TypeOf(new(Logging))
@@ -28,172 +24,324 @@ func TestLogging_Descriptor(t *testing.T) {
} }
func TestLogging_Provision(t *testing.T) { func TestLogging_Provision(t *testing.T) {
logging := new(Logging) for _, tc := range []struct {
fs := logging.Descriptor().FlagSet scenario string
level string
format string
fieldsPrefix string
expectLevel string
expectFormat string
expectFieldsPrefix string
}{
{
scenario: "default values",
expectLevel: infoLoggingLevel,
expectFormat: autoLoggingFormat,
expectFieldsPrefix: "",
},
{
scenario: "explicit values",
level: "debug",
format: "json",
fieldsPrefix: "gotenberg",
expectLevel: debugLoggingLevel,
expectFormat: jsonLoggingFormat,
expectFieldsPrefix: "gotenberg",
},
{
scenario: "wrong values", // no validation at this point.
level: "foo",
format: "foo",
expectLevel: "foo",
expectFormat: "foo",
expectFieldsPrefix: "",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
var flags []string
err := fs.Parse([]string{""}) if tc.level != "" {
if err != nil { flags = append(flags, "--log-level", tc.level)
t.Fatalf("expected no error but got: %v", err) }
}
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{FlagSet: fs}, nil) if tc.format != "" {
flags = append(flags, "--log-format", tc.format)
}
err = logging.Provision(ctx) if tc.fieldsPrefix != "" {
if err != nil { flags = append(flags, "--log-fields-prefix", tc.fieldsPrefix)
t.Errorf("expected no error but got: %v", err) }
logging := new(Logging)
fs := logging.Descriptor().FlagSet
err := fs.Parse(flags)
if err != nil {
t.Fatalf("expected no error while parsing flags but got: %v", err)
}
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{FlagSet: fs}, nil)
err = logging.Provision(ctx)
if err != nil {
t.Fatalf("expected no error while provisioning but got: %v", err)
}
if logging.level != tc.expectLevel {
t.Errorf("expected logging level '%s' but got '%s'", tc.expectLevel, logging.level)
}
if logging.format != tc.expectFormat {
t.Errorf("expected logging format '%s' but got '%s'", tc.expectFormat, logging.format)
}
if logging.fieldsPrefix != tc.expectFieldsPrefix {
t.Errorf("expected logging fields prefix '%s' but got '%s'", tc.expectFieldsPrefix, logging.fieldsPrefix)
}
})
} }
} }
func TestLogging_Validate(t *testing.T) { func TestLogging_Validate(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
level, format string scenario string
expectErr bool level string
format string
expectError bool
}{ }{
{ {
level: "foo", scenario: "invalid level",
expectErr: true, level: "foo",
expectError: true,
}, },
{ {
level: debugLoggingLevel, scenario: "invalid format",
format: "foo", level: debugLoggingLevel,
expectErr: true, format: "foo",
expectError: true,
}, },
{ {
level: debugLoggingLevel, scenario: "valid level and format",
format: autoLoggingFormat, level: debugLoggingLevel,
format: autoLoggingFormat,
}, },
} { } {
mod := new(Logging) logging := new(Logging)
mod.level = tc.level logging.level = tc.level
mod.format = tc.format logging.format = tc.format
err := mod.Validate() err := logging.Validate()
if tc.expectErr && err == nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Errorf("%s: expected error but got: %v", tc.scenario, err)
} }
if !tc.expectErr && err != nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Errorf("%s: expected no error but got: %v", tc.scenario, err)
} }
} }
} }
func TestLogging_Logger(t *testing.T) { func TestLogging_Logger(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
level, format string scenario string
expectErr bool level string
format string
fieldsPrefix string
expectError bool
}{ }{
{ {
level: "foo", scenario: "invalid level",
expectErr: true, level: "foo",
expectError: true,
}, },
{ {
level: debugLoggingLevel, scenario: "invalid format",
format: "foo", level: debugLoggingLevel,
expectErr: true, format: "foo",
expectError: true,
}, },
{ {
level: debugLoggingLevel, scenario: "valid level and format",
format: autoLoggingFormat, level: debugLoggingLevel,
format: autoLoggingFormat,
}, },
} { } {
mod := new(Logging) t.Run(tc.scenario, func(t *testing.T) {
mod.level = tc.level logging := new(Logging)
mod.format = tc.format logging.level = tc.level
logging.format = tc.format
logging.fieldsPrefix = tc.fieldsPrefix
_, err := mod.Logger(ProtoModule{ _, err := logging.Logger(&gotenberg.ModuleMock{
descriptor: func() gotenberg.ModuleDescriptor { DescriptorMock: func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: nil} return gotenberg.ModuleDescriptor{ID: "mock", New: nil}
}, },
})
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
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)
}
} }
} }
func TestNewLogLevel(t *testing.T) { func TestCustomCore(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
level string scenario string
expectZapLevel zapcore.Level level zapcore.Level
expectErr bool fieldsPrefix string
expectEntry bool
}{ }{
{ {
scenario: "level enabled",
level: zapcore.DebugLevel,
fieldsPrefix: "gotenberg",
expectEntry: true,
},
{
scenario: "no fields prefix",
level: zapcore.DebugLevel,
expectEntry: true,
},
{
scenario: "level disabled",
level: zapcore.ErrorLevel,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
core, obsvr := observer.New(tc.level)
lgr := zap.New(customCore{
Core: core,
fieldsPrefix: tc.fieldsPrefix,
}).With(zap.String("a_field", "a value"))
lgr.Debug("a debug message", zap.String("another_field", "another value"))
entries := obsvr.TakeAll()
if tc.expectEntry && len(entries) == 0 {
t.Fatal("expected an entry")
}
if !tc.expectEntry && len(entries) != 0 {
t.Fatal("expected no entry")
}
var prefix string
if tc.fieldsPrefix != "" {
prefix = tc.fieldsPrefix + "_"
}
for _, entry := range entries {
fields := entry.Context
if len(fields) != 2 {
t.Fatalf("expected 2 fields but got %d", len(fields))
}
if fields[0].Key != fmt.Sprintf("%sa_field", prefix) {
t.Errorf("expected 'gotenberg_a_field' but got '%s'", fields[0].Key)
}
if fields[1].Key != fmt.Sprintf("%sanother_field", prefix) {
t.Errorf("expected 'gotenberg_another_field' but got '%s'", fields[1].Key)
}
}
})
}
}
func Test_newLogLevel(t *testing.T) {
for _, tc := range []struct {
scenario string
level string
expectZapLevel zapcore.Level
expectError bool
}{
{
scenario: "error level",
level: errorLoggingLevel, level: errorLoggingLevel,
expectZapLevel: zapcore.ErrorLevel, expectZapLevel: zapcore.ErrorLevel,
}, },
{ {
scenario: "warning level",
level: warnLoggingLevel, level: warnLoggingLevel,
expectZapLevel: zapcore.WarnLevel, expectZapLevel: zapcore.WarnLevel,
}, },
{ {
scenario: "info level",
level: infoLoggingLevel, level: infoLoggingLevel,
expectZapLevel: zapcore.InfoLevel, expectZapLevel: zapcore.InfoLevel,
}, },
{ {
scenario: "debug level",
level: debugLoggingLevel, level: debugLoggingLevel,
expectZapLevel: zapcore.DebugLevel, expectZapLevel: zapcore.DebugLevel,
}, },
{ {
scenario: "invalid level",
level: "foo", level: "foo",
expectZapLevel: -2, expectZapLevel: zapcore.InvalidLevel,
expectErr: true, expectError: true,
}, },
} { } {
actual, err := newLogLevel(tc.level) t.Run(tc.scenario, func(t *testing.T) {
actual, err := newLogLevel(tc.level)
if tc.expectErr && err == nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatal("expected error but got none")
} }
if !tc.expectErr && err != nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if tc.expectZapLevel != actual { if tc.expectZapLevel != actual {
t.Errorf("test %d: expected %d level but got %d", i, tc.expectZapLevel, actual) t.Errorf("expected %d level but got %d", tc.expectZapLevel, actual)
} }
})
} }
} }
func TestNewLogEncoder(t *testing.T) { func Test_newLogEncoder(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
format string scenario string
expectErr bool format string
expectError bool
}{ }{
{ {
format: autoLoggingFormat, scenario: "auto format",
format: autoLoggingFormat,
}, },
{ {
format: textLoggingFormat, scenario: "text format",
format: textLoggingFormat,
}, },
{ {
format: jsonLoggingFormat, scenario: "json format",
format: jsonLoggingFormat,
}, },
{ {
format: "foo", scenario: "invalid format",
expectErr: true, format: "foo",
expectError: true,
}, },
} { } {
_, err := newLogEncoder(tc.format) t.Run(tc.scenario, func(t *testing.T) {
_, err := newLogEncoder(tc.format)
if tc.expectErr && err == nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatal("expected error but got none")
} }
if !tc.expectErr && err != nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Errorf("expected no error but got: %v", err)
} }
})
} }
} }
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
)

View File

@@ -1,4 +1,7 @@
// Package pdfcpu provides a module which wraps the // Package pdfcpu provides an implementation of the gotenberg.PdfEngine
// https://github.com/pdfcpu/pdfcpu library and implements the // interface using the pdfcpu library. This package allows for:
// gotenberg.PDFEngine interface. //
// 1. The merging of PDF files.
//
// See: https://github.com/pdfcpu/pdfcpu.
package pdfcpu package pdfcpu

View File

@@ -4,44 +4,44 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
pdfcpuAPI "github.com/pdfcpu/pdfcpu/pkg/api" pdfcpuAPI "github.com/pdfcpu/pdfcpu/pkg/api"
pdfcpuLog "github.com/pdfcpu/pdfcpu/pkg/log" pdfcpuLog "github.com/pdfcpu/pdfcpu/pkg/log"
pdfcpuConfig "github.com/pdfcpu/pdfcpu/pkg/pdfcpu" pdfcpuConfig "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func init() { func init() {
gotenberg.MustRegisterModule(PDFcpu{}) gotenberg.MustRegisterModule(new(PdfCpu))
} }
// PDFcpu is a module which wraps the https://github.com/pdfcpu/pdfcpu library // PdfCpu abstracts the pdfcpu library and implements the [gotenberg.PdfEngine]
// and implements the gotenberg.PDFEngine interface. // interface.
type PDFcpu struct { type PdfCpu struct {
conf *pdfcpuConfig.Configuration conf *pdfcpuConfig.Configuration
} }
// Descriptor returns a PDFcpu's module descriptor. // Descriptor returns a [PdfCpu]'s module descriptor.
func (PDFcpu) Descriptor() gotenberg.ModuleDescriptor { func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ return gotenberg.ModuleDescriptor{
ID: "pdfcpu", ID: "pdfcpu",
New: func() gotenberg.Module { return new(PDFcpu) }, New: func() gotenberg.Module { return new(PdfCpu) },
} }
} }
// Provision sets the engine properties. // Provision sets the engine properties.
func (engine *PDFcpu) Provision(_ *gotenberg.Context) error { func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error {
pdfcpuConfig.ConfigPath = "disable" pdfcpuConfig.ConfigPath = "disable"
pdfcpuLog.DisableLoggers() pdfcpuLog.DisableLoggers()
engine.conf = pdfcpuConfig.NewDefaultConfiguration() engine.conf = pdfcpuConfig.NewDefaultConfiguration()
return nil return nil
} }
// Merge merges the given PDFs into a unique PDF. // Merge combines multiple PDFs into a single PDF.
func (engine PDFcpu) Merge(_ context.Context, _ *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, engine.conf) err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, false, engine.conf)
if err == nil { if err == nil {
return nil return nil
} }
@@ -49,14 +49,24 @@ func (engine PDFcpu) Merge(_ context.Context, _ *zap.Logger, inputPaths []string
return fmt.Errorf("merge PDFs with PDFcpu: %w", err) return fmt.Errorf("merge PDFs with PDFcpu: %w", err)
} }
// Convert is not available for this PDF engine. // Convert is not available in this implementation.
func (engine PDFcpu) Convert(_ context.Context, _ *zap.Logger, format, _, _ string) error { func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%s' with PDFcpu: %w", format, gotenberg.ErrPDFEngineMethodNotAvailable) return fmt.Errorf("convert PDF to '%+v' with PDFcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadMetadata is not available in this implementation.
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, fmt.Errorf("read PDF metadata with PDFcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteMetadata is not available in this implementation.
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return fmt.Errorf("write PDF metadata with PDFcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*PDFcpu)(nil) _ gotenberg.Module = (*PdfCpu)(nil)
_ gotenberg.Provisioner = (*PDFcpu)(nil) _ gotenberg.Provisioner = (*PdfCpu)(nil)
_ gotenberg.PDFEngine = (*PDFcpu)(nil) _ gotenberg.PdfEngine = (*PdfCpu)(nil)
) )

View File

@@ -7,92 +7,116 @@ import (
"reflect" "reflect"
"testing" "testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestPDFcpu_Descriptor(t *testing.T) { func TestPdfCpu_Descriptor(t *testing.T) {
descriptor := PDFcpu{}.Descriptor() descriptor := new(PdfCpu).Descriptor()
actual := reflect.TypeOf(descriptor.New()) actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(PDFcpu)) expect := reflect.TypeOf(new(PdfCpu))
if actual != expect { if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual) t.Errorf("expected '%s' but got '%s'", expect, actual)
} }
} }
func TestPDFcpu_Provision(t *testing.T) { func TestPdfCpu_Provision(t *testing.T) {
mod := new(PDFcpu) engine := new(PdfCpu)
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil) ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
err := mod.Provision(ctx) err := engine.Provision(ctx)
if err != nil { if err != nil {
t.Errorf("expected no error but got: %v", err) t.Errorf("expected no error but got: %v", err)
} }
} }
func TestPDFcpu_Merge(t *testing.T) { func TestPdfCpu_Merge(t *testing.T) {
for i, tc := range []struct { for _, tc := range []struct {
inputPaths []string scenario string
expectErr bool inputPaths []string
expectError bool
}{ }{
{ {
scenario: "invalid input path",
inputPaths: []string{
"foo",
},
expectError: true,
},
{
scenario: "single file success",
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
}, },
expectError: false,
}, },
{ {
scenario: "many files success",
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf", "/tests/test/testdata/pdfengines/sample2.pdf",
}, },
}, },
{
inputPaths: []string{
"foo",
},
expectErr: true,
},
} { } {
func() { t.Run(tc.scenario, func(t *testing.T) {
mod := new(PDFcpu) engine := new(PdfCpu)
err := engine.Provision(nil)
err := mod.Provision(nil)
if err != nil { if err != nil {
t.Fatalf("test %d: expected error but got: %v", i, err) t.Fatalf("expected error but got: %v", err)
} }
outputDir, err := gotenberg.MkdirAll() fs := gotenberg.NewFileSystem()
outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("test %d: expected error but got: %v", i, err) t.Fatalf("expected error but got: %v", err)
} }
defer func() { defer func() {
err := os.RemoveAll(outputDir) err = os.RemoveAll(fs.WorkingDirPath())
if err != nil { if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err) t.Fatalf("expected no error while cleaning up but got: %v", err)
} }
}() }()
err = mod.Merge(nil, nil, tc.inputPaths, outputDir+"/foo.pdf") err = engine.Merge(nil, nil, tc.inputPaths, outputDir+"/foo.pdf")
if tc.expectErr && err == nil { if !tc.expectError && err != nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Fatalf("expected no error but got: %v", err)
} }
if !tc.expectErr && err != nil { if tc.expectError && err == nil {
t.Errorf("test %d: expected no error but got: %v", i, err) t.Fatal("expected error but got none")
} }
}() })
} }
} }
func TestPDFcpu_Convert(t *testing.T) { func TestPdfCpu_Convert(t *testing.T) {
mod := new(PDFcpu) mod := new(PdfCpu)
err := mod.Convert(context.TODO(), zap.NewNop(), "", "", "") err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) { if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err) t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
engine := new(PdfCpu)
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
engine := new(PdfCpu)
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
} }
} }

View File

@@ -1,3 +1,3 @@
// Package pdfengines provides a module which gathers modules that implements // Package pdfengines a way to gather and manage multiple modules that
// the gotenberg.PDFEngine interface. // implement the gotenberg.PdfEngine interface.
package pdfengines package pdfengines

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