Compare commits

..

140 Commits

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

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

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

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

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

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

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

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

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

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

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

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

* Fix expected file extensions length

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

Co-authored-by: Jana Storch <jana.storch@dpdhl.com>
2021-09-09 20:06:49 +02:00
Snyk bot
eef7fc4b51 fix: build/Dockerfile to reduce vulnerabilities (#336)
The
2021-09-03 22:25:54 +02:00
Julien Neuhart
a88435f6b6 feat: add Live Demo link in the README 2021-08-29 14:38:10 +02:00
Julien Neuhart
0b22598783 fix: typo in api module godoc -> remove pointer for middleware 2021-08-28 17:59:18 +02:00
Julien Neuhart
40cc019a51 fix: typo Docker Registry -> Docker Repository 2021-08-27 19:14:17 +02:00
Julien Neuhart
781094ab05 fix: typo arch64 -> amd64 2021-08-27 18:50:06 +02:00
Julien Neuhart
21cfe2e535 Merge branch 'main' of github.com:gotenberg/gotenberg 2021-08-27 18:48:08 +02:00
Julien Neuhart
4f72684080 chore: upgrade LibreOffice to version 7.2 (#335) 2021-08-27 18:47:48 +02:00
Julien Neuhart
5ac514366e Merge branch 'main' of github.com:gotenberg/gotenberg 2021-08-27 14:49:56 +02:00
Julien Neuhart
d5d6285eef fix: use google chrome stable on amd64 (#333) 2021-08-27 14:48:09 +02:00
Julien Neuhart
0e34b41bce Merge branch 'main' of github.com:gotenberg/gotenberg 2021-08-27 13:48:22 +02:00
Julien Neuhart
e157066bcc chore: upgrade Go version to 1.17 (#334) 2021-08-27 13:46:44 +02:00
Julien Neuhart
f43a63ac6e chore: upgrade Go version to 1.17 2021-08-27 12:54:07 +02:00
Julien Neuhart
a5828fa01a fix: add --disable-gpu flag to Chromium / chore: upgrade base image to bullseye (#331) 2021-08-27 09:41:59 +02:00
Julien Neuhart
662d3ff795 chore: use echo.WrapHandler for health route 2021-08-25 17:12:54 +02:00
Julien Neuhart
a97f05db3b chore: add markdown table sample & test 2021-08-24 19:24:16 +02:00
Julien Neuhart
5a439ca70c fix: remove internal package and move it to cmd (#321) 2021-08-23 18:33:26 +02:00
Julien Neuhart
551faca3a3 fix: wrong version number in startup message & version LABEL 2021-08-23 12:10:34 +02:00
Julien Neuhart
c25ffe9639 feat: add stale bot 2021-08-22 17:48:34 +02:00
98 changed files with 43092 additions and 3748 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"

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

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

View File

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

View File

@@ -14,12 +14,17 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Run linters
uses: golangci/golangci-lint-action@v2
- name: Setup Go
uses: actions/setup-go@v4
with:
version: v1.39
go-version: '1.20'
cache: false
- name: Checkout source code
uses: actions/checkout@v3
- name: Run linters
uses: golangci/golangci-lint-action@v3
with:
version: v1.51.2
tests:
needs:
@@ -30,11 +35,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Checkout source code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Build testing environment
run: make build build-tests
- name: Run tests

View File

@@ -2,7 +2,6 @@ linters:
disable-all: true
enable:
- bodyclose
- deadcode
- errcheck
- gofmt
- goimports
@@ -13,11 +12,9 @@ linters:
- misspell
- prealloc
- staticcheck
- structcheck
- typecheck
- unconvert
- unused
- varcheck
run:
deadline: 5m

View File

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

View File

@@ -5,13 +5,14 @@ help: ## Show the help
.PHONY: it
it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.16
DOCKER_REGISTRY=gotenberg
GOLANG_VERSION=1.20
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
PDFTK_VERSION=1353200058 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.39.0 # See https://github.com/golangci/golangci-lint/releases.
NOTO_COLOR_EMOJI_VERSION=v2.038 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.51.2 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build
build: ## Build the Gotenberg's Docker image
@@ -20,96 +21,119 @@ build: ## Build the Gotenberg's Docker image
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOTENBERG_USER_GID=$(GOTENBERG_USER_GID) \
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
-t $(DOCKER_REGISTRY)/gotenberg:$(GOTENBERG_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
-f build/Dockerfile .
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000
API_PORT_FROM_ENV=
API_READ_TIMEOUT=30s
API_PROCESS_TIMEOUT=30s
API_WRITE_TIMEOUT=30s
API_TIMEOUT=30s
API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace
API_DISABLE_HEALTH_CHECK_LOGGING=false
API_WEBHOOK_ALLOW_LIST=
API_WEBHOOK_DENY_LIST=
API_WEBHOOK_ERROR_ALLOW_LIST=
API_WEBHOOK_ERROR_DENY_LIST=
API_WEBHOOK_MAX_RETRY=4
API_WEBHOOK_RETRY_MIN_WAIT=1s
API_WEBHOOK_RETRY_MAX_WAIT=30s
API_DISABLE_WEBHOOK=false
CHROMIUM_USER_AGENT=
CHROMIUM_FAILED_STARTS_THRESHOLD=5
CHROMIUM_INCOGNITO=false
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
CHROMIUM_DISABLE_WEB_SECURITY=false
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER=
CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_DISABLES_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
PDFENGINES_ENGINES=
PDFENGINES_DISABLE_ROUTES=false
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_COLLECT=false
UNO_LISTENER_START_TIMEOUT=10s
UNO_LISTENER_RESTART_THRESHOLD=10
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST=
WEBHOOK_ERROR_DENY_LIST=
WEBHOOK_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s
WEBHOOK_CLIENT_TIMEOUT=30s
WEBHOOK_DISABLE=false
.PHONY: run
run: ## Start a Gotenberg container
docker run --rm -it \
-p $(API_PORT):$(API_PORT) \
$(DOCKER_REGISTRY)/gotenberg:$(GOTENBERG_VERSION) \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-read-timeout=$(API_READ_TIMEOUT) \
--api-process-timeout=$(API_PROCESS_TIMEOUT) \
--api-write-timeout=$(API_WRITE_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--api-webhook-allow-list=$(API_WEBHOOK_ALLOW_LIST) \
--api-webhook-deny-list=$(API_WEBHOOK_DENY_LIST) \
--api-webhook-error-allow-list=$(API_WEBHOOK_ERROR_ALLOW_LIST) \
--api-webhook-error-deny-list=$(API_WEBHOOK_ERROR_DENY_LIST) \
--api-webhook-max-retry=$(API_WEBHOOK_MAX_RETRY) \
--api-webhook-retry-min-wait=$(API_WEBHOOK_RETRY_MIN_WAIT) \
--api-webhook-retry-max-wait=$(API_WEBHOOK_RETRY_MAX_WAIT) \
--api-disable-webhook=$(API_DISABLE_WEBHOOK) \
--chromium-user-agent=$(CHROMIUM_USER_AGENT) \
--chromium-failed-starts-threshold=$(CHROMIUM_FAILED_STARTS_THRESHOLD) \
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES)
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--uno-listener-start-timeout=$(UNO_LISTENER_START_TIMEOUT) \
--uno-listener-restart-threshold=$(UNO_LISTENER_RESTART_THRESHOLD) \
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
--webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \
--webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
--webhook-disable=$(WEBHOOK_DISABLE)
.PHONY: build-tests
build-tests: ## Build the tests' Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REGISTRY)/gotenberg:$(GOTENBERG_VERSION)-tests \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile .
.PHONY: tests
tests: ## Start the testing environment
docker run --rm -it \
-v $(PWD):/tests \
$(DOCKER_REGISTRY)/gotenberg:$(GOTENBERG_VERSION)-tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
bash
.PHONY: tests-once
tests-once: ## Run the tests once (prefer the "tests" command while developing)
docker run --rm \
-v $(PWD):/tests \
$(DOCKER_REGISTRY)/gotenberg:$(GOTENBERG_VERSION)-tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
gotest
.PHONY: fmt
@@ -123,11 +147,12 @@ godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/go
godoc -http=:6060
.PHONY: release
release: ## Build the Gotenberg's Docker image for linux/amd64 and linux/arm64 platforms, then push it to a Docker Registry
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
./scripts/release.sh \
$(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \
$(GOTENBERG_USER_GID) \
$(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \
$(DOCKER_REGISTRY)
$(DOCKER_REPOSITORY)

View File

@@ -1,14 +1,14 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a><!-- &#183; <a href="#">OpenAPI</a></p>-->
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a> &#183; 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</a></p>
</p>
---
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice to convert many
documents to PDF, transform them, merge them, and more!
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
## Quick Start
@@ -18,7 +18,7 @@ Open a terminal and run the following command:
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
```
Alternatively, using the historic Docker registry 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
@@ -43,4 +43,3 @@ Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to in
[![Continuous Integration](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg)](https://pkg.go.dev/github.com/gotenberg/gotenberg/v7)
[![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg)
[![Go Report Card](https://goreportcard.com/badge/github.com/gotenberg/gotenberg)](https://goreportcard.com/report/gotenberg/gotenberg)

View File

@@ -1,6 +1,7 @@
# Note: ARG instructions do not create additional layers.
# Instead, next layers will concatenate them.
ARG GOLANG_VERSION
# Note: we have to repeat ARG instructions in each build stage that uses them.
ARG GOTENBERG_VERSION
FROM golang:$GOLANG_VERSION AS builder
@@ -18,18 +19,21 @@ RUN go mod download &&\
# Copy the source code.
COPY cmd ./cmd
COPY internal ./internal
COPY pkg ./pkg
# Build the binary.
RUN go build -o gotenberg -ldflags "-X gotenberg.version=$GOTENBERG_VERSION" cmd/gotenberg/main.go
ARG GOTENBERG_VERSION
FROM debian:buster-slim
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
FROM debian:11-slim
ARG GOTENBERG_VERSION
LABEL author="Julien Neuhart" \
description="A Docker-powered stateless API for PDF files." \
github="https://github.com/gotenberg/gotenberg" \
version="$GOTENBER_VERSION" \
version="$GOTENBERG_VERSION" \
website="https://gotenberg.dev"
# Improve fonts subpixel hinting and smoothing.
@@ -44,8 +48,14 @@ COPY build/pdftk.sh /usr/bin/pdftk
# Setup the Docker image.
ARG GOTENBERG_USER_GID
ARG GOTENBERG_USER_UID
ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION
# Script for installing either Google Chrome stable on amd64 architecture or
# Chromium on other architectures.
# See https://github.com/gotenberg/gotenberg/issues/328.
COPY build/install-chromium.sh /tmp/install-chromium.sh
RUN \
# Create a non-root user.
# All processes in the Docker container will run with this dedicated user.
@@ -54,10 +64,10 @@ RUN \
mkdir /home/gotenberg &&\
chown gotenberg: /home/gotenberg &&\
# Install dependencies required for the next instructions or debugging.
# Note: procps for "top" command (useful when debugging processes).
# Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg procps tini &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg htop tini python3 default-jre-headless &&\
ln -s /usr/bin/htop /usr/bin/top &&\
# Install fonts.
# Credits:
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
@@ -80,6 +90,7 @@ RUN \
fonts-telu \
fonts-thai-tlwg \
ttf-wqy-zenhei \
fonts-arphic-ukai \
fonts-arphic-uming \
fonts-ipafont-mincho \
fonts-ipafont-gothic \
@@ -92,29 +103,51 @@ RUN \
fonts-liberation \
fonts-liberation2 \
fonts-linuxlibertine \
fonts-noto-cjk \
fonts-noto-core \
fonts-noto-mono \
fonts-noto-ui-core \
fonts-sil-gentium \
fonts-sil-gentium-basic &&\
rm -f ./ttf-mscorefonts-installer_3.8_all.deb &&\
echo "deb https://httpredir.debian.org/debian/ buster-backports main contrib non-free" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
# Install Chromium and LibreOffice.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t buster-backports libreoffice &&\
# Add Color and Black-and-White Noto emoji font.
# Credits:
# https://github.com/gotenberg/gotenberg/pull/325.
# https://github.com/googlefonts/noto-emoji.
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
# Install Google Chrome / Chromium.
/tmp/install-chromium.sh &&\
# Install LibreOffice.
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libreoffice &&\
# Next lines are not currently relevant, as latest versions of LibreOffice
# (i.e., > 7.0.4) are causing troubles.
# See:
# https://packages.debian.org/search?keywords=libreoffice.
# https://github.com/gotenberg/gotenberg/issues/442.
# https://github.com/gotenberg/gotenberg/issues/516.
# https://github.com/gotenberg/gotenberg/issues/529.
# https://github.com/gotenberg/gotenberg/issues/537.
# https://github.com/gotenberg/gotenberg/issues/568.
# 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.
# Credits: https://github.com/thecodingmachine/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/pdftk-java/pdftk/-/jobs/$PDFTK_VERSION/artifacts/raw/build/libs/pdftk-all.jar" &&\
# 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 &&\
# 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 &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends default-jre-headless &&\
mkdir -p /usr/share/man/man1 &&\
# 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.
@@ -124,7 +157,8 @@ RUN \
chromium --version &&\
libreoffice --version &&\
unoconv --version &&\
pdftk --version
pdftk --version &&\
qpdf --version
# Copy the Gotenberg binary from the builder stage.
COPY --from=builder /home/gotenberg /usr/bin/
@@ -133,7 +167,9 @@ COPY --from=builder /home/gotenberg /usr/bin/
ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg"
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
ENV UNOCONV_BIN_PATH /usr/bin/unoconv
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
ENV PDFTK_BIN_PATH /usr/bin/pdftk
ENV QPDF_BIN_PATH /usr/bin/qpdf
USER gotenberg
WORKDIR /home/gotenberg
@@ -142,4 +178,4 @@ WORKDIR /home/gotenberg
EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ]
CMD [ "gotenberg" ]

View File

@@ -1,9 +1,9 @@
# Note: ARG instructions do not create additional layers.
# Instead, next layers will concatenate them.
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/gotenberg:$GOTENBERG_VERSION
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
USER root

15
build/install-chromium.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/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,14 +1,16 @@
package gotenberg
package gotenbergcmd
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag"
"golang.org/x/sync/errgroup"
)
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
@@ -25,12 +27,15 @@ Version: %s
-------------------------------------------------------
`
var version = "snapshot"
// Version is the... version of the Gotenberg application. We set it at the
// build stage of the Docker image.
var Version = "snapshot"
// Run starts the Gotenberg application. Call this in the main of your program.
func Run() {
fmt.Printf(banner, version)
fmt.Printf(banner, Version)
// Creates the roo` FlagSet and adds the modules flags to it.
// Create the root FlagSet and adds the modules flags to it.
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
@@ -43,14 +48,14 @@ func Run() {
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
// Parses the flags...
// Parse the flags...
err := fs.Parse(os.Args[1:])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
// ...and creates a wrapper around those.
// ...and create a wrapper around those.
parsedFlags := gotenberg.ParsedFlags{FlagSet: fs}
// Get the graceful shutdown duration.
@@ -58,7 +63,7 @@ func Run() {
ctx := gotenberg.NewContext(parsedFlags, descriptors)
// Starts application modules.
// Start application modules.
apps, err := ctx.Modules(new(gotenberg.App))
if err != nil {
fmt.Printf("[FATAL] %s\n", err)
@@ -84,13 +89,29 @@ func Run() {
fmt.Printf("[SYSTEM] %s: %s\n", id, startupMessage)
}(a.(gotenberg.App))
}
// Get modules that want to print system messages.
sysLoggers, err := ctx.Modules(new(gotenberg.SystemLogger))
if err != nil {
fmt.Printf("[FATAL] %s\n", err)
os.Exit(1)
}
for _, l := range sysLoggers {
go func(logger gotenberg.SystemLogger) {
id := logger.(gotenberg.Module).Descriptor().ID
for _, message := range logger.SystemMessages() {
fmt.Printf("[SYSTEM] %s: %s\n", id, message)
}
}(l.(gotenberg.SystemLogger))
}
quit := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C).
signal.Notify(quit, os.Interrupt)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM (Kubernetes).
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Block until we receive our signal.
<-quit
@@ -98,18 +119,40 @@ func Run() {
gracefulShutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownDuration)
defer cancel()
forceQuit := make(chan os.Signal, 1)
signal.Notify(forceQuit, syscall.SIGINT)
go func() {
// In case of force quit, cancel the context.
<-forceQuit
cancel()
}()
fmt.Printf("[SYSTEM] graceful shutdown of %s\n", gracefulShutdownDuration)
eg, _ := errgroup.WithContext(gracefulShutdownCtx)
for _, a := range apps {
id := a.(gotenberg.Module).Descriptor().ID
app := a.(gotenberg.App)
eg.Go(func(app gotenberg.App) func() error {
return func() error {
id := app.(gotenberg.Module).Descriptor().ID
err = app.Stop(gracefulShutdownCtx)
if err != nil {
fmt.Printf("[ERROR] stopping %s: %s\n", id, err)
}
err = app.Stop(gracefulShutdownCtx)
if err != nil {
return fmt.Errorf("stopping %s: %w", id, err)
}
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
return nil
}
}(a.(gotenberg.App)))
}
err = eg.Wait()
if err != nil {
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1)
}
os.Exit(0)

View File

@@ -1,16 +1,12 @@
package main
import (
gotenbergapp "github.com/gotenberg/gotenberg/v7/internal/app/gotenberg"
gotenbergcmd "github.com/gotenberg/gotenberg/v7/cmd"
// Gotenberg modules.
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
// PDF engines.
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfcpu"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdftk"
)
func main() {
gotenbergapp.Run()
gotenbergcmd.Run()
}

744
docs/openapi.yaml Normal file
View File

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

87
go.mod
View File

@@ -1,38 +1,69 @@
module github.com/gotenberg/gotenberg/v7
go 1.16
go 1.20
require (
github.com/alexliesenfeld/health v0.6.0
github.com/andybalholm/brotli v1.0.3 // indirect
github.com/chromedp/cdproto v0.0.0-20210808225517-c36c1bd4c35e
github.com/chromedp/chromedp v0.7.4
github.com/alexliesenfeld/health v0.7.0
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/chromedp/cdproto v0.0.0-20230524011726-8501c74e8228
github.com/chromedp/chromedp v0.9.1
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.0
github.com/klauspost/compress v1.13.4 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/labstack/echo/v4 v4.5.0
github.com/labstack/gommon v0.3.0
github.com/mattn/go-isatty v0.0.13 // indirect
github.com/mholt/archiver/v3 v3.5.0
github.com/microcosm-cc/bluemonday v1.0.15
github.com/nwaples/rardecode v1.1.2 // indirect
github.com/pdfcpu/pdfcpu v0.3.12
github.com/pierrec/lz4/v4 v4.1.8 // indirect
github.com/hashicorp/go-retryablehttp v0.7.2
github.com/klauspost/compress v1.16.5 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.10.2
github.com/labstack/gommon v0.4.0
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.24
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.4.1
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/prometheus/client_golang v1.15.1
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.10 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.7.0
go.uber.org/zap v1.19.0
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55 // indirect
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
golang.org/x/text v0.3.7
golang.org/x/tools v0.1.5 // indirect
github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/image v0.7.0 // indirect
golang.org/x/net v0.10.0
golang.org/x/sync v0.2.0
golang.org/x/sys v0.8.0 // indirect
golang.org/x/term v0.8.0
golang.org/x/text v0.9.0
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.2.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.10.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

276
go.sum
View File

@@ -1,198 +1,214 @@
github.com/alexliesenfeld/health v0.6.0 h1:HRBTCgybNSe4lqGEk7nU82c3bjwh9W+3b46W6UvD4CQ=
github.com/alexliesenfeld/health v0.6.0/go.mod h1:N4NDIeQtlWumG+6z1ne1v62eQxktz5ylEgGgH9emdMw=
github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM=
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/alexliesenfeld/health v0.7.0 h1:U3mSZ3ussRbGx+/rXBjNVxjLX5cKaNh8Ly9Hx3q+yfY=
github.com/alexliesenfeld/health v0.7.0/go.mod h1:6Nnjbu7vBYHoZqIuZeOnTpnW7OH14ulR+wIBE2QuJ8I=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/chromedp/cdproto v0.0.0-20210713064928-7d28b402946a/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
github.com/chromedp/cdproto v0.0.0-20210808225517-c36c1bd4c35e h1:uYQIvPKwL7YQh+tQC+EUXmvWosBeOYbJZKrh4NnS0UM=
github.com/chromedp/cdproto v0.0.0-20210808225517-c36c1bd4c35e/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
github.com/chromedp/chromedp v0.7.4 h1:U+0d3WbB/Oj4mDuBOI0P7S3PJEued5UZIl5AJ3QulwU=
github.com/chromedp/chromedp v0.7.4/go.mod h1:dBj+SXuQHznp6ZPwZeDDEBZKwclUwDLbZ0hjMialMYs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20230524011726-8501c74e8228 h1:t5YUtCgmUJJ4H5mwQhst70ZxpaCK4SGDtFRPKKw+cYU=
github.com/chromedp/cdproto v0.0.0-20230524011726-8501c74e8228/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.1 h1:CC7cC5p1BeLiiS2gfNNPwp3OaUxtRMBjfiw3E3k6dFA=
github.com/chromedp/chromedp v0.9.1/go.mod h1:DUgZWRvYoEfgi66CgZ/9Yv+psgi+Sksy5DTScENWjaQ=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4=
github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hhrutter/lzw v0.0.0-20190827003112-58b82c5a41cc/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 h1:1yY/RQWNSBjJe2GDCIYoLmpWVidrooriUr4QS/zaATQ=
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 h1:o1wMw7uTNyA58IlEdDpxIrtFHTgnvYzA8sCQz8luv94=
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7/go.mod h1:WkUxfS2JUu3qPo6tRld7ISb8HiC0gVSU91kooBMDVok=
github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0=
github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/tiff v1.0.0 h1:T8/QVXiABO6Er7XCoExh4XPGyMO+X1ynf0V8kHui3t4=
github.com/hhrutter/tiff v1.0.0/go.mod h1:zluYmeCkNexc8HFzfc2MTVwA8gcPuFQp/ngjvIQ0CFo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.13.4 h1:0zhec2I8zGnjWcKyLl6i3gPqKANCCn5e9xmviEEeX6s=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.4/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo/v4 v4.5.0 h1:JXk6H5PAw9I3GwizqUHhYyS4f45iyGebR/c1xNCeOCY=
github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y=
github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0=
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mholt/archiver/v3 v3.5.0 h1:nE8gZIrw66cu4osS/U7UW7YDuGMHssxKutU8IfWxwWE=
github.com/mholt/archiver/v3 v3.5.0/go.mod h1:qqTTPUK/HZPFgFQ/TJ3BzvTpF/dPtFVJXdQbCmeMxwc=
github.com/microcosm-cc/bluemonday v1.0.15 h1:J4uN+qPng9rvkBZBoBb8YGR+ijuklIMpSOZZLjYpbeY=
github.com/microcosm-cc/bluemonday v1.0.15/go.mod h1:ZLvAzeakRwrGnzQEvstVzVt3ZpqOF2+sdFr0Om+ce30=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.24 h1:NGQoPtwGVcbGkKfvyYk1yRqknzBuoMiUrO6R7uFTPlw=
github.com/microcosm-cc/bluemonday v1.0.24/go.mod h1:ArQySAMps0790cHSkdPEJ7bGkF2VePWH773hsJNSHf8=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.2 h1:Cj0yZY6T1Zx1R7AhTbyGSALm44/Mmq+BAPc4B/p/d3M=
github.com/nwaples/rardecode v1.1.2/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5 h1:1SoBaSPudixRecmlHXb/GxmaD3fLMtHIDN13QujwQuc=
github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.3.12 h1:B+MdKisilWNSk5OCO58Z9U6H93usH73xqk6hMOaZCls=
github.com/pdfcpu/pdfcpu v0.3.12/go.mod h1:8XVBtVxuuIuSZL4Ez15Q4QoC+H8zeAaGnuiOEwAk8jA=
github.com/pierrec/lz4/v4 v4.0.3/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4=
github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.4.1 h1:oKgcST93zXdq1vE+B8dQBlE8S7WqsBVgkLxg82M3Fgk=
github.com/pdfcpu/pdfcpu v0.4.1/go.mod h1:MojCBFW2uljNs3CBmyTDeFAvu7vI1LrJhWNMCjY3kg4=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.10.0 h1:UkG7GPYkO4UZyLnyXjaWYcgOSONqwdBqFUT95ugmt6I=
github.com/prometheus/procfs v0.10.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.19.0 h1:mZQZefskPPCMIBCSEH0v2/iUqqLrYtaeqwD6FUGUnFE=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw=
golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55 h1:rw6UNGRMfarCepjI8qOepea/SXwIBVfTKjztZ5gBbq4=
golang.org/x/sys v0.0.0-20210820121016-41cdb8703e55/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -31,7 +31,7 @@ func Command(logger *zap.Logger, binPath string, args ...string) Cmd {
return Cmd{
ctx: nil,
logger: logger.Named("cmd"),
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd,
}
}
@@ -51,7 +51,7 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
return Cmd{
ctx: ctx,
logger: logger.Named("cmd"),
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
process: cmd,
}, nil
}
@@ -73,22 +73,37 @@ func (cmd Cmd) Start() error {
return nil
}
// Wait waits for the command to complete. It should be called when using the
// Start method, so that the command does not leak zombies.
func (cmd Cmd) Wait() error {
err := cmd.process.Wait()
if err != nil {
return fmt.Errorf("wait for unix process: %w", err)
}
return nil
}
// Exec executes the command and wait for its completion or until the context
// is done. In any case, it kills the unix process and all its children.
func (cmd Cmd) Exec() error {
func (cmd Cmd) Exec() (int, error) {
if cmd.ctx == nil {
return errors.New("nil context")
return 10, errors.New("nil context")
}
err := cmd.Start()
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)
go func() {
errChan <- cmd.process.Wait()
errChan <- cmd.Wait()
}()
select {
@@ -99,17 +114,21 @@ func (cmd Cmd) Exec() error {
}
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():
errProc := cmd.Kill()
if errProc != nil {
cmd.logger.Error(errProc.Error())
}
return fmt.Errorf("context done: %w", cmd.ctx.Err())
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
}
}

View File

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

View File

@@ -25,6 +25,17 @@ func (f *ParsedFlags) MustString(name string) string {
return val
}
// MustDeprecatedString returns the string value of a deprecated flag if it was
// explicitly set or the string value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedString(deprecated string, newName string) string {
if f.Changed(deprecated) {
return f.MustString(deprecated)
}
return f.MustString(newName)
}
// MustStringSlice returns the string slice value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustStringSlice(name string) []string {
@@ -36,6 +47,17 @@ func (f *ParsedFlags) MustStringSlice(name string) []string {
return val
}
// MustDeprecatedStringSlice returns the string slice value of a deprecated
// flag if it was explicitly set or the string slice value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedStringSlice(deprecated string, newName string) []string {
if f.Changed(deprecated) {
return f.MustStringSlice(deprecated)
}
return f.MustStringSlice(newName)
}
// MustBool returns the boolean value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustBool(name string) bool {
@@ -47,6 +69,17 @@ func (f *ParsedFlags) MustBool(name string) bool {
return val
}
// MustDeprecatedBool returns the boolean value of a deprecated flag if it was
// explicitly set or the int value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedBool(deprecated string, newName string) bool {
if f.Changed(deprecated) {
return f.MustBool(deprecated)
}
return f.MustBool(newName)
}
// MustInt returns the int value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustInt(name string) int {
@@ -58,6 +91,17 @@ func (f *ParsedFlags) MustInt(name string) int {
return val
}
// MustDeprecatedInt returns the int value of a deprecated flag if it was
// explicitly set or the int value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedInt(deprecated string, newName string) int {
if f.Changed(deprecated) {
return f.MustInt(deprecated)
}
return f.MustInt(newName)
}
// MustFloat64 returns the float value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustFloat64(name string) float64 {
@@ -69,6 +113,17 @@ func (f *ParsedFlags) MustFloat64(name string) float64 {
return val
}
// MustDeprecatedFloat64 returns the float value of a deprecated flag if it was
// explicitly set or the float value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedFloat64(deprecated string, newName string) float64 {
if f.Changed(deprecated) {
return f.MustFloat64(deprecated)
}
return f.MustFloat64(newName)
}
// MustDuration returns the time.Duration value of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustDuration(name string) time.Duration {
@@ -80,6 +135,17 @@ func (f *ParsedFlags) MustDuration(name string) time.Duration {
return val
}
// MustDeprecatedDuration returns the time.Duration value of a deprecated flag
// if it was explicitly set or the time.Duration value of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string) time.Duration {
if f.Changed(deprecated) {
return f.MustDuration(deprecated)
}
return f.MustDuration(newName)
}
// MustHumanReadableBytesString returns the human-readable bytes string of a
// flag given by name.
// It panics if an error occurs.
@@ -97,6 +163,18 @@ func (f *ParsedFlags) MustHumanReadableBytesString(name string) string {
return val
}
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
// string of a deprecated flag if it was explicitly set or the human-readable
// bytes string of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
if f.Changed(deprecated) {
return f.MustHumanReadableBytesString(deprecated)
}
return f.MustHumanReadableBytesString(newName)
}
// MustRegexp returns the regular expression of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
@@ -107,3 +185,14 @@ func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
return regexp.MustCompile(val)
}
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
// it was explicitly set or the regular expression of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
if f.Changed(deprecated) {
return f.MustRegexp(deprecated)
}
return f.MustRegexp(newName)
}

View File

@@ -1,6 +1,8 @@
package gotenberg
import (
"reflect"
"regexp"
"testing"
"time"
@@ -52,6 +54,42 @@ func TestParsedFlags_MustString(t *testing.T) {
}
}
func TestParsedFlags_MustDeprecatedString(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue string
}{
{
rawFlags: []string{"--foo=foo"},
expectValue: "foo",
},
{
rawFlags: []string{"--bar=bar"},
expectValue: "bar",
},
{
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: "foo",
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedString("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustStringSlice(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", make([]string, 0), "")
@@ -97,6 +135,42 @@ func TestParsedFlags_MustStringSlice(t *testing.T) {
}
}
func TestParsedFlags_MustDeprecatedStringSlice(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue []string
}{
{
rawFlags: []string{"--foo=foo"},
expectValue: []string{"foo"},
},
{
rawFlags: []string{"--bar=bar"},
expectValue: []string{"bar"},
},
{
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: []string{"foo"},
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", make([]string, 0), "")
fs.StringSlice("bar", make([]string, 0), "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedStringSlice("foo", "bar")
if !reflect.DeepEqual(actual, tc.expectValue) {
t.Errorf("test %d: expected %+v but got %+v", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustBool(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Bool("foo", false, "")
@@ -142,6 +216,42 @@ func TestParsedFlags_MustBool(t *testing.T) {
}
}
func TestParsedFlags_MustDeprecatedBool(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue bool
}{
{
rawFlags: []string{"--foo=true"},
expectValue: true,
},
{
rawFlags: []string{"--bar=false"},
expectValue: false,
},
{
rawFlags: []string{"--foo=true", "--bar=false"},
expectValue: true,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Bool("foo", false, "")
fs.Bool("bar", true, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedBool("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %v but got %v", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustInt(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int("foo", 0, "")
@@ -187,6 +297,42 @@ func TestParsedFlags_MustInt(t *testing.T) {
}
}
func TestParsedFlags_MustDeprecatedInt(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue int
}{
{
rawFlags: []string{"--foo=1"},
expectValue: 1,
},
{
rawFlags: []string{"--bar=2"},
expectValue: 2,
},
{
rawFlags: []string{"--foo=1", "--bar=2"},
expectValue: 1,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Int("foo", 0, "")
fs.Int("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedInt("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %d but got %d", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustFloat64(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Float64("foo", 1.0, "")
@@ -232,6 +378,42 @@ func TestParsedFlags_MustFloat64(t *testing.T) {
}
}
func TestParsedFlags_MustDeprecatedFloat64(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue float64
}{
{
rawFlags: []string{"--foo=1.0"},
expectValue: 1.0,
},
{
rawFlags: []string{"--bar=2.0"},
expectValue: 2.0,
},
{
rawFlags: []string{"--foo=1.0", "--bar=2.0"},
expectValue: 1.0,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Float64("foo", 0, "")
fs.Float64("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedFloat64("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected %f but got %f", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustDuration(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Duration("foo", time.Duration(1)*time.Second, "")
@@ -277,6 +459,42 @@ func TestParsedFlags_MustDuration(t *testing.T) {
}
}
func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue time.Duration
}{
{
rawFlags: []string{"--foo=1s"},
expectValue: time.Duration(1) * time.Second,
},
{
rawFlags: []string{"--bar=2s"},
expectValue: time.Duration(2) * time.Second,
},
{
rawFlags: []string{"--foo=1s", "--bar=2s"},
expectValue: time.Duration(1) * time.Second,
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.Duration("foo", 0, "")
fs.Duration("bar", 0, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedDuration("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "1MB", "")
@@ -327,6 +545,42 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
}
}
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue string
}{
{
rawFlags: []string{"--foo=1MB"},
expectValue: "1MB",
},
{
rawFlags: []string{"--bar=2MB"},
expectValue: "2MB",
},
{
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
expectValue: "1MB",
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
if actual != tc.expectValue {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue, actual)
}
}
}
func TestParsedFlags_MustRegexp(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
@@ -376,3 +630,39 @@ func TestParsedFlags_MustRegexp(t *testing.T) {
}()
}
}
func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
for i, tc := range []struct {
rawFlags []string
expectValue *regexp.Regexp
}{
{
rawFlags: []string{"--foo=foo"},
expectValue: regexp.MustCompile("foo"),
},
{
rawFlags: []string{"--bar=bar"},
expectValue: regexp.MustCompile("bar"),
},
{
rawFlags: []string{"--foo=foo", "--bar=bar"},
expectValue: regexp.MustCompile("foo"),
},
} {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "", "")
fs.String("bar", "", "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
actual := parsedFlags.MustDeprecatedRegexp("foo", "bar")
if actual.String() != tc.expectValue.String() {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectValue.String(), actual.String())
}
}
}

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

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

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

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

View File

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

View File

@@ -69,6 +69,12 @@ type App interface {
Stop(ctx context.Context) error
}
// SystemLogger is a module interface for modules which want to display
// messages on startup.
type SystemLogger interface {
SystemMessages() []string
}
// MustRegisterModule registers a module.
//
// To register a module, create an init() method in the module main go file:

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
@@ -19,54 +18,58 @@ import (
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/http2"
)
func init() {
gotenberg.MustRegisterModule(API{})
}
// API is a module which provides an HTTP server. Other modules may add
// "multipart/form-data" routes, middlewares or health checks.
// API is a module which provides an HTTP server. Other modules may add routes,
// middlewares or health checks.
type API struct {
port int
readTimeout time.Duration
processTimeout time.Duration
writeTimeout time.Duration
timeout time.Duration
rootPath string
traceHeader string
disableHealthCheckLogging bool
webhookAllowList *regexp.Regexp
webhookDenyList *regexp.Regexp
webhookErrorAllowList *regexp.Regexp
webhookErrorDenyList *regexp.Regexp
webhookMaxRetry int
webhookRetryMinWait time.Duration
webhookRetryMaxWait time.Duration
disableWebhook bool
multipartFormDataRoutes []MultipartFormDataRoute
externalMiddlewares []Middleware
healthChecks []health.CheckerOption
logger *zap.Logger
srv *echo.Echo
routes []Route
externalMiddlewares []Middleware
healthChecks []health.CheckerOption
gcGraceDuration time.Duration
logger *zap.Logger
srv *echo.Echo
}
// MultipartFormDataRouter is a module interface which adds
// "multipart/form-data" routes to the API.
type MultipartFormDataRouter interface {
Routes() ([]MultipartFormDataRoute, error)
// Router is a module interface which adds routes to the API.
type Router interface {
Routes() ([]Route, error)
}
// MultipartFormDataRoute represents a "multipart/form-data" route. All routes
// uses the HTTP POST method.
type MultipartFormDataRoute struct {
// Route represents a route from a Router.
type Route struct {
// Method is the HTTP method of the route (i.e., GET, POST, etc.).
// Required.
Method string
// Path is the sub path of the route. Must start with a slash.
// Required.
Path string
// IsMultipart tells if the route is "multipart/form-data".
// Optional.
IsMultipart bool
// DisableLogging disables the logging for this route.
// Optional.
DisableLogging bool
// Handler is the function which handles the request.
// Required.
Handler func(ctx *Context) error
Handler echo.HandlerFunc
}
// MiddlewareProvider is a module interface which adds middlewares to the API.
@@ -74,8 +77,18 @@ type MiddlewareProvider interface {
Middlewares() ([]Middleware, error)
}
// MiddlewareStack is a type which helps to determine in which stack the
// middlewares provided by the MiddlewareProvider modules should be located.
type MiddlewareStack uint32
const (
DefaultStack MiddlewareStack = iota
PreRouterStack
MultipartStack
)
// MiddlewarePriority is a type which helps to determine the execution order of
// middlewares provided by the MiddlewareProvider modules.
// middlewares provided by the MiddlewareProvider modules in a stack.
type MiddlewarePriority uint32
const (
@@ -89,36 +102,36 @@ const (
// Middleware is a middleware which can be added to the API's middlewares
// chain.
//
// middleware := &Middleware{
// Handler: func() echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error {
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath)
// middleware := Middleware{
// Handler: func() echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error {
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath)
//
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
//
// // Your middleware process.
// // ...
// // Your middleware process.
// // ...
//
// // Call the next middleware in the chain.
// return next(c)
// }
// }
// }(),
// }
// // Call the next middleware in the chain.
// return next(c)
// }
// }
// }(),
// }
type Middleware struct {
// RunBeforeRouter tells if the middleware should run before the router
// process an HTTP request.
// Stack tells in which stack the middleware should be located.
// Default to DefaultStack.
// Optional.
RunBeforeRouter bool
Stack MiddlewareStack
// Priority tells if the middleware should be positioned high or not in
// the middlewares chain.
// its stack.
// Default to VeryLowPriority.
// Optional.
Priority MiddlewarePriority
@@ -136,6 +149,12 @@ type HealthChecker interface {
Checks() ([]health.CheckerOption, error)
}
// GarbageCollectorGraceDurationIncrementer is a module interface for
// increasing the grace duration provided by the API for the garbage collector.
type GarbageCollectorGraceDurationIncrementer interface {
AddGraceDuration() time.Duration
}
// Descriptor returns an API's module descriptor.
func (API) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
@@ -147,17 +166,19 @@ func (API) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body")
fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request")
fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
fs.String("api-webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("api-webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("api-webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
fs.String("api-webhook-error-deny-list", "", "Set the denied URLs in case of an error for the webhook feature using a regular expression")
fs.Int("api-webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
fs.Duration("api-webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
fs.Duration("api-webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
fs.Bool("api-disable-webhook", false, "Disable the webhook feature")
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
}(),
@@ -169,20 +190,12 @@ func (API) Descriptor() gotenberg.ModuleDescriptor {
func (a *API) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port")
a.readTimeout = flags.MustDuration("api-read-timeout")
a.processTimeout = flags.MustDuration("api-process-timeout")
a.writeTimeout = flags.MustDuration("api-write-timeout")
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
a.webhookAllowList = flags.MustRegexp("api-webhook-allow-list")
a.webhookDenyList = flags.MustRegexp("api-webhook-deny-list")
a.webhookErrorAllowList = flags.MustRegexp("api-webhook-error-allow-list")
a.webhookErrorDenyList = flags.MustRegexp("api-webhook-error-deny-list")
a.webhookMaxRetry = flags.MustInt("api-webhook-max-retry")
a.webhookRetryMinWait = flags.MustDuration("api-webhook-retry-min-wait")
a.webhookRetryMaxWait = flags.MustDuration("api-webhook-retry-max-wait")
a.disableWebhook = flags.MustBool("api-disable-webhook")
// Port from env?
portEnvVar := flags.MustString("api-port-from-env")
@@ -206,14 +219,14 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
}
// Get routes from modules.
mods, err := ctx.Modules(new(MultipartFormDataRouter))
mods, err := ctx.Modules(new(Router))
if err != nil {
return fmt.Errorf("get multipart/form-data routers: %w", err)
return fmt.Errorf("get routers: %w", err)
}
routers := make([]MultipartFormDataRouter, len(mods))
routers := make([]Router, len(mods))
for i, router := range mods {
routers[i] = router.(MultipartFormDataRouter)
routers[i] = router.(Router)
}
for _, router := range routers {
@@ -222,7 +235,7 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
return fmt.Errorf("get routes: %w", err)
}
a.multipartFormDataRoutes = append(a.multipartFormDataRoutes, routes...)
a.routes = append(a.routes, routes...)
}
// Get middlewares from modules.
@@ -270,6 +283,18 @@ func (a *API) Provision(ctx *gotenberg.Context) error {
a.healthChecks = append(a.healthChecks, checks...)
}
// Grace duration.
a.gcGraceDuration = a.timeout
mods, err = ctx.Modules(new(GarbageCollectorGraceDurationIncrementer))
if err != nil {
return fmt.Errorf("get garbage collector grace duration increments: %w", err)
}
for _, incrementer := range mods {
a.gcGraceDuration += incrementer.(GarbageCollectorGraceDurationIncrementer).AddGraceDuration()
}
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
@@ -317,26 +342,35 @@ func (a API) Validate() error {
return err
}
routesMap := make(map[string]MultipartFormDataRoute, len(a.multipartFormDataRoutes))
routesMap := make(map[string]string, len(a.routes)+1)
routesMap["/health"] = "/health"
for _, route := range a.multipartFormDataRoutes {
for _, route := range a.routes {
if route.Path == "" {
return errors.New("route with empty path cannot be registered")
}
if !strings.HasPrefix(route.Path, "/") {
return fmt.Errorf("route %s does not start with /", route.Path)
return fmt.Errorf("route '%s' does not start with /", route.Path)
}
if route.IsMultipart && !strings.HasPrefix(route.Path, "/forms") {
return fmt.Errorf("multipart/form-data route '%s' does not start with /forms", route.Path)
}
if route.Method == "" {
return fmt.Errorf("route '%s' has an empty method", route.Path)
}
if route.Handler == nil {
return fmt.Errorf("route %s has a nil handler", route.Path)
return fmt.Errorf("route '%s' has a nil handler", route.Path)
}
if _, ok := routesMap[route.Path]; ok {
return fmt.Errorf("route %s is already registered", route.Path)
return fmt.Errorf("route '%s' is already registered", route.Path)
}
routesMap[route.Path] = route
routesMap[route.Path] = route.Path
}
for _, middleware := range a.externalMiddlewares {
@@ -354,101 +388,88 @@ func (a *API) Start() error {
a.srv.HideBanner = true
a.srv.HidePort = true
a.srv.Server.ReadTimeout = a.readTimeout
a.srv.Server.WriteTimeout = a.writeTimeout
a.srv.HTTPErrorHandler = httpErrorHandler(a.traceHeader)
a.srv.Server.IdleTimeout = a.timeout
// See https://github.com/gotenberg/gotenberg/issues/396.
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes.
var disableLoggingForPaths []string
for i, route := range a.routes {
a.routes[i].Path = strings.TrimPrefix(route.Path, "/")
if route.DisableLogging {
disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/"))
}
}
// Check if the user wish to add logging entries related to the health
// check route.
if a.disableHealthCheckLogging {
disableLoggingForPaths = append(disableLoggingForPaths, "health")
}
// Add the API middlewares.
a.srv.Pre(
latencyMiddleware(),
rootPathMiddleware(a.rootPath),
traceMiddleware(a.traceHeader),
loggerMiddleware(a.logger, a.disableHealthCheckLogging),
loggerMiddleware(a.logger, disableLoggingForPaths),
)
// Add the modules' middlewares in their respective stacks.
var externalMultipartMiddlewares []Middleware
for _, externalMiddleware := range a.externalMiddlewares {
if externalMiddleware.RunBeforeRouter {
switch externalMiddleware.Stack {
case PreRouterStack:
a.srv.Pre(externalMiddleware.Handler)
continue
case MultipartStack:
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
default:
a.srv.Use(externalMiddleware.Handler)
}
a.srv.Use(externalMiddleware.Handler)
}
hardTimeout := a.processTimeout + (time.Duration(5) * time.Second)
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
a.srv.GET(
fmt.Sprintf("%shealth", a.rootPath),
func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.processTimeout))
checker := health.NewChecker(checks...)
// Add the modules' routes and their specific middlewares.
for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc
return func(echoCtx echo.Context) error {
health.NewHandler(checker).ServeHTTP(echoCtx.Response().Writer, echoCtx.Request())
if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.timeout))
return nil
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
}
}(),
timeoutMiddleware(hardTimeout),
)
}
formsGroup := a.srv.Group(
fmt.Sprintf("%sforms", a.rootPath),
contextMiddleware(
contextMiddlewareConfig{
traceHeader: a.traceHeader,
timeout: struct {
process time.Duration
write time.Duration
}{
process: a.processTimeout,
write: a.writeTimeout,
},
webhook: struct {
allowList *regexp.Regexp
denyList *regexp.Regexp
errorAllowList *regexp.Regexp
errorDenyList *regexp.Regexp
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
disable bool
}{
allowList: a.webhookAllowList,
denyList: a.webhookDenyList,
errorAllowList: a.webhookErrorAllowList,
errorDenyList: a.webhookErrorDenyList,
maxRetry: a.webhookMaxRetry,
retryMinWait: a.webhookRetryMinWait,
retryMaxWait: a.webhookRetryMaxWait,
disable: a.disableWebhook,
},
},
),
timeoutMiddleware(hardTimeout),
)
middlewares = append(middlewares, hardTimeoutMiddleware(hardTimeout))
// Add routes from other modules.
for _, route := range a.multipartFormDataRoutes {
formsGroup.POST(
route.Path,
func(route MultipartFormDataRoute) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
err := route.Handler(ctx)
if err != nil {
return fmt.Errorf("handle request: %w", err)
}
return nil
}
}(route),
a.srv.Add(
route.Method,
fmt.Sprintf("%s%s", a.rootPath, route.Path),
route.Handler,
middlewares...,
)
}
// As the listen method is blocking, run it in a goroutine.
// Let's not forget the health check route.
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker))
}(),
hardTimeoutMiddleware(hardTimeout),
)
// As the following code is blocking, run it in a goroutine.
go func() {
err := a.srv.Start(fmt.Sprintf(":%d", a.port))
server := &http2.Server{}
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error())
}
@@ -470,18 +491,7 @@ func (a API) Stop(ctx context.Context) error {
// GraceDuration updates the expiration time of files and directories parsed by
// the gc.GarbageCollector.
func (a API) GraceDuration() time.Duration {
duration := a.readTimeout + a.processTimeout + a.writeTimeout
if a.disableWebhook {
return duration
}
for i := 0; i < a.webhookMaxRetry; i++ {
// Yep... Golang does not allow int * time.Duration.
duration += a.webhookRetryMaxWait
}
return duration
return a.gcGraceDuration
}
// Interface guards.

View File

@@ -35,12 +35,12 @@ func (mod ProtoValidator) Validate() error {
return mod.validate()
}
type ProtoMultipartFormDataRouter struct {
type ProtoRouter struct {
ProtoValidator
routes func() ([]MultipartFormDataRoute, error)
routes func() ([]Route, error)
}
func (mod ProtoMultipartFormDataRouter) Routes() ([]MultipartFormDataRoute, error) {
func (mod ProtoRouter) Routes() ([]Route, error) {
return mod.routes()
}
@@ -62,6 +62,15 @@ func (mod ProtoHealthChecker) Checks() ([]health.CheckerOption, error) {
return mod.checks()
}
type ProtoGarbageCollectorGraceDurationIncrementer struct {
ProtoValidator
addGraceDuration func() time.Duration
}
func (mod ProtoGarbageCollectorGraceDurationIncrementer) AddGraceDuration() time.Duration {
return mod.addGraceDuration()
}
type ProtoLoggerProvider struct {
ProtoModule
logger func(mod gotenberg.Module) (*zap.Logger, error)
@@ -84,11 +93,12 @@ func TestAPI_Descriptor(t *testing.T) {
func TestAPI_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
setEnv func(i int)
expectPort int
expectMiddlewares []Middleware
expectErr bool
ctx *gotenberg.Context
setEnv func(i int)
expectPort int
expectMiddlewares []Middleware
expectGraceDuration time.Duration
expectErr bool
}{
{
ctx: func() *gotenberg.Context {
@@ -183,14 +193,14 @@ func TestAPI_Provision(t *testing.T) {
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMultipartFormDataRouter }{}
mod := struct{ ProtoRouter }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.routes = func() ([]MultipartFormDataRoute, error) {
mod.routes = func() ([]Route, error) {
return nil, nil
}
@@ -255,14 +265,14 @@ func TestAPI_Provision(t *testing.T) {
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMultipartFormDataRouter }{}
mod := struct{ ProtoRouter }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.routes = func() ([]MultipartFormDataRoute, error) {
mod.routes = func() ([]Route, error) {
return nil, errors.New("foo")
}
@@ -325,6 +335,60 @@ func TestAPI_Provision(t *testing.T) {
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct {
ProtoGarbageCollectorGraceDurationIncrementer
}{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.addGraceDuration = func() time.Duration {
return 0
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct {
ProtoGarbageCollectorGraceDurationIncrementer
}{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.addGraceDuration = func() time.Duration {
return time.Duration(3) * time.Second
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(API).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectGraceDuration: time.Duration(33) * time.Second,
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
@@ -359,15 +423,15 @@ func TestAPI_Provision(t *testing.T) {
},
{
ctx: func() *gotenberg.Context {
mod1 := struct{ ProtoMultipartFormDataRouter }{}
mod1 := struct{ ProtoRouter }{}
mod1.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
}
mod1.validate = func() error {
return nil
}
mod1.routes = func() ([]MultipartFormDataRoute, error) {
return []MultipartFormDataRoute{{}}, nil
mod1.routes = func() ([]Route, error) {
return []Route{{}}, nil
}
mod2 := struct{ ProtoMiddlewareProvider }{}
@@ -455,11 +519,15 @@ func TestAPI_Provision(t *testing.T) {
err := mod.Provision(tc.ctx)
if tc.expectPort != 0 && mod.port != tc.expectPort {
t.Errorf("expected port %d but got %d", tc.expectPort, mod.port)
t.Errorf("test %d: expected port %d but got %d", i, tc.expectPort, mod.port)
}
if !reflect.DeepEqual(mod.externalMiddlewares, tc.expectMiddlewares) {
t.Errorf("expected %+v, but got: %+v", tc.expectMiddlewares, mod.externalMiddlewares)
t.Errorf("test %d: expected %+v, but got: %+v", i, tc.expectMiddlewares, mod.externalMiddlewares)
}
if tc.expectGraceDuration != 0 && mod.gcGraceDuration != tc.expectGraceDuration {
t.Errorf("test %d: expected gc grace duration '%s' but got '%s'", i, tc.expectGraceDuration, mod.gcGraceDuration)
}
if tc.expectErr && err == nil {
@@ -477,7 +545,7 @@ func TestAPI_Validate(t *testing.T) {
port int
rootPath string
traceHeader string
routes []MultipartFormDataRoute
routes []Route
middlewares []Middleware
expectErr bool
}{
@@ -494,7 +562,7 @@ func TestAPI_Validate(t *testing.T) {
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
routes: []Route{
{
Path: "",
},
@@ -505,7 +573,7 @@ func TestAPI_Validate(t *testing.T) {
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
routes: []Route{
{
Path: "foo",
},
@@ -516,9 +584,10 @@ func TestAPI_Validate(t *testing.T) {
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
routes: []Route{
{
Path: "/foo",
Path: "/foo",
IsMultipart: true,
},
},
expectErr: true,
@@ -527,14 +596,41 @@ func TestAPI_Validate(t *testing.T) {
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
routes: []Route{
{
Path: "/forms/foo",
IsMultipart: true,
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Method: http.MethodPost,
Path: "/forms/foo",
IsMultipart: true,
},
},
expectErr: true,
},
{
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Method: http.MethodPost,
Path: "/foo",
Handler: func(_ *Context) error { return nil },
Handler: func(_ echo.Context) error { return nil },
},
{
Method: http.MethodPost,
Path: "/foo",
Handler: func(_ *Context) error { return nil },
Handler: func(_ echo.Context) error { return nil },
},
},
expectErr: true,
@@ -554,10 +650,11 @@ func TestAPI_Validate(t *testing.T) {
port: 10,
rootPath: "/foo/",
traceHeader: "foo",
routes: []MultipartFormDataRoute{
routes: []Route{
{
Method: http.MethodGet,
Path: "/foo",
Handler: func(_ *Context) error { return nil },
Handler: func(_ echo.Context) error { return nil },
},
},
middlewares: []Middleware{
@@ -575,11 +672,11 @@ func TestAPI_Validate(t *testing.T) {
},
} {
mod := API{
port: tc.port,
rootPath: tc.rootPath,
traceHeader: tc.traceHeader,
multipartFormDataRoutes: tc.routes,
externalMiddlewares: tc.middlewares,
port: tc.port,
rootPath: tc.rootPath,
traceHeader: tc.traceHeader,
routes: tc.routes,
externalMiddlewares: tc.middlewares,
}
err := mod.Validate()
@@ -598,10 +695,15 @@ func TestAPI_Start(t *testing.T) {
mod := new(API)
mod.port = 3000
mod.rootPath = "/"
mod.multipartFormDataRoutes = []MultipartFormDataRoute{
mod.disableHealthCheckLogging = true
mod.routes = []Route{
{
Path: "/foo",
Handler: func(ctx *Context) error {
Method: http.MethodPost,
Path: "/forms/foo",
IsMultipart: true,
DisableLogging: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
}
@@ -610,13 +712,35 @@ func TestAPI_Start(t *testing.T) {
},
},
{
Path: "/bar",
Handler: func(_ *Context) error { return errors.New("foo") },
Method: http.MethodPost,
Path: "/forms/bar",
IsMultipart: true,
Handler: func(_ echo.Context) error { return errors.New("foo") },
},
}
mod.externalMiddlewares = []Middleware{
{
RunBeforeRouter: true,
Stack: PreRouterStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: DefaultStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -721,10 +845,11 @@ func TestAPI_StartupMessage(t *testing.T) {
func TestAPI_Stop(t *testing.T) {
mod := API{
port: 3000,
multipartFormDataRoutes: []MultipartFormDataRoute{
routes: []Route{
{
Method: http.MethodGet,
Path: "/foo",
Handler: func(_ *Context) error { return nil },
Handler: func(_ echo.Context) error { return nil },
},
},
logger: zap.NewNop(),
@@ -742,52 +867,35 @@ func TestAPI_Stop(t *testing.T) {
}
func TestAPI_GraceDuration(t *testing.T) {
for i, tc := range []struct {
mod API
expect time.Duration
}{
{
mod: API{
readTimeout: time.Duration(1) * time.Second,
processTimeout: time.Duration(1) * time.Second,
writeTimeout: time.Duration(1) * time.Second,
disableWebhook: true,
},
expect: time.Duration(3) * time.Second,
},
{
mod: API{
readTimeout: time.Duration(1) * time.Second,
processTimeout: time.Duration(1) * time.Second,
writeTimeout: time.Duration(1) * time.Second,
webhookMaxRetry: 5,
webhookRetryMaxWait: time.Duration(5) * time.Second,
},
expect: time.Duration(28) * time.Second,
},
} {
actual := tc.mod.GraceDuration()
mod := API{
gcGraceDuration: time.Duration(3) * time.Second,
}
if actual != tc.expect {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expect, actual)
}
expect := time.Duration(3) * time.Second
actual := mod.GraceDuration()
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.Module = (*ProtoValidator)(nil)
_ MultipartFormDataRouter = (*ProtoMultipartFormDataRouter)(nil)
_ gotenberg.Module = (*ProtoMultipartFormDataRouter)(nil)
_ gotenberg.Validator = (*ProtoMultipartFormDataRouter)(nil)
_ MiddlewareProvider = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Module = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Validator = (*ProtoMiddlewareProvider)(nil)
_ HealthChecker = (*ProtoHealthChecker)(nil)
_ gotenberg.Module = (*ProtoHealthChecker)(nil)
_ gotenberg.Validator = (*ProtoHealthChecker)(nil)
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.Module = (*ProtoValidator)(nil)
_ Router = (*ProtoRouter)(nil)
_ gotenberg.Module = (*ProtoRouter)(nil)
_ gotenberg.Validator = (*ProtoRouter)(nil)
_ MiddlewareProvider = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Module = (*ProtoMiddlewareProvider)(nil)
_ gotenberg.Validator = (*ProtoMiddlewareProvider)(nil)
_ HealthChecker = (*ProtoHealthChecker)(nil)
_ gotenberg.Module = (*ProtoHealthChecker)(nil)
_ gotenberg.Validator = (*ProtoHealthChecker)(nil)
_ GarbageCollectorGraceDurationIncrementer = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
_ gotenberg.Module = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
_ gotenberg.Validator = (*ProtoGarbageCollectorGraceDurationIncrementer)(nil)
_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
_ gotenberg.Module = (*ProtoLoggerProvider)(nil)
)

View File

@@ -125,10 +125,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
copyToDisk := func(fh *multipart.FileHeader) error {
// Avoid directory traversal and normalize filename.
// See https://github.com/gotenberg/gotenberg/issues/104.
// See https://github.com/gotenberg/gotenberg/issues/228.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
filename, _, err := transform.String(t, strings.ToLower(filepath.Base(fh.Filename)))
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
if err != nil {
return fmt.Errorf("transform filename: %w", err)
}
@@ -228,9 +227,9 @@ func (ctx Context) Log() *zap.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.
func (ctx Context) buildOutputFile() (string, error) {
func (ctx Context) BuildOutputFile() (string, error) {
if ctx.cancelled {
return "", ErrContextAlreadyClosed
}
@@ -266,54 +265,14 @@ func (ctx Context) buildOutputFile() (string, error) {
return archivePath, nil
}
// MockContext is a helper for tests.
//
// ctx := &api.MockContext{Context: &api.Context{}}
type MockContext struct {
*Context
}
// OutputFilename returns the filename based on the given output path or the
// "Gotenberg-Output-Filename" header's value.
func (ctx Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename")
// 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
}
if filename == "" {
return filepath.Base(outputPath)
}
// 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
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
}

View File

@@ -248,7 +248,7 @@ func TestContext_Log(t *testing.T) {
}
}
func TestContext_buildOutputFile(t *testing.T) {
func TestContext_BuildOutputFile(t *testing.T) {
for i, tc := range []struct {
ctx *Context
expectErr bool
@@ -285,7 +285,7 @@ func TestContext_buildOutputFile(t *testing.T) {
tc.ctx.dirPath = dirPath
tc.ctx.logger = zap.NewNop()
_, err = tc.ctx.buildOutputFile()
_, err = tc.ctx.BuildOutputFile()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
@@ -302,72 +302,35 @@ func TestContext_buildOutputFile(t *testing.T) {
}
}
func TestMockContext_SetDirPath(t *testing.T) {
mock := &MockContext{&Context{}}
mock.SetDirPath("/foo")
func TestContext_OutputFilename(t *testing.T) {
for i, tc := range []struct {
ctx *Context
outputPath string
expectOutputFilename string
}{
{
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
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"},
return &Context{echoCtx: c}
}(),
outputPath: "/foo/bar.txt",
expectOutputFilename: "foo.txt",
},
}
{
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
return &Context{echoCtx: c}
}(),
outputPath: "/foo/foo.txt",
expectOutputFilename: "foo.txt",
},
} {
actual := tc.ctx.OutputFilename(tc.outputPath)
actual := mock.OutputPaths()
expect := []string{"/foo"}
if !reflect.DeepEqual(actual, expect) {
t.Errorf("expected %+v but got: %+v", expect, actual)
if actual != tc.expectOutputFilename {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectOutputFilename, actual)
}
}
}

View File

@@ -14,7 +14,7 @@ type SentinelHTTPError struct {
}
// NewSentinelHTTPError creates a SentinelHTTPError. The message will be sent
// as the response's body if returned from an 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.
func NewSentinelHTTPError(status int, message string) SentinelHTTPError {
return SentinelHTTPError{
@@ -52,15 +52,15 @@ func (w sentinelWrappedError) HTTPError() (int, string) {
// will be displayed in a log, while the SentinelHTTPError will be sent in the
// response.
//
// return api.WrapError(
// // This first error will be logged.
// fmt.Errorf("my action: %w", err),
// // The HTTP error will be sent as a response.
// api.NewSentinelHTTPError(
// http.StatusForbidden,
// "Hey, you did something wrong!"
// ),
// )
// return api.WrapError(
// // This first error will be logged.
// fmt.Errorf("my action: %w", err),
// // The HTTP error will be sent as a response.
// api.NewSentinelHTTPError(
// http.StatusForbidden,
// "Hey, you did something wrong!"
// ),
// )
func WrapError(err error, sentinel SentinelHTTPError) error {
return sentinelWrappedError{
error: err,

View File

@@ -2,8 +2,8 @@ package api
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
@@ -16,7 +16,7 @@ import (
// FormData is a helper for validating and hydrating values from a
// "multipart/form-data" request.
//
// form := ctx.FormData()
// form := ctx.FormData()
type FormData struct {
values map[string][]string
files map[string]string
@@ -27,11 +27,11 @@ type FormData struct {
// SentinelHTTPError (status code 400, errors' details as message) wrapped
// inside.
//
// var foo string
// var foo string
//
// err := ctx.FormData().
// MandatoryString("foo", &foo, "bar").
// Validate()
// err := ctx.FormData().
// MandatoryString("foo", &foo, "bar").
// Validate()
func (form FormData) Validate() error {
if form.errors == nil {
return nil
@@ -45,9 +45,9 @@ func (form FormData) Validate() error {
// String binds a form data value 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 {
return form.mustValue(key, target, defaultValue)
}
@@ -55,9 +55,9 @@ func (form *FormData) String(key string, target *string, defaultValue string) *F
// MandatoryString binds a form data value to a string variable. It populates
// an error if the value is empty or the "key" does not exist.
//
// var foo string
// var foo string
//
// ctx.FormData().MandatoryString("foo", &foo)
// ctx.FormData().MandatoryString("foo", &foo)
func (form *FormData) MandatoryString(key string, target *string) *FormData {
return form.mustMandatoryValue(key, target)
}
@@ -65,9 +65,9 @@ func (form *FormData) MandatoryString(key string, target *string) *FormData {
// Bool binds a form data value to a bool variable. It populates an error if
// 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 {
return form.mustValue(key, target, defaultValue)
}
@@ -75,9 +75,9 @@ func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormDat
// MandatoryBool binds a form data value to a bool variable. It populates an
// error if the value is not bool, is empty, or the "key" does not exist.
//
// var foo bool
// var foo bool
//
// ctx.FormData().MandatoryBool("foo", &foo)
// ctx.FormData().MandatoryBool("foo", &foo)
func (form *FormData) MandatoryBool(key string, target *bool) *FormData {
return form.mustMandatoryValue(key, target)
}
@@ -85,9 +85,9 @@ func (form *FormData) MandatoryBool(key string, target *bool) *FormData {
// Int binds a form data value to an int variable. It populates an error if the
// 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 {
return form.mustValue(key, target, defaultValue)
}
@@ -95,9 +95,9 @@ func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
// MandatoryInt binds a form data value to an int variable. It populates an
// error if the value is not int, is empty, or the "key" does not exist.
//
// var foo int
// var foo int
//
// ctx.FormData().MandatoryInt("foo", &foo)
// ctx.FormData().MandatoryInt("foo", &foo)
func (form *FormData) MandatoryInt(key string, target *int) *FormData {
return form.mustMandatoryValue(key, target)
}
@@ -105,9 +105,9 @@ func (form *FormData) MandatoryInt(key string, target *int) *FormData {
// Float64 binds a form data value to a float64 variable. It populates an error
// 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 {
return form.mustValue(key, target, defaultValue)
}
@@ -115,9 +115,9 @@ func (form *FormData) Float64(key string, target *float64, defaultValue float64)
// MandatoryFloat64 binds a form data value to a float64 variable. It populates
// an error if the is not float64, is empty, or the "key" does not exist.
//
// var foo float64
// var foo float64
//
// ctx.FormData().MandatoryFloat64("foo", &foo)
// ctx.FormData().MandatoryFloat64("foo", &foo)
func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData {
return form.mustMandatoryValue(key, target)
}
@@ -125,9 +125,9 @@ func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData {
// Duration binds a form data value to a time.Duration variable. It populates
// an error if the form data value is not time.Duration.
//
// var foo time.Duration
// 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 {
return form.mustValue(key, target, defaultValue)
}
@@ -136,31 +136,31 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
// populates an error if the value is not time.Duration, is empty, or the "key"
// 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 {
return form.mustMandatoryValue(key, target)
}
// Custom helps to define a custom binding function for a form data value.
//
// var foo map[string]string
// var foo map[string]string
//
// ctx.FormData().Custom("foo", func(value string) error {
// if value == "" {
// foo = "bar"
// ctx.FormData().Custom("foo", func(value string) error {
// if value == "" {
// foo = "bar"
//
// return nil
// }
// return nil
// }
//
// err := json.Unmarshal([]byte(value), &foo)
// if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err)
// }
// err := json.Unmarshal([]byte(value), &foo)
// if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err)
// }
//
// return nil
// })
// return nil
// })
func (form *FormData) Custom(key string, assign func(value string) error) *FormData {
var value string
form.mustValue(key, &value, "")
@@ -179,16 +179,16 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
// value. It populates an error if the value is empty or the "key" does not
// exist.
//
// var foo map[string]string
// var foo map[string]string
//
// ctx.FormData().MandatoryCustom("foo", func(value string) error {
// err := json.Unmarshal([]byte(value), &foo)
// if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err)
// }
// ctx.FormData().MandatoryCustom("foo", func(value string) error {
// err := json.Unmarshal([]byte(value), &foo)
// if err != nil {
// return fmt.Errorf("unmarshal foo: %w", err)
// }
//
// return nil
// })
// return nil
// })
func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData {
var value string
form.mustMandatoryValue(key, &value)
@@ -209,9 +209,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.
//
// 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 {
return form.path(filename, target)
}
@@ -219,18 +219,18 @@ func (form *FormData) Path(filename string, target *string) *FormData {
// MandatoryPath binds the absolute path ofa form data file to a string
// variable. It populates an error if the file does not exist.
//
// var path string
// var path string
//
// ctx.FormData().MandatoryPath("foo.txt", &path)
// ctx.FormData().MandatoryPath("foo.txt", &path)
func (form *FormData) MandatoryPath(filename string, target *string) *FormData {
return form.mandatoryPath(filename, target)
}
// Content binds the content of a form data file to a string variable.
//
// var content string
// 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 {
var path string
form.path(filename, &path)
@@ -247,9 +247,9 @@ func (form *FormData) Content(filename string, target *string, defaultValue stri
// MandatoryContent binds the content of a form data file to a string variable.
// It populates an error if the file does not exist.
//
// var content string
// var content string
//
// ctx.FormData().MandatoryContent("foo.txt", &content)
// ctx.FormData().MandatoryContent("foo.txt", &content)
func (form *FormData) MandatoryContent(filename string, target *string) *FormData {
var path string
form.mandatoryPath(filename, &path)
@@ -264,9 +264,9 @@ func (form *FormData) MandatoryContent(filename string, target *string) *FormDat
// Paths binds the absolute paths of form data files, according to a list of
// 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 {
return form.paths(extensions, target)
}
@@ -275,9 +275,9 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
// list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions.
//
// var paths []string
// var paths []string
//
// ctx.FormData().MandatoryPaths([]string{".txt"}, &paths)
// ctx.FormData().MandatoryPaths([]string{".txt"}, &paths)
func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *FormData {
form.paths(extensions, target)
@@ -396,7 +396,9 @@ func (form *FormData) mustAssign(key, value string, target interface{}) *FormDat
// path binds the absolute path of a form data file to a string variable.
func (form *FormData) path(filename string, target *string) *FormData {
for name, path := range form.files {
if name == filename {
// See https://github.com/gotenberg/gotenberg/issues/228.
nameLowerExt := strings.TrimSuffix(name, filepath.Ext(name)) + strings.ToLower(filepath.Ext(name))
if name == filename || nameLowerExt == filename {
*target = path
return form
}
@@ -424,7 +426,7 @@ func (form *FormData) mandatoryPath(filename string, target *string) *FormData {
// readFile binds the content of a file to a string variable. It populates an
// error if it fails to read the file content.
func (form *FormData) readFile(path, filename string, target *string) *FormData {
b, err := ioutil.ReadFile(path)
b, err := os.ReadFile(path)
if err != nil {
form.append(
fmt.Errorf("form file '%s' is invalid (%w)", filename, err),

View File

@@ -850,6 +850,7 @@ func TestFormData_MandatoryPath(t *testing.T) {
func TestFormData_Content(t *testing.T) {
for i, tc := range []struct {
form *FormData
filename string
defaultValue string
expect string
expectErr bool
@@ -863,6 +864,7 @@ func TestFormData_Content(t *testing.T) {
"bar": "/bar",
},
},
filename: "foo",
},
{
form: &FormData{
@@ -870,6 +872,7 @@ func TestFormData_Content(t *testing.T) {
"bar": "/bar",
},
},
filename: "foo",
defaultValue: "foo",
expect: "foo",
},
@@ -879,6 +882,7 @@ func TestFormData_Content(t *testing.T) {
"foo": "/foo",
},
},
filename: "foo",
expectErr: true,
},
{
@@ -887,12 +891,31 @@ func TestFormData_Content(t *testing.T) {
"foo": "/tests/test/testdata/api/sample1.txt",
},
},
expect: "foo",
filename: "foo",
expect: "foo",
},
{
form: &FormData{
files: map[string]string{
"foo.TXT": "/tests/test/testdata/api/sample1.txt",
},
},
filename: "foo.txt",
expect: "foo",
},
{
form: &FormData{
files: map[string]string{
"foo.txt": "/tests/test/testdata/api/sample1.txt",
},
},
filename: "foo.txt",
expect: "foo",
},
} {
var actual string
tc.form.Content("foo", &actual, tc.defaultValue)
tc.form.Content(tc.filename, &actual, tc.defaultValue)
if actual != tc.expect {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expect, actual)
@@ -911,11 +934,13 @@ func TestFormData_Content(t *testing.T) {
func TestFormData_MandatoryContent(t *testing.T) {
for i, tc := range []struct {
form *FormData
filename string
expect string
expectErr bool
}{
{
form: &FormData{},
filename: "foo",
expectErr: true,
},
{
@@ -924,6 +949,7 @@ func TestFormData_MandatoryContent(t *testing.T) {
"bar": "/bar",
},
},
filename: "foo",
expectErr: true,
},
{
@@ -932,6 +958,7 @@ func TestFormData_MandatoryContent(t *testing.T) {
"foo": "/foo",
},
},
filename: "foo",
expectErr: true,
},
{
@@ -940,12 +967,31 @@ func TestFormData_MandatoryContent(t *testing.T) {
"foo": "/tests/test/testdata/api/sample1.txt",
},
},
expect: "foo",
filename: "foo",
expect: "foo",
},
{
form: &FormData{
files: map[string]string{
"foo.TXT": "/tests/test/testdata/api/sample1.txt",
},
},
filename: "foo.txt",
expect: "foo",
},
{
form: &FormData{
files: map[string]string{
"foo.txt": "/tests/test/testdata/api/sample1.txt",
},
},
filename: "foo.txt",
expect: "foo",
},
} {
var actual string
tc.form.MandatoryContent("foo", &actual)
tc.form.MandatoryContent(tc.filename, &actual)
if actual != tc.expect {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expect, actual)
@@ -995,6 +1041,21 @@ func TestFormData_Paths(t *testing.T) {
},
expectCount: 2,
},
{
form: &FormData{
files: map[string]string{
"foo.zip": "/foo.zip",
"b.PDF": "/b.PDF",
"a.pdf": "/a.pdf",
},
},
extensions: []string{".pdf"},
expect: []string{
"/a.pdf",
"/b.PDF",
},
expectCount: 2,
},
} {
var actual []string
@@ -1051,6 +1112,21 @@ func TestFormData_MandatoryPaths(t *testing.T) {
},
expectCount: 2,
},
{
form: &FormData{
files: map[string]string{
"foo.zip": "/foo.zip",
"b.PDF": "/b.PDF",
"a.pdf": "/a.pdf",
},
},
extensions: []string{".pdf"},
expect: []string{
"/a.pdf",
"/b.PDF",
},
expectCount: 2,
},
} {
var actual []string

View File

@@ -1,95 +1,55 @@
package api
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
// ErrAsyncProcess happens when a handler or middleware handles a request in an
// asynchronous fashion.
var ErrAsyncProcess = errors.New("async process")
// ParseError parses an error and returns the corresponding HTTP status and
// HTTP message.
func ParseError(err error) (int, string) {
echoErr, ok := err.(*echo.HTTPError)
if ok {
return echoErr.Code, http.StatusText(echoErr.Code)
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
}
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.HTTPError()
}
// Default 500 status code.
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
}
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
// returns either a response as "text/plain; charset=UTF-8" or, if a webhook
// client exists in the echo.Context, sends a request to the webhook error URL
// with a JSON body containing the trace, the status and the error message.
func httpErrorHandler(traceHeader string) echo.HTTPErrorHandler {
// returns a response as "text/plain; charset=UTF-8".
func httpErrorHandler() echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
parseError := func(err error) (int, string) {
echoErr, ok := err.(*echo.HTTPError)
if ok {
return echoErr.Code, http.StatusText(echoErr.Code)
}
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
}
var httpErr HTTPError
if errors.As(err, &httpErr) {
return httpErr.HTTPError()
}
// Default 500 status code.
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
}
status, message := parseError(err)
logger := c.Get("logger").(*zap.Logger)
clientOrNil := c.Get("webhookClient")
status, message := ParseError(err)
// No webhook client, meaning we can send the error as a response.
if clientOrNil == nil {
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
err = c.String(status, message)
if err != nil {
logger.Error(fmt.Sprintf("send error response: %s", err.Error()))
}
return
}
// We have to send the error to the webhook.
client := clientOrNil.(*webhookClient)
body := struct {
Status int `json:"status"`
Message string `json:"message"`
}{
Status: status,
Message: message,
}
b, err := json.Marshal(body)
err = c.String(status, message)
if err != nil {
logger.Error(fmt.Sprintf("marshal JSON: %s", err.Error()))
return
}
headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSONCharsetUTF8,
traceHeader: c.Get("trace").(string),
}
err = client.send(bytes.NewReader(b), headers, true)
if err != nil {
logger.Error(fmt.Sprintf("send error response to webhook: %s", err.Error()))
logger.Error(fmt.Sprintf("send error response: %s", err.Error()))
}
}
}
@@ -97,7 +57,7 @@ func httpErrorHandler(traceHeader string) echo.HTTPErrorHandler {
// latencyMiddleware sets the start time in the echo.Context under "startTime".
// Its value will be used later to calculate a request latency.
//
// startTime := c.Get("startTime").(time.Time)
// startTime := c.Get("startTime").(time.Time)
func latencyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -115,14 +75,14 @@ func latencyMiddleware() echo.MiddlewareFunc {
// Its value may be used to skip a middleware execution based on a request
// URI.
//
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath)
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%s/health", rootPath)
//
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -138,7 +98,8 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
// "trace". Its value is either retrieved from the trace header or generated if
// the header is not present / its value is empty.
//
// trace := c.Get("trace").(string)
// trace := c.Get("trace").(string)
// traceHeader := c.Get("traceHeader").(string).
func traceMiddleware(header string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -150,6 +111,7 @@ func traceMiddleware(header string) echo.MiddlewareFunc {
}
c.Set("trace", trace)
c.Set("traceHeader", header)
c.Response().Header().Add(header, trace)
// Call the next middleware in the chain.
@@ -159,19 +121,25 @@ func traceMiddleware(header string) echo.MiddlewareFunc {
}
// loggerMiddleware sets the logger in the echo.Context under "logger" and logs
// a request result (but does not log a webhook call result, which is the job
// of the webhookClient).
// a synchronous request result.
//
// logger := c.Get("logger").(*zap.Logger)
func loggerMiddleware(logger *zap.Logger, skipHealthRouteLogging bool) echo.MiddlewareFunc {
// logger := c.Get("logger").(*zap.Logger)
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
startTime := c.Get("startTime").(time.Time)
trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string)
// Create the request logger and add it to our locals.
reqLogger := logger.With(zap.String("trace", trace))
c.Set("logger", reqLogger)
c.Set("logger", reqLogger.Named(func() string {
return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
"",
)
}()))
// Call the next middleware in the chain.
err := next(c)
@@ -179,11 +147,10 @@ func loggerMiddleware(logger *zap.Logger, skipHealthRouteLogging bool) echo.Midd
c.Error(err)
}
if skipHealthRouteLogging {
rootPath := c.Get("rootPath").(string)
healthURI := fmt.Sprintf("%shealth", rootPath)
for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
if c.Request().RequestURI == healthURI {
if c.Request().RequestURI == URI {
return nil
}
}
@@ -225,317 +192,65 @@ func loggerMiddleware(logger *zap.Logger, skipHealthRouteLogging bool) echo.Midd
}
}
type contextMiddlewareConfig struct {
traceHeader string
timeout struct {
process time.Duration
write time.Duration
}
webhook struct {
allowList *regexp.Regexp
denyList *regexp.Regexp
errorAllowList *regexp.Regexp
errorDenyList *regexp.Regexp
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
disable bool
}
}
// contextMiddleware handles the result of a "multipart/form-data" request. If
// a webhook URL is present in the headers, exit early and process the result
// in a goroutine.
func contextMiddleware(cfg contextMiddlewareConfig) echo.MiddlewareFunc {
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
// Context and related context.CancelFunc in the echo.Context under "context"
// and "cancel". If the process is synchronous, it also handles the result of a
// "multipart/form-data" request.
//
// ctx := c.Get("context").(*api.Context)
// cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(timeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
webhookURL := c.Request().Header.Get("Gotenberg-Webhook-Url")
logger := c.Get("logger").(*zap.Logger).With(zap.Bool("webhook", webhookURL != ""))
logger := c.Get("logger").(*zap.Logger)
// We create a context with a timeout so that underlying processes are
// able to stop early and handle correctly a timeout scenario.
ctx, cancel, err := newContext(c, logger, cfg.timeout.process)
ctx, cancel, err := newContext(c, logger, timeout)
if err != nil {
cancel()
return fmt.Errorf("create request context: %w", err)
}
c.Set("context", ctx)
c.Set("cancel", cancel)
// Helper function for retrieving/creating the output filename.
outputFilename := func(outputPath string) string {
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
// Call the next middleware in the chain.
err = next(c)
if filename == "" {
return filepath.Base(outputPath)
}
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
if errors.Is(err, ErrAsyncProcess) {
// A middleware/handler tells us that it's handling the process
// in an asynchronous fashion. Therefore, we must not cancel
// the context nor send an output file.
return c.NoContent(http.StatusNoContent)
}
if webhookURL == "" {
defer cancel()
defer cancel()
// No webhook URL, call the next middleware in the chain.
err := next(c)
if err != nil {
return err
}
// No error, let's build the output file.
outputPath, err := ctx.buildOutputFile()
if err != nil {
return fmt.Errorf("build output file: %w", err)
}
// Send the output file.
err = c.Attachment(outputPath, outputFilename(outputPath))
if err != nil {
return fmt.Errorf("send response: %w", err)
}
return nil
}
// Ok, we got a webhook URL.
if cfg.webhook.disable {
// The client requested the webhook feature, but it has been
// disabled. Let's tell the client about that.
cancel()
return WrapError(
errors.New("webhook feature requested but it is disabled"),
NewSentinelHTTPError(http.StatusForbidden, "Invalid 'Gotenberg-Webhook-Url' header: feature is disabled"),
)
}
// Do we have a webhook error URL in case of... error?
webhookErrorURL := c.Request().Header.Get("Gotenberg-Webhook-Error-Url")
if webhookErrorURL == "" {
cancel()
return WrapError(
errors.New("empty webhook error URL"),
NewSentinelHTTPError(http.StatusBadRequest, "Invalid 'Gotenberg-Webhook-Error-Url' header: empty value or header not provided"),
)
}
// Let's check if the webhook URLs are acceptable according to our
// allowed/denied lists.
filter := func(URL, header string, allowList, denyList *regexp.Regexp) error {
if !allowList.MatchString(URL) {
return WrapError(
fmt.Errorf("'%s' does not match the expression from the allowed list", URL),
NewSentinelHTTPError(
http.StatusForbidden,
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
),
)
}
if denyList.String() != "" && denyList.MatchString(URL) {
return WrapError(
fmt.Errorf("'%s' matches the expression from the denied list", URL),
NewSentinelHTTPError(
http.StatusForbidden,
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
),
)
}
return nil
}
err = filter(webhookURL, "Gotenberg-Webhook-Url", cfg.webhook.allowList, cfg.webhook.denyList)
if err != nil {
cancel()
return fmt.Errorf("filter webhook URL: %w", err)
return err
}
err = filter(webhookErrorURL, "Gotenberg-Webhook-Error-Url", cfg.webhook.errorAllowList, cfg.webhook.errorDenyList)
// No error, let's build the output file.
outputPath, err := ctx.BuildOutputFile()
if err != nil {
cancel()
return fmt.Errorf("filter webhook error URL: %w", err)
return fmt.Errorf("build output file: %w", err)
}
// Let's check the HTTP methods for calling the webhook URLs.
methodFromHeader := func(header string) (string, error) {
method := c.Request().Header.Get(header)
if method == "" {
return http.MethodPost, nil
}
method = strings.ToUpper(method)
switch method {
case http.MethodPost:
return method, nil
case http.MethodPatch:
return method, nil
case http.MethodPut:
return method, nil
}
return "", WrapError(
fmt.Errorf("webhook method '%s' is not '%s', '%s' or '%s'", method, http.MethodPost, http.MethodPatch, http.MethodPut),
NewSentinelHTTPError(
http.StatusBadRequest,
fmt.Sprintf("Invalid '%s' header value: expected '%s', '%s' or '%s', but got '%s'", header, http.MethodPost, http.MethodPatch, http.MethodPut, method),
),
)
}
webhookMethod, err := methodFromHeader("Gotenberg-Webhook-Method")
// Send the output file.
err = c.Attachment(outputPath, ctx.OutputFilename(outputPath))
if err != nil {
cancel()
return fmt.Errorf("get method to use for webhook: %w", err)
return fmt.Errorf("send response: %w", err)
}
webhookErrorMethod, err := methodFromHeader("Gotenberg-Webhook-Error-Method")
if err != nil {
cancel()
return fmt.Errorf("get method to use for webhook error: %w", err)
}
// What about extra HTTP headers?
var extraHTTPHeaders map[string]string
extraHTTPHeadersJSON := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHTTPHeadersJSON != "" {
err = json.Unmarshal([]byte(extraHTTPHeadersJSON), &extraHTTPHeaders)
if err != nil {
cancel()
return WrapError(
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Gotenberg-Webhook-Extra-Http-Headers' header value: %s", err.Error())),
)
}
}
client := &webhookClient{
url: webhookURL,
method: webhookMethod,
errorURL: webhookErrorURL,
errorMethod: webhookErrorMethod,
extraHTTPHeaders: extraHTTPHeaders,
startTime: c.Get("startTime").(time.Time),
client: &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: cfg.timeout.write,
},
RetryMax: cfg.webhook.maxRetry,
RetryWaitMin: cfg.webhook.retryMinWait,
RetryWaitMax: cfg.webhook.retryMaxWait,
Logger: leveledLogger{
logger: logger,
},
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
},
logger: logger,
}
c.Set("webhookClient", client)
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
go func() {
defer cancel()
// Call the next middleware in the chain.
err := next(c)
if err != nil {
// The process failed for whatever reason. Let's send the
// details to the webhook.
ctx.Log().Error(err.Error())
c.Error(err)
return
}
// No error, let's get build the output file.
outputPath, err := ctx.buildOutputFile()
if err != nil {
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
c.Error(err)
return
}
outputFile, err := os.Open(outputPath)
if err != nil {
ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
c.Error(err)
return
}
defer func() {
err := outputFile.Close()
if err != nil {
ctx.Log().Error(fmt.Sprintf("close output file: %s", err))
}
}()
fileHeader := make([]byte, 512)
_, err = outputFile.Read(fileHeader)
if err != nil {
ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err))
c.Error(err)
return
}
fileStat, err := outputFile.Stat()
if err != nil {
ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err))
c.Error(err)
return
}
_, err = outputFile.Seek(0, 0)
if err != nil {
ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err))
c.Error(err)
return
}
headers := map[string]string{
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", outputFilename(outputPath)),
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
cfg.traceHeader: c.Get("trace").(string),
}
// Send the output file to the webhook.
err = client.send(bufio.NewReader(outputFile), headers, false)
if err != nil {
ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
c.Error(err)
}
}()
return c.NoContent(http.StatusNoContent)
return nil
}
}
}
// timeoutMiddleware manages hard timeout scenarios, i.e., when a route handler
// fails to timeout as expected.
func timeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
// hardTimeoutMiddleware manages hard timeout scenarios, i.e., when a route
// handler fails to timeout as expected.
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)

View File

@@ -3,28 +3,21 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestHttpErrorHandler(t *testing.T) {
func TestParseError(t *testing.T) {
for i, tc := range []struct {
err error
webhookClient *webhookClient
expectStatus int
expectMessage string
}{
@@ -46,30 +39,42 @@ func TestHttpErrorHandler(t *testing.T) {
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
},
} {
actualStatus, actualMessage := ParseError(tc.err)
if actualStatus != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, actualStatus)
}
if actualMessage != tc.expectMessage {
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, actualMessage)
}
}
}
func TestHttpErrorHandler(t *testing.T) {
for i, tc := range []struct {
err error
expectStatus int
expectMessage string
}{
{
err: echo.ErrInternalServerError,
webhookClient: &webhookClient{
errorURL: "http://localhost:%d/",
errorMethod: http.MethodPost,
client: retryablehttp.NewClient(),
logger: zap.NewNop(),
},
err: echo.ErrInternalServerError,
expectStatus: http.StatusInternalServerError,
expectMessage: http.StatusText(http.StatusInternalServerError),
},
{
err: echo.ErrInternalServerError,
webhookClient: &webhookClient{
errorURL: "non-existent",
errorMethod: http.MethodPost,
client: func() *retryablehttp.Client {
client := retryablehttp.NewClient()
client.RetryMax = 0
return client
}(),
logger: zap.NewNop(),
},
err: context.DeadlineExceeded,
expectStatus: http.StatusServiceUnavailable,
expectMessage: http.StatusText(http.StatusServiceUnavailable),
},
{
err: WrapError(
errors.New("foo"),
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
),
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
},
} {
recorder := httptest.NewRecorder()
@@ -81,105 +86,24 @@ func TestHttpErrorHandler(t *testing.T) {
c := srv.NewContext(request, recorder)
c.Set("logger", zap.NewNop())
c.Set("trace", "foo")
if tc.webhookClient != nil {
c.Set("webhookClient", tc.webhookClient)
handler := httpErrorHandler()
handler(tc.err, c)
contentType := recorder.Header().Get(echo.HeaderContentType)
if contentType != echo.MIMETextPlainCharsetUTF8 {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8, contentType)
}
if tc.webhookClient == nil {
handler := httpErrorHandler("Gotenberg-Trace")
handler(tc.err, c)
// Note: we cannot test the trace header in the response here, as it is set in the trace middleware.
contentType := recorder.Header().Get(echo.HeaderContentType)
if contentType != echo.MIMETextPlainCharsetUTF8 {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8, contentType)
}
// Note: we cannot test the trace header in the response here, as it is set in the trace middleware.
if recorder.Code != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
}
if recorder.Body.String() != tc.expectMessage {
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, recorder.Body.String())
}
continue
if recorder.Code != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
}
func() {
rand.Seed(time.Now().UnixNano())
webhookPort := rand.Intn(65535-1+1) + 1
tc.webhookClient.errorURL = fmt.Sprintf(tc.webhookClient.errorURL, webhookPort)
c.Set("webhookClient", tc.webhookClient)
webhook := echo.New()
webhook.HideBanner = true
webhook.HidePort = true
webhook.POST(
"/",
func() echo.HandlerFunc {
return func(c echo.Context) error {
contentType := c.Request().Header.Get(echo.HeaderContentType)
if contentType != echo.MIMEApplicationJSONCharsetUTF8 {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8, contentType)
}
trace := c.Request().Header.Get("Gotenberg-Trace")
if trace != "foo" {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, "Gotenberg-Trace", "foo", trace)
}
body, err := ioutil.ReadAll(c.Request().Body)
if err != nil {
t.Fatalf("test %d: expected not error but got: %v", i, err)
}
result := struct {
Status int `json:"status"`
Message string `json:"message"`
}{}
err = json.Unmarshal(body, &result)
if err != nil {
t.Fatalf("test %d: expected not error but got: %v", i, err)
}
if result.Status != tc.expectStatus {
t.Errorf("test %d: expected status %d from JSON but got %d", i, tc.expectStatus, result.Status)
}
if result.Message != tc.expectMessage {
t.Errorf("test %d: expected message '%s' from JSON but got '%s'", i, tc.expectMessage, result.Message)
}
return nil
}
}(),
)
go func(server *echo.Echo, port, i int) {
err := webhook.Start(fmt.Sprintf(":%d", port))
if !errors.Is(err, http.ErrServerClosed) {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}(webhook, webhookPort, i)
defer func() {
err := webhook.Shutdown(context.TODO())
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}()
handler := httpErrorHandler("Gotenberg-Trace")
handler(tc.err, c)
}()
if recorder.Body.String() != tc.expectMessage {
t.Errorf("test %d: expected message '%s' but got '%s'", i, tc.expectMessage, recorder.Body.String())
}
}
}
@@ -300,9 +224,9 @@ func TestTraceMiddleware(t *testing.T) {
func TestLoggerMiddleware(t *testing.T) {
for i, tc := range []struct {
request *http.Request
next echo.HandlerFunc
skipHealthRouteLogging bool
request *http.Request
next echo.HandlerFunc
skipLogging bool
}{
{
request: httptest.NewRequest(http.MethodGet, "/", nil),
@@ -319,7 +243,7 @@ func TestLoggerMiddleware(t *testing.T) {
return nil
}
}(),
skipHealthRouteLogging: true,
skipLogging: true,
},
{
request: httptest.NewRequest(http.MethodGet, "/health", nil),
@@ -341,7 +265,12 @@ func TestLoggerMiddleware(t *testing.T) {
c.Set("trace", "foo")
c.Set("rootPath", "/")
err := loggerMiddleware(zap.NewNop(), tc.skipHealthRouteLogging)(tc.next)(c)
var disableLoggingForPaths []string
if tc.skipLogging {
disableLoggingForPaths = append(disableLoggingForPaths, tc.request.RequestURI)
}
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
@@ -349,7 +278,7 @@ func TestLoggerMiddleware(t *testing.T) {
}
}
func TestContextMiddlewareWithoutWebhook(t *testing.T) {
func TestContextMiddleware(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
@@ -376,6 +305,7 @@ func TestContextMiddlewareWithoutWebhook(t *testing.T) {
request *http.Request
next echo.HandlerFunc
expectErr bool
expectStatus int
expectContentType string
expectFilename string
}{
@@ -383,6 +313,15 @@ func TestContextMiddlewareWithoutWebhook(t *testing.T) {
request: httptest.NewRequest(http.MethodGet, "/", nil),
expectErr: true,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return ErrAsyncProcess
}
}(),
expectStatus: http.StatusNoContent,
},
{
request: buildMultipartFormDataRequest(),
next: func() echo.HandlerFunc {
@@ -418,6 +357,7 @@ func TestContextMiddlewareWithoutWebhook(t *testing.T) {
return nil
}
}(),
expectStatus: http.StatusOK,
expectContentType: "application/pdf",
expectFilename: "foo.pdf",
},
@@ -434,6 +374,7 @@ func TestContextMiddlewareWithoutWebhook(t *testing.T) {
return nil
}
}(),
expectStatus: http.StatusOK,
expectContentType: "application/zip",
},
} {
@@ -448,16 +389,7 @@ func TestContextMiddlewareWithoutWebhook(t *testing.T) {
c.Set("trace", "foo")
c.Set("startTime", time.Now())
cfg := contextMiddlewareConfig{
timeout: struct {
process time.Duration
write time.Duration
}{
process: time.Duration(10) * time.Second,
},
}
err := contextMiddleware(cfg)(tc.next)(c)
err := contextMiddleware(time.Duration(10) * time.Second)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
@@ -471,8 +403,12 @@ func TestContextMiddlewareWithoutWebhook(t *testing.T) {
continue
}
if recorder.Code != http.StatusOK {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, http.StatusOK, recorder.Code)
if recorder.Code != tc.expectStatus {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, tc.expectStatus, recorder.Code)
}
if tc.expectStatus == http.StatusNoContent {
continue
}
contentType := recorder.Header().Get(echo.HeaderContentType)
@@ -487,470 +423,7 @@ func TestContextMiddlewareWithoutWebhook(t *testing.T) {
}
}
func TestContextMiddlewareWithWebhook(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
buildContextMiddlewareConfig := func() contextMiddlewareConfig {
return contextMiddlewareConfig{
traceHeader: "Gotenberg-Trace",
timeout: struct {
process time.Duration
write time.Duration
}{
process: time.Duration(10) * time.Second,
write: time.Duration(10) * time.Second,
},
webhook: struct {
allowList *regexp.Regexp
denyList *regexp.Regexp
errorAllowList *regexp.Regexp
errorDenyList *regexp.Regexp
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
disable bool
}{
allowList: regexp.MustCompile(""),
denyList: regexp.MustCompile(""),
errorAllowList: regexp.MustCompile(""),
errorDenyList: regexp.MustCompile(""),
},
}
}
for i, tc := range []struct {
request *http.Request
cfg contextMiddlewareConfig
next echo.HandlerFunc
autoWebhookURLs bool
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
expectWebhookContentType string
expectWebhookMethod string
expectWebhookExtraHTTPHeaders map[string]string
expectWebhookFilename string
}{
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
return req
}(),
cfg: func() contextMiddlewareConfig {
cfg := buildContextMiddlewareConfig()
cfg.webhook.disable = true
return cfg
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
return req
}(),
cfg: buildContextMiddlewareConfig(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
cfg: func() contextMiddlewareConfig {
cfg := buildContextMiddlewareConfig()
cfg.webhook.allowList = regexp.MustCompile("bar")
return cfg
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
cfg: func() contextMiddlewareConfig {
cfg := buildContextMiddlewareConfig()
cfg.webhook.denyList = regexp.MustCompile("foo")
return cfg
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
cfg: func() contextMiddlewareConfig {
cfg := buildContextMiddlewareConfig()
cfg.webhook.errorAllowList = regexp.MustCompile("foo")
return cfg
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
cfg: func() contextMiddlewareConfig {
cfg := buildContextMiddlewareConfig()
cfg.webhook.errorDenyList = regexp.MustCompile("bar")
return cfg
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodGet)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
cfg: buildContextMiddlewareConfig(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
cfg: buildContextMiddlewareConfig(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPost)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
cfg: buildContextMiddlewareConfig(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPatch)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
cfg: buildContextMiddlewareConfig(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPut)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
cfg: buildContextMiddlewareConfig(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", "foo")
return req
}(),
cfg: buildContextMiddlewareConfig(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: buildMultipartFormDataRequest(),
cfg: buildContextMiddlewareConfig(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
autoWebhookURLs: true,
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
},
{
request: buildMultipartFormDataRequest(),
cfg: buildContextMiddlewareConfig(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
autoWebhookURLs: true,
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Output-Filename", "foo")
req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", `{ "foo": "bar" }`)
return req
}(),
cfg: buildContextMiddlewareConfig(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample2.pdf",
}
return nil
}
}(),
autoWebhookURLs: true,
expectWebhookContentType: "application/pdf",
expectWebhookMethod: http.MethodPost,
expectWebhookFilename: "foo",
expectWebhookExtraHTTPHeaders: map[string]string{"foo": "bar"},
},
{
request: buildMultipartFormDataRequest(),
cfg: buildContextMiddlewareConfig(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
"/tests/test/testdata/api/sample2.pdf",
}
return nil
}
}(),
autoWebhookURLs: true,
expectWebhookContentType: "application/zip",
expectWebhookMethod: http.MethodPost,
},
} {
func() {
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
srv.HTTPErrorHandler = httpErrorHandler(tc.cfg.traceHeader)
c := srv.NewContext(tc.request, recorder)
c.Set("logger", zap.NewNop())
c.Set("trace", "foo")
c.Set("startTime", time.Now())
webhook := echo.New()
webhook.HideBanner = true
webhook.HidePort = true
rand.Seed(time.Now().UnixNano())
webhookPort := rand.Intn(65535-1+1) + 1
if tc.autoWebhookURLs {
c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
c.Request().Header.Set("Gotenberg-Webhook-Error-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
}
errChan := make(chan error, 1)
webhook.POST(
"/",
func() echo.HandlerFunc {
return func(c echo.Context) error {
contentType := c.Request().Header.Get(echo.HeaderContentType)
if contentType != tc.expectWebhookContentType {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, echo.HeaderContentType, tc.expectWebhookContentType, contentType)
}
trace := c.Request().Header.Get(tc.cfg.traceHeader)
if trace != "foo" {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, "Gotenberg-Trace", "foo", trace)
}
method := c.Request().Method
if method != tc.expectWebhookMethod {
t.Errorf("test %d: expected HTTP method '%s' but got '%s'", i, tc.expectWebhookMethod, method)
}
for key, expect := range tc.expectWebhookExtraHTTPHeaders {
actual := c.Request().Header.Get(key)
if actual != expect {
t.Errorf("test %d: expected %s '%s' but got '%s'", i, key, expect, actual)
}
}
if tc.expectWebhookContentType == echo.MIMEApplicationJSONCharsetUTF8 {
errChan <- nil
return nil
}
contentLength := c.Request().Header.Get(echo.HeaderContentLength)
if contentLength == "" {
t.Errorf("test %d: expected non empty %s", i, echo.HeaderContentLength)
}
contentDisposition := c.Request().Header.Get(echo.HeaderContentDisposition)
if !strings.Contains(contentDisposition, tc.expectWebhookFilename) {
t.Errorf("test %d: expected %s '%s' to contain '%s'", i, echo.HeaderContentDisposition, contentDisposition, tc.expectWebhookFilename)
}
body, err := ioutil.ReadAll(c.Request().Body)
if err != nil {
errChan <- err
return nil
}
if body == nil || len(body) == 0 {
t.Errorf("test %d: expected non nil body", i)
}
errChan <- nil
return nil
}
}(),
)
go func(server *echo.Echo, port, i int) {
err := server.Start(fmt.Sprintf(":%d", webhookPort))
if !errors.Is(err, http.ErrServerClosed) {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}(webhook, webhookPort, i)
defer func() {
err := webhook.Shutdown(context.TODO())
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}()
err := contextMiddleware(tc.cfg)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
var 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 err != nil {
return
}
if recorder.Code != http.StatusNoContent {
t.Errorf("test %d: expected HTTP status code %d but got %d", i, http.StatusNoContent, recorder.Code)
}
err = <-errChan
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}()
}
}
func TestTimeoutMiddleware(t *testing.T) {
func TestHardTimeoutMiddleware(t *testing.T) {
for i, tc := range []struct {
next echo.HandlerFunc
timeout time.Duration
@@ -1007,7 +480,7 @@ func TestTimeoutMiddleware(t *testing.T) {
c := srv.NewContext(request, recorder)
c.Set("logger", zap.NewNop())
err := timeoutMiddleware(tc.timeout)(tc.next)(c)
err := hardTimeoutMiddleware(tc.timeout)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)

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

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

View File

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

View File

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

View File

@@ -3,13 +3,13 @@ package chromium
import (
"context"
"errors"
"io/ioutil"
"os"
"reflect"
"regexp"
"testing"
"time"
"github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
)
@@ -173,6 +173,96 @@ func TestChromium_Validate(t *testing.T) {
}
}
func TestChromium_Metrics(t *testing.T) {
metrics, err := new(Chromium).Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
}
actual := metrics[0].Read()
if actual != 0 {
t.Errorf("expected %d Chromium instances, but got %f", 0, actual)
}
actual = metrics[1].Read()
if actual != 0 {
t.Errorf("expected %d Chromium failed starts, but got %f", 0, actual)
}
}
func TestChromium_Checks(t *testing.T) {
tests := []struct {
name string
mod Chromium
tearUp func()
tearDown func()
expectAvailabilityStatus health.AvailabilityStatus
}{
{
name: "ignore Chromium failed starts",
mod: Chromium{
failedStartsThreshold: 0,
},
},
{
name: "with Chromium failed starts threshold not reached",
mod: Chromium{
failedStartsThreshold: 1,
},
expectAvailabilityStatus: health.StatusUp,
},
{
name: "with Chromium failed starts threshold reached",
mod: Chromium{
failedStartsThreshold: 1,
},
tearUp: func() {
failedStartsCount = 1
},
tearDown: func() {
failedStartsCount = 0
},
expectAvailabilityStatus: health.StatusDown,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.tearUp != nil {
tc.tearUp()
}
checks, err := tc.mod.Checks()
if err != nil {
t.Fatalf("expected no error from mod.Checks(), but got: %v", err)
}
if len(checks) == 0 {
return
}
if len(checks) != 1 {
t.Fatalf("expected 1 check from mod.Checks(), but got %d", len(checks))
}
checker := health.NewChecker(checks...)
result := checker.Check(context.Background())
if result.Status != tc.expectAvailabilityStatus {
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
}
if tc.tearDown != nil {
tc.tearDown()
}
})
}
}
func TestChromium_Chromium(t *testing.T) {
mod := new(Chromium)
@@ -209,30 +299,71 @@ func TestChromium_Routes(t *testing.T) {
}
func TestChromium_PDF(t *testing.T) {
for i, tc := range []struct {
timeout time.Duration
cancel context.CancelFunc
URL string
options Options
userAgent string
incognito bool
ignoreCertificateErrors bool
allowList *regexp.Regexp
denyList *regexp.Regexp
expectErr bool
for _, tc := range []struct {
name string
timeout time.Duration
cancel context.CancelFunc
URL string
options Options
userAgent string
incognito bool
allowInsecureLocalhost bool
ignoreCertificateErrors bool
disableWebSecurity bool
allowFileAccessFromFiles bool
hostResolverRules string
proxyServer string
allowList *regexp.Regexp
denyList *regexp.Regexp
disableJavaScript bool
expectErr bool
}{
{
name: "context has no deadline",
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
expectErr: true,
},
{
name: "URL does not match the expression from the allowed list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
allowList: regexp.MustCompile("file:///tmp/*"),
expectErr: true,
},
{
name: "URL does not match the expression from the denied list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
denyList: regexp.MustCompile("file:///tests/*"),
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
name: "with user agent",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
UserAgent: "foo",
},
},
{
name: "fail on console exceptions",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample10/index.html",
options: Options{
FailOnConsoleExceptions: true,
},
expectErr: true,
},
{
name: "disable JavaScript",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample9/index.html",
disableJavaScript: true,
},
{
name: "with extra HTTP headers",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
ExtraHTTPHeaders: map[string]string{
"foo": "bar",
@@ -240,12 +371,85 @@ func TestChromium_PDF(t *testing.T) {
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
name: "with extra link tags",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraLinkTags: []LinkTag{
{
Href: "font.woff",
},
{
Href: "style.css",
},
},
},
},
{
name: "with invalid emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "foo",
},
expectErr: true,
},
{
name: "with screen emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "screen",
},
},
{
name: "with print emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "print",
},
},
{
name: "with omit background but not print background",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
OmitBackground: true,
},
expectErr: true,
},
{
name: "with omit background and print background",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
OmitBackground: true,
PrintBackground: true,
},
},
{
name: "with extra script tags",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraScriptTags: []ScriptTag{
{
Src: "script.js",
},
},
},
},
{
name: "with wait delay",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitDelay: time.Duration(1) * time.Nanosecond,
},
},
{
name: "with invalid wait window status",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
@@ -254,6 +458,7 @@ func TestChromium_PDF(t *testing.T) {
expectErr: true,
},
{
name: "with wait window status",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
@@ -261,41 +466,86 @@ func TestChromium_PDF(t *testing.T) {
},
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
name: "with wait for expression that should not happen",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'foo'",
},
expectErr: true,
},
{
name: "with valid wait for expression",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'ready'",
},
},
{
name: "with invalid wait for expression",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitForExpression: "return undefined",
},
expectErr: true,
},
{
name: "with too big margin bottom",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
MarginBottom: 100,
},
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
name: "with invalid page ranges",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
PageRanges: "foo",
},
expectErr: true,
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
userAgent: "foo",
incognito: true,
ignoreCertificateErrors: true,
name: "with a lot of properties",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
userAgent: "foo",
incognito: true,
ignoreCertificateErrors: true,
allowInsecureLocalhost: true,
disableWebSecurity: true,
allowFileAccessFromFiles: true,
hostResolverRules: "foo",
proxyServer: "foo",
},
{
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
name: "with file using local and remote assets",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
},
{
name: "URL does match the expression from the allowed list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
allowList: regexp.MustCompile("file:///tests/*"),
},
{
name: "URL does match the expression from the denied list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
denyList: regexp.MustCompile("file:///etc/*"),
},
{
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
name: "with custom header and footer templates",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: func() string {
b, err := ioutil.ReadFile("/tests/test/testdata/chromium/url/sample2/header.html")
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/header.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -303,7 +553,7 @@ func TestChromium_PDF(t *testing.T) {
return string(b)
}(),
FooterTemplate: func() string {
b, err := ioutil.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -312,13 +562,75 @@ func TestChromium_PDF(t *testing.T) {
}(),
},
},
{
name: "with custom header template only",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/header.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
FooterTemplate: DefaultOptions().FooterTemplate,
},
},
{
name: "with custom footer template only",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: DefaultOptions().HeaderTemplate,
FooterTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
},
},
{
name: "without custom header and footer templates",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: DefaultOptions().HeaderTemplate,
FooterTemplate: DefaultOptions().FooterTemplate,
},
},
{
name: "with file using a .gif",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample5/index.html",
},
{
name: "with allow file access from files",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample6/index.html",
allowFileAccessFromFiles: true,
},
{
name: "with file using a style attribute",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample7/index.html",
},
} {
func() {
mod := new(Chromium)
mod.binPath = os.Getenv("CHROMIUM_BIN_PATH")
mod.userAgent = tc.userAgent
mod.incognito = tc.incognito
mod.allowInsecureLocalhost = tc.allowInsecureLocalhost
mod.ignoreCertificateErrors = tc.ignoreCertificateErrors
mod.disableWebSecurity = tc.disableWebSecurity
mod.allowFileAccessFromFiles = tc.allowFileAccessFromFiles
mod.hostResolverRules = tc.hostResolverRules
mod.proxyServer = tc.proxyServer
if tc.allowList == nil {
tc.allowList = regexp.MustCompile("")
@@ -330,16 +642,17 @@ func TestChromium_PDF(t *testing.T) {
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)
t.Fatalf("test %s: expected error but got: %v", tc.name, err)
}
defer func() {
err := os.RemoveAll(outputDir)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
t.Fatalf("test %s: expected no error but got: %v", tc.name, err)
}
}()
@@ -353,11 +666,11 @@ func TestChromium_PDF(t *testing.T) {
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
t.Errorf("test %s: expected error but got: %v", tc.name, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
t.Errorf("test %s: expected no error but got: %v", tc.name, err)
}
}()
}

View File

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

View File

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

View File

@@ -4,12 +4,15 @@ import (
"context"
"fmt"
"regexp"
"sync"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
@@ -59,6 +62,23 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
})
}
// listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262.
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *runtime.EventExceptionThrown:
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
consoleExceptionsMu.Lock()
defer consoleExceptionsMu.Unlock()
*consoleExceptions = multierr.Append(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
}
})
}
// waitForEventDomContentEventFired waits until the event DomContentEventFired
// is fired or the context timeout.
func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) func() error {

View File

@@ -6,14 +6,15 @@ import (
"errors"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"go.uber.org/multierr"
@@ -25,10 +26,14 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
userAgent string
extraHTTPHeaders map[string]string
landscape, printBackground bool
emulatedMediaType string
landscape, printBackground, omitBackground bool
scale, paperWidth, paperHeight float64
marginTop, marginBottom, marginLeft, marginRight float64
pageRanges string
@@ -37,8 +42,11 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
)
form := ctx.FormData().
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
String("userAgent", &userAgent, defaultOptions.UserAgent).
Custom("extraHttpHeaders", func(value string) error {
if value == "" {
extraHTTPHeaders = defaultOptions.ExtraHTTPHeaders
@@ -53,8 +61,24 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
Custom("emulatedMediaType", func(value string) error {
if value == "" {
emulatedMediaType = defaultOptions.EmulatedMediaType
return nil
}
if value != "screen" && value != "print" {
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
}
emulatedMediaType = value
return nil
}).
Bool("landscape", &landscape, defaultOptions.Landscape).
Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground).
Float64("scale", &scale, defaultOptions.Scale).
Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth).
Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight).
@@ -68,33 +92,42 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
Bool("preferCssPageSize", &preferCSSPageSize, defaultOptions.PreferCSSPageSize)
options := Options{
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
ExtraHTTPHeaders: extraHTTPHeaders,
Landscape: landscape,
PrintBackground: printBackground,
Scale: scale,
PaperWidth: paperWidth,
PaperHeight: paperHeight,
MarginTop: marginTop,
MarginBottom: marginBottom,
MarginLeft: marginLeft,
MarginRight: marginRight,
PageRanges: pageRanges,
HeaderTemplate: headerTemplate,
FooterTemplate: footerTemplate,
PreferCSSPageSize: preferCSSPageSize,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
UserAgent: userAgent,
ExtraHTTPHeaders: extraHTTPHeaders,
ExtraLinkTags: defaultOptions.ExtraLinkTags,
EmulatedMediaType: emulatedMediaType,
ExtraScriptTags: defaultOptions.ExtraScriptTags,
Landscape: landscape,
PrintBackground: printBackground,
OmitBackground: omitBackground,
Scale: scale,
PaperWidth: paperWidth,
PaperHeight: paperHeight,
MarginTop: marginTop,
MarginBottom: marginBottom,
MarginLeft: marginLeft,
MarginRight: marginRight,
PageRanges: pageRanges,
HeaderTemplate: headerTemplate,
FooterTemplate: footerTemplate,
PreferCSSPageSize: preferCSSPageSize,
}
return form, options
}
// convertURLRoute returns an api.MultipartFormDataRoute route which can
// convert a URL to PDF.
func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.MultipartFormDataRoute {
return api.MultipartFormDataRoute{
Path: "/chromium/convert/url",
Handler: func(ctx *api.Context) error {
// convertURLRoute returns an api.Route which can convert a URL to PDF.
func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/url",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
var (
@@ -105,6 +138,30 @@ func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.MultipartForm
err := form.
MandatoryString("url", &URL).
String("pdfFormat", &PDFformat, "").
Custom("extraLinkTags", func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options.ExtraLinkTags)
if err != nil {
return fmt.Errorf("unmarshal extra link tags: %w", err)
}
return nil
}).
Custom("extraScriptTags", func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options.ExtraScriptTags)
if err != nil {
return fmt.Errorf("unmarshal extra script tags: %w", err)
}
return nil
}).
Validate()
if err != nil {
@@ -121,12 +178,14 @@ func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.MultipartForm
}
}
// convertHTMLRoute returns an api.MultipartFormDataRoute route which can
// convert an HTML file to PDF.
func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.MultipartFormDataRoute {
return api.MultipartFormDataRoute{
Path: "/chromium/convert/html",
Handler: func(ctx *api.Context) error {
// convertHTMLRoute returns an api.Route which can convert an HTML file to PDF.
func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/html",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
var (
@@ -155,12 +214,15 @@ func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.MultipartFor
}
}
// convertMarkdownRoute returns an api.MultipartFormDataRoute route which can
// convert markdown files to PDF.
func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.MultipartFormDataRoute {
return api.MultipartFormDataRoute{
Path: "/chromium/convert/markdown",
Handler: func(ctx *api.Context) error {
// convertMarkdownRoute returns an api.Route which can convert markdown files
// to PDF.
func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/markdown",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
var (
@@ -210,7 +272,7 @@ func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.Multipar
return "", nil
}
b, err := ioutil.ReadFile(path)
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
}
@@ -280,6 +342,33 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
)
}
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusBadRequest,
"omitBackground requires printBackground set to true",
),
)
}
if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return
// an ErrInvalidEvaluationExpression error. In such a scenario,
// we return a 500.
return fmt.Errorf("convert to PDF: %w", err)
}
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusBadRequest,
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
),
)
}
if errors.Is(err, ErrInvalidPrinterSettings) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
@@ -300,6 +389,16 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
)
}
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
return fmt.Errorf("convert to PDF: %w", err)
}

View File

@@ -3,28 +3,29 @@ package chromium
import (
"context"
"errors"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"net/http"
"os"
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestFormDataChromiumPDFOptions(t *testing.T) {
for i, tc := range []struct {
ctx *api.MockContext
ctx *api.ContextMock
options Options
}{
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
options: DefaultOptions(),
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
"foo",
@@ -36,8 +37,8 @@ func TestFormDataChromiumPDFOptions(t *testing.T) {
options: DefaultOptions(),
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
`{"foo":"bar"}`,
@@ -52,6 +53,50 @@ func TestFormDataChromiumPDFOptions(t *testing.T) {
"foo": "bar",
}
return options
}(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
"foo",
},
})
return ctx
}(),
options: DefaultOptions(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"emulatedMediaType": {
"foo",
},
})
return ctx
}(),
options: DefaultOptions(),
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"emulatedMediaType": {
"screen",
},
})
return ctx
}(),
options: func() Options {
options := DefaultOptions()
options.EmulatedMediaType = "screen"
return options
}(),
},
@@ -59,14 +104,14 @@ func TestFormDataChromiumPDFOptions(t *testing.T) {
_, actual := FormDataChromiumPDFOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.options) {
t.Errorf("test %d: expected %v but got: %v", i, tc.options, actual)
t.Errorf("test %d: expected %+v but got: %+v", i, tc.options, actual)
}
}
}
func TestConvertURLHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.MockContext
ctx *api.ContextMock
api API
expectErr bool
expectHTTPErr bool
@@ -74,14 +119,14 @@ func TestConvertURLHandler(t *testing.T) {
expectOutputPathsCount int
}{
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"",
@@ -95,8 +140,8 @@ func TestConvertURLHandler(t *testing.T) {
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
@@ -116,8 +161,8 @@ func TestConvertURLHandler(t *testing.T) {
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
@@ -136,8 +181,90 @@ func TestConvertURLHandler(t *testing.T) {
}(),
expectOutputPathsCount: 1,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
"extraLinkTags": {
"foo",
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
"extraScriptTags": {
"foo",
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"url": {
"foo",
},
"extraLinkTags": {
`[{"href":"https://cdn.foo/foo.css"},{"href":"https://cdn.bar/bar.css"}]`,
},
"extraScriptTags": {
`[{"src":"https://cdn.foo/foo.js"},{"src":"https://cdn.bar/bar.js"}]`,
},
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
expectOutputPathsCount: 1,
},
} {
err := convertURLRoute(tc.api, nil).Handler(tc.ctx.Context)
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertURLRoute(tc.api, nil).Handler(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
@@ -173,7 +300,7 @@ func TestConvertURLHandler(t *testing.T) {
func TestConvertHTMLHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.MockContext
ctx *api.ContextMock
api API
expectErr bool
expectHTTPErr bool
@@ -181,14 +308,14 @@ func TestConvertHTMLHandler(t *testing.T) {
expectOutputPathsCount int
}{
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.html": "/foo/foo.html",
})
@@ -200,8 +327,8 @@ func TestConvertHTMLHandler(t *testing.T) {
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
})
@@ -219,8 +346,8 @@ func TestConvertHTMLHandler(t *testing.T) {
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
})
@@ -238,7 +365,10 @@ func TestConvertHTMLHandler(t *testing.T) {
expectOutputPathsCount: 1,
},
} {
err := convertHTMLRoute(tc.api, nil).Handler(tc.ctx.Context)
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertHTMLRoute(tc.api, nil).Handler(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
@@ -274,7 +404,7 @@ func TestConvertHTMLHandler(t *testing.T) {
func TestConvertMarkdownHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.MockContext
ctx *api.ContextMock
api API
outputDir string
expectErr bool
@@ -283,14 +413,14 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectOutputPathsCount int
}{
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.html": "/foo/foo.html",
})
@@ -302,8 +432,8 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
})
@@ -315,8 +445,8 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/foo/foo.html",
"markdown.md": "/foo/markdown.md",
@@ -327,8 +457,8 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample2/index.html",
"markdown1.md": "/foo/markdown1.md",
@@ -341,8 +471,8 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample1/index.html",
"markdown1.md": "/foo/markdown1.md",
@@ -361,8 +491,8 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample1/index.html",
"markdown1.md": "/tests/test/testdata/chromium/markdown/sample1/markdown1.md",
@@ -383,8 +513,8 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetDirPath("/tmp/foo")
ctx.SetFiles(map[string]string{
@@ -408,8 +538,8 @@ func TestConvertMarkdownHandler(t *testing.T) {
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetDirPath("/tmp/foo")
ctx.SetFiles(map[string]string{
@@ -432,6 +562,29 @@ func TestConvertMarkdownHandler(t *testing.T) {
outputDir: "/tmp/foo",
expectOutputPathsCount: 1,
},
{
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetDirPath("/tmp/foo")
ctx.SetFiles(map[string]string{
"index.html": "/tests/test/testdata/chromium/markdown/sample3/index.html",
"markdown1.md": "/tests/test/testdata/chromium/markdown/sample3/markdown.md",
})
return ctx
}(),
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return nil
}
return chromiumAPI
}(),
outputDir: "/tmp/foo",
expectOutputPathsCount: 1,
},
} {
func() {
if tc.outputDir != "" {
@@ -449,7 +602,10 @@ func TestConvertMarkdownHandler(t *testing.T) {
}()
}
err := convertMarkdownRoute(tc.api, nil).Handler(tc.ctx.Context)
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
err := convertMarkdownRoute(tc.api, nil).Handler(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
@@ -486,17 +642,18 @@ func TestConvertMarkdownHandler(t *testing.T) {
func TestConvertURL(t *testing.T) {
for i, tc := range []struct {
ctx *api.MockContext
ctx *api.ContextMock
api API
engine gotenberg.PDFEngine
PDFformat string
options Options
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
expectOutputPathsCount int
}{
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -505,12 +662,61 @@ func TestConvertURL(t *testing.T) {
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrOmitBackgroundWithoutPrintBackground
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrInvalidEvaluationExpression
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrInvalidEvaluationExpression
}
return chromiumAPI
}(),
options: func() Options {
options := DefaultOptions()
options.WaitForExpression = "foo"
return options
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -519,12 +725,13 @@ func TestConvertURL(t *testing.T) {
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -533,12 +740,28 @@ func TestConvertURL(t *testing.T) {
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
return ErrConsoleExceptions
}
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusConflict,
},
{
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -547,10 +770,11 @@ func TestConvertURL(t *testing.T) {
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -567,12 +791,13 @@ func TestConvertURL(t *testing.T) {
}
}(),
PDFformat: "foo",
options: DefaultOptions(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -589,10 +814,11 @@ func TestConvertURL(t *testing.T) {
}
}(),
PDFformat: "foo",
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -609,11 +835,12 @@ func TestConvertURL(t *testing.T) {
}
}(),
PDFformat: "foo",
options: DefaultOptions(),
expectOutputPathsCount: 1,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetCancelled(true)
return ctx
@@ -626,10 +853,11 @@ func TestConvertURL(t *testing.T) {
return chromiumAPI
}(),
options: DefaultOptions(),
expectErr: true,
},
{
ctx: &api.MockContext{Context: &api.Context{}},
ctx: &api.ContextMock{Context: &api.Context{}},
api: func() API {
chromiumAPI := struct{ ProtoAPI }{}
chromiumAPI.pdf = func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error {
@@ -638,10 +866,11 @@ func TestConvertURL(t *testing.T) {
return chromiumAPI
}(),
options: DefaultOptions(),
expectOutputPathsCount: 1,
},
} {
err := convertURL(tc.ctx.Context, tc.api, tc.engine, "", tc.PDFformat, DefaultOptions())
err := convertURL(tc.ctx.Context, tc.api, tc.engine, "", tc.PDFformat, tc.options)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)

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

@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
@@ -202,19 +201,10 @@ func (gc *GarbageCollector) Stop(ctx context.Context) error {
}
// Block until the context is done so that other module may gracefully stop
// before we do a shutdown cleanup. We skip this step if we receive a
// SIGINT in the meantime.
// before we do a shutdown cleanup.
gc.logger.Debug("wait for the end of grace duration")
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
select {
case <-quit:
return nil
case <-ctx.Done():
break
}
<-ctx.Done()
gc.ticker.Stop()
gc.done <- true

View File

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

View File

@@ -5,7 +5,7 @@ import (
"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/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
flag "github.com/spf13/pflag"
)
@@ -16,7 +16,7 @@ func init() {
// LibreOffice is a module which provides a route for converting documents to
// PDF with LibreOffice.
type LibreOffice struct {
unoconv unoconv.API
unoAPI uno.API
engine gotenberg.PDFEngine
disableRoutes bool
}
@@ -40,17 +40,17 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.disableRoutes = flags.MustBool("libreoffice-disable-routes")
provider, err := ctx.Module(new(unoconv.Provider))
provider, err := ctx.Module(new(uno.Provider))
if err != nil {
return fmt.Errorf("get unoconv provider: %w", err)
return fmt.Errorf("get unoAPI provider: %w", err)
}
uno, err := provider.(unoconv.Provider).Unoconv()
unoAPI, err := provider.(uno.Provider).UNO()
if err != nil {
return fmt.Errorf("get unoconv API: %w", err)
return fmt.Errorf("get unoAPI API: %w", err)
}
mod.unoconv = uno
mod.unoAPI = unoAPI
provider, err = ctx.Module(new(gotenberg.PDFEngineProvider))
if err != nil {
@@ -67,20 +67,20 @@ func (mod *LibreOffice) Provision(ctx *gotenberg.Context) error {
return nil
}
// Routes returns the API routes.
func (mod LibreOffice) Routes() ([]api.MultipartFormDataRoute, error) {
// Routes returns the HTTP routes.
func (mod LibreOffice) Routes() ([]api.Route, error) {
if mod.disableRoutes {
return nil, nil
}
return []api.MultipartFormDataRoute{
convertRoute(mod.unoconv, mod.engine),
return []api.Route{
convertRoute(mod.unoAPI, mod.engine),
}, nil
}
// Interface guards.
var (
_ gotenberg.Module = (*LibreOffice)(nil)
_ gotenberg.Provisioner = (*LibreOffice)(nil)
_ api.MultipartFormDataRouter = (*LibreOffice)(nil)
_ gotenberg.Module = (*LibreOffice)(nil)
_ gotenberg.Provisioner = (*LibreOffice)(nil)
_ api.Router = (*LibreOffice)(nil)
)

View File

@@ -1,68 +1,14 @@
package libreoffice
import (
"context"
"errors"
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
)
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) {
descriptor := LibreOffice{}.Descriptor()
@@ -75,15 +21,38 @@ func TestLibreOffice_Descriptor(t *testing.T) {
}
func TestLibreOffice_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectErr bool
tests := []struct {
name string
ctx *gotenberg.Context
expectProvisionErr bool
}{
{
name: "nominal behavior",
ctx: func() *gotenberg.Context {
mod := struct{ ProtoModule }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
provider1 := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider1
}}
}
provider1.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
provider2 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineProviderMock
}{}
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
return provider2
}}
}
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
return gotenberg.PDFEngineMock{}, nil
}
return gotenberg.NewContext(
@@ -91,41 +60,36 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
provider1.Descriptor(),
provider2.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.unoconv = func() (unoconv.API, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
name: "no UNO API provider",
ctx: gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
),
expectProvisionErr: true,
},
{
name: "no API from UNO API provider",
ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
provider := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
mod.unoconv = func() (unoconv.API, error) {
return struct{ ProtoUnoconvAPI }{}, nil
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, errors.New("foo")
}
return gotenberg.NewContext(
@@ -133,28 +97,26 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
provider.Descriptor(),
},
)
}(),
expectErr: true,
expectProvisionErr: true,
},
{
name: "no PDF engine provider",
ctx: func() *gotenberg.Context {
mod1 := struct{ ProtoUnoconvProvider }{}
mod1.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
provider := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
mod1.unoconv = func() (unoconv.API, error) {
return struct{ ProtoUnoconvAPI }{}, nil
}
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")
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
return gotenberg.NewContext(
@@ -162,29 +124,39 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod1.Descriptor(),
mod2.Descriptor(),
provider.Descriptor(),
},
)
}(),
expectErr: true,
expectProvisionErr: true,
},
{
name: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context {
mod1 := struct{ ProtoUnoconvProvider }{}
mod1.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod1 }}
provider1 := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider1
}}
}
mod1.unoconv = func() (unoconv.API, error) {
return struct{ ProtoUnoconvAPI }{}, nil
provider1.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
mod2 := struct{ ProtoPDFEngineProvider }{}
mod2.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod2 }}
provider2 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineProviderMock
}{}
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
return provider2
}}
}
mod2.pdfEngine = func() (gotenberg.PDFEngine, error) {
return struct{ ProtoPDFEngine }{}, nil
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
return gotenberg.PDFEngineMock{}, errors.New("foo")
}
return gotenberg.NewContext(
@@ -192,59 +164,60 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod1.Descriptor(),
mod2.Descriptor(),
provider1.Descriptor(),
provider2.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
} {
mod := new(LibreOffice)
err := mod.Provision(tc.ctx)
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := new(LibreOffice)
err := mod.Provision(tc.ctx)
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectProvisionErr && err == nil {
t.Error("expected mod.Provision() error, but got none")
}
if !tc.expectProvisionErr && err != nil {
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
}
})
}
}
func TestLibreOffice_Routes(t *testing.T) {
for i, tc := range []struct {
expectRoutes int
disableRoutes bool
tests := []struct {
name string
mod LibreOffice
expectRoutesCount int
}{
{
expectRoutes: 1,
name: "route not disabled",
mod: LibreOffice{},
expectRoutesCount: 1,
},
{
disableRoutes: true,
name: "route disabled",
mod: LibreOffice{
disableRoutes: true,
},
},
} {
mod := new(LibreOffice)
mod.disableRoutes = tc.disableRoutes
}
routes, err := mod.Routes()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
routes, err := tc.mod.Routes()
if err != nil {
t.Fatalf("expected no error from mod.Routes(), but got: %v", err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
}
if tc.expectRoutesCount != len(routes) {
t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, 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,4 @@
// Package pdfengine provides a module which abstracts the CLI tool unoconv and
// implements the gotenberg.PDFEngine interface.
// Package pdfengine provides a module which interacts with the UNO
// (Universal Network Objects) API and implements the gotenberg.PDFEngine
// interface.
package pdfengine

View File

@@ -2,75 +2,76 @@ package pdfengine
import (
"context"
"errors"
"fmt"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
"go.uber.org/zap"
)
func init() {
gotenberg.MustRegisterModule(UnoconvPDFEngine{})
gotenberg.MustRegisterModule(UNO{})
}
// UnoconvPDFEngine abstracts the CLI tool unoconv and implements the
// gotenberg.PDFEngine interface.
type UnoconvPDFEngine struct {
unoconv unoconv.API
// UNO interacts with the UNO (Universal Network Objects) API and implements
// the gotenberg.PDFEngine interface.
type UNO struct {
unoAPI uno.API
}
// Descriptor returns a UnoconvPDFEngine's module descriptor.
func (engine UnoconvPDFEngine) Descriptor() gotenberg.ModuleDescriptor {
// Descriptor returns a UNO's module descriptor.
func (UNO) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "unoconv-pdfengine",
New: func() gotenberg.Module { return new(UnoconvPDFEngine) },
ID: "uno-pdfengine",
New: func() gotenberg.Module { return new(UNO) },
}
}
// Provision sets the module properties.
func (engine *UnoconvPDFEngine) Provision(ctx *gotenberg.Context) error {
provider, err := ctx.Module(new(unoconv.Provider))
func (engine *UNO) Provision(ctx *gotenberg.Context) error {
provider, err := ctx.Module(new(uno.Provider))
if err != nil {
return fmt.Errorf("get unoconv provider: %w", err)
}
uno, err := provider.(unoconv.Provider).Unoconv()
unoAPI, err := provider.(uno.Provider).UNO()
if err != nil {
return fmt.Errorf("get unoconv API: %w", err)
}
engine.unoconv = uno
engine.unoAPI = unoAPI
return nil
}
// Merge is not available for this PDF engine.
func (engine UnoconvPDFEngine) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
func (engine UNO) Merge(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return fmt.Errorf("merge PDFs with unoconv: %w", gotenberg.ErrPDFEngineMethodNotAvailable)
}
// 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
// a gotenberg.ErrPDFFormatNotAvailable error.
func (engine UnoconvPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
if format != gotenberg.FormatPDFA1a {
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable)
}
err := engine.unoconv.PDF(ctx, logger, inputPath, outputPath, unoconv.Options{
PDFArchive: true,
// PDF/A-1a, PDF/A-2b and PDF/A-3b formats are available. If another PDF format
// is requested, it returns a gotenberg.ErrPDFFormatNotAvailable error.
func (engine UNO) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
err := engine.unoAPI.PDF(ctx, logger, inputPath, outputPath, uno.Options{
PDFformat: format,
})
if err == nil {
return nil
}
if errors.Is(err, uno.ErrInvalidPDFformat) {
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, gotenberg.ErrPDFFormatNotAvailable)
}
return fmt.Errorf("convert PDF to '%s' with unoconv: %w", format, err)
}
// Interface guards.
var (
_ gotenberg.Module = (*UnoconvPDFEngine)(nil)
_ gotenberg.Provisioner = (*UnoconvPDFEngine)(nil)
_ gotenberg.PDFEngine = (*UnoconvPDFEngine)(nil)
_ gotenberg.Module = (*UNO)(nil)
_ gotenberg.Provisioner = (*UNO)(nil)
_ gotenberg.PDFEngine = (*UNO)(nil)
)

View File

@@ -7,191 +7,168 @@ import (
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
flag "github.com/spf13/pflag"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
"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(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()
func TestUNO_Descriptor(t *testing.T) {
descriptor := UNO{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(UnoconvPDFEngine))
expect := reflect.TypeOf(new(UNO))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestUnoconvPDFEngine_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectErr bool
func TestUNO_Provider(t *testing.T) {
tests := []struct {
name string
ctx *gotenberg.Context
expectProvisionErr bool
}{
{
name: "nominal behavior",
ctx: func() *gotenberg.Context {
mod := struct{ ProtoModule }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
provider := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError),
FlagSet: new(UNO).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
provider.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.unoconv = func() (unoconv.API, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError),
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
name: "no UNO API provider",
ctx: gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(UNO).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
),
expectProvisionErr: true,
},
{
name: "no API from UNO API provider",
ctx: func() *gotenberg.Context {
mod := struct{ ProtoUnoconvProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
provider := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
mod.unoconv = func() (unoconv.API, error) {
return struct{ ProtoUnoconvAPI }{}, nil
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: flag.NewFlagSet("foo", flag.ExitOnError),
FlagSet: new(UNO).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
provider.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
} {
mod := new(UnoconvPDFEngine)
err := mod.Provision(tc.ctx)
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := new(UNO)
err := mod.Provision(tc.ctx)
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectProvisionErr && err == nil {
t.Error("expected mod.Provision() error, but got none")
}
if !tc.expectProvisionErr && err != nil {
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
}
})
}
}
func TestUnoconvPDFEngine_Merge(t *testing.T) {
mod := new(UnoconvPDFEngine)
err := mod.Merge(context.TODO(), zap.NewNop(), nil, "")
func TestUNO_Merge(t *testing.T) {
mod := new(UNO)
err := mod.Merge(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
t.Errorf("expected error %v from mod.Merge(), but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
}
}
func TestUnoconvPDFEngine_Convert(t *testing.T) {
for i, tc := range []struct {
api unoconv.API
format string
expectErr bool
func TestUNO_Convert(t *testing.T) {
tests := []struct {
name string
mod UNO
expectConvertErr bool
}{
{
format: "",
expectErr: true,
name: "nominal behavior",
mod: UNO{
unoAPI: uno.APIMock{
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error {
return nil
},
},
},
},
{
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,
name: "invalid PDF format",
mod: UNO{
unoAPI: uno.APIMock{
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error {
return uno.ErrInvalidPDFformat
},
},
},
expectConvertErr: true,
},
{
api: func() unoconv.API {
unoconvAPI := struct{ ProtoUnoconvAPI }{}
unoconvAPI.pdf = func(_ context.Context, _ *zap.Logger, _, __ string, _ unoconv.Options) error {
return nil
}
return unoconvAPI
}(),
format: gotenberg.FormatPDFA1a,
name: "convert fail",
mod: UNO{
unoAPI: uno.APIMock{
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options uno.Options) error {
return errors.New("foo")
},
},
},
expectConvertErr: true,
},
} {
mod := new(UnoconvPDFEngine)
mod.unoconv = tc.api
}
err := mod.Convert(context.TODO(), zap.NewNop(), tc.format, "", "")
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.mod.Convert(context.Background(), zap.NewNop(), "", "", "")
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if tc.expectConvertErr && err == nil {
t.Errorf("expected mod.Convert() error, but got none")
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if !tc.expectConvertErr && err != nil {
t.Fatalf("expected no error from mod.Convert(), but got: %v", err)
}
})
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ unoconv.Provider = (*ProtoUnoconvProvider)(nil)
_ gotenberg.Module = (*ProtoUnoconvProvider)(nil)
_ unoconv.API = (*ProtoUnoconvAPI)(nil)
)

View File

@@ -7,30 +7,37 @@ import (
"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/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
"github.com/labstack/echo/v4"
)
// convertRoute returns an api.MultipartFormDataRoute which can convert
// LibreOffice documents to PDF.
func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.MultipartFormDataRoute {
return api.MultipartFormDataRoute{
Path: "/libreoffice/convert",
Handler: func(ctx *api.Context) error {
// convertRoute returns an api.Route which can convert LibreOffice documents
// to PDF.
func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/libreoffice/convert",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them.
var (
inputPaths []string
landscape bool
nativePageRanges string
nativePDFA1aFormat bool
nativePDFformat string
PDFformat string
merge bool
)
err := ctx.FormData().
MandatoryPaths(uno.Extensions(), &inputPaths).
MandatoryPaths(unoAPI.Extensions(), &inputPaths).
Bool("landscape", &landscape, false).
String("nativePageRanges", &nativePageRanges, "").
Bool("nativePdfA1aFormat", &nativePDFA1aFormat, false).
String("nativePdfFormat", &nativePDFformat, "").
String("pdfFormat", &PDFformat, "").
Bool("merge", &merge, false).
Validate()
@@ -39,13 +46,35 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.MultipartForm
return fmt.Errorf("validate form data: %w", err)
}
if nativePDFA1aFormat {
ctx.Log().Warn("'nativePdfA1aFormat' is deprecated; prefer 'nativePdfFormat' or 'pdfFormat' form fields instead")
}
if nativePDFA1aFormat && nativePDFformat != "" {
return api.WrapError(
errors.New("got both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'nativePdfFormat' and 'nativePdfA1aFormat' form fields are provided"),
)
}
if nativePDFA1aFormat && PDFformat != "" {
return api.WrapError(
errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form values"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided"),
errors.New("got both 'pdfFormat' and 'nativePdfA1aFormat' form fields"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfA1aFormat' form fields are provided"),
)
}
if nativePDFformat != "" && PDFformat != "" {
return api.WrapError(
errors.New("got both 'pdfFormat' and 'nativePdfFormat' form fields"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Both 'pdfFormat' and 'nativePdfFormat' form fields are provided"),
)
}
if nativePDFA1aFormat {
nativePDFformat = gotenberg.FormatPDFA1a
}
// Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths))
@@ -53,16 +82,16 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.MultipartForm
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
options := unoconv.Options{
options := uno.Options{
Landscape: landscape,
PageRanges: nativePageRanges,
PDFArchive: nativePDFA1aFormat,
PDFformat: nativePDFformat,
}
err = uno.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
err = unoAPI.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
if err != nil {
if errors.Is(err, unoconv.ErrMalformedPageRanges) {
if errors.Is(err, uno.ErrMalformedPageRanges) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
@@ -87,10 +116,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.MultipartForm
// Now, let's check if the client want to convert this result
// PDF to a specific PDF format.
// 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.
// Note: nativePdfA1aFormat/nativePdfFormat have not been
// specified if PDFformat is not empty.
if PDFformat != "" {
convertInputPath := outputPath
@@ -130,9 +157,8 @@ func convertRoute(uno unoconv.API, engine gotenberg.PDFEngine) api.MultipartForm
// Ok, we don't have to merge the PDFs. Let's check if the client
// want to convert each PDF to a specific PDF format.
// 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.
// Note: nativePdfA1aFormat/nativePdfFormat have not been
// specified if PDFformat is not empty.
if PDFformat != "" {
convertOutputPaths := make([]string, len(outputPaths))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
// Package uno provides a module which interacts with the UNO
// (Universal Network Objects) API.
package uno

View File

@@ -0,0 +1,31 @@
package uno
import (
"fmt"
"net"
"strconv"
"go.uber.org/zap"
)
func freePort(logger *zap.Logger) (int, error) {
netListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("listen on the local network address: %w", err)
}
defer func() {
err := netListener.Close()
if err != nil {
logger.Error(fmt.Sprintf("close network listener: %s", err.Error()))
}
}()
addr := netListener.Addr().String()
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 0, fmt.Errorf("get free port from host: %w", err)
}
return strconv.Atoi(portStr)
}

View File

@@ -0,0 +1,379 @@
package uno
import (
"context"
"fmt"
"net"
"os"
"sync"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
)
type listener interface {
start(logger *zap.Logger) error
stop(logger *zap.Logger) error
restart(logger *zap.Logger) error
lock(ctx context.Context, logger *zap.Logger) error
unlock(logger *zap.Logger) error
port() int
queue() int
healthy() bool
}
// TODO: this implementation, even if it's working, is way too complex.
type libreOfficeListener struct {
binPath string
startTimeout time.Duration
threshold int
socketPort int
userProfileDirPath string
cmd gotenberg.Cmd
cfgMu sync.RWMutex
usage int
hadFirstStart bool
hadFirstStartMu sync.RWMutex
restarting bool
restartingMu sync.RWMutex
queueLength int
queueLengthMu sync.RWMutex
lockChan chan struct{}
logger *zap.Logger
}
func newLibreOfficeListener(logger *zap.Logger, binPath string, startTimeout time.Duration, threshold int) listener {
return &libreOfficeListener{
binPath: binPath,
startTimeout: startTimeout,
threshold: threshold,
lockChan: make(chan struct{}, 1),
logger: logger.Named("listener"),
}
}
func (listener *libreOfficeListener) start(logger *zap.Logger) error {
listener.hadFirstStartMu.Lock()
listener.hadFirstStart = true
listener.hadFirstStartMu.Unlock()
port, err := freePort(logger)
if err != nil {
return fmt.Errorf("get free port: %w", err)
}
// Good to know: the garbage collector might delete the next directory
// while it is still running. It does seem to cause any issue though.
userProfileDirPath := gotenberg.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(), listener.startTimeout)
defer cancel()
cmd, err := gotenberg.CommandContext(ctx, logger, listener.binPath, args...)
if err != nil {
return fmt.Errorf("create LibreOffice listener 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 listener: %w", err)
}
logger.Debug("got exit code 81, e.g., LibreOffice listener first start")
// Second start (daemon).
cmd = gotenberg.Command(logger, listener.binPath, args...)
err = cmd.Start()
if err != nil {
return fmt.Errorf("start LibreOffice listener: %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 listener: %v", err))
}
break
}
}()
var success bool
defer func() {
if success {
listener.cfgMu.Lock()
listener.socketPort = port
listener.userProfileDirPath = userProfileDirPath
listener.cmd = cmd
listener.cfgMu.Unlock()
return
}
// Let's make sure the process is killed.
err = cmd.Kill()
if err != nil {
logger.Debug(fmt.Sprintf("kill LibreOffice listener process: %v", err))
}
}()
logger.Debug("waiting for the LibreOffice listener socket to be available...")
for {
select {
case err = <-connChan:
if err != nil {
return fmt.Errorf("LibreOffice listener socket not available: %w", err)
}
logger.Debug("LibreOffice listener socket available")
success = true
return nil
case err = <-waitChan:
return fmt.Errorf("LibreOffice listener process exited: %w", err)
}
}
}
func (listener *libreOfficeListener) stop(logger *zap.Logger) error {
listener.cfgMu.RLock()
defer func() {
defer listener.cfgMu.RUnlock()
err := os.RemoveAll(listener.userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice listener user profile directory: %v", err))
}
}()
err := listener.cmd.Kill()
if err != nil {
return fmt.Errorf("kill LibreOffice listener process: %w", err)
}
return nil
}
func (listener *libreOfficeListener) restart(logger *zap.Logger) error {
listener.restartingMu.Lock()
listener.restarting = true
listener.restartingMu.Unlock()
defer func() {
listener.restartingMu.Lock()
listener.restarting = false
listener.restartingMu.Unlock()
}()
err := listener.stop(logger)
if err != nil {
return fmt.Errorf("stop LibreOffice listener: %w", err)
}
err = listener.start(logger)
if err != nil {
return fmt.Errorf("start LibreOffice listener: %w", err)
}
listener.usage = 0
return nil
}
func (listener *libreOfficeListener) lock(ctx context.Context, logger *zap.Logger) error {
listener.queueLengthMu.Lock()
listener.queueLength += 1
listener.queueLengthMu.Unlock()
defer func() {
listener.queueLengthMu.Lock()
listener.queueLength -= 1
listener.queueLengthMu.Unlock()
}()
doWithContext := func(ctx context.Context, do func() error) error {
doChan := make(chan error, 1)
go func() {
doChan <- do()
}()
for {
select {
case err := <-doChan:
return err
case <-ctx.Done():
return ctx.Err()
}
}
}
select {
case listener.lockChan <- struct{}{}:
logger.Debug("LibreOffice listener lock acquired")
listener.hadFirstStartMu.RLock()
if !listener.hadFirstStart {
listener.hadFirstStartMu.RUnlock()
logger.Debug("starting LibreOffice listener...")
err := doWithContext(ctx, func() error {
return listener.start(logger)
})
if err == nil {
return nil
}
return fmt.Errorf("start long-running LibreOffice listener: %w", err)
}
listener.hadFirstStartMu.RUnlock()
if !listener.healthy() {
logger.Debug("LibreOffice listener is unhealthy, restarting it...")
err := doWithContext(ctx, func() error {
return listener.restart(logger)
})
if err == nil {
return nil
}
return fmt.Errorf("restart long-running LibreOffice listener: %w", err)
}
return nil
case <-ctx.Done():
logger.Debug("failed to acquire LibreOffice listener lock before deadline")
return fmt.Errorf("acquire LibreOffice listener lock: %w", ctx.Err())
}
}
func (listener *libreOfficeListener) unlock(logger *zap.Logger) error {
defer func() {
<-listener.lockChan
logger.Debug("LibreOffice listener lock released")
}()
if !listener.healthy() {
logger.Debug("LibreOffice listener is unhealthy, restarting it...")
err := listener.restart(logger)
if err == nil {
return nil
}
return fmt.Errorf("restart LibreOffice listener: %w", err)
}
listener.usage += 1
if listener.usage < listener.threshold {
return nil
}
logger.Debug("LibreOffice listener threshold reached, restarting it...")
err := listener.restart(logger)
if err == nil {
return nil
}
return fmt.Errorf("restart LibreOffice listener: %w", err)
}
func (listener *libreOfficeListener) port() int {
listener.cfgMu.RLock()
defer listener.cfgMu.RUnlock()
return listener.socketPort
}
func (listener *libreOfficeListener) queue() int {
listener.queueLengthMu.RLock()
defer listener.queueLengthMu.RUnlock()
return listener.queueLength
}
func (listener *libreOfficeListener) healthy() bool {
listener.hadFirstStartMu.RLock()
defer listener.hadFirstStartMu.RUnlock()
if !listener.hadFirstStart {
return true
}
listener.restartingMu.RLock()
defer listener.restartingMu.RUnlock()
if listener.restarting {
return true
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", listener.port()), time.Duration(1)*time.Second)
if err == nil {
err := conn.Close()
if err != nil {
listener.logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice listener: %v", err))
}
return true
}
return false
}
// Interface guards.
var (
_ listener = (*libreOfficeListener)(nil)
)

View File

@@ -0,0 +1,431 @@
package uno
import (
"context"
"os"
"testing"
"time"
"go.uber.org/zap"
)
func TestListener_start(t *testing.T) {
tests := []struct {
name string
listener listener
expectStartErr bool
}{
{
name: "nominal behavior",
listener: newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10),
},
{
name: "non-exit code 81 on first start",
listener: newLibreOfficeListener(zap.NewNop(), "foo", time.Duration(10)*time.Second, 10),
expectStartErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.listener.start(zap.NewNop())
if tc.expectStartErr && err == nil {
t.Fatalf("expected listener.start() error, but got none")
}
if !tc.expectStartErr && err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
if err != nil {
if tc.listener.healthy() {
t.Error("expected a non-running LibreOffice listener")
}
return
}
err = tc.listener.stop(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
}
})
}
}
func TestListener_stop(t *testing.T) {
listener := newLibreOfficeListener(
zap.NewNop(),
os.Getenv("LIBREOFFICE_BIN_PATH"),
time.Duration(10)*time.Second,
10,
)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
err = listener.stop(zap.NewNop())
if err != nil {
t.Errorf("expected no error from listener.stop(), but got: %v", err)
}
}
func TestListener_restart(t *testing.T) {
listener := newLibreOfficeListener(
zap.NewNop(),
os.Getenv("LIBREOFFICE_BIN_PATH"),
time.Duration(10)*time.Second,
10,
)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
err = listener.restart(zap.NewNop())
if err != nil {
t.Errorf("expected no error from listener.stop(), but got: %v", err)
}
if !listener.healthy() {
t.Error("expected an healthy LibreOffice listener")
}
}
func TestListener_lock(t *testing.T) {
tests := []struct {
name string
listener listener
ctx context.Context
teardown func(listener listener) error
expectLockErr bool
}{
{
name: "nominal behavior",
listener: func() listener {
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
return listener
}(),
ctx: context.Background(),
teardown: func(listener listener) error {
return listener.stop(zap.NewNop())
},
},
{
name: "first start",
listener: newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10),
ctx: context.Background(),
teardown: func(listener listener) error {
return listener.stop(zap.NewNop())
},
},
{
name: "unhealthy listener",
listener: func() listener {
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
err = listener.stop(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
}
return listener
}(),
ctx: context.Background(),
teardown: func(listener listener) error {
return listener.stop(zap.NewNop())
},
},
{
name: "context done",
listener: func() listener {
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
err = listener.lock(context.Background(), zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
}
return listener
}(),
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectLockErr: true,
teardown: func(listener listener) error {
return listener.stop(zap.NewNop())
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
defer func() {
err := tc.teardown(tc.listener)
if err != nil {
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
}
}()
err := tc.listener.lock(tc.ctx, zap.NewNop())
if tc.expectLockErr && err == nil {
t.Fatalf("expected listener.lock() error, but got none")
}
if !tc.expectLockErr && err != nil {
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
}
})
}
}
func TestListener_unlock(t *testing.T) {
tests := []struct {
name string
listener listener
teardown func(listener listener) error
}{
{
name: "nominal behavior",
listener: func() listener {
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
err = listener.lock(context.Background(), zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
}
return listener
}(),
teardown: func(listener listener) error {
return listener.stop(zap.NewNop())
},
},
{
name: "unhealthy listener",
listener: func() listener {
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 10)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
err = listener.lock(context.Background(), zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
}
err = listener.stop(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
}
return listener
}(),
teardown: func(listener listener) error {
return listener.stop(zap.NewNop())
},
},
{
name: "threshold reached",
listener: func() listener {
listener := newLibreOfficeListener(zap.NewNop(), os.Getenv("LIBREOFFICE_BIN_PATH"), time.Duration(10)*time.Second, 1)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
err = listener.lock(context.Background(), zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
}
return listener
}(),
teardown: func(listener listener) error {
return listener.stop(zap.NewNop())
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
defer func() {
err := tc.teardown(tc.listener)
if err != nil {
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
}
}()
err := tc.listener.unlock(zap.NewNop())
if err != nil {
t.Errorf("expected no error from listener.unlock(), but got: %v", err)
}
})
}
}
func TestListener_port(t *testing.T) {
listener := newLibreOfficeListener(
zap.NewNop(),
os.Getenv("LIBREOFFICE_BIN_PATH"),
time.Duration(10)*time.Second,
10,
)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
port := listener.port()
if port == 0 {
t.Error("expected a non-zero value from listener.port")
}
err = listener.stop(zap.NewNop())
if err != nil {
t.Errorf("expected no error from listener.stop(), but got: %v", err)
}
}
func TestListener_queue(t *testing.T) {
listener := newLibreOfficeListener(
zap.NewNop(),
os.Getenv("LIBREOFFICE_BIN_PATH"),
time.Duration(10)*time.Second,
10,
)
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
defer func() {
err := listener.stop(zap.NewNop())
if err != nil {
t.Errorf("expected no error from listener.stop(), but got: %v", err)
}
}()
queueLength := listener.queue()
if queueLength != 0 {
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
err = listener.lock(ctx, zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.lock(), but got: %v", err)
}
queueLength = listener.queue()
if queueLength != 0 {
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
}
go func() {
_ = listener.lock(ctx, zap.NewNop())
}()
time.Sleep(time.Duration(100) * time.Millisecond)
queueLength = listener.queue()
if queueLength != 1 {
t.Fatalf("expected 1 from listener.queue(), but got %d", queueLength)
}
go func() {
_ = listener.lock(ctx, zap.NewNop())
}()
time.Sleep(time.Duration(100) * time.Millisecond)
queueLength = listener.queue()
if queueLength != 2 {
t.Fatalf("expected 2 from listener.queue(), but got %d", queueLength)
}
cancel()
time.Sleep(time.Duration(100) * time.Millisecond)
queueLength = listener.queue()
if queueLength != 0 {
t.Fatalf("expected a zero value from listener.queue(), but got %d", queueLength)
}
}
func TestListener_healthy(t *testing.T) {
listener := &libreOfficeListener{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
startTimeout: time.Duration(10) * time.Second,
threshold: 10,
lockChan: make(chan struct{}, 1),
logger: zap.NewNop(),
}
// i.e., first start.
if !listener.healthy() {
t.Error("expected an healthy LibreOffice listener")
}
err := listener.start(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.start(), but got: %v", err)
}
if !listener.healthy() {
t.Error("expected an healthy LibreOffice listener")
}
err = listener.stop(zap.NewNop())
if err != nil {
t.Fatalf("expected no error from listener.stop(), but got: %v", err)
}
time.Sleep(time.Duration(1) * time.Second)
if listener.healthy() {
t.Errorf("expected a non-healthy LibreOffice listener")
}
listener.restarting = true
if !listener.healthy() {
t.Error("expected an healthy LibreOffice listener")
}
}

View File

@@ -0,0 +1,36 @@
package uno
import (
"context"
"go.uber.org/zap"
)
// APIMock is a mock for the API 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 {
UNOMock func() (API, error)
}
func (provider ProviderMock) UNO() (API, error) {
return provider.UNOMock()
}
// Interface guards.
var (
_ API = (*APIMock)(nil)
_ Provider = (*ProviderMock)(nil)
)

View File

@@ -0,0 +1,42 @@
package uno
import (
"context"
"testing"
"go.uber.org/zap"
)
func TestAPIMock(t *testing.T) {
mock := APIMock{
PDFMock: func(ctx context.Context, logger *zap.Logger, inputPath, 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 mock.PDF(), but got: %v", err)
}
ext := mock.Extensions()
if ext != nil {
t.Errorf("expected no extensions from mock.Extensions(), but got: %+v", ext)
}
}
func TestProviderMock(t *testing.T) {
mock := ProviderMock{
UNOMock: func() (API, error) {
return APIMock{}, nil
},
}
_, err := mock.UNO()
if err != nil {
t.Errorf("expected no error from mock.UNO(), but got: %v", err)
}
}

View File

@@ -0,0 +1,515 @@
package uno
import (
"context"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
)
func init() {
gotenberg.MustRegisterModule(UNO{})
}
var (
// ErrInvalidPDFformat happens if the PDF format option cannot be handled
// by LibreOffice.
ErrInvalidPDFformat = errors.New("invalid PDF format")
// ErrMalformedPageRanges happens if the page ranges option cannot be
// interpreted by LibreOffice.
ErrMalformedPageRanges = errors.New("page ranges are malformed")
)
// UNO is a module which provides an API to interact with LibreOffice.
type UNO struct {
unoconvBinPath string
libreOfficeBinPath string
libreOfficeStartTimeout time.Duration
libreOfficeRestartThreshold int
listener listener
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
// PDFformat allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b, or
// PDF/A-3b.
// Optional.
PDFformat string
}
// API is an abstraction on top of uno.
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(uno.Provider))
// unoAPI, _ := provider.(uno.Provider).UNO()
// }
type Provider interface {
UNO() (API, error)
}
// Descriptor returns a UNO's module descriptor.
func (UNO) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "uno",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("uno", flag.ExitOnError)
fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for restarting the LibreOffice listener")
fs.Int("uno-listener-restart-threshold", 10, "Conversions limit after which the LibreOffice listener is restarted - 0 means no long-running LibreOffice listener")
fs.Bool("unoconv-disable-listener", false, "Do not start a long-running listener - save resources in detriment of unitary performance")
err := fs.MarkDeprecated("unoconv-disable-listener", "use uno-listener-restart-threshold with 0 instead")
if err != nil {
panic(fmt.Errorf("create deprecated flags for the uno module: %v", err))
}
return fs
}(),
New: func() gotenberg.Module { return new(UNO) },
}
}
// Provision sets the module properties. It returns an error if the environment
// variables UNOCONV_BIN_PATH and LIBREOFFICE_BIN_PATH are not set.
func (mod *UNO) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.libreOfficeStartTimeout = flags.MustDuration("uno-listener-start-timeout")
mod.libreOfficeRestartThreshold = flags.MustInt("uno-listener-restart-threshold")
disableListener := flags.MustBool("unoconv-disable-listener")
if disableListener {
mod.libreOfficeRestartThreshold = 0
}
unoconvBinPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
if !ok {
return errors.New("UNOCONV_BIN_PATH environment variable is not set")
}
mod.unoconvBinPath = unoconvBinPath
libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH")
if !ok {
return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set")
}
mod.libreOfficeBinPath = libreOfficeBinPath
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
mod.listener = newLibreOfficeListener(
mod.logger,
mod.libreOfficeBinPath,
mod.libreOfficeStartTimeout,
mod.libreOfficeRestartThreshold,
)
return nil
}
// Validate validates the module properties.
func (mod UNO) Validate() error {
var err error
_, statErr := os.Stat(mod.unoconvBinPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("unoconv binary path does not exist: %w", statErr))
}
_, statErr = os.Stat(mod.libreOfficeBinPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
}
return err
}
// Start does nothing: it is here to validate the contract from the
// gotenberg.App interface. The long-running LibreOffice Listener will be
// started on the first call to PDF.
func (mod UNO) Start() error {
return nil
}
// StartupMessage returns a custom startup message.
func (mod UNO) StartupMessage() string {
if mod.libreOfficeRestartThreshold == 0 {
return "long-running LibreOffice listener disabled"
}
return "long-running LibreOffice listener ready to start"
}
// Stop stops the long-running LibreOffice Listener if it exists.
func (mod UNO) Stop(ctx context.Context) error {
if mod.libreOfficeRestartThreshold == 0 {
return nil
}
// 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.listener.stop(mod.logger)
if err == nil {
return nil
}
return fmt.Errorf("stop long-running LibreOffice listener")
}
// Metrics returns the metrics.
func (mod UNO) 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: "libreoffice_listener_active_instances_count",
Description: "Current number of active LibreOffice listener instances.",
Read: func() float64 {
if mod.libreOfficeRestartThreshold == 0 {
listenerActiveInstancesCountMu.RLock()
defer listenerActiveInstancesCountMu.RUnlock()
return listenerActiveInstancesCount
}
if mod.listener.healthy() {
return 1
}
return 0
},
},
{
Name: "unoconv_listener_active_instances_count",
Description: "Current number of active unoconv listener instances - deprecated, prefer libreoffice_listener_active_instances_count.",
Read: func() float64 {
if mod.libreOfficeRestartThreshold == 0 {
listenerActiveInstancesCountMu.RLock()
defer listenerActiveInstancesCountMu.RUnlock()
return listenerActiveInstancesCount
}
if mod.listener.healthy() {
return 1
}
return 0
},
},
{
Name: "libreoffice_listener_queue_length",
Description: "Current number of processes in the LibreOffice listener queue.",
Read: func() float64 {
return float64(mod.listener.queue())
},
},
{
Name: "unoconv_listener_queue_length",
Description: "Current number of processes in the queue - deprecated, prefer libreoffice_listener_queue_length.",
Read: func() float64 {
return float64(mod.listener.queue())
},
},
}, nil
}
// Checks adds a health check that verifies the health of the long-running
// LibreOffice listener.
func (mod UNO) Checks() ([]health.CheckerOption, error) {
if mod.libreOfficeRestartThreshold == 0 {
return nil, nil
}
return []health.CheckerOption{
health.WithCheck(health.Check{
Name: "uno",
Check: func(_ context.Context) error {
if mod.listener.healthy() {
return nil
}
return errors.New("long-running LibreOffice listener unhealthy")
},
}),
}, nil
}
// PDF converts a document to PDF.
//
// If there is no long-running LibreOffice listener, it creates a dedicated
// LibreOffice instance for the conversion. Substantial calls to this method
// may increase CPU and memory usage drastically
//
// If there is a long-running LibreOffice listener, the conversion performance
// improves substantially. However, it cannot perform parallel operations.
func (mod UNO) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
args := []string{
"--no-launch",
"--format",
"pdf",
}
switch mod.libreOfficeRestartThreshold {
case 0:
listener := newLibreOfficeListener(logger, mod.libreOfficeBinPath, mod.libreOfficeStartTimeout, 0)
err := listener.start(logger)
if err != nil {
return fmt.Errorf("start LibreOffice listener: %w", err)
}
defer func() {
err := listener.stop(logger)
if err != nil {
logger.Error(fmt.Sprintf("stop LibreOffice listener: %v", err))
}
}()
args = append(args, "--port", fmt.Sprintf("%d", listener.port()))
default:
err := mod.listener.lock(ctx, logger)
if err != nil {
return fmt.Errorf("lock long-running LibreOffice listener: %w", err)
}
defer func() {
go func() {
err := mod.listener.unlock(logger)
if err != nil {
mod.logger.Error(fmt.Sprintf("unlock long-running LibreOffice listener: %v", err))
return
}
}()
}()
// If the LibreOffice listener is restarting while acquiring the lock,
// the port will change. It's therefore important to add the port args
// after we acquire the lock.
args = append(args, "--port", fmt.Sprintf("%d", mod.listener.port()))
}
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))
}
switch options.PDFformat {
case "":
case gotenberg.FormatPDFA1a:
args = append(args, "--export", "SelectPdfVersion=1")
case gotenberg.FormatPDFA2b:
args = append(args, "--export", "SelectPdfVersion=2")
case gotenberg.FormatPDFA3b:
args = append(args, "--export", "SelectPdfVersion=3")
default:
return ErrInvalidPDFformat
}
args = append(args, "--output", outputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, mod.unoconvBinPath, 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()
exitCode, err := cmd.Exec()
activeInstancesCountMu.Lock()
activeInstancesCount -= 1
activeInstancesCountMu.Unlock()
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 exitCode == 5 && options.PageRanges != "" {
return ErrMalformedPageRanges
}
// Possible errors:
// 1. Unoconv/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("unoconv PDF: %w", err)
}
// Extensions returns the file extensions available for conversions.
func (mod UNO) 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",
".odg",
".dotx",
".xltx",
}
}
// UNO returns an API for interacting with LibreOffice.
func (mod UNO) UNO() (API, error) {
return mod, nil
}
var (
listenerActiveInstancesCount float64
listenerActiveInstancesCountMu sync.RWMutex
activeInstancesCount float64
activeInstancesCountMu sync.RWMutex
)
// Interface guards.
var (
_ gotenberg.Module = (*UNO)(nil)
_ gotenberg.Provisioner = (*UNO)(nil)
_ gotenberg.Validator = (*UNO)(nil)
_ gotenberg.App = (*UNO)(nil)
_ gotenberg.MetricsProvider = (*UNO)(nil)
_ api.HealthChecker = (*UNO)(nil)
_ API = (*UNO)(nil)
_ Provider = (*UNO)(nil)
)

View File

@@ -0,0 +1,822 @@
package uno
import (
"context"
"errors"
"os"
"reflect"
"testing"
"time"
"github.com/alexliesenfeld/health"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
flag "github.com/spf13/pflag"
"go.uber.org/zap"
)
func TestUNO_Descriptor(t *testing.T) {
descriptor := UNO{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(UNO))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestUNO_Provision(t *testing.T) {
tests := []struct {
name string
ctx *gotenberg.Context
expectProvisionErr bool
}{
{
name: "nominal behavior",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(UNO).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
},
{
name: "threshold from deprecated flag --unoconv-disable-listener",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: func() *flag.FlagSet {
fs := new(UNO).Descriptor().FlagSet
err := fs.Parse([]string{"--unoconv-disable-listener=true"})
if err != nil {
t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
}
return fs
}(),
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
},
{
name: "no logger provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(UNO).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectProvisionErr: true,
},
{
name: "no logger from logger provider",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(UNO).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := new(UNO)
err := mod.Provision(tc.ctx)
if tc.expectProvisionErr && err == nil {
t.Errorf("expected mod.Provision() error, but got none")
}
if !tc.expectProvisionErr && err != nil {
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
}
})
}
}
func TestUNO_Validate(t *testing.T) {
tests := []struct {
name string
unoconvBinPath string
libreOfficeBinPath string
expectValidateErr bool
}{
{
name: "nominal behavior",
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
},
{
name: "unoconv bin path does not exist",
unoconvBinPath: "/foo",
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
expectValidateErr: true,
},
{
name: "LibreOffice bin path does not exist",
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: "/foo",
expectValidateErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := UNO{
unoconvBinPath: tc.unoconvBinPath,
libreOfficeBinPath: tc.libreOfficeBinPath,
}
err := mod.Validate()
if tc.expectValidateErr && err == nil {
t.Errorf("expected mod.Validate() error, but got none")
}
if !tc.expectValidateErr && err != nil {
t.Errorf("expected no error from mod.Validate(), but got: %v", err)
}
})
}
}
func TestUNO_Start(t *testing.T) {
mod := new(UNO)
err := mod.Start()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestUNO_StartupMessage(t *testing.T) {
tests := []struct {
name string
mod UNO
expectMessage string
}{
{
name: "long-running LibreOffice listener ready to start",
mod: UNO{
libreOfficeRestartThreshold: 10,
},
expectMessage: "long-running LibreOffice listener ready to start",
},
{
name: "long-running LibreOffice listener disabled",
mod: UNO{
libreOfficeRestartThreshold: 0,
},
expectMessage: "long-running LibreOffice listener disabled",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
actual := tc.mod.StartupMessage()
if tc.expectMessage != actual {
t.Errorf("expected '%s' from mod.StartupMessage(), but got '%s'", tc.expectMessage, actual)
}
})
}
}
func TestUNO_Stop(t *testing.T) {
tests := []struct {
name string
mod UNO
expectStopErr bool
}{
{
name: "nominal behavior",
mod: UNO{
libreOfficeRestartThreshold: 10,
listener: listenerMock{
stopMock: func(logger *zap.Logger) error {
return nil
},
},
logger: zap.NewNop(),
},
},
{
name: "no long-running LibreOffice listener",
mod: UNO{
libreOfficeRestartThreshold: 0,
logger: zap.NewNop(),
},
},
{
name: "stop error",
mod: UNO{
libreOfficeRestartThreshold: 10,
listener: listenerMock{
stopMock: func(logger *zap.Logger) error {
return errors.New("foo")
},
},
logger: zap.NewNop(),
},
expectStopErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
cancel()
err := tc.mod.Stop(ctx)
if tc.expectStopErr && err == nil {
t.Errorf("expected mod.Stop() error, but got none")
}
if !tc.expectStopErr && err != nil {
t.Errorf("expected no error from mod.Stop(), but got: %v", err)
}
})
}
}
func TestUNO_Metrics(t *testing.T) {
tests := []struct {
name string
mod UNO
expectUnoconvActiveInstancesCount float64
expectLibreOfficeListenerActiveInstancesCount float64
expectLibreOfficeListenerQueueLength float64
}{
{
name: "with healthy long-running LibreOffice listener",
mod: UNO{
libreOfficeRestartThreshold: 10,
listener: listenerMock{
queueMock: func() int {
return 0
},
healthyMock: func() bool {
return true
},
},
},
expectLibreOfficeListenerActiveInstancesCount: 1,
},
{
name: "with unhealthy long-running LibreOffice listener",
mod: UNO{
libreOfficeRestartThreshold: 10,
listener: listenerMock{
queueMock: func() int {
return 0
},
healthyMock: func() bool {
return false
},
},
},
},
{
name: "with no long-running LibreOffice listener",
mod: UNO{
libreOfficeRestartThreshold: 0,
listener: listenerMock{
queueMock: func() int {
return 0
},
healthyMock: func() bool {
return false
},
},
},
},
{
name: "with a queue of 3",
mod: UNO{
libreOfficeRestartThreshold: 0,
listener: listenerMock{
queueMock: func() int {
return 3
},
healthyMock: func() bool {
return true
},
},
},
expectLibreOfficeListenerQueueLength: 3,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
metrics, err := tc.mod.Metrics()
if err != nil {
t.Fatalf("expected no error from mod.Metrics(), but got: %v", err)
}
for _, metric := range metrics {
switch metric.Name {
case "unoconv_active_instances_count":
actual := metric.Read()
if actual != tc.expectUnoconvActiveInstancesCount {
t.Errorf("expected 'unoconv_active_instances_count' to be %.0f, but got %.0f", tc.expectUnoconvActiveInstancesCount, actual)
}
case "libreoffice_listener_active_instances_count":
actual := metric.Read()
if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
t.Errorf("expected 'libreoffice_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
}
case "unoconv_listener_active_instances_count":
actual := metric.Read()
if actual != tc.expectLibreOfficeListenerActiveInstancesCount {
t.Errorf("expected 'unoconv_listener_active_instances_count' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerActiveInstancesCount, actual)
}
case "libreoffice_listener_queue_length":
actual := metric.Read()
if actual != tc.expectLibreOfficeListenerQueueLength {
t.Errorf("expected 'libreoffice_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
}
case "unoconv_listener_queue_length":
actual := metric.Read()
if actual != tc.expectLibreOfficeListenerQueueLength {
t.Errorf("expected 'unoconv_listener_queue_length' to be %.0f, but got %.0f", tc.expectLibreOfficeListenerQueueLength, actual)
}
}
}
})
}
}
func TestUNO_Checks(t *testing.T) {
tests := []struct {
name string
mod UNO
expectAvailabilityStatus health.AvailabilityStatus
}{
{
name: "no long-running LibreOffice listener",
mod: UNO{
libreOfficeRestartThreshold: 0,
},
},
{
name: "with healthy long-running LibreOffice listener",
mod: UNO{
libreOfficeRestartThreshold: 10,
listener: listenerMock{
healthyMock: func() bool {
return true
},
},
},
expectAvailabilityStatus: health.StatusUp,
},
{
name: "with unhealthy long-running LibreOffice listener",
mod: UNO{
libreOfficeRestartThreshold: 10,
listener: listenerMock{
healthyMock: func() bool {
return false
},
},
},
expectAvailabilityStatus: health.StatusDown,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
checks, err := tc.mod.Checks()
if err != nil {
t.Fatalf("expected no error from mod.Checks(), but got: %v", err)
}
if len(checks) == 0 {
return
}
if len(checks) != 1 {
t.Fatalf("expected 1 check from mod.Checks(), but got %d", len(checks))
}
checker := health.NewChecker(checks...)
result := checker.Check(context.Background())
if result.Status != tc.expectAvailabilityStatus {
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
}
})
}
}
func TestUNO_PDF(t *testing.T) {
tests := []struct {
name string
mod UNO
ctx context.Context
logger *zap.Logger
inputPath string
options Options
expectPDFErr bool
teardown func(mod UNO) error
}{
{
name: "nominal behavior with no long-running LibreOffice listener",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
},
{
name: "nominal behavior with a long-running LibreOffice listener",
mod: func() UNO {
mod := UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 10,
logger: zap.NewNop(),
}
mod.listener = newLibreOfficeListener(
mod.logger,
mod.libreOfficeBinPath,
mod.libreOfficeStartTimeout,
mod.libreOfficeRestartThreshold,
)
return mod
}(),
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
teardown: func(mod UNO) error {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return mod.Stop(ctx)
},
},
{
name: "convert with a debug logger",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewExample(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
},
{
name: "convert with landscape",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
Landscape: true,
},
},
{
name: "convert with page ranges",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PageRanges: "1-2",
},
},
{
name: "convert with invalid page ranges",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PageRanges: "foo",
},
expectPDFErr: true,
},
{
name: "convert to PDF/A-1a",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PDFformat: gotenberg.FormatPDFA1a,
},
},
{
name: "convert to PDF/A-2b",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PDFformat: gotenberg.FormatPDFA2b,
},
},
{
name: "convert to PDF/A-3b",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PDFformat: gotenberg.FormatPDFA3b,
},
},
{
name: "convert to invalid PDF format",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PDFformat: "foo",
},
expectPDFErr: true,
},
{
name: "nil context",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: nil,
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
expectPDFErr: true,
},
{
name: "expired context",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 0,
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
expectPDFErr: true,
},
{
name: "cannot lock long-running LibreOffice listener",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 10,
listener: listenerMock{
lockMock: func(ctx context.Context, logger *zap.Logger) error {
return errors.New("foo")
},
},
logger: zap.NewNop(),
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
expectPDFErr: true,
},
{
name: "cannot unlock long-running LibreOffice listener",
mod: UNO{
unoconvBinPath: os.Getenv("UNOCONV_BIN_PATH"),
libreOfficeBinPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
libreOfficeStartTimeout: time.Duration(10) * time.Second,
libreOfficeRestartThreshold: 10,
listener: listenerMock{
lockMock: func(ctx context.Context, logger *zap.Logger) error {
return nil
},
unlockMock: func(logger *zap.Logger) error {
return errors.New("foo")
},
portMock: func() int {
return 2002
},
},
logger: zap.NewNop(),
},
ctx: context.Background(),
logger: zap.NewNop(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
expectPDFErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if tc.teardown == nil {
return
}
err := tc.teardown(tc.mod)
if err != nil {
t.Errorf("expected no error from tc.teardown(), but got: %v", err)
}
}()
outputDir, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("expected no error from gotenberg.MkdirAll(), but got: %v", err)
}
defer func() {
err := os.RemoveAll(outputDir)
if err != nil {
t.Errorf("expected no error from os.RemoveAll(), but got: %v", err)
}
}()
err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options)
if tc.expectPDFErr && err == nil {
t.Fatalf("expected mod.PDF() error, but got none")
}
if !tc.expectPDFErr && err != nil {
t.Fatalf("expected no error from mod.PDF(), but got: %v", err)
}
})
}
}
func TestUNO_Extensions(t *testing.T) {
mod := new(UNO)
extensions := mod.Extensions()
actual := len(extensions)
expect := 79
if actual != expect {
t.Errorf("expected %d extensions, but got %d", expect, actual)
}
}
func TestUNO_UNO(t *testing.T) {
mod := new(UNO)
_, err := mod.UNO()
if err != nil {
t.Errorf("expected no error from mod.UNO(), but got: %v", err)
}
}
type listenerMock struct {
startMock func(logger *zap.Logger) error
stopMock func(logger *zap.Logger) error
restartMock func(logger *zap.Logger) error
lockMock func(ctx context.Context, logger *zap.Logger) error
unlockMock func(logger *zap.Logger) error
portMock func() int
queueMock func() int
healthyMock func() bool
}
func (listener listenerMock) start(logger *zap.Logger) error {
return listener.startMock(logger)
}
func (listener listenerMock) stop(logger *zap.Logger) error {
return listener.stopMock(logger)
}
func (listener listenerMock) restart(logger *zap.Logger) error {
return listener.restartMock(logger)
}
func (listener listenerMock) lock(ctx context.Context, logger *zap.Logger) error {
return listener.lockMock(ctx, logger)
}
func (listener listenerMock) unlock(logger *zap.Logger) error {
return listener.unlockMock(logger)
}
func (listener listenerMock) port() int {
return listener.portMock()
}
func (listener listenerMock) queue() int {
return listener.queueMock()
}
func (listener listenerMock) healthy() bool {
return listener.healthyMock()
}
// Interface guards.
var (
_ listener = (*listenerMock)(nil)
)

View File

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

View File

@@ -1,287 +0,0 @@
package unoconv
import (
"context"
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"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
}
// 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",
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(_ *gotenberg.Context) error {
binPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
if !ok {
return errors.New("UNOCONV_BIN_PATH environment variable is not set")
}
mod.binPath = binPath
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
}
// Unoconv returns an API for interacting with unoconv.
func (mod Unoconv) Unoconv() (API, error) {
return mod, nil
}
// PDF converts a document to PDF. 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.
func (mod Unoconv) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
port, err := func() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("listen on the local network address: %w", err)
}
defer func() {
err := listener.Close()
if err != nil {
logger.Error(fmt.Sprintf("close listener: %s", err.Error()))
}
}()
addr := listener.Addr().String()
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 0, fmt.Errorf("get free port from host: %w", err)
}
return strconv.Atoi(portStr)
}()
if err != nil {
return fmt.Errorf("get free port: %w", err)
}
userProfileDirPath := gotenberg.NewDirPath()
args := []string{
"--user-profile",
fmt.Sprintf("//%s", userProfileDirPath),
"--port",
fmt.Sprintf("%d", port),
"--format",
"pdf",
}
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)
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))
err = cmd.Exec()
// 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",
".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",
}
}
// Interface guards.
var (
_ gotenberg.Module = (*Unoconv)(nil)
_ gotenberg.Provisioner = (*Unoconv)(nil)
_ gotenberg.Validator = (*Unoconv)(nil)
_ API = (*Unoconv)(nil)
_ Provider = (*Unoconv)(nil)
)

View File

@@ -7,7 +7,7 @@ import (
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
pdfcpuAPI "github.com/pdfcpu/pdfcpu/pkg/api"
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"
)
@@ -22,7 +22,7 @@ type PDFcpu struct {
}
// Descriptor returns a PDFcpu's module descriptor.
func (engine PDFcpu) Descriptor() gotenberg.ModuleDescriptor {
func (PDFcpu) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "pdfcpu",
New: func() gotenberg.Module { return new(PDFcpu) },

View File

@@ -3,213 +3,178 @@ package pdfengines
import (
"context"
"errors"
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
)
func TestNewMultiPDFEngines(t *testing.T) {
engine1 := &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
}
engine2 := &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return errors.New("foo")
},
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return errors.New("foo")
},
}
multi := newMultiPDFEngines(engine1, engine2)
if len(multi.engines) != 2 {
t.Fatalf("expected %d engines but got %d", 2, len(multi.engines))
}
if !reflect.DeepEqual(engine1, multi.engines[0]) {
t.Errorf("expected %v, but got: %v", engine1, multi.engines[0])
}
if !reflect.DeepEqual(engine2, multi.engines[1]) {
t.Errorf("expected %v, but got: %v", engine2, multi.engines[1])
}
}
func TestMultiPDFEngines_Merge(t *testing.T) {
for i, tc := range []struct {
ctx context.Context
engines []gotenberg.PDFEngine
expectErr bool
tests := []struct {
name string
engine *multiPDFEngines
ctx context.Context
expectMergeErr bool
}{
{
ctx: context.TODO(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
name: "nominal behavior",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
}
}(),
},
),
ctx: context.Background(),
},
{
ctx: context.TODO(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return errors.New("foo")
},
name: "at least one engine does not return an error",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
},
gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
}
}(),
},
),
ctx: context.Background(),
},
{
ctx: context.TODO(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return errors.New("foo")
},
name: "all engines return an error",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return errors.New("bar")
},
},
gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
}
}(),
expectErr: true,
},
),
ctx: context.Background(),
expectMergeErr: true,
},
{
name: "context expired",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
),
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.TODO())
defer cancel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
},
}
}(),
expectErr: true,
expectMergeErr: true,
},
} {
multi := newMultiPDFEngines(tc.engines...)
err := multi.Merge(tc.ctx, nil, nil, "")
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.engine.Merge(tc.ctx, zap.NewNop(), nil, "")
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectMergeErr && err == nil {
t.Errorf("expected engine.Merge() error, but got none")
}
if !tc.expectMergeErr && err != nil {
t.Errorf("expected no error from engine.Merge(), but got: %v", err)
}
})
}
}
func TestMultiPDFEngines_Convert(t *testing.T) {
for i, tc := range []struct {
ctx context.Context
engines []gotenberg.PDFEngine
expectErr bool
tests := []struct {
name string
engine *multiPDFEngines
ctx context.Context
expectConvertErr bool
}{
{
ctx: context.TODO(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
name: "nominal behavior",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
}
}(),
},
),
ctx: context.Background(),
},
{
ctx: context.TODO(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return errors.New("foo")
},
name: "at least one engine does not return an error",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return errors.New("foo")
},
ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
},
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
}
}(),
},
),
ctx: context.Background(),
},
{
ctx: context.TODO(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return errors.New("foo")
},
name: "all engines return an error",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return errors.New("foo")
},
ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return errors.New("bar")
},
},
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return errors.New("foo")
},
}
}(),
expectErr: true,
},
),
ctx: context.Background(),
expectConvertErr: true,
},
{
name: "context expired",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
},
),
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.TODO())
defer cancel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
engines: func() []gotenberg.PDFEngine {
return []gotenberg.PDFEngine{
ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
},
}
}(),
expectErr: true,
expectConvertErr: true,
},
} {
multi := newMultiPDFEngines(tc.engines...)
err := multi.Convert(tc.ctx, nil, "", "", "")
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.engine.Convert(tc.ctx, zap.NewNop(), "", "", "")
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectConvertErr && err == nil {
t.Errorf("expected engine.Convert() error, but got none")
}
if !tc.expectConvertErr && err != nil {
t.Errorf("expected no error from engine.Convert(), but got: %v", err)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package pdfengines
import (
"errors"
"fmt"
"strings"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
@@ -36,7 +37,7 @@ func (PDFEngines) Descriptor() gotenberg.ModuleDescriptor {
ID: "pdfengines",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines - all by default")
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines and their order - all by default")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
return fs
@@ -52,6 +53,18 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error {
names := flags.MustStringSlice("pdfengines-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
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)
}
logger = logger.Named("pdfengines")
engines, err := ctx.Modules(new(gotenberg.PDFEngine))
if err != nil {
return fmt.Errorf("get PDF engines: %w", err)
@@ -67,6 +80,13 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error {
// Selection from user.
mod.names = names
for i, name := range names {
logger.Warn("unoconv-pdfengine is deprecated; prefer uno-pdfengine instead")
if name == "unoconv-pdfengine" {
mod.names[i] = "uno-pdfengine"
}
}
return nil
}
@@ -118,21 +138,32 @@ func (mod PDFEngines) Validate() error {
return fmt.Errorf("non-existing PDF engine(s): %s - available PDF engine(s): %s", nonExistingEngines, availableEngines)
}
// SystemMessages returns one message with the selected gotenberg.PDFEngine
// modules.
func (mod PDFEngines) SystemMessages() []string {
return []string{
strings.Join(mod.names[:], " "),
}
}
// PDFEngine returns a gotenberg.PDFEngine.
func (mod PDFEngines) PDFEngine() (gotenberg.PDFEngine, error) {
engines := make([]gotenberg.PDFEngine, len(mod.engines))
engines := make([]gotenberg.PDFEngine, len(mod.names))
i := 0
for _, engine := range mod.engines {
engines[i] = engine
i++
for i, name := range mod.names {
for _, engine := range mod.engines {
if name == engine.(gotenberg.Module).Descriptor().ID {
engines[i] = engine
break
}
}
}
return newMultiPDFEngines(engines...), nil
}
// Routes returns the API routes.
func (mod PDFEngines) Routes() ([]api.MultipartFormDataRoute, error) {
// Routes returns the HTTP routes.
func (mod PDFEngines) Routes() ([]api.Route, error) {
if mod.disableRoutes {
return nil, nil
}
@@ -144,7 +175,7 @@ func (mod PDFEngines) Routes() ([]api.MultipartFormDataRoute, error) {
return nil, fmt.Errorf("get pdf engine: %w", err)
}
return []api.MultipartFormDataRoute{
return []api.Route{
mergeRoute(engine),
convertRoute(engine),
}, nil
@@ -155,6 +186,7 @@ var (
_ gotenberg.Module = (*PDFEngines)(nil)
_ gotenberg.Provisioner = (*PDFEngines)(nil)
_ gotenberg.Validator = (*PDFEngines)(nil)
_ gotenberg.SystemLogger = (*PDFEngines)(nil)
_ gotenberg.PDFEngineProvider = (*PDFEngines)(nil)
_ api.MultipartFormDataRouter = (*PDFEngines)(nil)
_ api.Router = (*PDFEngines)(nil)
)

View File

@@ -1,47 +1,16 @@
package pdfengines
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"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 ProtoPDFEngine struct {
ProtoValidator
merge func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
convert func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath 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 TestPDFEngine_Descriptor(t *testing.T) {
func TestPDFEngines_Descriptor(t *testing.T) {
descriptor := PDFEngines{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
@@ -52,71 +21,98 @@ func TestPDFEngine_Descriptor(t *testing.T) {
}
}
func TestPDFEngine_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectNames []string
expectEnginesCount int
expectErr bool
func TestPDFEngines_Provision(t *testing.T) {
tests := []struct {
name string
ctx *gotenberg.Context
expectPDFEngineNames []string
expectProvisionErr bool
}{
{
name: "no selection from user",
ctx: func() *gotenberg.Context {
engine := struct{ ProtoPDFEngine }{}
engine.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
engine := struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PDFEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
}
engine.ValidateMock = func() error {
return nil
}
engine.validate = func() error { return errors.New("foo") }
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PDFEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
engine.Descriptor(),
},
)
}(),
expectErr: true,
expectPDFEngineNames: []string{"bar"},
},
{
name: "selection from user",
ctx: func() *gotenberg.Context {
engine := struct{ ProtoPDFEngine }{}
engine.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
engine.validate = func() error { return nil }
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PDFEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
engine.Descriptor(),
},
)
}(),
expectNames: []string{"foo"},
expectEnginesCount: 1,
},
{
ctx: func() *gotenberg.Context {
engine1 := struct{ ProtoPDFEngine }{}
engine1.descriptor = func() gotenberg.ModuleDescriptor {
engine1 := struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PDFEngineMock
}{}
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }}
}
engine1.validate = func() error { return nil }
engine1.ValidateMock = func() error {
return nil
}
engine2 := struct{ ProtoPDFEngine }{}
engine2.descriptor = func() gotenberg.ModuleDescriptor {
engine2 := struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PDFEngineMock
}{}
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }}
}
engine2.validate = func() error { return nil }
engine2.ValidateMock = func() error {
return nil
}
fs := new(PDFEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-engines=b", "--pdfengines-engines=a"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
}
return gotenberg.NewContext(
@@ -124,33 +120,47 @@ func TestPDFEngine_Provision(t *testing.T) {
FlagSet: fs,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
engine1.Descriptor(),
engine2.Descriptor(),
},
)
}(),
expectNames: []string{"b", "a"},
expectEnginesCount: 2,
expectPDFEngineNames: []string{"b", "a"},
},
{
name: "user select deprecated unoconv-pdfengine",
ctx: func() *gotenberg.Context {
engine1 := struct{ ProtoPDFEngine }{}
engine1.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "a", New: func() gotenberg.Module { return engine1 }}
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
engine1.validate = func() error { return nil }
engine2 := struct{ ProtoPDFEngine }{}
engine2.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "b", New: func() gotenberg.Module { return engine2 }}
engine := struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PDFEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "uno-pdfengine", New: func() gotenberg.Module { return engine }}
}
engine.ValidateMock = func() error {
return nil
}
engine2.validate = func() error { return nil }
fs := new(PDFEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-engines=b"})
err := fs.Parse([]string{"--pdfengines-engines=unoconv-pdfengine"})
if err != nil {
t.Fatalf("expected error but got: %v", err)
t.Fatalf("expected no error from fs.Parse(), but got: %v", err)
}
return gotenberg.NewContext(
@@ -158,56 +168,136 @@ func TestPDFEngine_Provision(t *testing.T) {
FlagSet: fs,
},
[]gotenberg.ModuleDescriptor{
engine1.Descriptor(),
engine2.Descriptor(),
provider.Descriptor(),
engine.Descriptor(),
},
)
}(),
expectNames: []string{"b"},
expectEnginesCount: 2,
expectPDFEngineNames: []string{"uno-pdfengine"},
},
} {
mod := new(PDFEngines)
err := mod.Provision(tc.ctx)
{
name: "no logger provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PDFEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectProvisionErr: true,
},
{
name: "no logger from logger provider",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PDFEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
{
name: "no valid PDF engines",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
engine := struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PDFEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine }}
}
engine.ValidateMock = func() error {
return errors.New("foo")
}
if len(tc.expectNames) != len(mod.names) {
t.Errorf("test %d: expected %d names but got %d", i, len(tc.expectNames), len(mod.names))
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PDFEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
engine.Descriptor(),
},
)
}(),
expectProvisionErr: true,
},
}
if tc.expectEnginesCount != len(mod.engines) {
t.Errorf("test %d: expected %d engines but got %d", i, tc.expectEnginesCount, len(mod.engines))
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := new(PDFEngines)
err := mod.Provision(tc.ctx)
for index, name := range mod.names {
if name != tc.expectNames[index] {
t.Errorf("test %d: expected name at index %d to be %s, but got: %s", i, index, name, tc.expectNames[index])
if tc.expectProvisionErr && err == nil {
t.Fatal("expected mod.Provision() error, but got none")
}
}
if !tc.expectProvisionErr && err != nil {
t.Fatalf("expected no error from mod.Provision(), but got: %v", err)
}
if len(tc.expectPDFEngineNames) != len(mod.names) {
t.Errorf("expected %d names but got %d", len(tc.expectPDFEngineNames), len(mod.names))
}
for index, name := range mod.names {
if name != tc.expectPDFEngineNames[index] {
t.Errorf("expected name at index %d to be %s, but got: %s", index, name, tc.expectPDFEngineNames[index])
}
}
})
}
}
func TestPDFEngine_Validate(t *testing.T) {
for i, tc := range []struct {
names []string
engines []gotenberg.PDFEngine
expectErr bool
func TestPDFEngines_Validate(t *testing.T) {
tests := []struct {
name string
names []string
engines []gotenberg.PDFEngine
expectValidateErr bool
}{
{
expectErr: true,
},
{
name: "existing PDF engine",
names: []string{"foo"},
engines: func() []gotenberg.PDFEngine {
engine := struct{ ProtoPDFEngine }{}
engine.descriptor = func() gotenberg.ModuleDescriptor {
engine := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine }}
}
@@ -217,15 +307,22 @@ func TestPDFEngine_Validate(t *testing.T) {
}(),
},
{
name: "non-existing bar PDF engine",
names: []string{"foo", "bar", "baz"},
engines: func() []gotenberg.PDFEngine {
engine1 := struct{ ProtoPDFEngine }{}
engine1.descriptor = func() gotenberg.ModuleDescriptor {
engine1 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineMock
}{}
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
}
engine2 := struct{ ProtoPDFEngine }{}
engine2.descriptor = func() gotenberg.ModuleDescriptor {
engine2 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineMock
}{}
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "baz", New: func() gotenberg.Module { return engine2 }}
}
@@ -234,71 +331,115 @@ func TestPDFEngine_Validate(t *testing.T) {
engine2,
}
}(),
expectErr: true,
expectValidateErr: true,
},
} {
mod := new(PDFEngines)
mod.names = tc.names
mod.engines = tc.engines
err := mod.Validate()
{
name: "no PDF engine",
expectValidateErr: true,
},
}
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := PDFEngines{
names: tc.names,
engines: tc.engines,
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
err := mod.Validate()
if tc.expectValidateErr && err == nil {
t.Errorf("expected mod.Validate() error, but got none")
}
if !tc.expectValidateErr && err != nil {
t.Errorf("expected no error from mod.Validate(), but got: %v", err)
}
})
}
}
func TestPDFEngine_PDFEngine(t *testing.T) {
func TestPDFEngines_SystemMessages(t *testing.T) {
mod := new(PDFEngines)
mod.engines = []gotenberg.PDFEngine{
struct{ ProtoPDFEngine }{},
mod.names = []string{"foo", "bar"}
messages := mod.SystemMessages()
if len(messages) != 1 {
t.Errorf("expected one and only one message from mod.SystemMessages(), but got %d", len(messages))
}
expect := strings.Join(mod.names[:], " ")
if messages[0] != expect {
t.Errorf("expected message '%s' from mod.SystemMessages(), but got '%s'", expect, messages[0])
}
}
func TestPDFEngines_PDFEngine(t *testing.T) {
mod := PDFEngines{
names: []string{"foo", "bar"},
engines: func() []gotenberg.PDFEngine {
engine1 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineMock
}{}
engine1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return engine1 }}
}
engine2 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineMock
}{}
engine2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return engine2 }}
}
return []gotenberg.PDFEngine{
engine1,
engine2,
}
}(),
}
_, err := mod.PDFEngine()
if err != nil {
t.Errorf("expected no error but got: %v", err)
t.Errorf("expected no error from mod.PDFEngine, but got: %v", err)
}
}
func TestPDFEngine_Routes(t *testing.T) {
for i, tc := range []struct {
expectRoutes int
disableRoutes bool
func TestPDFEngines_Routes(t *testing.T) {
tests := []struct {
name string
mod PDFEngines
expectRoutesCount int
}{
{
expectRoutes: 2,
name: "route not disabled",
mod: PDFEngines{
engines: []gotenberg.PDFEngine{
gotenberg.PDFEngineMock{},
},
},
expectRoutesCount: 2,
},
{
disableRoutes: true,
name: "route disabled",
mod: PDFEngines{
disableRoutes: true,
},
},
} {
mod := new(PDFEngines)
mod.engines = []gotenberg.PDFEngine{
struct{ ProtoPDFEngine }{},
}
mod.disableRoutes = tc.disableRoutes
}
routes, err := mod.Routes()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
routes, err := tc.mod.Routes()
if err != nil {
t.Fatalf("expected no error from mod.Routes(), but got: %v", err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
}
if tc.expectRoutesCount != len(routes) {
t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes))
}
})
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.Module = (*ProtoValidator)(nil)
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil)
_ gotenberg.Module = (*ProtoPDFEngine)(nil)
_ gotenberg.Validator = (*ProtoPDFEngine)(nil)
)

View File

@@ -7,13 +7,18 @@ import (
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
)
// mergeRoute returns an api.MultipartFormDataRoute which can merge PDFs.
func mergeRoute(engine gotenberg.PDFEngine) api.MultipartFormDataRoute {
return api.MultipartFormDataRoute{
Path: "/pdfengines/merge",
Handler: func(ctx *api.Context) error {
// mergeRoute returns an api.Route which can merge PDFs.
func mergeRoute(engine gotenberg.PDFEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/merge",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them.
var (
inputPaths []string
@@ -79,12 +84,16 @@ func mergeRoute(engine gotenberg.PDFEngine) api.MultipartFormDataRoute {
}
}
// convertRoute returns an api.MultipartFormDataRoute which can convert a PDF
// to a specific PDF format.
func convertRoute(engine gotenberg.PDFEngine) api.MultipartFormDataRoute {
return api.MultipartFormDataRoute{
Path: "/pdfengines/convert",
Handler: func(ctx *api.Context) error {
// convertRoute returns an api.Route which can convert a PDF to a specific PDF
// format.
func convertRoute(engine gotenberg.PDFEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/convert",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them.
var (
inputPaths []string

View File

@@ -8,12 +8,14 @@ import (
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestMergeHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.MockContext
tests := []struct {
name string
ctx *api.ContextMock
engine gotenberg.PDFEngine
expectErr bool
expectHTTPErr bool
@@ -21,34 +23,75 @@ func TestMergeHandler(t *testing.T) {
expectOutputPathsCount int
}{
{
ctx: &api.MockContext{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
name: "nominal behavior",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return errors.New("foo")
},
}
engine: gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
expectOutputPathsCount: 1,
},
{
name: "invalid form data: no PDF",
ctx: &api.ContextMock{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
name: "merge fail",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
return ctx
}(),
engine: gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
name: "nominal behavior with a PDF format",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
gotenberg.FormatPDFA1a,
},
})
return ctx
}(),
engine: gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
},
expectOutputPathsCount: 1,
},
{
name: "convert to PDF format fail",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
@@ -60,24 +103,20 @@ func TestMergeHandler(t *testing.T) {
return ctx
}(),
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 gotenberg.ErrPDFFormatNotAvailable
},
}
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
engine: gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return errors.New("foo")
},
},
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
name: "invalid PDF format",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
@@ -89,122 +128,82 @@ func TestMergeHandler(t *testing.T) {
return ctx
}(),
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,
engine: gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return gotenberg.ErrPDFFormatNotAvailable
},
},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
name: "cannot add output paths",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
ctx.SetCancelled(true)
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
}
}(),
engine: gotenberg.PDFEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
expectErr: true,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
},
})
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
return ctx
}(),
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: 1,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
err := mergeRoute(tc.engine).Handler(c)
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
merge: func(_ context.Context, _ *zap.Logger, _ []string, _ string) error {
return nil
},
}
}(),
expectOutputPathsCount: 1,
},
} {
err := mergeRoute(tc.engine).Handler(tc.ctx.Context)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
var httpErr api.HTTPError
isHTTPErr := errors.As(err, &httpErr)
if tc.expectHTTPErr && !isHTTPErr {
t.Errorf("test %d: expected HTTP error but got: %v", i, err)
}
if !tc.expectHTTPErr && isHTTPErr {
t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr)
}
if err != nil && tc.expectHTTPErr && isHTTPErr {
status, _ := httpErr.HTTPError()
if status != tc.expectHTTPStatus {
t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status)
if tc.expectErr && err == nil {
t.Fatal("expected error from merge handler, but got none")
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
if !tc.expectErr && err != nil {
t.Fatalf("expected no error from merge handler, but got: %v", err)
}
var httpErr api.HTTPError
isHTTPErr := errors.As(err, &httpErr)
if tc.expectHTTPErr && !isHTTPErr {
t.Errorf("expected HTTP error from merge handler, but got: %v", err)
}
if !tc.expectHTTPErr && isHTTPErr {
t.Errorf("expected no HTTP error from merge handler, but got one: %v", httpErr)
}
if err != nil && tc.expectHTTPErr && isHTTPErr {
status, _ := httpErr.HTTPError()
if status != tc.expectHTTPStatus {
t.Errorf("expected %d HTTP status code from merge handler, but got %d", tc.expectHTTPStatus, status)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("expected %d output paths from merge handler, but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
})
}
}
func TestConvertHandler(t *testing.T) {
for i, tc := range []struct {
ctx *api.MockContext
tests := []struct {
name string
ctx *api.ContextMock
engine gotenberg.PDFEngine
expectErr bool
expectHTTPErr bool
@@ -212,132 +211,109 @@ func TestConvertHandler(t *testing.T) {
expectOutputPathsCount int
}{
{
ctx: &api.MockContext{Context: &api.Context{}},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
},
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
name: "nominal behavior",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
gotenberg.FormatPDFA1a,
},
})
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return gotenberg.ErrPDFFormatNotAvailable
},
}
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
},
})
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
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.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
},
})
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
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.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
"foo",
},
})
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
}
}(),
engine: gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
},
expectOutputPathsCount: 1,
},
{
ctx: func() *api.MockContext {
ctx := &api.MockContext{Context: &api.Context{}}
name: "nominal behavior, but with 3 PDFs",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
"bar.pdf": "/bar/bar.pdf",
"baz.pdf": "/baz/baz.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
gotenberg.FormatPDFA1a,
},
})
return ctx
}(),
engine: gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
},
expectOutputPathsCount: 3,
},
{
name: "invalid form data: no PDF",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"pdfFormat": {
gotenberg.FormatPDFA1a,
},
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
name: "invalid form data: no PDF format",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
return ctx
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
name: "convert to PDF format fail",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
gotenberg.FormatPDFA1a,
},
})
return ctx
}(),
engine: gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return errors.New("foo")
},
},
expectErr: true,
},
{
name: "PDF format not available",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
"bar.pdf": "/foo/bar.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
@@ -347,46 +323,76 @@ func TestConvertHandler(t *testing.T) {
return ctx
}(),
engine: func() gotenberg.PDFEngine {
return &ProtoPDFEngine{
convert: func(_ context.Context, _ *zap.Logger, _, _, _ string) error {
return nil
},
}
}(),
expectOutputPathsCount: 2,
engine: gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return gotenberg.ErrPDFFormatNotAvailable
},
},
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
} {
err := convertRoute(tc.engine).Handler(tc.ctx.Context)
{
name: "cannot add output paths",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetFiles(map[string]string{
"foo.pdf": "/foo/foo.pdf",
})
ctx.SetValues(map[string][]string{
"pdfFormat": {
gotenberg.FormatPDFA1a,
},
})
ctx.SetCancelled(true)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
return ctx
}(),
engine: gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return nil
},
},
expectErr: true,
},
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
var httpErr api.HTTPError
isHTTPErr := errors.As(err, &httpErr)
err := convertRoute(tc.engine).Handler(c)
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.expectErr && err == nil {
t.Fatal("expected error from convert handler, but got none")
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
if !tc.expectErr && err != nil {
t.Fatalf("expected no error from convert handler, but got: %v", err)
}
var httpErr api.HTTPError
isHTTPErr := errors.As(err, &httpErr)
if tc.expectHTTPErr && !isHTTPErr {
t.Errorf("expected HTTP error from convert handler, but got: %v", err)
}
if !tc.expectHTTPErr && isHTTPErr {
t.Errorf("expected no HTTP error from convert handler, but got one: %v", httpErr)
}
if err != nil && tc.expectHTTPErr && isHTTPErr {
status, _ := httpErr.HTTPError()
if status != tc.expectHTTPStatus {
t.Errorf("expected %d HTTP status code from convert handler, but got %d", tc.expectHTTPStatus, status)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("expected %d output paths from convert handler, but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"sync"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
@@ -21,7 +22,7 @@ type PDFtk struct {
}
// Descriptor returns a PDFtk's module descriptor.
func (engine PDFtk) Descriptor() gotenberg.ModuleDescriptor {
func (PDFtk) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "pdftk",
New: func() gotenberg.Module { return new(PDFtk) },
@@ -51,6 +52,22 @@ func (engine PDFtk) Validate() error {
return nil
}
// Metrics returns the metrics.
func (engine PDFtk) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "pdftk_active_instances_count",
Description: "Current number of active PDFtk instances.",
Read: func() float64 {
activeInstancesCountMu.RLock()
defer activeInstancesCountMu.RUnlock()
return activeInstancesCount
},
},
}, nil
}
// Merge merges the given PDFs into a unique PDF.
func (engine PDFtk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
@@ -62,7 +79,16 @@ func (engine PDFtk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
return fmt.Errorf("create command: %w", err)
}
err = cmd.Exec()
activeInstancesCountMu.Lock()
activeInstancesCount += 1
activeInstancesCountMu.Unlock()
_, err = cmd.Exec()
activeInstancesCountMu.Lock()
activeInstancesCount -= 1
activeInstancesCountMu.Unlock()
if err == nil {
return nil
}
@@ -75,10 +101,16 @@ func (engine PDFtk) Convert(_ context.Context, _ *zap.Logger, format, _, _ strin
return fmt.Errorf("convert PDF to '%s' with PDFtk: %w", format, gotenberg.ErrPDFEngineMethodNotAvailable)
}
var (
activeInstancesCount float64
activeInstancesCountMu sync.RWMutex
)
// Interface guards.
var (
_ gotenberg.Module = (*PDFtk)(nil)
_ gotenberg.Provisioner = (*PDFtk)(nil)
_ gotenberg.Validator = (*PDFtk)(nil)
_ gotenberg.PDFEngine = (*PDFtk)(nil)
_ gotenberg.Module = (*PDFtk)(nil)
_ gotenberg.Provisioner = (*PDFtk)(nil)
_ gotenberg.Validator = (*PDFtk)(nil)
_ gotenberg.MetricsProvider = (*PDFtk)(nil)
_ gotenberg.PDFEngine = (*PDFtk)(nil)
)

View File

@@ -62,6 +62,22 @@ func TestPDFtk_Validate(t *testing.T) {
}
}
func TestPDFtk_Metrics(t *testing.T) {
metrics, err := new(PDFtk).Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
}
actual := metrics[0].Read()
if actual != 0 {
t.Errorf("expected %d PDFtk instances, but got %f", 0, actual)
}
}
func TestPDFtk_Merge(t *testing.T) {
for i, tc := range []struct {
ctx context.Context

View File

@@ -0,0 +1,3 @@
// Package prometheus provides a module which collects metrics and exposes them
// via an HTTP route.
package prometheus

View File

@@ -0,0 +1,189 @@
package prometheus
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
flag "github.com/spf13/pflag"
)
func init() {
gotenberg.MustRegisterModule(Prometheus{})
}
// Prometheus is a module which collects metrics and exposes them via an HTTP
// route.
type Prometheus struct {
namespace string
interval time.Duration
disableRouteLogging bool
disableCollect bool
metrics []gotenberg.Metric
registry *prometheus.Registry
}
// Descriptor returns a Prometheus's module descriptor.
func (Prometheus) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "prometheus",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("prometheus", flag.ExitOnError)
fs.String("prometheus-namespace", "gotenberg", "Set the namespace of modules' metrics")
fs.Duration("prometheus-collect-interval", time.Duration(1)*time.Second, "Set the interval for collecting modules' metrics")
fs.Bool("prometheus-disable-route-logging", false, "Disable the route logging")
fs.Bool("prometheus-disable-collect", false, "Disable the collect of metrics")
return fs
}(),
New: func() gotenberg.Module { return new(Prometheus) },
}
}
// Provision sets the modules properties.
func (mod *Prometheus) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.namespace = flags.MustString("prometheus-namespace")
mod.interval = flags.MustDuration("prometheus-collect-interval")
mod.disableRouteLogging = flags.MustBool("prometheus-disable-route-logging")
mod.disableCollect = flags.MustBool("prometheus-disable-collect")
if mod.disableCollect {
// Exit early.
return nil
}
// Get metrics from modules.
mods, err := ctx.Modules(new(gotenberg.MetricsProvider))
if err != nil {
return fmt.Errorf("get metrics providers: %w", err)
}
metricsProviders := make([]gotenberg.MetricsProvider, len(mods))
for i, metricsProvider := range mods {
metricsProviders[i] = metricsProvider.(gotenberg.MetricsProvider)
}
for _, metricsProvider := range metricsProviders {
metrics, err := metricsProvider.Metrics()
if err != nil {
return fmt.Errorf("get metrics: %w", err)
}
mod.metrics = append(mod.metrics, metrics...)
}
mod.registry = prometheus.NewRegistry()
return nil
}
// Validate validates the module properties.
func (mod Prometheus) Validate() error {
if mod.disableCollect {
// Exit early.
return nil
}
if mod.namespace == "" {
return errors.New("namespace must not be empty")
}
metricsMap := make(map[string]string, len(mod.metrics))
for _, metric := range mod.metrics {
if metric.Name == "" {
return errors.New("metric name cannot be empty")
}
if metric.Read == nil {
return fmt.Errorf("metric '%s' has nil read method", metric.Name)
}
if _, ok := metricsMap[metric.Name]; ok {
return fmt.Errorf("metric '%s' is already registered", metric.Name)
}
metricsMap[metric.Name] = metric.Name
}
return nil
}
// Start starts the collect.
func (mod Prometheus) Start() error {
if mod.disableCollect {
// Exit early.
return nil
}
for _, metric := range mod.metrics {
gauge := prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: mod.namespace,
Name: metric.Name,
Help: metric.Description,
},
)
mod.registry.MustRegister(gauge)
go func(gauge prometheus.Gauge, metric gotenberg.Metric) {
for {
gauge.Set(metric.Read())
time.Sleep(mod.interval)
}
}(gauge, metric)
}
return nil
}
// StartupMessage returns a custom startup message.
func (mod Prometheus) StartupMessage() string {
if mod.disableCollect {
return "collect disabled"
}
return "collecting metrics"
}
// Stop does nothing.
func (mod Prometheus) Stop(_ context.Context) error {
return nil
}
// Routes returns the HTTP route.
func (mod Prometheus) Routes() ([]api.Route, error) {
if mod.disableCollect {
return nil, nil
}
return []api.Route{
{
Method: http.MethodGet,
Path: "/prometheus/metrics",
DisableLogging: mod.disableRouteLogging,
Handler: echo.WrapHandler(
promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}),
),
},
}, nil
}
// Interface guards.
var (
_ gotenberg.Module = (*Prometheus)(nil)
_ gotenberg.Provisioner = (*Prometheus)(nil)
_ gotenberg.Validator = (*Prometheus)(nil)
_ gotenberg.App = (*Prometheus)(nil)
_ api.Router = (*Prometheus)(nil)
)

View File

@@ -0,0 +1,360 @@
package prometheus
import (
"errors"
"reflect"
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/prometheus/client_golang/prometheus"
)
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 ProtoMetricsProvider struct {
ProtoValidator
metrics func() ([]gotenberg.Metric, error)
}
func (mod ProtoMetricsProvider) Metrics() ([]gotenberg.Metric, error) {
return mod.metrics()
}
func TestPrometheus_Descriptor(t *testing.T) {
descriptor := Prometheus{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Prometheus))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestPrometheus_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectMetrics []gotenberg.Metric
expectErr bool
}{
{
ctx: func() *gotenberg.Context {
fs := new(Prometheus).Descriptor().FlagSet
err := fs.Parse([]string{"--prometheus-disable-collect=true"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMetricsProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return errors.New("foo")
}
mod.metrics = func() ([]gotenberg.Metric, error) {
return nil, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMetricsProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.metrics = func() ([]gotenberg.Metric, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectErr: true,
},
{
ctx: func() *gotenberg.Context {
mod := struct{ ProtoMetricsProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
}
mod.validate = func() error {
return nil
}
mod.metrics = func() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "foo",
Description: "Bar.",
},
}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Prometheus).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectMetrics: []gotenberg.Metric{
{
Name: "foo",
Description: "Bar.",
},
},
},
} {
mod := new(Prometheus)
err := mod.Provision(tc.ctx)
if !reflect.DeepEqual(mod.metrics, tc.expectMetrics) {
t.Errorf("test %d: expected %+v, but got: %+v", i, tc.expectMetrics, mod.metrics)
}
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 TestPrometheus_Validate(t *testing.T) {
for i, tc := range []struct {
namespace string
metrics []gotenberg.Metric
disableCollect bool
expectErr bool
}{
{
disableCollect: true,
},
{
namespace: "",
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "",
},
},
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
},
},
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
{
Name: "foo",
Read: func() float64 {
return 0
},
},
},
expectErr: true,
},
{
namespace: "foo",
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
{
Name: "bar",
Read: func() float64 {
return 0
},
},
},
},
} {
mod := Prometheus{
namespace: tc.namespace,
metrics: tc.metrics,
disableCollect: tc.disableCollect,
}
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 TestPrometheus_Start(t *testing.T) {
for i, tc := range []struct {
metrics []gotenberg.Metric
disableCollect bool
}{
{
disableCollect: true,
},
{
metrics: []gotenberg.Metric{
{
Name: "foo",
Read: func() float64 {
return 0
},
},
},
},
} {
mod := Prometheus{
namespace: "foo",
interval: time.Duration(1) * time.Second,
metrics: tc.metrics,
disableCollect: tc.disableCollect,
registry: prometheus.NewRegistry(),
}
err := mod.Start()
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}
}
func TestPrometheus_StartupMessage(t *testing.T) {
for i, tc := range []struct {
disableCollect bool
expectMessage string
}{
{
disableCollect: true,
expectMessage: "collect disabled",
},
{
expectMessage: "collecting metrics",
},
} {
mod := Prometheus{
disableCollect: tc.disableCollect,
}
actual := mod.StartupMessage()
if actual != tc.expectMessage {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectMessage, actual)
}
}
}
func TestPrometheus_Stop(t *testing.T) {
err := Prometheus{}.Stop(nil)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestPrometheus_Routes(t *testing.T) {
for i, tc := range []struct {
expectRoutes int
disableCollect bool
}{
{
disableCollect: true,
},
{
expectRoutes: 1,
},
} {
mod := Prometheus{
disableCollect: tc.disableCollect,
registry: prometheus.NewRegistry(),
}
routes, err := mod.Routes()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
if tc.expectRoutes != len(routes) {
t.Errorf("test %d: expected %d routes but got %d", i, tc.expectRoutes, len(routes))
}
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ gotenberg.Validator = (*ProtoValidator)(nil)
_ gotenberg.Module = (*ProtoValidator)(nil)
_ gotenberg.MetricsProvider = (*ProtoMetricsProvider)(nil)
_ gotenberg.Module = (*ProtoMetricsProvider)(nil)
_ gotenberg.Validator = (*ProtoMetricsProvider)(nil)
)

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

@@ -0,0 +1,3 @@
// Package qpdf provides a module which abstracts the CLI tool QPDF and
// implements the gotenberg.PDFEngine interface.
package qpdf

117
pkg/modules/qpdf/qpdf.go Normal file
View File

@@ -0,0 +1,117 @@
package qpdf
import (
"context"
"errors"
"fmt"
"os"
"sync"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
)
func init() {
gotenberg.MustRegisterModule(QPDF{})
}
// QPDF abstracts the CLI tool QPDF and implements the gotenberg.PDFEngine
// interface.
type QPDF struct {
binPath string
}
// Descriptor returns a QPDF's module descriptor.
func (QPDF) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "qpdf",
New: func() gotenberg.Module { return new(QPDF) },
}
}
// Provision sets the modules properties. It returns an error if the
// environment variable QPDF_BIN_PATH is not set.
func (engine *QPDF) Provision(_ *gotenberg.Context) error {
binPath, ok := os.LookupEnv("QPDF_BIN_PATH")
if !ok {
return errors.New("QPDF_BIN_PATH environment variable is not set")
}
engine.binPath = binPath
return nil
}
// Validate validates the module properties.
func (engine QPDF) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("QPDF binary path does not exist: %w", err)
}
return nil
}
// Metrics returns the metrics.
func (engine QPDF) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "qpdf_active_instances_count",
Description: "Current number of active QPDF instances.",
Read: func() float64 {
activeInstancesCountMu.RLock()
defer activeInstancesCountMu.RUnlock()
return activeInstancesCount
},
},
}, nil
}
// Merge merges the given PDFs into a unique PDF.
func (engine QPDF) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args = append(args, "--empty")
args = append(args, "--pages")
args = append(args, inputPaths...)
args = append(args, "--", outputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
activeInstancesCountMu.Lock()
activeInstancesCount += 1
activeInstancesCountMu.Unlock()
_, err = cmd.Exec()
activeInstancesCountMu.Lock()
activeInstancesCount -= 1
activeInstancesCountMu.Unlock()
if err == nil {
return nil
}
return fmt.Errorf("merge PDFs with QPDF: %w", err)
}
// Convert is not available for this PDF engine.
func (engine QPDF) Convert(_ context.Context, _ *zap.Logger, format, _, _ string) error {
return fmt.Errorf("convert PDF to '%s' with QPDF: %w", format, gotenberg.ErrPDFEngineMethodNotAvailable)
}
var (
activeInstancesCount float64
activeInstancesCountMu sync.RWMutex
)
var (
_ gotenberg.Module = (*QPDF)(nil)
_ gotenberg.Provisioner = (*QPDF)(nil)
_ gotenberg.Validator = (*QPDF)(nil)
_ gotenberg.MetricsProvider = (*QPDF)(nil)
_ gotenberg.PDFEngine = (*QPDF)(nil)
)

View File

@@ -1,7 +1,8 @@
package unoconv
package qpdf
import (
"context"
"errors"
"os"
"reflect"
"testing"
@@ -10,19 +11,19 @@ import (
"go.uber.org/zap"
)
func TestUnoconv_Descriptor(t *testing.T) {
descriptor := Unoconv{}.Descriptor()
func TestQPDF_Descriptor(t *testing.T) {
descriptor := QPDF{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Unoconv))
expect := reflect.TypeOf(new(QPDF))
if actual != expect {
t.Errorf("expected '%'s' but got '%s'", expect, actual)
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestUnoconv_Provision(t *testing.T) {
mod := new(Unoconv)
func TestQPDF_Provision(t *testing.T) {
mod := new(QPDF)
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
err := mod.Provision(ctx)
@@ -31,7 +32,7 @@ func TestUnoconv_Provision(t *testing.T) {
}
}
func TestUnoconv_Validate(t *testing.T) {
func TestQPDF_Validate(t *testing.T) {
for i, tc := range []struct {
binPath string
expectErr bool
@@ -44,10 +45,10 @@ func TestUnoconv_Validate(t *testing.T) {
expectErr: true,
},
{
binPath: os.Getenv("UNOCONV_BIN_PATH"),
binPath: os.Getenv("QPDF_BIN_PATH"),
},
} {
mod := new(Unoconv)
mod := new(QPDF)
mod.binPath = tc.binPath
err := mod.Validate()
@@ -61,55 +62,55 @@ func TestUnoconv_Validate(t *testing.T) {
}
}
func TestUnoconv_Unoconv(t *testing.T) {
mod := new(Unoconv)
_, err := mod.Unoconv()
func TestQPDF_Metrics(t *testing.T) {
metrics, err := new(QPDF).Metrics()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
if len(metrics) != 1 {
t.Errorf("expected %d metrics, but got %d", 1, len(metrics))
}
actual := metrics[0].Read()
if actual != 0 {
t.Errorf("expected %d QPDF instances, but got %f", 0, actual)
}
}
func TestUnoconv_PDF(t *testing.T) {
func TestQPDF_Merge(t *testing.T) {
for i, tc := range []struct {
ctx context.Context
inputPath string
options Options
expectErr bool
ctx context.Context
inputPaths []string
expectErr bool
}{
{
expectErr: true,
},
{
ctx: context.Background(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
Landscape: true,
PageRanges: "1-2",
PDFArchive: true,
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
},
},
{
ctx: context.Background(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
options: Options{
PageRanges: "foo",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf",
},
},
{
ctx: nil,
expectErr: true,
},
{
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.TODO())
defer cancel()
return ctx
}(),
inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
ctx: context.TODO(),
inputPaths: []string{
"foo",
},
expectErr: true,
},
} {
func() {
mod := new(Unoconv)
mod := new(QPDF)
err := mod.Provision(nil)
if err != nil {
@@ -128,7 +129,7 @@ func TestUnoconv_PDF(t *testing.T) {
}
}()
err = mod.PDF(tc.ctx, zap.NewNop(), tc.inputPath, outputDir+"/foo.pdf", tc.options)
err = mod.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
@@ -141,14 +142,11 @@ func TestUnoconv_PDF(t *testing.T) {
}
}
func TestUnoconv_Extensions(t *testing.T) {
mod := new(Unoconv)
extensions := mod.Extensions()
func TestQPDF_Convert(t *testing.T) {
mod := new(QPDF)
err := mod.Convert(context.TODO(), zap.NewNop(), "", "", "")
actual := len(extensions)
expect := 73
if actual != expect {
t.Errorf("expected %d extensions but got %d", expect, actual)
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
}
}

View File

@@ -1,8 +1,9 @@
package api
package webhook
import (
"fmt"
"io"
"net/http"
"strconv"
"time"
@@ -11,8 +12,8 @@ import (
"go.uber.org/zap"
)
// webhookClient gathers all the data required to send a request to a webhook.
type webhookClient struct {
// client gathers all the data required to send a request to a webhook.
type client struct {
url string
method string
errorURL string
@@ -25,15 +26,15 @@ type webhookClient struct {
}
// send call the webhook either to send the success response or the error response.
func (webhook webhookClient) send(body io.Reader, headers map[string]string, erroed bool) error {
URL := webhook.url
func (c client) send(body io.Reader, headers map[string]string, erroed bool) error {
URL := c.url
if erroed {
URL = webhook.errorURL
URL = c.errorURL
}
method := webhook.method
method := c.method
if erroed {
method = webhook.errorMethod
method = c.errorMethod
}
req, err := retryablehttp.NewRequest(method, URL, body)
@@ -44,7 +45,7 @@ func (webhook webhookClient) send(body io.Reader, headers map[string]string, err
req.Header.Set("User-Agent", "Gotenberg")
// Extra HTTP headers are the custom headers from the user.
for key, value := range webhook.extraHTTPHeaders {
for key, value := range c.extraHTTPHeaders {
req.Header.Set(key, value)
}
@@ -57,7 +58,7 @@ func (webhook webhookClient) send(body io.Reader, headers map[string]string, err
// Worse, the "Content-Length" header is also removed. Therefore, in
// order to keep this valuable information, we have to trust the caller
// by reading the value of the "Content-Length" entry and set it as the
// content length of the request. It's kinda sub-optimal, but hey, at
// content length of the request. It's kinda suboptimal, but hey, at
// least it works.
bodySize, err := strconv.ParseInt(contentLength, 10, 64)
@@ -73,15 +74,19 @@ func (webhook webhookClient) send(body io.Reader, headers map[string]string, err
req.Header.Set(key, value)
}
resp, err := webhook.client.Do(req)
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("send '%s' request to '%s': %w", method, URL, err)
}
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, URL, resp.Status)
}
defer func() {
err := resp.Body.Close()
if err != nil {
webhook.logger.Error(fmt.Sprintf("close response body from '%s': %s", URL, err))
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", URL, err))
}
}()
@@ -92,17 +97,17 @@ func (webhook webhookClient) send(body io.Reader, headers map[string]string, err
fields := make([]zap.Field, 5)
fields[0] = zap.String("webhook_url", URL)
fields[1] = zap.String("method", method)
fields[2] = zap.Int64("latency", int64(finishTime.Sub(webhook.startTime)))
fields[3] = zap.String("latency_human", finishTime.Sub(webhook.startTime).String())
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
fields[4] = zap.Int64("bytes_out", req.ContentLength)
if erroed {
webhook.logger.Warn("request to webhook with error details handled", fields...)
c.logger.Warn("request to webhook with error details handled", fields...)
return nil
}
webhook.logger.Info("request to webhook handled", fields...)
c.logger.Info("request to webhook handled", fields...)
return nil
}

View File

@@ -1,4 +1,4 @@
package api
package webhook
import (
"testing"

View File

@@ -0,0 +1,3 @@
// Package webhook provides a module which adds a middleware for uploading
// output files to any destinations in an asynchronous fashion.
package webhook

View File

@@ -0,0 +1,278 @@
package webhook
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
)
func webhookMiddleware(w Webhook) api.Middleware {
return api.Middleware{
Stack: api.MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
webhookURL := c.Request().Header.Get("Gotenberg-Webhook-Url")
if webhookURL == "" {
// No webhook URL, call the next middleware in the chain.
return next(c)
}
ctx := c.Get("context").(*api.Context)
cancel := c.Get("cancel").(context.CancelFunc)
// Do we have a webhook error URL in case of... error?
webhookErrorURL := c.Request().Header.Get("Gotenberg-Webhook-Error-Url")
if webhookErrorURL == "" {
return api.WrapError(
errors.New("empty webhook error URL"),
api.NewSentinelHTTPError(http.StatusBadRequest, "Invalid 'Gotenberg-Webhook-Error-Url' header: empty value or header not provided"),
)
}
// Let's check if the webhook URLs are acceptable according to our
// allowed/denied lists.
filter := func(URL, header string, allowList, denyList *regexp.Regexp) error {
if !allowList.MatchString(URL) {
return api.WrapError(
fmt.Errorf("'%s' does not match the expression from the allowed list", URL),
api.NewSentinelHTTPError(
http.StatusForbidden,
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
),
)
}
if denyList.String() != "" && denyList.MatchString(URL) {
return api.WrapError(
fmt.Errorf("'%s' matches the expression from the denied list", URL),
api.NewSentinelHTTPError(
http.StatusForbidden,
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
),
)
}
return nil
}
err := filter(webhookURL, "Gotenberg-Webhook-Url", w.allowList, w.denyList)
if err != nil {
return fmt.Errorf("filter webhook URL: %w", err)
}
err = filter(webhookErrorURL, "Gotenberg-Webhook-Error-Url", w.errorAllowList, w.errorDenyList)
if err != nil {
return fmt.Errorf("filter webhook error URL: %w", err)
}
// Let's check the HTTP methods for calling the webhook URLs.
methodFromHeader := func(header string) (string, error) {
method := c.Request().Header.Get(header)
if method == "" {
return http.MethodPost, nil
}
method = strings.ToUpper(method)
switch method {
case http.MethodPost:
return method, nil
case http.MethodPatch:
return method, nil
case http.MethodPut:
return method, nil
}
return "", api.WrapError(
fmt.Errorf("webhook method '%s' is not '%s', '%s' or '%s'", method, http.MethodPost, http.MethodPatch, http.MethodPut),
api.NewSentinelHTTPError(
http.StatusBadRequest,
fmt.Sprintf("Invalid '%s' header value: expected '%s', '%s' or '%s', but got '%s'", header, http.MethodPost, http.MethodPatch, http.MethodPut, method),
),
)
}
webhookMethod, err := methodFromHeader("Gotenberg-Webhook-Method")
if err != nil {
return fmt.Errorf("get method to use for webhook: %w", err)
}
webhookErrorMethod, err := methodFromHeader("Gotenberg-Webhook-Error-Method")
if err != nil {
return fmt.Errorf("get method to use for webhook error: %w", err)
}
// What about extra HTTP headers?
var extraHTTPHeaders map[string]string
extraHTTPHeadersJSON := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHTTPHeadersJSON != "" {
err = json.Unmarshal([]byte(extraHTTPHeadersJSON), &extraHTTPHeaders)
if err != nil {
return api.WrapError(
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Gotenberg-Webhook-Extra-Http-Headers' header value: %s", err.Error())),
)
}
}
client := &client{
url: webhookURL,
method: webhookMethod,
errorURL: webhookErrorURL,
errorMethod: webhookErrorMethod,
extraHTTPHeaders: extraHTTPHeaders,
startTime: c.Get("startTime").(time.Time),
client: &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: w.clientTimeout,
},
RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait,
Logger: leveledLogger{
logger: ctx.Log(),
},
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
},
logger: ctx.Log(),
}
// This method parses an "asynchronous" error and sends a
// request to the webhook error URL with a JSON body
// containing the status and the error message.
handleAsyncError := func(err error) {
status, message := api.ParseError(err)
body := struct {
Status int `json:"status"`
Message string `json:"message"`
}{
Status: status,
Message: message,
}
b, err := json.Marshal(body)
if err != nil {
ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error()))
return
}
headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSONCharsetUTF8,
c.Get("traceHeader").(string): c.Get("trace").(string),
}
err = client.send(bytes.NewReader(b), headers, true)
if err != nil {
ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error()))
}
}
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
go func() {
defer cancel()
// Call the next middleware in the chain.
err := next(c)
if err != nil {
// The process failed for whatever reason. Let's send the
// details to the webhook.
ctx.Log().Error(err.Error())
handleAsyncError(err)
return
}
// No error, let's get build the output file.
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
handleAsyncError(err)
return
}
outputFile, err := os.Open(outputPath)
if err != nil {
ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
handleAsyncError(err)
return
}
defer func() {
err := outputFile.Close()
if err != nil {
ctx.Log().Error(fmt.Sprintf("close output file: %s", err))
}
}()
fileHeader := make([]byte, 512)
_, err = outputFile.Read(fileHeader)
if err != nil {
ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err))
handleAsyncError(err)
return
}
fileStat, err := outputFile.Stat()
if err != nil {
ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err))
handleAsyncError(err)
return
}
_, err = outputFile.Seek(0, 0)
if err != nil {
ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err))
handleAsyncError(err)
return
}
headers := map[string]string{
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
c.Get("traceHeader").(string): c.Get("trace").(string),
}
// Send the output file to the webhook.
err = client.send(bufio.NewReader(outputFile), headers, false)
if err != nil {
ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
handleAsyncError(err)
}
}()
return api.ErrAsyncProcess
}
}
}(),
}
}

View File

@@ -0,0 +1,562 @@
package webhook
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
func TestWebhookMiddlewareGuards(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
buildWebhookModule := func() Webhook {
return Webhook{
allowList: regexp.MustCompile(""),
denyList: regexp.MustCompile(""),
errorAllowList: regexp.MustCompile(""),
errorDenyList: regexp.MustCompile(""),
maxRetry: 0,
retryMinWait: 0,
retryMaxWait: 0,
disable: false,
}
}
for i, tc := range []struct {
request *http.Request
mod Webhook
next echo.HandlerFunc
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
}{
{
request: buildMultipartFormDataRequest(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return nil
}
}(),
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
return req
}(),
mod: buildWebhookModule(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() Webhook {
mod := buildWebhookModule()
mod.allowList = regexp.MustCompile("bar")
return mod
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() Webhook {
mod := buildWebhookModule()
mod.denyList = regexp.MustCompile("foo")
return mod
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() Webhook {
mod := buildWebhookModule()
mod.errorAllowList = regexp.MustCompile("foo")
return mod
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: func() Webhook {
mod := buildWebhookModule()
mod.errorDenyList = regexp.MustCompile("bar")
return mod
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusForbidden,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodGet)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: buildWebhookModule(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPost)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPatch)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Method", http.MethodPut)
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Error-Method", http.MethodGet)
return req
}(),
mod: buildWebhookModule(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", "foo")
return req
}(),
mod: buildWebhookModule(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
},
} {
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, httptest.NewRecorder())
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", func() context.CancelFunc {
return func() {
return
}
}())
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
var 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)
}
}
}
}
func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
buildMultipartFormDataRequest := func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
buildWebhookModule := func() Webhook {
return Webhook{
allowList: regexp.MustCompile(""),
denyList: regexp.MustCompile(""),
errorAllowList: regexp.MustCompile(""),
errorDenyList: regexp.MustCompile(""),
maxRetry: 0,
retryMinWait: 0,
retryMaxWait: 0,
clientTimeout: time.Duration(30) * time.Second,
disable: false,
}
}
for i, tc := range []struct {
request *http.Request
mod Webhook
next echo.HandlerFunc
expectWebhookContentType string
expectWebhookMethod string
expectWebhookExtraHTTPHeaders map[string]string
expectWebhookFilename string
expectWebhookErrorStatus int
expectWebhookErrorMessage string
returnedError *echo.HTTPError
}{
{
request: buildMultipartFormDataRequest(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return errors.New("foo")
}
}(),
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusInternalServerError,
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
},
{
request: buildMultipartFormDataRequest(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
return api.NewSentinelHTTPError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
}
}(),
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusBadRequest,
expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest),
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Output-Filename", "foo")
req.Header.Set("Gotenberg-Webhook-Extra-Http-Headers", `{ "foo": "bar" }`)
return req
}(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
return ctx.AddOutputPaths("/tests/test/testdata/api/sample2.pdf")
}
}(),
expectWebhookContentType: "application/pdf",
expectWebhookMethod: http.MethodPost,
expectWebhookFilename: "foo",
expectWebhookExtraHTTPHeaders: map[string]string{"foo": "bar"},
},
{
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Output-Filename", "foo")
return req
}(),
mod: buildWebhookModule(),
next: func() echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
return ctx.AddOutputPaths("/tests/test/testdata/api/sample1.pdf")
}
}(),
returnedError: echo.ErrInternalServerError,
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusInternalServerError,
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
expectWebhookFilename: "foo",
},
} {
func() {
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, httptest.NewRecorder())
c.Set("logger", zap.NewNop())
c.Set("traceHeader", "Gotenberg-Trace")
c.Set("trace", "foo")
c.Set("startTime", time.Now())
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetLogger(zap.NewNop())
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", func() context.CancelFunc {
return func() {
return
}
}())
webhook := echo.New()
webhook.HideBanner = true
webhook.HidePort = true
rand.Seed(time.Now().UnixNano())
webhookPort := rand.Intn(65535-1025+1) + 1025
c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
c.Request().Header.Set("Gotenberg-Webhook-Error-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
errChan := make(chan error, 1)
webhook.POST(
"/",
func() echo.HandlerFunc {
return func(c echo.Context) error {
contentType := c.Request().Header.Get(echo.HeaderContentType)
if contentType != tc.expectWebhookContentType {
t.Errorf("test %d: expected '%s' '%s' but got '%s'", i, echo.HeaderContentType, tc.expectWebhookContentType, contentType)
}
trace := c.Request().Header.Get("Gotenberg-Trace")
if trace != "foo" {
t.Errorf("test %d: expected '%s' '%s' but got '%s'", i, "Gotenberg-Trace", "foo", trace)
}
method := c.Request().Method
if method != tc.expectWebhookMethod {
t.Errorf("test %d: expected HTTP method '%s' but got '%s'", i, tc.expectWebhookMethod, method)
}
for key, expect := range tc.expectWebhookExtraHTTPHeaders {
actual := c.Request().Header.Get(key)
if actual != expect {
t.Errorf("test %d: expected '%s' '%s' but got '%s'", i, key, expect, actual)
}
}
if contentType == echo.MIMEApplicationJSONCharsetUTF8 {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
errChan <- err
return nil
}
result := struct {
Status int `json:"status"`
Message string `json:"message"`
}{}
err = json.Unmarshal(body, &result)
if err != nil {
errChan <- err
return nil
}
if result.Status != tc.expectWebhookErrorStatus {
t.Errorf("test %d: expected status %d from JSON but got %d", i, tc.expectWebhookErrorStatus, result.Status)
}
if result.Message != tc.expectWebhookErrorMessage {
t.Errorf("test %d: expected message '%s' from JSON but got '%s'", i, tc.expectWebhookErrorMessage, result.Message)
}
errChan <- nil
return nil
}
contentLength := c.Request().Header.Get(echo.HeaderContentLength)
if contentLength == "" {
t.Errorf("test %d: expected non empty '%s'", i, echo.HeaderContentLength)
}
contentDisposition := c.Request().Header.Get(echo.HeaderContentDisposition)
if !strings.Contains(contentDisposition, tc.expectWebhookFilename) {
t.Errorf("test %d: expected '%s' '%s' to contain '%s'", i, echo.HeaderContentDisposition, contentDisposition, tc.expectWebhookFilename)
}
body, err := io.ReadAll(c.Request().Body)
if err != nil {
errChan <- err
return nil
}
if body == nil || len(body) == 0 {
t.Errorf("test %d: expected non nil body", i)
}
errChan <- nil
if tc.returnedError != nil {
return tc.returnedError
}
return nil
}
}(),
)
go func() {
err := webhook.Start(fmt.Sprintf(":%d", webhookPort))
if !errors.Is(err, http.ErrServerClosed) {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}()
defer func() {
err := webhook.Shutdown(context.TODO())
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}()
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
if err != nil && err != api.ErrAsyncProcess {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
err = <-errChan
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
}()
}
}

View File

@@ -0,0 +1,129 @@
package webhook
import (
"fmt"
"regexp"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
)
func init() {
gotenberg.MustRegisterModule(Webhook{})
}
// Webhook is a module which provides a middleware for uploading output files
// to any destinations in an asynchronous fashion.
type Webhook struct {
allowList *regexp.Regexp
denyList *regexp.Regexp
errorAllowList *regexp.Regexp
errorDenyList *regexp.Regexp
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
clientTimeout time.Duration
disable bool
}
// Descriptor returns an Webhook's module descriptor.
func (Webhook) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "webhook",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("webhook", flag.ExitOnError)
// Deprecated flags.
fs.String("api-webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("api-webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("api-webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
fs.String("api-webhook-error-deny-list", "", "Set the denied URLs in case of an error for the webhook feature using a regular expression")
fs.Int("api-webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
fs.Duration("api-webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
fs.Duration("api-webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
fs.Bool("api-disable-webhook", false, "Disable the webhook feature")
var err error
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-allow-list", "use webhook-allow-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-deny-list", "use webhook-deny-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-error-allow-list", "use webhook-error-allow-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-error-deny-list", "use webhook-error-deny-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-max-retry", "use webhook-max-retry instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-retry-min-wait", "use webhook-retry-min-wait instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-retry-max-wait", "use webhook-retry-max-wait instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-disable-webhook", "use webhook-disable instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the webhook module: %v", err))
}
// New flags.
fs.String("webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
fs.String("webhook-error-deny-list", "", "Set the denied URLs in case of an error for the webhook feature using a regular expression")
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
fs.Duration("webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
fs.Duration("webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
fs.Duration("webhook-client-timeout", time.Duration(30)*time.Second, "Set the time limit for requests to the webhook")
fs.Bool("webhook-disable", false, "Disable the webhook feature")
return fs
}(),
New: func() gotenberg.Module { return new(Webhook) },
}
}
// Provision sets the module properties.
func (w *Webhook) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
w.allowList = flags.MustDeprecatedRegexp("api-webhook-allow-list", "webhook-allow-list")
w.denyList = flags.MustDeprecatedRegexp("api-webhook-deny-list", "webhook-deny-list")
w.errorAllowList = flags.MustDeprecatedRegexp("api-webhook-error-allow-list", "webhook-error-allow-list")
w.errorDenyList = flags.MustDeprecatedRegexp("api-webhook-error-deny-list", "webhook-error-deny-list")
w.maxRetry = flags.MustDeprecatedInt("api-webhook-max-retry", "webhook-max-retry")
w.retryMinWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-min-wait")
w.retryMaxWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-max-wait")
w.clientTimeout = flags.MustDuration("webhook-client-timeout")
w.disable = flags.MustDeprecatedBool("api-disable-webhook", "webhook-disable")
return nil
}
// Middlewares returns the middleware.
func (w Webhook) Middlewares() ([]api.Middleware, error) {
if w.disable {
return nil, nil
}
return []api.Middleware{
webhookMiddleware(w),
}, nil
}
// AddGraceDuration increases the grace duration provided by the API for the
// garbage collector.
func (w Webhook) AddGraceDuration() time.Duration {
var duration time.Duration
if w.disable {
return duration
}
for i := 0; i < w.maxRetry; i++ {
// Yep... Golang does not allow int * time.Duration.
duration += w.retryMaxWait
}
return duration
}
// Interface guards.
var (
_ gotenberg.Module = (*Webhook)(nil)
_ gotenberg.Provisioner = (*Webhook)(nil)
_ api.MiddlewareProvider = (*Webhook)(nil)
_ api.GarbageCollectorGraceDurationIncrementer = (*Webhook)(nil)
)

View File

@@ -0,0 +1,89 @@
package webhook
import (
"reflect"
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestWebhook_Descriptor(t *testing.T) {
descriptor := Webhook{}.Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Webhook))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestWebhook_Provision(t *testing.T) {
mod := new(Webhook)
ctx := gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Webhook).Descriptor().FlagSet,
},
nil,
)
err := mod.Provision(ctx)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
}
func TestWebhook_Middlewares(t *testing.T) {
for i, tc := range []struct {
expectMiddlewares int
disable bool
}{
{
expectMiddlewares: 1,
},
{
disable: true,
},
} {
mod := new(Webhook)
mod.disable = tc.disable
middlewares, err := mod.Middlewares()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
if tc.expectMiddlewares != len(middlewares) {
t.Errorf("test %d: expected %d middlewares but got %d", i, tc.expectMiddlewares, len(middlewares))
}
}
}
func TestWebhook_AddGraceDuration(t *testing.T) {
for i, tc := range []struct {
maxRetry int
retryMaxWait time.Duration
expectDuration time.Duration
disable bool
}{
{
maxRetry: 3,
retryMaxWait: time.Duration(1) * time.Second,
expectDuration: time.Duration(3) * time.Second,
},
{
disable: true,
},
} {
mod := new(Webhook)
mod.maxRetry = tc.maxRetry
mod.retryMaxWait = tc.retryMaxWait
mod.disable = tc.disable
actual := mod.AddGraceDuration()
if actual != tc.expectDuration {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectDuration, actual)
}
}
}

View File

@@ -1,11 +1,18 @@
package standard
import (
// Standard Gotenberg modules.
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/api"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/chromium"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/gc"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/unoconv"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/logging"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfcpu"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfengines"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdftk"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/prometheus"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/qpdf"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/webhook"
)

View File

@@ -6,8 +6,9 @@ GOLANG_VERSION="$1"
GOTENBERG_VERSION="$2"
GOTENBERG_USER_GID="$3"
GOTENBERG_USER_UID="$4"
PDFTK_VERSION="$5"
DOCKER_REGISTRY="$6"
NOTO_COLOR_EMOJI_VERSION="$5"
PDFTK_VERSION="$6"
DOCKER_REPOSITORY="$7"
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
SEMVER=( ${GOTENBERG_VERSION//./ } )
@@ -23,25 +24,28 @@ docker buildx build \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--build-arg GOTENBERG_USER_GID="$GOTENBERG_USER_GID" \
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \
--platform linux/amd64 \
--platform linux/arm64 \
-t "$DOCKER_REGISTRY/gotenberg:latest" \
-t "$DOCKER_REGISTRY/gotenberg:${SEMVER[0]}" \
-t "$DOCKER_REGISTRY/gotenberg:${SEMVER[0]}.${SEMVER[1]}" \
-t "$DOCKER_REGISTRY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}" \
--platform linux/arm/v7 \
--platform linux/386 \
-t "$DOCKER_REPOSITORY/gotenberg:latest" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}" \
--push \
-f build/Dockerfile .
# Cloud Run variant.
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
docker buildx build \
--build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--platform linux/amd64 \
--platform linux/arm64 \
-t "$DOCKER_REGISTRY/gotenberg:latest-cloudrun" \
-t "$DOCKER_REGISTRY/gotenberg:${SEMVER[0]}-cloudrun" \
-t "$DOCKER_REGISTRY/gotenberg:${SEMVER[0]}.${SEMVER[1]}-cloudrun" \
-t "$DOCKER_REGISTRY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:latest-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}-cloudrun" \
--push \
-f build/Dockerfile.cloudrun .

View File

@@ -1,13 +1,13 @@
ARG GOLANG_VERSION
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
ARG GOLANGCI_LINT_VERSION
FROM golang:$GOLANG_VERSION-stretch as golang
FROM golang:$GOLANG_VERSION-bullseye as golang
# We're extending the Gotenberg's Docker image because our code relies on external
# dependencies like Google Chrome, LibreOffice, etc.
FROM $DOCKER_REGISTRY/gotenberg:$GOTENBERG_VERSION
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
USER root
@@ -42,4 +42,4 @@ RUN apt-get update -qq &&\
# Pristine working directory.
WORKDIR /tests
ENTRYPOINT [ "docker-entrypoint.sh" ]
ENTRYPOINT [ "/usr/bin/tini", "--", "docker-entrypoint.sh" ]

View File

@@ -32,7 +32,7 @@
<div class="center">
<h1>This image is loaded from a URL</h1>
<img src="https://gutendev.com/wp-content/uploads/2018/10/01_03-1.jpg">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png">
</div>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gutenberg</title>
</head>
<body>
<p>Console API</p>
<script type="application/javascript">
console.log("a simple message")
console.debug("a debug message")
console.warn("a warning message")
console.error("an error message")
</script>
<script type="application/javascript">
throw new Error("Exception 1")
</script>
<script type="application/javascript">
throw new Error("Exception 2")
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gutenberg</title>
</head>
<body>
<h1>Gutenberg</h1>
<img src="MyImg.gif">
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="style.css">
<title>Gutenberg</title>
</head>
<body>
<div class="center">
<h1>Gutenberg</h1>
</div>
<blockquote cite="https://sites.google.com/site/johanngutenbergper5/q">
<p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p>
<footer><a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></cite></footer>
</blockquote>
<script src="paged.polyfill.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
body {
font-family: Arial, Helvetica, sans-serif;
}
.center {
text-align: center;
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gutenberg</title>
</head>
<body>
<h1>Gutenberg</h1>
<p style="color: #d1d0d0;">
It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gutenberg</title>
<style>
@media print {
#screen { display: none }
}
@media screen {
#print { display: none }
}
</style>
</head>
<body>
<p id="print">Print media type</p>
<p id="screen">Screen media type</p>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gutenberg</title>
</head>
<body>
<p id="javascript">
JavaScript disabled.
</p>
<script type="application/javascript">
document.getElementById('javascript').innerText = 'JavaScript not disabled.'
</script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gutenberg</title>
</head>
<body>
<h1>Table in markdown</h1>
<h2>toHTML</h2>
{{ toHTML "markdown.md" }}
<h2>Actual HTML</h2>
<textarea readonly="readonly" cols="100" rows="50">
{{ toHTML "markdown.md" }}
</textarea>
</body>
</html>

View File

@@ -0,0 +1,5 @@
| Tables | Are | Cool |
|----------|:-------------:|------:|
| col 1 is | left-aligned | $1600 |
| col 2 is | centered | $12 |
| col 3 is | right-aligned | $1 |