Compare commits

...

110 Commits

Author SHA1 Message Date
Julien Neuhart
25ce0b9aee fix(pdfengines): only preserve filenames in zip archive 2024-02-18 18:27:34 +01:00
Julien Neuhart
376a7db545 feat(pdfengines): preserve filenames in convert route 2024-02-18 18:17:12 +01:00
Julien Neuhart
f906ed741d fix(flags): typos 2024-02-18 17:52:51 +01:00
Julien Neuhart
ad152e62e5 fix(chromium): better default deny list regexp 2024-02-18 17:18:40 +01:00
Julien Neuhart
7f3a61ef43 chore: update dependencies 2024-02-18 17:18:08 +01:00
Julien Neuhart
a7ba8c8213 chore(testdata): add more data 2024-02-18 13:13:01 +01:00
Julien Neuhart
b1f3d6d4c0 feat: handling more ErrMaximumQueueSizeExceeded scenarios 2024-02-18 13:05:48 +01:00
Julien Neuhart
5707ae027f fix(libreoffice): expect output paths tests 2024-02-18 12:00:37 +01:00
timgrohmann
770208024d feat: enable max queue size for chromium and libreoffice
fix gotenberg/gotenberg#463
2024-02-18 11:35:49 +01:00
Julien Neuhart
975a9f5344 fix(libreoffice): wrong archive filename 2024-02-16 19:50:17 +01:00
Julien Neuhart
a45008411e chore: update dependencies 2024-02-16 19:50:17 +01:00
Julien Neuhart
9091ffd1b3 fix(libreoffice): keep original extension all the time for preserved filenames in zipped output 2024-02-16 19:50:17 +01:00
Chris Hughes
089f161d1e feat(libreoffice): preserve filenames for zipped files instead of using randomly generated UUID filenames (#793)
* feat(libreoffice): preserve filenames for zipped files instead of using generated UUID filenames

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

I noted that the versions of `actions/checkout` in use were out of date. Didn't want to leave it unautomated.
2023-03-17 13:23:52 +01:00
Pieter Oliver
fd0ff633e6 chore: add 503 to every endpoint as this is a known error
As detailed on the website documentation.
2023-03-08 15:06:05 +01:00
Julien Neuhart
02923fe9ce chore: update Golang to 1.20 & update Go dependencies & update Golangci-lint to 1.51.2 2023-02-22 15:35:30 +01:00
Julien Neuhart
875f97813e fix: tests 2023-01-13 10:21:36 +01:00
Julien Neuhart
6a29385762 chore: update Go dependencies 2023-01-12 15:53:08 +01:00
Julien Neuhart
fbc9f9dec9 feat(libreoffice): add .odg, .dotx, .xltx support - close #567, close #573 2023-01-12 15:48:53 +01:00
Julien Neuhart
4607ade8e9 chore: update LICENSE 2023-01-04 17:01:56 +01:00
Julien Neuhart
e4a553c022 fix(libreoffice): downgrade LibreOffice version (7.0.4 at the time of writing) - fix #442 & fix #516 & fix #529 & fix #537 & fix #568 2023-01-04 15:29:02 +01:00
Julien Neuhart
bc733bab0c chore: update Go dependencies 2023-01-02 17:41:59 +01:00
Julien Neuhart
1a522ddf48 Merge branch 'main' of github.com:gotenberg/gotenberg 2022-12-03 16:12:29 +01:00
Dorian de Koning
1de0f4c4fa fix(webhook/client): error on http status codes >= 400 2022-12-03 16:10:39 +01:00
Julien Neuhart
71eb5c25b5 chore: upgrade golangci-lint to version 1.50.1 2022-12-03 15:51:00 +01:00
Julien Neuhart
ea34979a29 chore: update Go dependencies 2022-12-03 15:49:07 +01:00
Julien Neuhart
1f784ad44c chore(chromium): add test without custom header/footer templates 2022-10-21 12:10:16 +02:00
Julien Neuhart
f89726752f feat(chromium): add new omitBackground form field (closes #226) 2022-10-21 11:51:06 +02:00
Julien Neuhart
921b6b7ba4 chore: upgrade golangci-lint from 1.49 to 1.50 2022-10-21 11:51:06 +02:00
Julien Neuhart
6c5cc7a0ce chore: update Go dependencies 2022-10-21 11:51:06 +02:00
Yohann Tilotti
f851e2f2cf fix(libreoffice): Typo error on mkdir 2022-10-17 22:16:44 +02:00
160 changed files with 14359 additions and 59936 deletions

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

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

2
.github/stale.yml vendored
View File

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

View File

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

3
.gitignore vendored
View File

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

View File

@@ -1,10 +1,19 @@
linters-settings:
gci:
sections:
- standard
- default
- prefix(github.com/gotenberg/gotenberg/v8)
skip-generated: true
custom-order: true
linters:
disable-all: true
enable:
- bodyclose
- errcheck
- gofmt
- goimports
- gci
- gofumpt
- gosec
- gosimple
- govet
@@ -17,7 +26,7 @@ linters:
- unused
run:
deadline: 5m
timeout: 5m
issues-exit-code: 1
tests: false

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 Julien Neuhart
Copyright (c) 2024 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,14 +5,14 @@ help: ## Show the help
.PHONY: it
it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.19
GOLANG_VERSION=1.21
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.038 # See https://github.com/googlefonts/noto-emoji/releases.
NOTO_COLOR_EMOJI_VERSION=v2.042 # 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.49.0 # See https://github.com/golangci/golangci-lint/releases.
GOLANGCI_LINT_VERSION=v1.55.2 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build
build: ## Build the Gotenberg's Docker image
@@ -29,10 +29,15 @@ build: ## Build the Gotenberg's Docker image
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000
API_PORT_FROM_ENV=
API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace
API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
CHROMIUM_INCOGNITO=false
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
@@ -41,20 +46,25 @@ CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER=
CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_DISABLES_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES=
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=
@@ -74,10 +84,15 @@ run: ## Start a Gotenberg container
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-start-timeout=$(API_START_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) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
@@ -85,23 +100,28 @@ run: ## Start a Gotenberg container
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLES_ROUTES) \
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--uno-listener-start-timeout=$(UNO_LISTENER_START_TIMEOUT) \
--uno-listener-restart-threshold=$(UNO_LISTENER_RESTART_THRESHOLD) \
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
@@ -134,14 +154,18 @@ tests-once: ## Run the tests once (prefer the "tests" command while developing)
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
gotest
# go install mvdan.cc/gofumpt@latest
# go install github.com/daixiang0/gci@latest
.PHONY: fmt
fmt: ## Format the code and "optimize" the dependencies
go fmt ./...
gofumpt -l -w .
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order .
go mod tidy
# go install golang.org/x/tools/cmd/godoc@latest
.PHONY: godoc
godoc: ## Run a webserver with Gotenberg godoc (go get golang.org/x/tools/cmd/godoc)
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7)
godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060
.PHONY: release

View File

@@ -2,7 +2,7 @@
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center"><a href="https://gotenberg.dev/docs/about">Documentation</a> &#183; 🔥 <a href="https://gotenberg.dev/docs/get-started/live-demo">Live Demo</a></p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
</p>
---
@@ -15,18 +15,18 @@ numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, an
Open a terminal and run the following command:
```
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
```
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8
```
The API is now available on your host at http://localhost:3000.
Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to interact with it 🚀
Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduction) to learn how to interact with it 🚀
## Sponsors
@@ -41,5 +41,5 @@ Head to the [documentation](https://gotenberg.dev/docs/about) to learn how to in
[![Docker pulls](https://img.shields.io/docker/pulls/gotenberg/gotenberg)](https://hub.docker.com/r/gotenberg/gotenberg)
[![Docker pulls](https://img.shields.io/docker/pulls/thecodingmachine/gotenberg)](https://hub.docker.com/r/thecodingmachine/gotenberg)
[![Continuous Integration](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg)](https://pkg.go.dev/github.com/gotenberg/gotenberg/v7)
[![Go Reference](https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg)](https://pkg.go.dev/github.com/gotenberg/gotenberg/v8)
[![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg)

41
SECURITY.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

81
go.mod
View File

@@ -1,66 +1,67 @@
module github.com/gotenberg/gotenberg/v7
module github.com/gotenberg/gotenberg/v8
go 1.19
go 1.21
require (
github.com/alexliesenfeld/health v0.6.0
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/chromedp/cdproto v0.0.0-20221011223153-490dc4d81f7c
github.com/chromedp/chromedp v0.8.6
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/chromedp/cdproto v0.0.0-20240214232516-ad4608604e9e
github.com/chromedp/chromedp v0.9.5
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.1
github.com/klauspost/compress v1.15.11 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/labstack/echo/v4 v4.9.1
github.com/labstack/gommon v0.4.0
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.11.4
github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.21
github.com/microcosm-cc/bluemonday v1.0.26
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.3.13
github.com/pierrec/lz4/v4 v4.1.17 // indirect
github.com/prometheus/client_golang v1.13.0
github.com/pdfcpu/pdfcpu v0.6.0
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/prometheus/client_golang v1.18.0
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.10.0 // indirect
go.uber.org/multierr v1.8.0
go.uber.org/zap v1.23.0
golang.org/x/crypto v0.0.0-20221012134737-56aed061732a // indirect
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43 // indirect
golang.org/x/term v0.0.0-20220919170432-7a66f970e087
golang.org/x/text v0.3.8
github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.21.0
golang.org/x/sync v0.6.0
golang.org/x/sys v0.17.0 // indirect
golang.org/x/term v0.17.0
golang.org/x/text v0.14.0
)
require github.com/dlclark/regexp2 v1.10.0
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/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.1.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 // indirect
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 // indirect
github.com/gobwas/ws v1.3.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.47.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
google.golang.org/protobuf v1.28.1 // indirect
google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

642
go.sum
View File

@@ -1,607 +1,163 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexliesenfeld/health v0.6.0 h1:HRBTCgybNSe4lqGEk7nU82c3bjwh9W+3b46W6UvD4CQ=
github.com/alexliesenfeld/health v0.6.0/go.mod h1:N4NDIeQtlWumG+6z1ne1v62eQxktz5ylEgGgH9emdMw=
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20221011223153-490dc4d81f7c h1:JQvTh3Lqw0aGKFu/roG/DTMgqVCmSE6aVOegf+mMNrY=
github.com/chromedp/cdproto v0.0.0-20221011223153-490dc4d81f7c/go.mod h1:5Y4sD/eXpwrChIuxhSr/G20n9CdbCmoerOHnuAf0Zr0=
github.com/chromedp/chromedp v0.8.6 h1:KobeeqR2dpfKSG1prS3Y6+FbffMmGC6xmAobRXA9QEQ=
github.com/chromedp/chromedp v0.8.6/go.mod h1:nBYHoD6YSNzrr82cIeuOzhw1Jo/s2o0QQ+ifTeoCZ+c=
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-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20240214232516-ad4608604e9e h1:kXEolCWQZzuEFcuaTzfqXToX+e29OcvK87BcBiBBJ1c=
github.com/chromedp/cdproto v0.0.0-20240214232516-ad4608604e9e/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/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/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
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/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
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.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/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/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/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.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
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/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.15.11 h1:Lcadnb3RKGin4FYM/orgq0qde+nc15E5Cbqg4B9Sx9c=
github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
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.9.1 h1:GliPYSpzGKlyOhqIbG8nmHBo3i1saKWFOgh41AN3b+Y=
github.com/labstack/echo/v4 v4.9.1/go.mod h1:Pop5HLc+xoc4qhTZ1ip6C0RtP7Z+4VzRLWZZFKqbbjo=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
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/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.2 h1:hAHbPm5IJGijwng3PWk09JkG9WeqChjprR5s9bBZ+OM=
github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/pdfcpu/pdfcpu v0.3.13 h1:VFon2Yo1PJt+sA57vPAeXWGLSZ7Ux3Jl4h02M0+s3dg=
github.com/pdfcpu/pdfcpu v0.3.13/go.mod h1:UJc5xsXg0fpmjp1zOPdyYcAQArc/Zf3V0nv5URe+9fg=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI=
github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.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.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/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 v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.13.0 h1:b71QUfeo5M8gq2+evJdTPfZhYMAU0uKPkyPJ7TPsloU=
github.com/prometheus/client_golang v1.13.0/go.mod h1:vTeo+zgvILHsnnj/39Ou/1fPN5nJFOEMgftOUOmlvYQ=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
github.com/prometheus/common v0.47.0 h1:p5Cz0FNHo7SnWOmWmoRozVcjEp0bIVU8cV7OShpjL1k=
github.com/prometheus/common v0.47.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
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/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1 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.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20221012134737-56aed061732a h1:NmSIgad6KjE6VvHciPZuNRTKxGhlPfD6OA87W/PLkqg=
golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b h1:tvrvnPFcdzp294diPnrdZZZ8XUt2Tyj7svb7X52iDuU=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-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-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
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.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43 h1:OK7RB6t2WQX54srQQYSXMW8dF5C6/8+oA/s5QBmmto4=
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/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/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

@@ -10,7 +10,6 @@ import (
func TestCommand(t *testing.T) {
cmd := Command(zap.NewNop(), "foo")
if !cmd.process.SysProcAttr.Setpgid {
t.Error("expected cmd.process.SysProcAttr.Setpgid to be true")
}
@@ -18,34 +17,36 @@ func TestCommand(t *testing.T) {
func TestCommandContext(t *testing.T) {
tests := []struct {
name string
ctx context.Context
expectCommandContextErr bool
scenario string
ctx context.Context
expectCommandContextError bool
}{
{
name: "nominal behavior",
ctx: context.Background(),
scenario: "nominal behavior",
ctx: context.Background(),
expectCommandContextError: false,
},
{
name: "nil context",
expectCommandContextErr: true,
scenario: "nil context",
ctx: nil,
expectCommandContextError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) {
cmd, err := CommandContext(tc.ctx, zap.NewNop(), "foo")
if err == nil && !cmd.process.SysProcAttr.Setpgid {
t.Fatal("expected cmd.process.SysProcAttr.Setpgid to be true")
}
if tc.expectCommandContextErr && err == nil {
t.Error("expected error from CommandContext(), but got none")
if !tc.expectCommandContextError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectCommandContextErr && err != nil {
t.Errorf("expected no error from CommandContext(), but got: %v", err)
if tc.expectCommandContextError && err == nil {
t.Fatal("expected error but got none")
}
})
}
@@ -53,31 +54,32 @@ func TestCommandContext(t *testing.T) {
func TestCmd_Start(t *testing.T) {
tests := []struct {
name string
cmd Cmd
expectStartErr bool
scenario string
cmd *Cmd
expectStartError bool
}{
{
name: "nominal behavior",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
scenario: "nominal behavior",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectStartError: false,
},
{
name: "start error",
cmd: Command(zap.NewNop(), "foo"),
expectStartErr: true,
scenario: "start error",
cmd: Command(zap.NewNop(), "foo"),
expectStartError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.Start()
if tc.expectStartErr && err == nil {
t.Error("expected error from cmd.Start(), but got none")
if !tc.expectStartError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectStartErr && err != nil {
t.Errorf("expected no error from cmd.Start(), but got: %v", err)
if tc.expectStartError && err == nil {
t.Fatal("expected error but got none")
}
})
}
@@ -85,54 +87,50 @@ func TestCmd_Start(t *testing.T) {
func TestCmd_Wait(t *testing.T) {
tests := []struct {
name string
cmd Cmd
expectWaitErr bool
scenario string
cmd *Cmd
expectWaitError bool
}{
{
name: "nominal behavior",
cmd: func() Cmd {
scenario: "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)
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectWaitError: false,
},
{
name: "wait error",
cmd: func() Cmd {
scenario: "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)
t.Fatalf("expected no error but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectWaitErr: true,
expectWaitError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Run(tc.scenario, 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.expectWaitError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectWaitErr && err != nil {
t.Errorf("expected no error from cmd.Wait(), but got: %v", err)
if tc.expectWaitError && err == nil {
t.Fatal("expected error but got none")
}
})
}
@@ -140,49 +138,48 @@ func TestCmd_Wait(t *testing.T) {
func TestCmd_Exec(t *testing.T) {
tests := []struct {
name string
cmd Cmd
timeout time.Duration
expectExecErr bool
scenario string
cmd *Cmd
timeout time.Duration
expectExecError bool
}{
{
name: "nominal behavior",
cmd: func() Cmd {
scenario: "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
}(),
expectExecError: false,
},
{
name: "nil context",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectExecErr: true,
scenario: "nil context",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
expectExecError: true,
},
{
name: "start error",
cmd: func() Cmd {
scenario: "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)
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectExecErr: true,
expectExecError: true,
},
{
name: "context done",
cmd: Command(zap.NewNop(), "sleep", "2"),
timeout: time.Duration(1) * time.Second,
expectExecErr: true,
scenario: "context done",
cmd: Command(zap.NewNop(), "sleep", "2"),
timeout: time.Duration(1) * time.Second,
expectExecError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) {
if tc.timeout > 0 {
ctx, cancel := context.WithTimeout(context.TODO(), tc.timeout)
defer cancel()
@@ -192,12 +189,12 @@ func TestCmd_Exec(t *testing.T) {
_, err := tc.cmd.Exec()
if tc.expectExecErr && err == nil {
t.Error("expected error from cmd.Exec(), but got none")
if !tc.expectExecError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectExecErr && err != nil {
t.Errorf("expected no error from cmd.Exec(), but got: %v", err)
if tc.expectExecError && err == nil {
t.Fatal("expected error but got none")
}
})
}
@@ -205,67 +202,68 @@ func TestCmd_Exec(t *testing.T) {
func TestCmd_pipeOutput(t *testing.T) {
tests := []struct {
name string
cmd Cmd
run bool
expectPipeOutputErr bool
scenario string
cmd *Cmd
run bool
expectPipeOutputError bool
}{
{
name: "nominal behavior",
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
scenario: "nominal behavior",
cmd: Command(zap.NewExample(), "echo", "Hello", "World"),
run: true,
expectPipeOutputError: false,
},
{
name: "no debug, no pipe",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
scenario: "no debug, no pipe",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
run: false,
expectPipeOutputError: false,
},
{
name: "stdout already piped",
cmd: func() Cmd {
scenario: "stdout already piped",
cmd: func() *Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StdoutPipe()
if err != nil {
t.Fatalf("expected no error from cmd.process.StdoutPipe(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectPipeOutputErr: true,
run: false,
expectPipeOutputError: true,
},
{
name: "stderr already piped",
cmd: func() Cmd {
scenario: "stderr already piped",
cmd: func() *Cmd {
cmd := Command(zap.NewExample(), "echo", "Hello", "World")
_, err := cmd.process.StderrPipe()
if err != nil {
t.Fatalf("expected no error from cmd.process.StderrPipe(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectPipeOutputErr: true,
run: false,
expectPipeOutputError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.pipeOutput()
if tc.run {
errStart := tc.cmd.process.Start()
if errStart != nil {
t.Fatalf("expected no error from tc.cmd.process.Start(), but got: %v", errStart)
t.Fatalf("expected no error but got: %v", err)
}
}
if tc.expectPipeOutputErr && err == nil {
t.Error("expected error from cmd.pipeOutput(), but got none")
if !tc.expectPipeOutputError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectPipeOutputErr && err != nil {
t.Errorf("expected no error from cmd.pipeOutput(), but got: %v", err)
if tc.expectPipeOutputError && err == nil {
t.Fatal("expected error but got none")
}
})
}
@@ -273,51 +271,46 @@ func TestCmd_pipeOutput(t *testing.T) {
func TestCmd_Kill(t *testing.T) {
tests := []struct {
name string
cmd Cmd
scenario string
cmd *Cmd
}{
{
name: "nominal behavior",
cmd: func() Cmd {
scenario: "nominal behavior",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
},
{
name: "no process",
cmd: Cmd{logger: zap.NewNop()},
scenario: "no process",
cmd: &Cmd{logger: zap.NewNop()},
},
{
name: "process already killed",
cmd: func() Cmd {
scenario: "process already killed",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "sleep", "60")
err := cmd.process.Start()
if err != nil {
t.Fatalf("expected no error from cmd.process.Start(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error from cmd.Kill(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.cmd.Kill()
if err != nil {
t.Errorf("expected no error from cmd.Kill(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
})
}

View File

@@ -5,7 +5,7 @@ import (
"reflect"
)
// Context is a struct which helps initializing modules. When provisioning, a
// Context is a struct which helps to initialize modules. When provisioning, a
// module may use the context to get other modules that it needs internally.
type Context struct {
flags ParsedFlags
@@ -13,8 +13,8 @@ type Context struct {
moduleInstances map[string]interface{}
}
// NewContext creates a Context.
// In a module, prefer the Provisioner interface to get a Context.
// NewContext creates a [Context].
// In a module, prefer the [Provisioner] interface to get a [Context].
func NewContext(
flags ParsedFlags,
descriptors []ModuleDescriptor,
@@ -32,7 +32,7 @@ func NewContext(
// flags := ctx.ParsedFlags()
// m.foo = flags.RequiredString("foo")
// }
func (ctx Context) ParsedFlags() ParsedFlags {
func (ctx *Context) ParsedFlags() ParsedFlags {
return ctx.flags
}
@@ -47,7 +47,6 @@ func (ctx Context) ParsedFlags() ParsedFlags {
// initializes it. Otherwise, returns the already initialized instance.
func (ctx *Context) Module(kind interface{}) (interface{}, error) {
mods, err := ctx.Modules(kind)
if err != nil {
return nil, fmt.Errorf("get module: %w", err)
}
@@ -101,7 +100,7 @@ func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
}
// loadModule calls the Provision and/or Validate methods of the requested
// module if it satisfies the Provisioner and/or Validator interfaces.
// module if it satisfies the [Provisioner] and/or [Validator] interfaces.
func (ctx *Context) loadModule(id string, instance interface{}) error {
if prov, ok := instance.(Provisioner); ok {
// The instance can be provisioned.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,59 +1,55 @@
package gotenberg
import (
"fmt"
"os"
"strings"
"testing"
)
func TestTmpPath(t *testing.T) {
osTempDir := os.TempDir()
tmpPath := TmpPath()
func TestFileSystem_WorkingDir(t *testing.T) {
fs := NewFileSystem()
dirName := fs.WorkingDir()
if tmpPath != osTempDir {
t.Errorf("expected path '%s' but got '%s'", osTempDir, tmpPath)
if dirName == "" {
t.Error("expected directory name but got empty string")
}
}
func TestNewDirPath(t *testing.T) {
newDirPath := NewDirPath()
tmpPath := TmpPath()
func TestFileSystem_WorkingDirPath(t *testing.T) {
fs := NewFileSystem()
expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
if !strings.HasPrefix(newDirPath, tmpPath) {
t.Fatalf("expected path '%s' to start with '%s'", newDirPath, tmpPath)
}
newDirPaths := make([]string, 1000)
for i := range newDirPaths {
newDirPaths[i] = NewDirPath()
}
for i, newDirPath := range newDirPaths {
for j, comparison := range newDirPaths {
if i == j {
continue
}
if newDirPath == comparison {
t.Fatalf("expected path '%s' (index %d) to be unique, but found an identical path on index %d", newDirPath, i, j)
}
}
if fs.WorkingDirPath() != expectedPath {
t.Errorf("expected path '%s' but got '%s'", expectedPath, fs.WorkingDirPath())
}
}
func TestMkdirAll(t *testing.T) {
path, err := MkdirAll()
func TestFileSystem_NewDirPath(t *testing.T) {
fs := NewFileSystem()
newDir := fs.NewDirPath()
expectedPrefix := fs.WorkingDirPath()
if !strings.HasPrefix(newDir, expectedPrefix) {
t.Errorf("expected new directory to start with '%s' but got '%s'", expectedPrefix, newDir)
}
}
func TestFileSystem_MkdirAll(t *testing.T) {
fs := NewFileSystem()
newPath, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
tmpPath := TmpPath()
if !strings.HasPrefix(path, tmpPath) {
t.Fatalf("expected path '%s' to start with '%s'", path, tmpPath)
_, err = os.Stat(newPath)
if os.IsNotExist(err) {
t.Errorf("expected directory '%s' to exist but it doesn't", newPath)
}
_, err = os.Stat(path)
if os.IsNotExist(err) {
t.Errorf("expected path '%s' to exist but got: %v", path, err)
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}

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

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

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

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

View File

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

View File

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

View File

@@ -6,61 +6,135 @@ import (
"go.uber.org/zap"
)
// ModuleMock is a mock for the Module interface.
// ModuleMock is a mock for the [Module] interface.
type ModuleMock struct {
DescriptorMock func() ModuleDescriptor
}
func (mod ModuleMock) Descriptor() ModuleDescriptor {
func (mod *ModuleMock) Descriptor() ModuleDescriptor {
return mod.DescriptorMock()
}
// ValidatorMock is a mock for the Validator interface.
// ProvisionerMock is a mock for the [Provisioner] interface.
type ProvisionerMock struct {
ProvisionMock func(*Context) error
}
func (mod *ProvisionerMock) Provision(ctx *Context) error {
return mod.ProvisionMock(ctx)
}
// ValidatorMock is a mock for the [Validator] interface.
type ValidatorMock struct {
ValidateMock func() error
}
func (mod ValidatorMock) Validate() error {
func (mod *ValidatorMock) Validate() error {
return mod.ValidateMock()
}
// PDFEngineMock is a mock for the PDFEngine interface.
type PDFEngineMock struct {
// 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
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
}
func (engine PDFEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, 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)
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
// PDFEngineProviderMock is a mock for the PDFEngineProvider interface.
type PDFEngineProviderMock struct {
PDFEngineMock func() (PDFEngine, error)
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
type PdfEngineProviderMock struct {
PdfEngineMock func() (PdfEngine, error)
}
func (provider PDFEngineProviderMock) PDFEngine() (PDFEngine, error) {
return provider.PDFEngineMock()
func (provider *PdfEngineProviderMock) PdfEngine() (PdfEngine, error) {
return provider.PdfEngineMock()
}
// LoggerProviderMock is a mock for the LoggerProvider interface.
// ProcessMock is a mock for the [Process] interface.
type ProcessMock struct {
StartMock func(logger *zap.Logger) error
StopMock func(logger *zap.Logger) error
HealthyMock func(logger *zap.Logger) bool
}
func (p *ProcessMock) Start(logger *zap.Logger) error {
return p.StartMock(logger)
}
func (p *ProcessMock) Stop(logger *zap.Logger) error {
return p.StopMock(logger)
}
func (p *ProcessMock) Healthy(logger *zap.Logger) bool {
return p.HealthyMock(logger)
}
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
type ProcessSupervisorMock struct {
LaunchMock func() error
ShutdownMock func() error
HealthyMock func() bool
RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error
ReqQueueSizeMock func() int64
RestartsCountMock func() int64
}
func (s *ProcessSupervisorMock) Launch() error {
return s.LaunchMock()
}
func (s *ProcessSupervisorMock) Shutdown() error {
return s.ShutdownMock()
}
func (s *ProcessSupervisorMock) Healthy() bool {
return s.HealthyMock()
}
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
return s.RunMock(ctx, logger, task)
}
func (s *ProcessSupervisorMock) ReqQueueSize() int64 {
return s.ReqQueueSizeMock()
}
func (s *ProcessSupervisorMock) RestartsCount() int64 {
return s.RestartsCountMock()
}
// LoggerProviderMock is a mock for the [LoggerProvider] interface.
type LoggerProviderMock struct {
LoggerMock func(mod Module) (*zap.Logger, error)
}
func (provider LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
return provider.LoggerMock(mod)
}
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
type MetricsProviderMock struct {
MetricsMock func() ([]Metric, error)
}
func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
return provider.MetricsMock()
}
// Interface guards.
var (
_ Module = (*ModuleMock)(nil)
_ Validator = (*ValidatorMock)(nil)
_ PDFEngine = (*PDFEngineMock)(nil)
_ PDFEngineProvider = (*PDFEngineProviderMock)(nil)
_ PdfEngine = (*PdfEngineMock)(nil)
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
_ Process = (*ProcessMock)(nil)
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil)
)

View File

@@ -8,7 +8,7 @@ import (
)
func TestModuleMock(t *testing.T) {
mock := ModuleMock{
mock := &ModuleMock{
DescriptorMock: func() ModuleDescriptor {
return ModuleDescriptor{ID: "foo", New: func() Module {
return nil
@@ -17,12 +17,25 @@ func TestModuleMock(t *testing.T) {
}
if mock.Descriptor().ID != "foo" {
t.Errorf("expected ID '%s' from mock.Descriptor(), but got '%s'", "foo", mock.Descriptor().ID)
t.Errorf("expected ID '%s' from ModuleMock.Descriptor, but got '%s'", "foo", mock.Descriptor().ID)
}
}
func TestProvisionerMock(t *testing.T) {
mock := &ProvisionerMock{
ProvisionMock: func(*Context) error {
return nil
},
}
err := mock.Provision(&Context{})
if err != nil {
t.Errorf("expected no error from ProvisionerMock.Provision, but got: %v", err)
}
}
func TestValidatorMock(t *testing.T) {
mock := ValidatorMock{
mock := &ValidatorMock{
ValidateMock: func() error {
return nil
},
@@ -30,53 +43,148 @@ func TestValidatorMock(t *testing.T) {
err := mock.Validate()
if err != nil {
t.Errorf("expected no error from mock.Validate(), but got: %v", err)
t.Errorf("expected no error from ValidatorMock.Validate, but got: %v", err)
}
}
func TestPDFEngineMock(t *testing.T) {
mock := PDFEngineMock{
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 {
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, 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)
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), "", "", "")
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
if err != nil {
t.Errorf("expected no error from mock.Convert(), but got: %v", err)
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
}
}
func TestPDFEngineProvider(t *testing.T) {
mock := PDFEngineProviderMock{
PDFEngineMock: func() (PDFEngine, error) {
return PDFEngineMock{}, nil
func TestPDFEngineProviderMock(t *testing.T) {
mock := &PdfEngineProviderMock{
PdfEngineMock: func() (PdfEngine, error) {
return new(PdfEngineMock), nil
},
}
_, err := mock.PDFEngine()
_, err := mock.PdfEngine()
if err != nil {
t.Errorf("expected no error from mock.PDFEngine(), but got: %v", err)
t.Errorf("expected no error from PdfEngineProviderMock.PdfEngine, but got: %v", err)
}
}
func TestProcessMock(t *testing.T) {
mock := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return nil
},
StopMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
err := mock.Start(zap.NewNop())
if err != nil {
t.Errorf("expected no error from ProcessMock.Start, but got: %v", err)
}
err = mock.Stop(zap.NewNop())
if err != nil {
t.Errorf("expected no error from ProcessMock.Stop, but got: %v", err)
}
healthy := mock.Healthy(zap.NewNop())
if !healthy {
t.Error("expected true from ProcessMock.Healthy, but got false")
}
}
func TestProcessSupervisorMock(t *testing.T) {
mock := &ProcessSupervisorMock{
LaunchMock: func() error {
return nil
},
ShutdownMock: func() error {
return nil
},
HealthyMock: func() bool {
return true
},
RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return nil
},
ReqQueueSizeMock: func() int64 {
return 0
},
RestartsCountMock: func() int64 {
return 0
},
}
err := mock.Launch()
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Launch, but got: %v", err)
}
err = mock.Shutdown()
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Shutdown, but got: %v", err)
}
healthy := mock.Healthy()
if !healthy {
t.Error("expected true from ProcessSupervisorMock.Healthy, but got false")
}
err = mock.Run(context.TODO(), zap.NewNop(), nil)
if err != nil {
t.Errorf("expected no error from ProcessSupervisorMock.Run, but got: %v", err)
}
size := mock.ReqQueueSize()
if size != 0 {
t.Errorf("expected 0 from ProcessSupervisorMock.ReqQueueSize, but got: %d", size)
}
restarts := mock.RestartsCount()
if restarts != 0 {
t.Errorf("expected 0 from ProcessSupervisorMock.RestartsCount, but got: %d", restarts)
}
}
func TestLoggerProviderMock(t *testing.T) {
mock := LoggerProviderMock{
mock := &LoggerProviderMock{
LoggerMock: func(mod Module) (*zap.Logger, error) {
return nil, nil
},
}
_, err := mock.Logger(ModuleMock{})
_, err := mock.Logger(new(ModuleMock))
if err != nil {
t.Errorf("expected no error from mock.Logger(), but got: %v", err)
t.Errorf("expected no error from LoggerProviderMock.Logger, but got: %v", err)
}
}
func TestMetricsProviderMock(t *testing.T) {
mock := &MetricsProviderMock{
MetricsMock: func() ([]Metric, error) {
return nil, nil
},
}
_, err := mock.Metrics()
if err != nil {
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
}
}

View File

@@ -83,7 +83,7 @@ type SystemLogger interface {
// gotenberg.MustRegisterModule(YourModule{})
// }
//
// Then, in the main command (github.com/gotenberg/gotenberg/v7/cmd/gotenberg),
// Then, in the main command (github.com/gotenberg/gotenberg/v8/cmd/gotenberg),
// import the module:
//
// imports (

View File

@@ -5,32 +5,6 @@ import (
"testing"
)
type ProtoModule struct {
descriptor func() ModuleDescriptor
}
func (mod ProtoModule) Descriptor() ModuleDescriptor {
return mod.descriptor()
}
type ProtoProvisioner struct {
ProtoModule
provision func(ctx *Context) error
}
func (mod ProtoProvisioner) Provision(ctx *Context) error {
return mod.provision(ctx)
}
type ProtoValidator struct {
ProtoModule
validate func() error
}
func (mod ProtoValidator) Validate() error {
return mod.validate()
}
func TestMustRegisterModule(t *testing.T) {
descriptorsMu.RLock()
descriptors = map[string]ModuleDescriptor{
@@ -38,44 +12,51 @@ func TestMustRegisterModule(t *testing.T) {
}
descriptorsMu.RUnlock()
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
ID string
New func() Module
expectPanic bool
}{
{
scenario: "no ID",
ID: "",
New: func() Module { return new(ProtoModule) },
New: func() Module { return new(ModuleMock) },
expectPanic: true,
},
{
scenario: "nil New method",
ID: "b",
New: nil,
expectPanic: true,
},
{
scenario: "nil module",
ID: "b",
New: func() Module { return nil },
expectPanic: true,
},
{
scenario: "existing module",
ID: "a",
New: func() Module { return new(ProtoModule) },
New: func() Module { return new(ModuleMock) },
expectPanic: true,
},
{
ID: "b",
New: func() Module { return new(ProtoModule) },
scenario: "success",
ID: "b",
New: func() Module { return new(ModuleMock) },
expectPanic: false,
},
} {
func() {
mod := struct{ ProtoModule }{}
mod.descriptor = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
t.Run(tc.scenario, func(t *testing.T) {
mod := &struct{ ModuleMock }{}
mod.DescriptorMock = func() ModuleDescriptor { return ModuleDescriptor{ID: tc.ID, New: tc.New} }
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Errorf("test %d: expected panic but got none", i)
t.Error("expected panic but got none")
}
}()
}
@@ -83,13 +64,13 @@ func TestMustRegisterModule(t *testing.T) {
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Errorf("test %d: expected no panic but got: %v", i, r)
t.Errorf("expected no panic but got: %v", r)
}
}()
}
MustRegisterModule(mod)
}()
})
}
descriptorsMu.RLock()
@@ -124,12 +105,3 @@ func TestGetModuleDescriptors(t *testing.T) {
descriptors = make(map[string]ModuleDescriptor)
descriptorsMu.RUnlock()
}
// Interface guards.
var (
_ Module = (*ProtoModule)(nil)
_ Provisioner = (*ProtoProvisioner)(nil)
_ Module = (*ProtoProvisioner)(nil)
_ Validator = (*ProtoValidator)(nil)
_ Module = (*ProtoValidator)(nil)
)

View File

@@ -8,45 +8,74 @@ import (
)
var (
// ErrPDFEngineMethodNotAvailable happens if a PDFEngine method is not
// available in the implementation.
ErrPDFEngineMethodNotAvailable = errors.New("method not available")
// ErrPdfEngineMethodNotSupported is returned when a specific method of the
// PdfEngine interface is not supported by its current implementation.
ErrPdfEngineMethodNotSupported = errors.New("method not supported")
// ErrPDFFormatNotAvailable happens if a PDFEngine Convert's method does
// not handle a specific format.
ErrPDFFormatNotAvailable = errors.New("PDF format not available")
// ErrPdfFormatNotSupported is returned when the Convert method of the
// PdfEngine interface does not support a requested PDF format conversion.
ErrPdfFormatNotSupported = errors.New("PDF format not supported")
)
const (
FormatPDFA1a string = "PDF/A-1a"
FormatPDFA1b string = "PDF/A-1b"
FormatPDFA2a string = "PDF/A-2a"
FormatPDFA2b string = "PDF/A-2b"
FormatPDFA2u string = "PDF/A-2u"
FormatPDFA3a string = "PDF/A-3a"
FormatPDFA3b string = "PDF/A-3b"
FormatPDFA3u string = "PDF/A-3u"
// PdfA1a represents the PDF/A-1a format.
PdfA1a string = "PDF/A-1a"
// PdfA1b represents the PDF/A-1b format.
PdfA1b string = "PDF/A-1b"
// PdfA2a represents the PDF/A-2a format.
PdfA2a string = "PDF/A-2a"
// PdfA2b represents the PDF/A-2b format.
PdfA2b string = "PDF/A-2b"
// PdfA2u represents the PDF/A-2u format.
PdfA2u string = "PDF/A-2u"
// PdfA3a represents the PDF/A-3a format.
PdfA3a string = "PDF/A-3a"
// PdfA3b represents the PDF/A-3b format.
PdfA3b string = "PDF/A-3b"
// PdfA3u represents the PDF/A-3u format.
PdfA3u string = "PDF/A-3u"
)
// PDFEngine is a module interface which exposes methods for manipulating one
// or more PDFs. Implementations may abstract powerful tools like PDFtk, or
// fulfill those methods contracts in Golang directly.
type PDFEngine interface {
// Merge merges the given PDFs into a unique PDF. The pages' order reflects
// order of the given files.
// PdfFormats specifies the target formats for a PDF conversion.
type PdfFormats struct {
// PdfA denotes the PDF/A standard format (e.g., PDF/A-1a).
PdfA string
// PdfUa indicates whether the PDF should comply
// with the PDF/UA (Universal Accessibility) standard.
PdfUa bool
}
// PdfEngine provides an interface for operations on PDFs. Implementations
// can utilize various tools like PDFtk, or implement functionality directly in
// Go.
type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
// Convert converts the given PDF to a specific PDF format.
Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error
// Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
}
// PDFEngineProvider is a module interface which exposes a method for creating a
// PDFEngine for other modules.
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
// This is used to decouple the creation of a [PdfEngine] from its consumers.
//
// Example:
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.PDFEngineProvider))
// pdfengines, _ := provider.(gotenberg.PDFEngineProvider).PDFEngine()
// provider, _ := ctx.Module(new(gotenberg.PdfEngineProvider))
// engine, _ := provider.(gotenberg.PdfEngineProvider).PdfEngine()
// }
type PDFEngineProvider interface {
PDFEngine() (PDFEngine, error)
type PdfEngineProvider interface {
// PdfEngine returns an instance of the [PdfEngine] interface for PDF operations.
PdfEngine() (PdfEngine, error)
}

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

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

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,16 +12,14 @@ import (
"path/filepath"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3"
"go.uber.org/zap"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
var (
@@ -48,8 +46,8 @@ type Context struct {
context.Context
}
// newContext returns a Context by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration) (*Context, context.CancelFunc, error) {
// newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
ctx := &Context{
@@ -81,7 +79,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
return
}
ctx.logger.Debug(fmt.Sprintf("'%s' removed", ctx.dirPath))
ctx.logger.Debug(fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
ctx.cancelled = true
}
}()
@@ -92,28 +90,28 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
if errors.Is(err, http.ErrNotMultipart) {
return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err),
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: want 'multipart/form-data'"),
NewSentinelHttpError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: want 'multipart/form-data'"),
)
}
if errors.Is(err, http.ErrMissingBoundary) {
return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err),
NewSentinelHTTPError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: no boundary"),
NewSentinelHttpError(http.StatusUnsupportedMediaType, "Invalid 'Content-Type' header value: no boundary"),
)
}
if strings.Contains(err.Error(), io.EOF.Error()) {
return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err),
NewSentinelHTTPError(http.StatusBadRequest, "Malformed body: it does not match the 'Content-Type' header boundaries"),
NewSentinelHttpError(http.StatusBadRequest, "Malformed body: it does not match the 'Content-Type' header boundaries"),
)
}
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
}
dirPath, err := gotenberg.MkdirAll()
dirPath, err := fs.MkdirAll()
if err != nil {
return nil, cancel, fmt.Errorf("create working directory: %w", err)
}
@@ -123,15 +121,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
ctx.files = make(map[string]string)
copyToDisk := func(fh *multipart.FileHeader) error {
// Avoid directory traversal and normalize filename.
// See https://github.com/gotenberg/gotenberg/issues/104.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
if err != nil {
return fmt.Errorf("transform filename: %w", err)
}
in, err := fh.Open()
if err != nil {
return fmt.Errorf("open multipart file: %w", err)
@@ -144,6 +133,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
}
}()
// Avoid directory traversal and make sure filename characters are
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
out, err := os.Create(path)
@@ -171,26 +164,25 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, timeout time.Duration)
for _, files := range form.File {
for _, fh := range files {
err = copyToDisk(fh)
if err != nil {
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
}
}
}
ctx.Log().Debug(fmt.Sprintf("form data values: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form data files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
return ctx, cancel, err
}
// Request returns the http.Request.
func (ctx Context) Request() *http.Request {
// Request returns the [http.Request].
func (ctx *Context) Request() *http.Request {
return ctx.echoCtx.Request()
}
// FormData return a FormData.
func (ctx Context) FormData() *FormData {
// FormData return a [FormData].
func (ctx *Context) FormData() *FormData {
return &FormData{
values: ctx.values,
files: ctx.files,
@@ -198,10 +190,15 @@ func (ctx Context) FormData() *FormData {
}
}
// GeneratePath generates a path within the context's working directory. It
// does not create a file.
func (ctx Context) GeneratePath(extension string) string {
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
// GeneratePath generates a path within the context's working directory.
// It either generates a new UUID-based filename or uses the provided filename.
// It does not create a file.
func (ctx *Context) GeneratePath(filename, extension string) string {
if filename == "" {
// Generate a new UUID-based filename
filename = uuid.New().String()
}
return fmt.Sprintf("%s/%s%s", ctx.dirPath, filename, extension)
}
// AddOutputPaths adds the given paths. Those paths will be used later to build
@@ -222,14 +219,14 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
return nil
}
// Log returns the context zap.Logger.
func (ctx Context) Log() *zap.Logger {
// Log returns the context [zap.Logger].
func (ctx *Context) Log() *zap.Logger {
return ctx.logger
}
// BuildOutputFile builds the output file according to the output paths
// registered in the context. If many output paths, an archive is created.
func (ctx Context) BuildOutputFile() (string, error) {
func (ctx *Context) BuildOutputFile() (string, error) {
if ctx.cancelled {
return "", ErrContextAlreadyClosed
}
@@ -253,7 +250,7 @@ func (ctx Context) BuildOutputFile() (string, error) {
ImplicitTopLevelFolder: false,
}
archivePath := ctx.GeneratePath(".zip")
archivePath := ctx.GeneratePath("", ".zip")
err := z.Archive(ctx.outputPaths, archivePath)
if err != nil {
@@ -267,7 +264,7 @@ func (ctx Context) BuildOutputFile() (string, error) {
// OutputFilename returns the filename based on the given output path or the
// "Gotenberg-Output-Filename" header's value.
func (ctx Context) OutputFilename(outputPath string) string {
func (ctx *Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Request().Header.Get("Gotenberg-Output-Filename")
if filename == "" {

View File

@@ -12,144 +12,141 @@ import (
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestNewContext(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
request *http.Request
expectErr bool
expectHTTPErr bool
expectHTTPStatus int
expectError bool
expectHttpError bool
expectHttpStatus int
}{
{
scenario: "http.ErrNotMultipart",
request: httptest.NewRequest(http.MethodPost, "/", nil),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusUnsupportedMediaType,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusUnsupportedMediaType,
},
{
scenario: "http.ErrMissingBoundary",
request: func() *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, echo.MIMEMultipartForm)
return req
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusUnsupportedMediaType,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusUnsupportedMediaType,
},
{
scenario: "malformed body",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
expectErr: true,
expectHTTPErr: true,
expectHTTPStatus: http.StatusBadRequest,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "success",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("foo", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = part.Write([]byte("foo"))
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
expectError: false,
expectHttpError: false,
},
} {
handler := func(c echo.Context) error {
_, cancel, err := newContext(c, zap.NewNop(), time.Duration(10)*time.Second)
defer cancel()
// Context already cancelled.
defer cancel()
t.Run(tc.scenario, func(t *testing.T) {
handler := func(c echo.Context) error {
_, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second)
defer cancel()
// Context already cancelled.
defer cancel()
if err != nil {
return err
if err != nil {
return err
}
return nil
}
return nil
}
recorder := httptest.NewRecorder()
recorder := httptest.NewRecorder()
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
srv := echo.New()
srv.HideBanner = true
srv.HidePort = true
c := srv.NewContext(tc.request, recorder)
err := handler(c)
c := srv.NewContext(tc.request, recorder)
err := handler(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
var httpErr HTTPError
isHTTPErr := errors.As(err, &httpErr)
if tc.expectHTTPErr && !isHTTPErr {
t.Errorf("test %d: expected HTTP error but got: %v", i, err)
}
if !tc.expectHTTPErr && isHTTPErr {
t.Errorf("test %d: expected no HTTP error but got one: %v", i, httpErr)
}
if err != nil && tc.expectHTTPErr && isHTTPErr {
status, _ := httpErr.HTTPError()
if status != tc.expectHTTPStatus {
t.Errorf("test %d: expected %d HTTP status code but got %d", i, tc.expectHTTPStatus, status)
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
var httpErr HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
})
}
}
@@ -158,7 +155,7 @@ func TestContext_Request(t *testing.T) {
recorder := httptest.NewRecorder()
c := echo.New().NewContext(request, recorder)
ctx := Context{
ctx := &Context{
echoCtx: c,
}
@@ -168,7 +165,7 @@ func TestContext_Request(t *testing.T) {
}
func TestContext_FormData(t *testing.T) {
ctx := Context{
ctx := &Context{
values: map[string][]string{
"foo": {"foo"},
},
@@ -189,52 +186,65 @@ func TestContext_FormData(t *testing.T) {
}
func TestContext_GeneratePath(t *testing.T) {
ctx := Context{
ctx := &Context{
dirPath: "/foo",
}
path := ctx.GeneratePath(".pdf")
path := ctx.GeneratePath("", ".pdf")
if !strings.HasPrefix(path, ctx.dirPath) {
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
}
path = ctx.GeneratePath("foo.txt", ".pdf")
if !strings.Contains(path, "foo.txt.pdf") {
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
}
}
func TestContext_AddOutputPaths(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
ctx *Context
path string
expectCount int
expectErr bool
expectError bool
}{
{
ctx: &Context{cancelled: true},
expectErr: true,
scenario: "ErrContextAlreadyClosed",
ctx: &Context{cancelled: true},
expectCount: 0,
expectError: true,
},
{
ctx: &Context{dirPath: "/foo"},
path: "/bar/foo.txt",
expectErr: true,
scenario: "ErrOutOfBoundsOutputPath",
ctx: &Context{dirPath: "/foo"},
path: "/bar/foo.txt",
expectCount: 0,
expectError: true,
},
{
scenario: "success",
ctx: &Context{dirPath: "/foo"},
path: "/foo/foo.txt",
expectCount: 1,
expectError: false,
},
} {
err := tc.ctx.AddOutputPaths(tc.path)
t.Run(tc.scenario, func(t *testing.T) {
err := tc.ctx.AddOutputPaths(tc.path)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(tc.ctx.outputPaths) != tc.expectCount {
t.Errorf("test %d: expected %d output paths but got %d", i, tc.expectCount, len(tc.ctx.outputPaths))
}
if len(tc.ctx.outputPaths) != tc.expectCount {
t.Errorf("expected %d output paths but got %d", tc.expectCount, len(tc.ctx.outputPaths))
}
})
}
}
@@ -249,76 +259,91 @@ func TestContext_Log(t *testing.T) {
}
func TestContext_BuildOutputFile(t *testing.T) {
for i, tc := range []struct {
ctx *Context
expectErr bool
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
ctx: &Context{cancelled: true},
expectErr: true,
scenario: "ErrContextAlreadyClosed",
ctx: &Context{cancelled: true},
expectError: true,
},
{
ctx: &Context{},
expectErr: true,
scenario: "no output path",
ctx: &Context{},
expectError: true,
},
{
ctx: &Context{outputPaths: []string{"foo.txt"}},
scenario: "success: one output path",
ctx: &Context{outputPaths: []string{"foo.txt"}},
expectError: false,
},
{
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
expectErr: true,
scenario: "cannot archive: invalid output paths",
ctx: &Context{outputPaths: []string{"foo.txt", "foo.pdf"}},
expectError: true,
},
{
scenario: "success: many output paths",
ctx: &Context{
outputPaths: []string{
"/tests/test/testdata/api/sample1.txt",
"/tests/test/testdata/api/sample1.txt",
},
},
expectError: false,
},
} {
dirPath, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("%d: expected no erro but got: %v", i, err)
}
t.Run(tc.scenario, func(t *testing.T) {
fs := gotenberg.NewFileSystem()
dirPath, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected no erro but got: %v", err)
}
tc.ctx.dirPath = dirPath
tc.ctx.logger = zap.NewNop()
defer func() {
err := os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
_, err = tc.ctx.BuildOutputFile()
tc.ctx.dirPath = dirPath
tc.ctx.logger = zap.NewNop()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
_, err = tc.ctx.BuildOutputFile()
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("%d: expected no erro but got: %v", i, err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_OutputFilename(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
ctx *Context
outputPath string
expectOutputFilename string
}{
{
scenario: "with Gotenberg-Output-Filename header",
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
c.Request().Header.Set("Gotenberg-Output-Filename", "foo")
return &Context{echoCtx: c}
}(),
outputPath: "/foo/bar.txt",
expectOutputFilename: "foo.txt",
},
{
scenario: "without custom filename",
ctx: func() *Context {
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/foo", nil), nil)
return &Context{echoCtx: c}
@@ -327,10 +352,12 @@ func TestContext_OutputFilename(t *testing.T) {
expectOutputFilename: "foo.txt",
},
} {
actual := tc.ctx.OutputFilename(tc.outputPath)
t.Run(tc.scenario, func(t *testing.T) {
actual := tc.ctx.OutputFilename(tc.outputPath)
if actual != tc.expectOutputFilename {
t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectOutputFilename, actual)
}
if actual != tc.expectOutputFilename {
t.Errorf("expected '%s' but got '%s'", tc.expectOutputFilename, actual)
}
})
}
}

View File

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

View File

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

View File

@@ -23,8 +23,8 @@ type FormData struct {
errors error
}
// Validate returns nil or an error related to the FormData values, with a
// SentinelHTTPError (status code 400, errors' details as message) wrapped
// Validate returns nil or an error related to the [FormData] values, with a
// [SentinelHttpError] (status code 400, errors' details as message) wrapped
// inside.
//
// var foo string
@@ -32,18 +32,18 @@ type FormData struct {
// err := ctx.FormData().
// MandatoryString("foo", &foo, "bar").
// Validate()
func (form FormData) Validate() error {
func (form *FormData) Validate() error {
if form.errors == nil {
return nil
}
return WrapError(
form.errors,
NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid form data: %s", form.errors)),
)
}
// String binds a form data value to a string variable.
// String binds a form field to a string variable.
//
// var foo string
//
@@ -52,17 +52,17 @@ func (form *FormData) String(key string, target *string, defaultValue string) *F
return form.mustValue(key, target, defaultValue)
}
// MandatoryString binds a form data value to a string variable. It populates
// MandatoryString binds a form field to a string variable. It populates
// an error if the value is empty or the "key" does not exist.
//
// var foo string
//
// ctx.FormData().MandatoryString("foo", &foo)
func (form *FormData) MandatoryString(key string, target *string) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Bool binds a form data value to a bool variable. It populates an error if
// Bool binds a form field to a bool variable. It populates an error if
// the value is not bool.
//
// var foo bool
@@ -72,17 +72,17 @@ func (form *FormData) Bool(key string, target *bool, defaultValue bool) *FormDat
return form.mustValue(key, target, defaultValue)
}
// MandatoryBool binds a form data value to a bool variable. It populates an
// MandatoryBool binds a form field to a bool variable. It populates an
// error if the value is not bool, is empty, or the "key" does not exist.
//
// var foo bool
//
// ctx.FormData().MandatoryBool("foo", &foo)
func (form *FormData) MandatoryBool(key string, target *bool) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Int binds a form data value to an int variable. It populates an error if the
// Int binds a form field to an int variable. It populates an error if the
// value is not int.
//
// var foo int
@@ -92,17 +92,17 @@ func (form *FormData) Int(key string, target *int, defaultValue int) *FormData {
return form.mustValue(key, target, defaultValue)
}
// MandatoryInt binds a form data value to an int variable. It populates an
// MandatoryInt binds a form field to an int variable. It populates an
// error if the value is not int, is empty, or the "key" does not exist.
//
// var foo int
//
// ctx.FormData().MandatoryInt("foo", &foo)
func (form *FormData) MandatoryInt(key string, target *int) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Float64 binds a form data value to a float64 variable. It populates an error
// Float64 binds a form field to a float64 variable. It populates an error
// if the value is not float64.
//
// var foo float64
@@ -112,18 +112,18 @@ func (form *FormData) Float64(key string, target *float64, defaultValue float64)
return form.mustValue(key, target, defaultValue)
}
// MandatoryFloat64 binds a form data value to a float64 variable. It populates
// MandatoryFloat64 binds a form field to a float64 variable. It populates
// an error if the is not float64, is empty, or the "key" does not exist.
//
// var foo float64
//
// ctx.FormData().MandatoryFloat64("foo", &foo)
func (form *FormData) MandatoryFloat64(key string, target *float64) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Duration binds a form data value to a time.Duration variable. It populates
// an error if the form data value is not time.Duration.
// Duration binds a form field to a time.Duration variable. It populates
// an error if the form field is not time.Duration.
//
// var foo time.Duration
//
@@ -132,7 +132,7 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
return form.mustValue(key, target, defaultValue)
}
// MandatoryDuration binds a form data value to a time.Duration variable. It
// MandatoryDuration binds a form field to a time.Duration variable. It
// populates an error if the value is not time.Duration, is empty, or the "key"
// does not exist.
//
@@ -140,10 +140,10 @@ func (form *FormData) Duration(key string, target *time.Duration, defaultValue t
//
// ctx.FormData().MandatoryDuration("foo", &foo)
func (form *FormData) MandatoryDuration(key string, target *time.Duration) *FormData {
return form.mustMandatoryValue(key, target)
return form.mustMandatoryField(key, target)
}
// Custom helps to define a custom binding function for a form data value.
// Custom helps to define a custom binding function for a form field.
//
// var foo map[string]string
//
@@ -168,16 +168,15 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
err := assign(value)
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}
return form
}
// MandatoryCustom helps to define a custom binding function for a form data
// value. It populates an error if the value is empty or the "key" does not
// exist.
// MandatoryCustom helps to define a custom binding function for a form field.
// It populates an error if the value is empty or the "key" does not exist.
//
// var foo map[string]string
//
@@ -191,7 +190,7 @@ func (form *FormData) Custom(key string, assign func(value string) error) *FormD
// })
func (form *FormData) MandatoryCustom(key string, assign func(value string) error) *FormData {
var value string
form.mustMandatoryValue(key, &value)
form.mustMandatoryField(key, &value)
if value == "" {
return form
@@ -200,7 +199,7 @@ func (form *FormData) MandatoryCustom(key string, assign func(value string) erro
err := assign(value)
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}
@@ -315,7 +314,7 @@ func (form *FormData) append(err error) {
form.errors = multierr.Append(form.errors, err)
}
// mustValue binds the target interface with a form data value. If the value is
// mustValue binds the target interface with a form field. If the value is
// empty or the "key" does not exist, it binds the default value. Currently,
// only the string, bool, int, float64 and time.Duration types are bindable.
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData {
@@ -343,16 +342,16 @@ func (form *FormData) mustValue(key string, target interface{}, defaultValue int
return form.mustAssign(key, val[0], target)
}
// mustMandatoryValue binds the target interface with a form data value. It
// mustMandatoryField binds the target interface with a form field. It
// populates an error if the value is empty or the "key" does not exist.
// Currently, only the string, bool, int, float64 and time.Duration types are
// bindable.
func (form *FormData) mustMandatoryValue(key string, target interface{}) *FormData {
func (form *FormData) mustMandatoryField(key string, target interface{}) *FormData {
val, ok := form.values[key]
if !ok || val[0] == "" {
form.append(
fmt.Errorf("form value '%s' is required", key),
fmt.Errorf("form field '%s' is required", key),
)
return form
@@ -386,7 +385,7 @@ func (form *FormData) mustAssign(key, value string, target interface{}) *FormDat
if err != nil {
form.append(
fmt.Errorf("form value '%s' is invalid (got '%s', resulting to %w)", key, value, err),
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -13,6 +13,8 @@ import (
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestParseError(t *testing.T) {
@@ -34,7 +36,7 @@ func TestParseError(t *testing.T) {
{
err: WrapError(
errors.New("foo"),
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
NewSentinelHttpError(http.StatusBadRequest, "foo"),
),
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
@@ -71,7 +73,7 @@ func TestHttpErrorHandler(t *testing.T) {
{
err: WrapError(
errors.New("foo"),
NewSentinelHTTPError(http.StatusBadRequest, "foo"),
NewSentinelHttpError(http.StatusBadRequest, "foo"),
),
expectStatus: http.StatusBadRequest,
expectMessage: "foo",
@@ -122,7 +124,6 @@ func TestLatencyMiddleware(t *testing.T) {
return nil
},
)(c)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -150,7 +151,6 @@ func TestRootPathMiddleware(t *testing.T) {
return nil
},
)(c)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -191,7 +191,6 @@ func TestTraceMiddleware(t *testing.T) {
return nil
},
)(c)
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
@@ -271,7 +270,6 @@ func TestLoggerMiddleware(t *testing.T) {
}
err := loggerMiddleware(zap.NewNop(), disableLoggingForPaths)(tc.next)(c)
if err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
@@ -389,7 +387,7 @@ func TestContextMiddleware(t *testing.T) {
c.Set("trace", "foo")
c.Set("startTime", time.Now())
err := contextMiddleware(time.Duration(10) * time.Second)(tc.next)(c)
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)

View File

@@ -1,6 +1,7 @@
package api
import (
"github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
@@ -20,6 +21,15 @@ func (ctx *ContextMock) SetDirPath(path string) {
ctx.dirPath = path
}
// DirPath returns the context's working directory path.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetDirPath("/foo")
// dirPath := ctx.DirPath()
func (ctx *ContextMock) DirPath() string {
return ctx.dirPath
}
// SetValues sets the values.
//
// ctx := &api.ContextMock{Context: &api.Context{}}
@@ -54,7 +64,7 @@ func (ctx *ContextMock) SetCancelled(cancelled bool) {
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// outputPaths := ctx.OutputPaths()
func (ctx ContextMock) OutputPaths() []string {
func (ctx *ContextMock) OutputPaths() []string {
return ctx.outputPaths
}
@@ -73,3 +83,42 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c
}
// RouterMock is a mock for the [Router] interface.
type RouterMock struct {
RoutesMock func() ([]Route, error)
}
func (router *RouterMock) Routes() ([]Route, error) {
return router.RoutesMock()
}
// MiddlewareProviderMock is a mock for the [MiddlewareProvider] interface.
type MiddlewareProviderMock struct {
MiddlewaresMock func() ([]Middleware, error)
}
func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) {
return provider.MiddlewaresMock()
}
// HealthCheckerMock is mock for the [HealthChecker] interface.
type HealthCheckerMock struct {
ChecksMock func() ([]health.CheckerOption, error)
ReadyMock func() error
}
func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) {
return mod.ChecksMock()
}
func (mod *HealthCheckerMock) Ready() error {
return mod.ReadyMock()
}
// Interface guards.
var (
_ Router = (*RouterMock)(nil)
_ MiddlewareProvider = (*MiddlewareProviderMock)(nil)
_ HealthChecker = (*HealthCheckerMock)(nil)
)

View File

@@ -4,6 +4,7 @@ import (
"reflect"
"testing"
"github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
@@ -20,6 +21,18 @@ func TestContextMock_SetDirPath(t *testing.T) {
}
}
func TestContextMock_DirPath(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetDirPath("/foo")
actual := mock.DirPath()
expect := "/foo"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestContextMock_SetValues(t *testing.T) {
mock := &ContextMock{&Context{}}
mock.SetValues(map[string][]string{
@@ -103,3 +116,50 @@ func TestContextMock_SetEchoContext(t *testing.T) {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestRouterMock(t *testing.T) {
mock := &RouterMock{
RoutesMock: func() ([]Route, error) {
return nil, nil
},
}
_, err := mock.Routes()
if err != nil {
t.Errorf("expected no error from RouterMock.Routes, but got: %v", err)
}
}
func TestMiddlewareProviderMock(t *testing.T) {
mock := &MiddlewareProviderMock{
MiddlewaresMock: func() ([]Middleware, error) {
return nil, nil
},
}
_, err := mock.Middlewares()
if err != nil {
t.Errorf("expected no error from MiddlewareProviderMock.Middlewares, but got: %v", err)
}
}
func TestHealthCheckerMock(t *testing.T) {
mock := &HealthCheckerMock{
ChecksMock: func() ([]health.CheckerOption, error) {
return nil, nil
},
ReadyMock: func() error {
return nil
},
}
_, err := mock.Checks()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
}
err = mock.Ready()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
}
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,55 +5,18 @@ import (
"errors"
"os"
"reflect"
"regexp"
"testing"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/alexliesenfeld/health"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type ProtoModule struct {
descriptor func() gotenberg.ModuleDescriptor
}
func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
return mod.descriptor()
}
type ProtoAPI struct {
pdf func(_ context.Context, _ *zap.Logger, _, _ string, _ Options) error
}
func (mod ProtoAPI) PDF(ctx context.Context, logger *zap.Logger, URL, outputPath string, options Options) error {
return mod.pdf(ctx, logger, URL, outputPath, options)
}
type ProtoPDFEngineProvider struct {
ProtoModule
pdfEngine func() (gotenberg.PDFEngine, error)
}
func (mod ProtoPDFEngineProvider) PDFEngine() (gotenberg.PDFEngine, error) {
return mod.pdfEngine()
}
type ProtoPDFEngine struct {
merge func(_ context.Context, _ *zap.Logger, _ []string, _ string) error
convert func(_ context.Context, _ *zap.Logger, _, _, _ string) error
}
func (mod ProtoPDFEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return mod.merge(ctx, logger, inputPaths, outputPath)
}
func (mod ProtoPDFEngine) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
return mod.convert(ctx, logger, format, inputPath, outputPath)
}
func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions()
notExpect := Options{}
actual := DefaultPdfOptions()
notExpect := PdfOptions{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
@@ -61,7 +24,7 @@ func TestDefaultOptions(t *testing.T) {
}
func TestChromium_Descriptor(t *testing.T) {
descriptor := Chromium{}.Descriptor()
descriptor := new(Chromium).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(Chromium))
@@ -72,11 +35,13 @@ func TestChromium_Descriptor(t *testing.T) {
}
func TestChromium_Provision(t *testing.T) {
for i, tc := range []struct {
ctx *gotenberg.Context
expectErr bool
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
scenario: "no logger provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
@@ -85,15 +50,19 @@ func TestChromium_Provision(t *testing.T) {
[]gotenberg.ModuleDescriptor{},
)
}(),
expectErr: true,
expectError: true,
},
{
scenario: "no logger from logger provider",
ctx: func() *gotenberg.Context {
mod := struct{ ProtoPDFEngineProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.pdfEngine = func() (gotenberg.PDFEngine, error) {
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
@@ -106,16 +75,78 @@ func TestChromium_Provision(t *testing.T) {
},
)
}(),
expectErr: true,
expectError: true,
},
{
scenario: "no PDF engine provider",
ctx: func() *gotenberg.Context {
mod := struct{ ProtoPDFEngineProvider }{}
mod.descriptor = func() gotenberg.ModuleDescriptor {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.pdfEngine = func() (gotenberg.PDFEngine, error) {
return struct{ ProtoPDFEngine }{}, nil
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(Chromium).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
mod.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
gotenberg.PdfEngineProviderMock
}{}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
mod.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return new(gotenberg.PdfEngineMock), nil
}
return gotenberg.NewContext(
@@ -129,62 +160,287 @@ func TestChromium_Provision(t *testing.T) {
}(),
},
} {
mod := new(Chromium)
err := mod.Provision(tc.ctx)
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
err := mod.Provision(tc.ctx)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Validate(t *testing.T) {
for i, tc := range []struct {
binPath string
expectErr bool
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
expectErr: true,
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
binPath: "/foo",
expectErr: true,
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
scenario: "validate success",
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
expectError: false,
},
} {
mod := new(Chromium)
mod.binPath = tc.binPath
err := mod.Validate()
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.args = browserArguments{
binPath: tc.binPath,
}
err := mod.Validate()
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %d: expected no error but got: %v", i, err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
expectError: false,
},
{
scenario: "auto-start success",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "auto-start failed",
autoStart: true,
supervisor: &gotenberg.ProcessSupervisorMock{LaunchMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.supervisor = tc.supervisor
err := mod.Start()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_StartupMessage(t *testing.T) {
mod := new(Chromium)
mod.autoStart = true
autoStartMsg := mod.StartupMessage()
mod.autoStart = false
noAutoStartMsg := mod.StartupMessage()
if autoStartMsg == noAutoStartMsg {
t.Errorf("expected differrent startup messages based on auto start, but got '%s'", autoStartMsg)
}
}
func TestChromium_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor *gotenberg.ProcessSupervisorMock
expectError bool
}{
{
scenario: "stop success",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return nil
}},
expectError: false,
},
{
scenario: "stop failed",
supervisor: &gotenberg.ProcessSupervisorMock{ShutdownMock: func() error {
return errors.New("foo")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.logger = zap.NewNop()
mod.supervisor = tc.supervisor
ctx, cancel := context.WithTimeout(context.Background(), 0*time.Second)
cancel()
err := mod.Stop(ctx)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestChromium_Metrics(t *testing.T) {
metrics, err := new(Chromium).Metrics()
mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{
ReqQueueSizeMock: func() int64 {
return 10
},
RestartsCountMock: func() int64 {
return 0
},
}
metrics, err := mod.Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
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)
if actual != float64(10) {
t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual)
}
actual = metrics[1].Read()
if actual != float64(0) {
t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual)
}
}
func TestChromium_Checks(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
expectAvailabilityStatus health.AvailabilityStatus
}{
{
scenario: "healthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return true
}},
expectAvailabilityStatus: health.StatusUp,
},
{
scenario: "unhealthy module",
supervisor: &gotenberg.ProcessSupervisorMock{HealthyMock: func() bool {
return false
}},
expectAvailabilityStatus: health.StatusDown,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = tc.supervisor
checks, err := mod.Checks()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
checker := health.NewChecker(checks...)
result := checker.Check(context.Background())
if result.Status != tc.expectAvailabilityStatus {
t.Errorf("expected '%s' as availability status, but got '%s'", tc.expectAvailabilityStatus, result.Status)
}
})
}
}
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
browser browser
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout}
mod.browser = tc.browser
err := mod.Ready()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
@@ -198,355 +454,118 @@ func TestChromium_Chromium(t *testing.T) {
}
func TestChromium_Routes(t *testing.T) {
for i, tc := range []struct {
for _, tc := range []struct {
scenario string
expectRoutes int
disableRoutes bool
}{
{
expectRoutes: 3,
scenario: "routes not disabled",
expectRoutes: 6,
disableRoutes: false,
},
{
scenario: "routes disabled",
expectRoutes: 0,
disableRoutes: true,
},
} {
mod := new(Chromium)
mod.disableRoutes = tc.disableRoutes
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.disableRoutes = tc.disableRoutes
routes, err := mod.Routes()
if err != nil {
t.Fatalf("test %d: expected no error but got: %v", i, err)
}
routes, err := mod.Routes()
if err != nil {
t.Fatalf("expected no error 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.expectRoutes != len(routes) {
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
}
})
}
}
func TestChromium_PDF(t *testing.T) {
func TestChromium_Pdf(t *testing.T) {
for _, 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
scenario string
supervisor gotenberg.ProcessSupervisor
browser browser
expectError bool
}{
{
name: "context has no deadline",
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
expectErr: true,
scenario: "PDF task success",
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
expectError: false,
},
{
name: "URL does not match the expression from the allowed list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
allowList: regexp.MustCompile("file:///tmp/*"),
expectErr: true,
},
{
name: "URL does not match the expression from the denied list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
denyList: regexp.MustCompile("file:///tests/*"),
expectErr: true,
},
{
name: "with user agent",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
UserAgent: "foo",
},
},
{
name: "fail on console exceptions",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample10/index.html",
options: Options{
FailOnConsoleExceptions: true,
},
expectErr: true,
},
{
name: "disable JavaScript",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample9/index.html",
disableJavaScript: true,
},
{
name: "with extra HTTP headers",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
ExtraHTTPHeaders: map[string]string{
"foo": "bar",
},
},
},
{
name: "with extra link tags",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraLinkTags: []LinkTag{
{
Href: "font.woff",
},
{
Href: "style.css",
},
},
},
},
{
name: "with invalid emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "foo",
},
expectErr: true,
},
{
name: "with screen emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "screen",
},
},
{
name: "with print emulated media type",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample8/index.html",
options: Options{
EmulatedMediaType: "print",
},
},
{
name: "with extra script tags",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample11/index.html",
options: Options{
ExtraScriptTags: []ScriptTag{
{
Src: "script.js",
},
},
},
},
{
name: "with wait delay",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitDelay: time.Duration(1) * time.Nanosecond,
},
},
{
name: "with invalid wait window status",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitWindowStatus: "foo",
},
expectErr: true,
},
{
name: "with wait window status",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitWindowStatus: "ready",
},
},
{
name: "with wait for expression that should not happen",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'foo'",
},
expectErr: true,
},
{
name: "with valid wait for expression",
timeout: time.Duration(3) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample2/index.html",
options: Options{
WaitForExpression: "window.status === 'ready'",
},
},
{
name: "with invalid wait for expression",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
WaitForExpression: "return undefined",
},
expectErr: true,
},
{
name: "with too big margin bottom",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
MarginBottom: 100,
},
expectErr: true,
},
{
name: "with invalid page ranges",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
PageRanges: "foo",
},
expectErr: true,
},
{
name: "with a lot of options",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
userAgent: "foo",
incognito: true,
ignoreCertificateErrors: true,
allowInsecureLocalhost: true,
disableWebSecurity: true,
allowFileAccessFromFiles: true,
hostResolverRules: "foo",
proxyServer: "foo",
},
{
name: "with file using local and remote assets",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample1/index.html",
},
{
name: "URL does match the expression from the allowed list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
allowList: regexp.MustCompile("file:///tests/*"),
},
{
name: "URL does match the expression from the denied list",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample3/index.html",
denyList: regexp.MustCompile("file:///etc/*"),
},
{
name: "with custom header and footer templates",
timeout: time.Duration(60) * time.Second,
URL: "file:///tests/test/testdata/chromium/html/sample4/index.html",
options: Options{
HeaderTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/header.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
FooterTemplate: func() string {
b, err := os.ReadFile("/tests/test/testdata/chromium/url/sample2/footer.html")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return string(b)
}(),
},
},
{
name: "with 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",
scenario: "PDF task error",
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return errors.New("PDF task error")
}},
expectError: true,
},
} {
func() {
t.Run(tc.scenario, func(t *testing.T) {
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
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
mod.browser = tc.browser
if tc.allowList == nil {
tc.allowList = regexp.MustCompile("")
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.denyList == nil {
tc.denyList = regexp.MustCompile("")
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
mod.allowList = tc.allowList
mod.denyList = tc.denyList
mod.disableJavaScript = tc.disableJavaScript
outputDir, err := gotenberg.MkdirAll()
if err != nil {
t.Fatalf("test %s: expected error but got: %v", tc.name, err)
}
defer func() {
err := os.RemoveAll(outputDir)
if err != nil {
t.Fatalf("test %s: expected no error but got: %v", tc.name, err)
}
}()
if tc.timeout == 0 {
err = mod.PDF(context.Background(), zap.NewNop(), tc.URL, outputDir+"/foo.pdf", tc.options)
} else {
ctx, cancel := context.WithTimeout(context.Background(), tc.timeout)
defer cancel()
err = mod.PDF(ctx, zap.NewNop(), tc.URL, outputDir+"/foo.pdf", tc.options)
}
if tc.expectErr && err == nil {
t.Errorf("test %s: expected error but got: %v", tc.name, err)
}
if !tc.expectErr && err != nil {
t.Errorf("test %s: expected no error but got: %v", tc.name, err)
}
}()
})
}
}
// Interface guards.
var (
_ gotenberg.Module = (*ProtoModule)(nil)
_ API = (*ProtoAPI)(nil)
_ gotenberg.PDFEngineProvider = (*ProtoPDFEngineProvider)(nil)
_ gotenberg.Module = (*ProtoPDFEngineProvider)(nil)
_ gotenberg.PDFEngine = (*ProtoPDFEngine)(nil)
)
func TestChromium_Screenshot(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
browser browser
expectError bool
}{
{
scenario: "Screenshot task success",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
}},
expectError: false,
},
{
scenario: "Screenshot task error",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return errors.New("screenshot task error")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
mod.browser = tc.browser
err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,54 +9,64 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
// FormDataChromiumPDFOptions creates Options form the form data. Fallback to
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
// default value if the considered key is not present.
func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
userAgent string
extraHTTPHeaders map[string]string
emulatedMediaType string
landscape, printBackground bool
scale, paperWidth, paperHeight float64
marginTop, marginBottom, marginLeft, marginRight float64
pageRanges string
headerTemplate, footerTemplate string
preferCSSPageSize bool
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
extraHttpHeaders map[string]string
emulatedMediaType string
omitBackground bool
)
form := ctx.FormData().
Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
Custom("failOnHttpStatusCodes", func(value string) error {
if value == "" {
failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
return nil
}
err := json.Unmarshal([]byte(value), &failOnHttpStatusCodes)
if err != nil {
return fmt.Errorf("unmarshal failOnHttpStatusCodes: %w", err)
}
return nil
}).
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
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
return nil
}
err := json.Unmarshal([]byte(value), &extraHTTPHeaders)
err := json.Unmarshal([]byte(value), &extraHttpHeaders)
if err != nil {
return fmt.Errorf("unmarshal extra HTTP headers: %w", err)
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
}
return nil
@@ -64,7 +74,6 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
Custom("emulatedMediaType", func(value string) error {
if value == "" {
emulatedMediaType = defaultOptions.EmulatedMediaType
return nil
}
@@ -76,97 +85,172 @@ func FormDataChromiumPDFOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
Bool("landscape", &landscape, defaultOptions.Landscape).
Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
Float64("scale", &scale, defaultOptions.Scale).
Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth).
Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight).
Float64("marginTop", &marginTop, defaultOptions.MarginTop).
Float64("marginBottom", &marginBottom, defaultOptions.MarginBottom).
Float64("marginLeft", &marginLeft, defaultOptions.MarginLeft).
Float64("marginRight", &marginRight, defaultOptions.MarginRight).
String("nativePageRanges", &pageRanges, defaultOptions.PageRanges).
Content("header.html", &headerTemplate, defaultOptions.HeaderTemplate).
Content("footer.html", &footerTemplate, defaultOptions.FooterTemplate).
Bool("preferCssPageSize", &preferCSSPageSize, defaultOptions.PreferCSSPageSize)
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
UserAgent: userAgent,
ExtraHTTPHeaders: extraHTTPHeaders,
ExtraLinkTags: defaultOptions.ExtraLinkTags,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
ExtraScriptTags: defaultOptions.ExtraScriptTags,
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,
OmitBackground: omitBackground,
}
return form, options
}
// convertURLRoute returns an api.Route which can convert a URL to PDF.
func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to
// default value if the considered key is not present.
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
form, options := FormDataChromiumOptions(ctx)
defaultPdfOptions := DefaultPdfOptions()
var (
landscape, printBackground bool
scale, paperWidth, paperHeight float64
marginTop, marginBottom, marginLeft, marginRight float64
pageRanges string
headerTemplate, footerTemplate string
preferCssPageSize bool
)
form.
Bool("landscape", &landscape, defaultPdfOptions.Landscape).
Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
Float64("scale", &scale, defaultPdfOptions.Scale).
Float64("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
Float64("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
Float64("marginTop", &marginTop, defaultPdfOptions.MarginTop).
Float64("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
Float64("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
Float64("marginRight", &marginRight, defaultPdfOptions.MarginRight).
String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize)
pdfOptions := PdfOptions{
Options: options,
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,
}
return form, pdfOptions
}
// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form
// data. Fallback to default value if the considered key is not present.
func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) {
form, options := FormDataChromiumOptions(ctx)
defaultScreenshotOptions := DefaultScreenshotOptions()
var (
format string
quality int
optimizeForSpeed bool
)
form.
Custom("format", func(value string) error {
if value == "" {
format = defaultScreenshotOptions.Format
return nil
}
if value != "png" && value != "jpeg" && value != "webp" {
return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'")
}
format = value
return nil
}).
Custom("quality", func(value string) error {
if value == "" {
quality = defaultScreenshotOptions.Quality
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 0 {
return errors.New("value is negative")
}
if intValue > 100 {
return errors.New("value is superior to 100")
}
quality = intValue
return nil
}).
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
screenshotOptions := ScreenshotOptions{
Options: options,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
}
return form, screenshotOptions
}
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
// data. Fallback to default value if the considered key is not present.
func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var (
pdfa string
pdfua bool
)
form.
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false)
return gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/url",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
var (
URL string
PDFformat string
)
form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form)
var url string
err := form.
MandatoryString("url", &URL).
String("pdfFormat", &PDFformat, "").
Custom("extraLinkTags", func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options.ExtraLinkTags)
if err != nil {
return fmt.Errorf("unmarshal extra link tags: %w", err)
}
return nil
}).
Custom("extraScriptTags", func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options.ExtraScriptTags)
if err != nil {
return fmt.Errorf("unmarshal extra script tags: %w", err)
}
return nil
}).
MandatoryString("url", &url).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -176,33 +260,57 @@ func convertURLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
}
}
// convertHTMLRoute returns an api.Route which can convert an HTML file to PDF.
func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
// screenshotUrlRoute returns an [api.Route] which can take a screenshot from a
// URL.
func screenshotUrlRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/url",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var url string
err := form.
MandatoryString("url", &url).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("URL screenshot: %w", err)
}
return nil
},
}
}
// convertHtmlRoute returns an [api.Route] which can convert an HTML file to
// PDF.
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/html",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
var (
inputPath string
PDFformat string
)
form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form)
var inputPath string
err := form.
MandatoryPath("index.html", &inputPath).
String("pdfFormat", &PDFformat, "").
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
URL := fmt.Sprintf("file://%s", inputPath)
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -212,108 +320,67 @@ func convertHTMLRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
}
}
// convertMarkdownRoute returns an api.Route which can convert markdown files
// screenshotHtmlRoute returns an [api.Route] which can take a screenshot from
// an HTML file.
func screenshotHtmlRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/html",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var inputPath string
err := form.
MandatoryPath("index.html", &inputPath).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
url := fmt.Sprintf("file://%s", inputPath)
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("HTML screenshot: %w", err)
}
return nil
},
}
}
// convertMarkdownRoute returns an [api.Route] which can convert markdown files
// to PDF.
func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/convert/markdown",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPDFOptions(ctx)
form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form)
var (
inputPath string
markdownPaths []string
PDFformat string
)
err := form.
MandatoryPath("index.html", &inputPath).
MandatoryPaths([]string{".md"}, &markdownPaths).
String("pdfFormat", &PDFformat, "").
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
// We have to convert each markdown file referenced in the HTML
// file to... HTML. Thanks to the "html/template" package, we are
// able to provide the "toHTML" function which the user may call
// directly inside the HTML file.
var markdownFilesNotFoundErr error
tmpl, err := template.
New(filepath.Base(inputPath)).
Funcs(template.FuncMap{
"toHTML": func(filename string) (template.HTML, error) {
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
if filename == markdownFilename {
path = markdownPath
break
}
}
if path == "" {
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)
return "", nil
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
}
unsafe := blackfriday.Run(b)
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
// #nosec
return template.HTML(sanitized), nil
},
}).ParseFiles(inputPath)
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
return fmt.Errorf("parse template file: %w", err)
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, &struct{}{})
if err != nil {
return fmt.Errorf("execute template: %w", err)
}
if markdownFilesNotFoundErr != nil {
return api.WrapError(
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
api.NewSentinelHTTPError(
http.StatusBadRequest,
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
),
)
}
inputPath = ctx.GeneratePath(".html")
err = os.WriteFile(inputPath, buffer.Bytes(), 0600)
if err != nil {
return fmt.Errorf("write template result: %w", err)
}
URL := fmt.Sprintf("file://%s", inputPath)
err = convertURL(ctx, chromium, engine, URL, PDFformat, options)
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -323,36 +390,132 @@ func convertMarkdownRoute(chromium API, engine gotenberg.PDFEngine) api.Route {
}
}
// convertURL is a stub which is called by the other methods of this file.
func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL, PDFformat string, options Options) error {
outputPath := ctx.GeneratePath(".pdf")
// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
// from markdown files.
func screenshotMarkdownRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/markdown",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
err := chromium.PDF(ctx, ctx.Log(), URL, outputPath, options)
if err != nil {
if errors.Is(err, ErrURLNotAuthorized) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusForbidden,
fmt.Sprintf("'%s' does not match the authorized URLs", URL),
),
var (
inputPath string
markdownPaths []string
)
}
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)
err := form.
MandatoryPath("index.html", &inputPath).
MandatoryPaths([]string{".md"}, &markdownPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("markdown screenshot: %w", err)
}
return nil
},
}
}
func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) {
// We have to convert each markdown file referenced in the HTML
// file to... HTML. Thanks to the "html/template" package, we are
// able to provide the "toHTML" function which the user may call
// directly inside the HTML file.
var markdownFilesNotFoundErr error
tmpl, err := template.
New(filepath.Base(inputPath)).
Funcs(template.FuncMap{
"toHTML": func(filename string) (template.HTML, error) {
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
if filename == markdownFilename {
path = markdownPath
break
}
}
if path == "" {
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)
return "", nil
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
}
unsafe := blackfriday.Run(b)
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
// #nosec
return template.HTML(sanitized), nil
},
}).ParseFiles(inputPath)
if err != nil {
return "", fmt.Errorf("parse template file: %w", err)
}
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, &struct{}{})
if err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
if markdownFilesNotFoundErr != nil {
return "", api.WrapError(
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
),
)
}
inputPath = ctx.GeneratePath("", ".html")
err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
if err != nil {
return "", fmt.Errorf("write template result: %w", err)
}
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options PdfOptions) error {
outputPath := ctx.GeneratePath("", ".pdf")
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, url, options.Options)
if err != nil {
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
"omitBackground requires printBackground set to true",
),
)
}
@@ -360,7 +523,7 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
if errors.Is(err, ErrInvalidPrinterSettings) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusBadRequest,
"Chromium does not handle the provided settings; please check for aberrant form values",
),
@@ -370,43 +533,42 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
if errors.Is(err, ErrPageRangesSyntaxError) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Chromium does not handle the page ranges '%s' (nativePageRanges)", options.PageRanges),
),
)
}
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(
http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
return fmt.Errorf("convert to PDF: %w", err)
}
// So far so good, the URL has been converted to PDF.
// Now, let's check if the client want to convert this result PDF
// to a specific PDF format.
if PDFformat != "" {
// Now, let's check if the client want to convert the resulting PDF
// to specific formats.
zeroValued := gotenberg.PdfFormats{}
if pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPath)
convertOutputPath := ctx.GeneratePath("", ".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
if err != nil {
if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat),
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
),
)
}
@@ -425,3 +587,86 @@ func convertURL(ctx *api.Context, chromium API, engine gotenberg.PDFEngine, URL,
return nil
}
func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error {
ext := fmt.Sprintf(".%s", options.Format)
outputPath := ctx.GeneratePath("", ext)
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, url, options.Options)
if err != nil {
return fmt.Errorf("screenshot: %w", err)
}
err = ctx.AddOutputPaths(outputPath)
if err != nil {
return fmt.Errorf("add output path: %w", err)
}
return nil
}
func handleChromiumError(err error, url string, options Options) error {
if err == nil {
return nil
}
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
if errors.Is(err, gotenberg.ErrFiltered) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusForbidden,
fmt.Sprintf("'%s' does not match the authorized URLs", url),
),
)
}
if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return
// an ErrInvalidEvaluationExpression error. In such a scenario,
// we return a 500.
return err
}
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
),
)
}
if errors.Is(err, ErrInvalidHttpStatusCode) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
),
)
}
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
return err
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,397 @@
package chromium
import (
"bufio"
"context"
"fmt"
"os"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"go.uber.org/zap"
)
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
printToPdf := page.PrintToPDF().
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperHeight(options.PaperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(options.PageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.Debug("no custom header nor footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
} else {
logger.Debug("with custom header and/or footer")
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
WithHeaderTemplate(options.HeaderTemplate).
WithFooterTemplate(options.FooterTemplate)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf))
_, stream, err := printToPdf.Do(ctx)
if err != nil {
return fmt.Errorf("print to PDF: %w", err)
}
reader := &streamReader{
ctx: ctx,
handle: stream,
r: nil,
pos: 0,
eof: false,
}
defer func() {
err = reader.Close()
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
}()
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
}()
buffer := bufio.NewReader(reader)
_, err = buffer.WriteTo(file)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}
}
func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
captureScreenshot := page.CaptureScreenshot().
WithCaptureBeyondViewport(true).
WithFromSurface(true).
WithOptimizeForSpeed(options.OptimizeForSpeed).
WithFormat(page.CaptureScreenshotFormat(options.Format))
if options.Format == "jpeg" {
captureScreenshot = captureScreenshot.
WithQuality(int64(options.Quality))
}
logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
buffer, err := captureScreenshot.Do(ctx)
if err != nil {
return fmt.Errorf("capture screenshot: %w", err)
}
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
}()
_, err = file.Write(buffer)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}
}
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
if !clear {
logger.Debug("cache not cleared")
return nil
}
logger.Debug("clear cache")
err := network.ClearBrowserCache().Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("clear cache: %w", err)
}
}
func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
if !clear {
logger.Debug("cookies not cleared")
return nil
}
logger.Debug("clear cookies")
err := network.ClearBrowserCookies().Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("clear cookies: %w", err)
}
}
func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/175.
if !disable {
logger.Debug("JavaScript not disabled")
return nil
}
logger.Debug("disable JavaScript")
err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("disable JavaScript: %w", err)
}
}
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers")
return nil
}
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
headers := make(network.Headers, len(extraHttpHeaders))
for key, value := range extraHttpHeaders {
headers[key] = value
}
err := network.SetExtraHTTPHeaders(headers).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("set extra HTTP headers: %w", err)
}
}
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
_, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", url, err)
}
waitFunc := []func() error{
waitForEventDomContentEventFired(ctx, logger),
waitForEventLoadEventFired(ctx, logger),
waitForEventLoadingFinished(ctx, logger),
}
if !skipNetworkIdleEvent {
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
} else {
logger.Debug("skipping network idle event")
}
err = runBatch(
ctx,
waitFunc...,
)
if err == nil {
return nil
}
return fmt.Errorf("wait for events: %w", err)
}
}
func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, printBackground bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/226.
if !omitBackground {
logger.Debug("default white background not hidden")
return nil
}
if !printBackground {
// See https://github.com/chromedp/chromedp/issues/1179#issuecomment-1284794416.
return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground)
}
logger.Debug("hide default white background")
err := emulation.SetDefaultBackgroundColorOverride().WithColor(
&cdp.RGBA{
R: 0,
G: 0,
B: 0,
A: 0,
}).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("hide default white background: %w", err)
}
}
func forceExactColorsActionFunc() chromedp.ActionFunc {
return func(ctx context.Context) error {
// See:
// https://github.com/gotenberg/gotenberg/issues/354
// https://github.com/puppeteer/puppeteer/issues/2685
// https://github.com/chromedp/chromedp/issues/520
script := `
(() => {
const css = 'html { -webkit-print-color-adjust: exact !important; }';
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
})();
`
evaluate := chromedp.Evaluate(script, nil)
err := evaluate.Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("add CSS for exact colors: %w", err)
}
}
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if mediaType == "" {
logger.Debug("no emulated media type")
return nil
}
if mediaType != "screen" && mediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
}
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia := emulation.SetEmulatedMedia()
err := emulatedMedia.WithMedia(mediaType).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("emulate media type '%s': %w", mediaType, err)
}
}
func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc {
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait delay")
return nil
}
if delay <= 0 {
logger.Debug("no wait delay")
return nil
}
// We wait for a given amount of time so that JavaScript
// scripts have a chance to finish before printing the page.
logger.Debug(fmt.Sprintf("wait '%s' before print", delay))
select {
case <-ctx.Done():
return fmt.Errorf("wait delay: %w", ctx.Err())
case <-time.After(delay):
return nil
}
}
}
func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait expression")
return nil
}
if expression == "" {
logger.Debug("no wait expression")
return nil
}
// We wait until the evaluation of the expression is true or
// until the context is done.
logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression))
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
case <-ticker.C:
var ok bool
evaluate := chromedp.Evaluate(expression, &ok)
err := evaluate.Do(ctx)
if err != nil {
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
}
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,361 @@
package api
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/alexliesenfeld/health"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func init() {
gotenberg.MustRegisterModule(new(Api))
}
var (
// ErrInvalidPdfFormats happens if the PDF formats option cannot be handled
// by LibreOffice.
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
// ErrMalformedPageRanges happens if the page ranges option cannot be
// interpreted by LibreOffice.
ErrMalformedPageRanges = errors.New("page ranges are malformed")
)
// Api is a module which provides a [Uno] to interact with LibreOffice.
type Api struct {
autoStart bool
args libreOfficeArguments
logger *zap.Logger
libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor
}
// Options gathers available options when converting a document to PDF.
type Options struct {
// Landscape allows to change the orientation of the resulting PDF.
// Optional.
Landscape bool
// PageRanges allows to select the pages to convert.
// Optional.
PageRanges string
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA.
// Optional.
PdfFormats gotenberg.PdfFormats
}
// Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface {
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
Extensions() []string
}
// Provider is a module interface which exposes a method for creating a
// [Uno] for other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(libreofficeapi.Provider))
// libreOffice, _ := provider.(api.Provider).LibreOffice()
// }
type Provider interface {
LibreOffice() (Uno, error)
}
// Descriptor returns a [Api]'s module descriptor.
func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "libreoffice-api",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
fs.Int64("libreoffice-max-queue-size", 0, "Maximum request queue size for LibreOffice. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
return fs
}(),
New: func() gotenberg.Module { return new(Api) },
}
}
// Provision sets the module properties.
func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
a.autoStart = flags.MustBool("libreoffice-auto-start")
libreOfficeBinPath, ok := os.LookupEnv("LIBREOFFICE_BIN_PATH")
if !ok {
return errors.New("LIBREOFFICE_BIN_PATH environment variable is not set")
}
unoBinPath, ok := os.LookupEnv("UNOCONVERTER_BIN_PATH")
if !ok {
return errors.New("UNOCONVERTER_BIN_PATH environment variable is not set")
}
a.args = libreOfficeArguments{
binPath: libreOfficeBinPath,
unoBinPath: unoBinPath,
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger.Named("libreoffice")
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
return nil
}
// Validate validates the module properties.
func (a *Api) Validate() error {
var err error
_, statErr := os.Stat(a.args.binPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
}
_, statErr = os.Stat(a.args.unoBinPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
}
return err
}
// Start does nothing if auto-start is not enabled. Otherwise, it starts a
// LibreOffice instance.
func (a *Api) Start() error {
if !a.autoStart {
return nil
}
err := a.supervisor.Launch()
if err != nil {
return fmt.Errorf("launch supervisor: %w", err)
}
return nil
}
// StartupMessage returns a custom startup message.
func (a *Api) StartupMessage() string {
if !a.autoStart {
return "LibreOffice ready to start"
}
return "LibreOffice automatically started"
}
// Stop stops the current browser instance.
func (a *Api) Stop(ctx context.Context) error {
// Block until the context is done so that other module may gracefully stop
// before we do a shutdown.
a.logger.Debug("wait for the end of grace duration")
<-ctx.Done()
err := a.supervisor.Shutdown()
if err == nil {
return nil
}
return fmt.Errorf("stop LibreOffice: %w", err)
}
// Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "libreoffice_requests_queue_size",
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
Read: func() float64 {
return float64(a.supervisor.ReqQueueSize())
},
},
{
Name: "libreoffice_restarts_count",
Description: "Current number of LibreOffice restarts.",
Read: func() float64 {
return float64(a.supervisor.RestartsCount())
},
},
}, nil
}
// Checks adds a health check that verifies if LibreOffice is healthy.
func (a *Api) Checks() ([]health.CheckerOption, error) {
return []health.CheckerOption{
health.WithCheck(health.Check{
Name: "libreoffice",
Check: func(_ context.Context) error {
if a.supervisor.Healthy() {
return nil
}
return errors.New("LibreOffice is unhealthy")
},
}),
}, nil
}
// Ready returns no error if the module is ready.
func (a *Api) Ready() error {
if !a.autoStart {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), a.args.startTimeout)
defer cancel()
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while waiting for LibreOffice to be ready: %w", ctx.Err())
case <-ticker.C:
ok := a.libreOffice.Healthy(a.logger)
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
// LibreOffice returns a [Uno] for interacting with LibreOffice.
func (a *Api) LibreOffice() (Uno, error) {
return a, nil
}
// Pdf converts a document to PDF.
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return a.supervisor.Run(ctx, logger, func() error {
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
}
// Extensions returns the file extensions available for conversions.
// FIXME: don't care, take all on the route level?
func (a *Api) Extensions() []string {
return []string{
".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",
}
}
// Interface guards.
var (
_ gotenberg.Module = (*Api)(nil)
_ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil)
_ gotenberg.MetricsProvider = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil)
_ Provider = (*Api)(nil)
)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,395 @@
package api
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type libreOffice interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
}
type libreOfficeArguments struct {
binPath string
unoBinPath string
startTimeout time.Duration
}
type libreOfficeProcess struct {
socketPort int
userProfileDirPath string
cmd *gotenberg.Cmd
cfgMu sync.RWMutex
isStarted atomic.Bool
arguments libreOfficeArguments
fs *gotenberg.FileSystem
}
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
p := &libreOfficeProcess{
arguments: arguments,
fs: gotenberg.NewFileSystem(),
}
p.isStarted.Store(false)
return p
}
func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
if p.isStarted.Load() {
return errors.New("LibreOffice is already started")
}
port, err := freePort(logger)
if err != nil {
return fmt.Errorf("get free port: %w", err)
}
userProfileDirPath := p.fs.NewDirPath()
args := []string{
"--headless",
"--invisible",
"--nocrashreport",
"--nodefault",
"--nologo",
"--nofirststartwizard",
"--norestore",
fmt.Sprintf("-env:UserInstallation=file://%s", userProfileDirPath),
fmt.Sprintf("--accept=socket,host=127.0.0.1,port=%d,tcpNoDelay=1;urp;StarOffice.ComponentContext", port),
}
ctx, cancel := context.WithTimeout(context.Background(), p.arguments.startTimeout)
defer cancel()
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
if err != nil {
return fmt.Errorf("create LibreOffice command: %w", err)
}
// For whatever reason, LibreOffice requires a first start before being
// able to run as a daemon.
exitCode, err := cmd.Exec()
if err != nil && exitCode != 81 {
return fmt.Errorf("execute LibreOffice: %w", err)
}
logger.Debug("got exit code 81, e.g., LibreOffice first start")
// Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
err = cmd.Start()
if err != nil {
return fmt.Errorf("start LibreOffice: %w", err)
}
waitChan := make(chan error, 1)
go func() {
// By waiting the process, we avoid the creation of a zombie process
// and make sure we catch an early exit if any.
waitChan <- cmd.Wait()
}()
connChan := make(chan error, 1)
go func() {
// As the LibreOffice socket may take some time to be available, we
// have to ensure that it is indeed accepting connections.
for {
if ctx.Err() != nil {
connChan <- ctx.Err()
break
}
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Duration(1)*time.Second)
if err != nil {
continue
}
connChan <- nil
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
}
break
}
}()
var success bool
defer func() {
if success {
p.cfgMu.Lock()
defer p.cfgMu.Unlock()
p.socketPort = port
p.userProfileDirPath = userProfileDirPath
p.cmd = cmd
p.isStarted.Store(true)
return
}
// Let's make sure the process is killed.
err = cmd.Kill()
if err != nil {
logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err))
}
// And the user profile directory is deleted.
err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
}
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}()
logger.Debug("waiting for the LibreOffice socket to be available...")
for {
select {
case err = <-connChan:
if err != nil {
return fmt.Errorf("LibreOffice socket not available: %w", err)
}
logger.Debug("LibreOffice socket available")
success = true
return nil
case err = <-waitChan:
return fmt.Errorf("LibreOffice process exited: %w", err)
}
}
}
func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
if !p.isStarted.Load() {
// No big deal? Like calling cancel twice.
return nil
}
// Always remove the user profile directory created by LibreOffice.
copyUserProfileDirPath := p.userProfileDirPath
defer func(userProfileDirPath string) {
go func() {
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
}
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
// Also remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
if err != nil {
logger.Error(err.Error())
}
}()
}(copyUserProfileDirPath)
p.cfgMu.Lock()
defer p.cfgMu.Unlock()
err := p.cmd.Kill()
if err != nil {
return fmt.Errorf("kill LibreOffice process: %w", err)
}
p.socketPort = 0
p.userProfileDirPath = ""
p.cmd = nil
p.isStarted.Store(false)
return nil
}
func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
// Good to know: the supervisor does not call this method if no first start
// or if the process is restarting.
if !p.isStarted.Load() {
// Non-started browser but not restarting?
return false
}
p.cfgMu.RLock()
defer p.cfgMu.RUnlock()
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.socketPort), time.Duration(10)*time.Second)
if err == nil {
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
}
return true
}
return false
}
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
if !p.isStarted.Load() {
return errors.New("LibreOffice not started, cannot handle PDF conversion")
}
args := []string{
"--no-launch",
"--format",
"pdf",
}
args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
if checkedEntry != nil {
args = append(args, "-vvv")
}
if options.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape")
}
if options.PageRanges != "" {
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
}
switch options.PdfFormats.PdfA {
case "":
case gotenberg.PdfA1b:
args = append(args, "--export", "SelectPdfVersion=1")
case gotenberg.PdfA2b:
args = append(args, "--export", "SelectPdfVersion=2")
case gotenberg.PdfA3b:
args = append(args, "--export", "SelectPdfVersion=3")
default:
return ErrInvalidPdfFormats
}
if options.PdfFormats.PdfUa {
args = append(
args,
"--export", "EnableTextAccessForAccessibilityTools=true",
"--export", "UseTaggedPDF=true",
)
}
inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath)
if err != nil {
return fmt.Errorf("non-basic latin characters guard: %w", err)
}
args = append(args, "--output", outputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...)
if err != nil {
return fmt.Errorf("create uno command: %w", err)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
exitCode, err := cmd.Exec()
if err == nil {
return nil
}
// LibreOffice's errors are not explicit.
// That's why we have to make an educated guess according to the exit code
// and given inputs.
if exitCode == 5 && options.PageRanges != "" {
return ErrMalformedPageRanges
}
// Possible errors:
// 1. LibreOffice failed for some reason.
// 2. Context done.
//
// On the second scenario, LibreOffice might not have time to remove some
// of its temporary files, as it has been killed without warning. The
// garbage collector will delete them for us (if the module is loaded).
return fmt.Errorf("convert to PDF: %w", err)
}
// LibreOffice cannot convert a file with a name containing non-basic Latin
// characters.
// See:
// https://github.com/gotenberg/gotenberg/issues/104
// https://github.com/gotenberg/gotenberg/issues/730
func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) {
hasNonBasicLatinChars := func(str string) bool {
for _, r := range str {
// Check if the character is outside basic Latin.
if r != '.' && (r < ' ' || r > '~') {
return true
}
}
return false
}
filename := filepath.Base(inputPath)
if !hasNonBasicLatinChars(filename) {
logger.Debug("no non-basic latin characters in filename, skip copy")
return inputPath, nil
}
logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename")
basePath := filepath.Dir(inputPath)
ext := filepath.Ext(inputPath)
newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext))
in, err := os.Open(inputPath)
if err != nil {
return "", fmt.Errorf("open file: %w", err)
}
defer func() {
err := in.Close()
if err != nil {
logger.Error(fmt.Sprintf("close file: %s", err))
}
}()
out, err := os.Create(newInputPath)
if err != nil {
return "", fmt.Errorf("create new file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close new file: %s", err))
}
}()
_, err = io.Copy(out, in)
if err != nil {
return "", fmt.Errorf("copy file to new file: %w", err)
}
return newInputPath, nil
}
// Interface guards.
var (
_ gotenberg.Process = (*libreOfficeProcess)(nil)
_ libreOffice = (*libreOfficeProcess)(nil)
)

View File

@@ -0,0 +1,650 @@
package api
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestLibreOfficeProcess_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
expectError bool
cleanup bool
}{
{
scenario: "successful start",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
expectError: false,
cleanup: true,
},
{
scenario: "LibreOffice already started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(true)
return p
}(),
expectError: true,
cleanup: false,
},
{
scenario: "non-exit code 81 on first start",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: "foo",
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
expectError: true,
cleanup: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
err := tc.libreOffice.Start(logger)
if tc.cleanup {
defer func(p libreOffice, logger *zap.Logger) {
err = p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLibreOfficeProcess_Stop(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
setup func(libreOffice libreOffice, logger *zap.Logger) error
expectError bool
}{
{
scenario: "successful stop",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
setup: func(p libreOffice, logger *zap.Logger) error {
return p.Start(logger)
},
expectError: false,
},
{
scenario: "LibreOffice already stopped",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
if tc.setup != nil {
err := tc.setup(tc.libreOffice, logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
}
err := tc.libreOffice.Stop(logger)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLibreOfficeProcess_Healthy(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
setup func(libreOffice libreOffice, logger *zap.Logger) error
expectHealthy bool
cleanup bool
}{
{
scenario: "healthy LibreOffice",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
setup: func(p libreOffice, logger *zap.Logger) error {
return p.Start(logger)
},
expectHealthy: true,
cleanup: true,
},
{
scenario: "LibreOffice not started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
expectHealthy: false,
cleanup: false,
},
{
scenario: "unhealthy LibreOffice",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(true)
p.socketPort = 12345
return p
}(),
expectHealthy: false,
cleanup: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
if tc.setup != nil {
err := tc.setup(tc.libreOffice, logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
}
if tc.cleanup {
defer func(p libreOffice, logger *zap.Logger) {
err := p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
healthy := tc.libreOffice.Healthy(logger)
if !tc.expectHealthy && healthy {
t.Fatal("expected unhealthy LibreOffice but got an healthy one")
}
if tc.expectHealthy && !healthy {
t.Fatal("expected a healthy LibreOffice but got an unhealthy one")
}
})
}
}
func TestLibreOfficeProcess_pdf(t *testing.T) {
for _, tc := range []struct {
scenario string
libreOffice libreOffice
fs *gotenberg.FileSystem
options Options
cancelledCtx bool
start bool
expectError bool
expectedError error
}{
{
scenario: "LibreOffice not started",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.isStarted.Store(false)
return p
}(),
fs: gotenberg.NewFileSystem(),
cancelledCtx: false,
start: false,
expectError: true,
},
{
scenario: "ErrInvalidPdfFormats",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.socketPort = 12345
p.isStarted.Store(true)
return p
}(),
fs: gotenberg.NewFileSystem(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
cancelledCtx: false,
start: false,
expectError: true,
expectedError: ErrInvalidPdfFormats,
},
{
scenario: "ErrMalformedPageRanges",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
options: Options{PageRanges: "foo"},
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: true,
expectedError: ErrMalformedPageRanges,
},
{
scenario: "context done",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: true,
start: true,
expectError: true,
},
{
scenario: "success (default options)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (landscape)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{Landscape: true},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (page ranges)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PageRanges: "1-1"},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-1b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-2b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA2b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/A-3b)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA3b}},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (PDF/UA)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfUa: true}},
cancelledCtx: false,
start: true,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
// Force the debug level.
logger := zap.NewExample()
defer func() {
err := os.RemoveAll(tc.fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}()
if tc.start {
err := tc.libreOffice.Start(logger)
if err != nil {
t.Fatalf("setup error: %v", err)
}
defer func(p libreOffice, logger *zap.Logger) {
err = p.Stop(logger)
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}(tc.libreOffice, logger)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
defer cancel()
if tc.cancelledCtx {
cancel()
}
err := tc.libreOffice.pdf(
ctx,
logger,
fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options,
)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}
func TestNonBasicLatinCharactersGuard(t *testing.T) {
for _, tc := range []struct {
scenario string
fs *gotenberg.FileSystem
filename string
expectSameInputPath bool
expectError bool
}{
{
scenario: "basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "document.txt",
expectSameInputPath: true,
expectError: false,
},
{
scenario: "non-basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "éèßàùä.txt",
expectSameInputPath: false,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}()
inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
newInputPath, err := nonBasicLatinCharactersGuard(
zap.NewNop(),
inputPath,
)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectSameInputPath && newInputPath != inputPath {
t.Fatalf("expected same input path, but got '%s'", newInputPath)
}
if !tc.expectSameInputPath && newInputPath == inputPath {
t.Fatalf("expected different input path, but got same '%s'", newInputPath)
}
})
}
}

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,12 @@ import (
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
func TestLibreOffice_Descriptor(t *testing.T) {
descriptor := LibreOffice{}.Descriptor()
descriptor := new(LibreOffice).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(LibreOffice))
@@ -21,38 +21,35 @@ func TestLibreOffice_Descriptor(t *testing.T) {
}
func TestLibreOffice_Provision(t *testing.T) {
tests := []struct {
name string
ctx *gotenberg.Context
expectProvisionErr bool
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
name: "nominal behavior",
scenario: "no LibreOffice API provider",
ctx: func() *gotenberg.Context {
provider1 := struct {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectError: true,
},
{
scenario: "no LibreOffice API from LibreOffice API provider",
ctx: func() *gotenberg.Context {
mod := &struct {
gotenberg.ModuleMock
uno.ProviderMock
libreofficeapi.ProviderMock
}{}
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider1
}}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
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
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
@@ -60,36 +57,24 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider1.Descriptor(),
provider2.Descriptor(),
mod.Descriptor(),
},
)
}(),
expectError: 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",
scenario: "no PDF engine provider",
ctx: func() *gotenberg.Context {
provider := struct {
mod := &struct {
gotenberg.ModuleMock
uno.ProviderMock
libreofficeapi.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, errors.New("foo")
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return new(libreofficeapi.ApiMock), nil
}
return gotenberg.NewContext(
@@ -97,26 +82,28 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
mod.Descriptor(),
},
)
}(),
expectProvisionErr: true,
expectError: true,
},
{
name: "no PDF engine provider",
scenario: "no PDF engine from PDF engine provider",
ctx: func() *gotenberg.Context {
provider := struct {
mod := &struct {
gotenberg.ModuleMock
uno.ProviderMock
libreofficeapi.ProviderMock
gotenberg.PdfEngineProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return new(libreofficeapi.ApiMock), nil
}
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
@@ -124,39 +111,28 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
mod.Descriptor(),
},
)
}(),
expectProvisionErr: true,
expectError: true,
},
{
name: "no PDF engine from PDF engine provider",
scenario: "provision success",
ctx: func() *gotenberg.Context {
provider1 := struct {
mod := &struct {
gotenberg.ModuleMock
uno.ProviderMock
libreofficeapi.ProviderMock
gotenberg.PdfEngineProviderMock
}{}
provider1.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider1
}}
mod.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module { return mod }}
}
provider1.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
mod.LibreOfficeMock = func() (libreofficeapi.Uno, error) {
return new(libreofficeapi.ApiMock), nil
}
provider2 := struct {
gotenberg.ModuleMock
gotenberg.PDFEngineProviderMock
}{}
provider2.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "bar", New: func() gotenberg.Module {
return provider2
}}
}
provider2.PDFEngineMock = func() (gotenberg.PDFEngine, error) {
return gotenberg.PDFEngineMock{}, errors.New("foo")
mod.PdfEngineMock = func() (gotenberg.PdfEngine, error) {
return new(gotenberg.PdfEngineMock), nil
}
return gotenberg.NewContext(
@@ -164,59 +140,56 @@ func TestLibreOffice_Provision(t *testing.T) {
FlagSet: new(LibreOffice).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider1.Descriptor(),
provider2.Descriptor(),
mod.Descriptor(),
},
)
}(),
expectProvisionErr: true,
expectError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(LibreOffice)
err := mod.Provision(tc.ctx)
if tc.expectProvisionErr && err == nil {
t.Error("expected mod.Provision() error, but got none")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectProvisionErr && err != nil {
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestLibreOffice_Routes(t *testing.T) {
tests := []struct {
name string
mod LibreOffice
expectRoutesCount int
for _, tc := range []struct {
scenario string
expectRoutes int
disableRoutes bool
}{
{
name: "route not disabled",
mod: LibreOffice{},
expectRoutesCount: 1,
scenario: "routes not disabled",
expectRoutes: 1,
disableRoutes: false,
},
{
name: "route disabled",
mod: LibreOffice{
disableRoutes: true,
},
scenario: "routes disabled",
expectRoutes: 0,
disableRoutes: true,
},
}
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(LibreOffice)
mod.disableRoutes = tc.disableRoutes
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
routes, err := tc.mod.Routes()
routes, err := mod.Routes()
if err != nil {
t.Fatalf("expected no error from mod.Routes(), but got: %v", err)
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectRoutesCount != len(routes) {
t.Errorf("expected %d routes from mod.Routes(), but got %d", tc.expectRoutesCount, len(routes))
if tc.expectRoutes != len(routes) {
t.Errorf("expected %d routes but got %d", tc.expectRoutes, len(routes))
}
})
}

View File

@@ -1,4 +1,4 @@
// Package pdfengine provides a module which interacts with the UNO
// (Universal Network Objects) API and implements the gotenberg.PDFEngine
// Package pdfengine provides a module which interacts with LibreOffice via the
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
// interface.
package pdfengine

View File

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

View File

@@ -6,168 +6,162 @@ import (
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
func TestUNO_Descriptor(t *testing.T) {
descriptor := UNO{}.Descriptor()
func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
descriptor := new(LibreOfficePdfEngine).Descriptor()
actual := reflect.TypeOf(descriptor.New())
expect := reflect.TypeOf(new(UNO))
expect := reflect.TypeOf(new(LibreOfficePdfEngine))
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}
func TestUNO_Provider(t *testing.T) {
tests := []struct {
name string
ctx *gotenberg.Context
expectProvisionErr bool
func TestLibreOfficePdfEngine_Provider(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectError bool
}{
{
name: "nominal behavior",
ctx: func() *gotenberg.Context {
provider := struct {
gotenberg.ModuleMock
uno.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, nil
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(UNO).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
},
{
name: "no UNO API provider",
scenario: "no LibreOffice API provider",
ctx: gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(UNO).Descriptor().FlagSet,
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
),
expectProvisionErr: true,
expectError: true,
},
{
name: "no API from UNO API provider",
scenario: "no API from LibreOffice API provider",
ctx: func() *gotenberg.Context {
provider := struct {
provider := &struct {
gotenberg.ModuleMock
uno.ProviderMock
api.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.UNOMock = func() (uno.API, error) {
return uno.APIMock{}, errors.New("foo")
provider.LibreOfficeMock = func() (api.Uno, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(UNO).Descriptor().FlagSet,
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectProvisionErr: true,
expectError: true,
},
}
{
scenario: "provision success",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
api.ProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LibreOfficeMock = func() (api.Uno, error) {
return new(api.ApiMock), nil
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mod := new(UNO)
err := mod.Provision(tc.ctx)
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(LibreOfficePdfEngine).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.Provision(tc.ctx)
if tc.expectProvisionErr && err == nil {
t.Error("expected mod.Provision() error, but got none")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectProvisionErr && err != nil {
t.Errorf("expected no error from mod.Provision(), but got: %v", err)
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestUNO_Merge(t *testing.T) {
mod := new(UNO)
err := mod.Merge(context.Background(), zap.NewNop(), nil, "")
func TestLibreOfficePdfEngine_Merge(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) {
t.Errorf("expected error %v from mod.Merge(), but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err)
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestUNO_Convert(t *testing.T) {
tests := []struct {
name string
mod UNO
expectConvertErr bool
func TestLibreOfficePdfEngine_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
api api.Uno
expectError bool
}{
{
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
},
scenario: "convert success",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return nil
},
},
expectError: false,
},
{
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
},
scenario: "invalid PDF format",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return api.ErrInvalidPdfFormats
},
},
expectConvertErr: true,
expectError: true,
},
{
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")
},
scenario: "convert fail",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return errors.New("foo")
},
},
expectConvertErr: true,
expectError: true,
},
}
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := &LibreOfficePdfEngine{unoApi: tc.api}
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.mod.Convert(context.Background(), zap.NewNop(), "", "", "")
if tc.expectConvertErr && err == nil {
t.Errorf("expected mod.Convert() error, but got none")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectConvertErr && err != nil {
t.Fatalf("expected no error from mod.Convert(), but got: %v", err)
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}

View File

@@ -4,16 +4,18 @@ import (
"errors"
"fmt"
"net/http"
"path/filepath"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/uno"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
// convertRoute returns an api.Route which can convert LibreOffice documents
// convertRoute returns an [api.Route] which can convert LibreOffice documents
// to PDF.
func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/libreoffice/convert",
@@ -23,78 +25,73 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
// 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
inputPaths []string
landscape bool
nativePageRanges string
pdfa string
pdfua bool
nativePdfFormats bool
merge bool
)
err := ctx.FormData().
MandatoryPaths(unoAPI.Extensions(), &inputPaths).
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
Bool("landscape", &landscape, false).
String("nativePageRanges", &nativePageRanges, "").
Bool("nativePdfA1aFormat", &nativePDFA1aFormat, false).
String("nativePdfFormat", &nativePDFformat, "").
String("pdfFormat", &PDFformat, "").
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false).
Validate()
if err != nil {
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 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
pdfFormats := gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
// Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
options := uno.Options{
// document.docx -> document.docx.pdf.
outputPaths[i] = ctx.GeneratePath(filepath.Base(inputPath), ".pdf")
options := libreofficeapi.Options{
Landscape: landscape,
PageRanges: nativePageRanges,
PDFformat: nativePDFformat,
}
err = unoAPI.PDF(ctx, ctx.Log(), inputPath, outputPaths[i], options)
if nativePdfFormats {
options.PdfFormats = pdfFormats
}
err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options)
if err != nil {
if errors.Is(err, uno.ErrMalformedPageRanges) {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHTTPError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
if errors.Is(err, libreofficeapi.ErrInvalidPdfFormats) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
),
)
}
if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
)
}
@@ -106,32 +103,48 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
// win: if there is only one PDF, skip this step.
if len(outputPaths) > 1 && merge {
outputPath := ctx.GeneratePath(".pdf")
outputPath := ctx.GeneratePath("", ".pdf")
err = engine.Merge(ctx, ctx.Log(), outputPaths, outputPath)
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("merge PDFs: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
return fmt.Errorf("merge PDFs: %w", err)
}
// Now, let's check if the client want to convert this result
// PDF to a specific PDF format.
// Note: nativePdfA1aFormat/nativePdfFormat have not been
// specified if PDFformat is not empty.
if PDFformat != "" {
// Now, let's check if the client want to convert this
// resulting PDF to specific PDF formats.
zeroValued := gotenberg.PdfFormats{}
if !nativePdfFormats && pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPath)
convertOutputPath := ctx.GeneratePath("", ".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
if err != nil {
if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat),
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
),
)
}
@@ -144,7 +157,7 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
}
// Last but not least, add the output path to the context so that
// the API is able to send it as a response to the client.
// the Uno is able to send it as a response to the client.
err = ctx.AddOutputPaths(outputPath)
if err != nil {
@@ -156,26 +169,33 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
// 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/nativePdfFormat have not been
// specified if PDFformat is not empty.
if PDFformat != "" {
zeroValued := gotenberg.PdfFormats{}
if !nativePdfFormats && pdfFormats != zeroValued {
convertOutputPaths := make([]string, len(outputPaths))
for i, outputPath := range outputPaths {
convertInputPath := outputPath
convertOutputPaths[i] = ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), PDFformat, convertInputPath, convertOutputPaths[i])
// document.docx -> document.docx.pdf.
convertOutputPaths[i] = ctx.GeneratePath(filepath.Base(inputPaths[i]), ".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
if err != nil {
if errors.Is(err, gotenberg.ErrPDFFormatNotAvailable) {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHTTPError(
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle the PDF format '%s' (pdfFormat), while other have failed to convert for other reasons", PDFformat),
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
),
)
}
@@ -190,7 +210,7 @@ func convertRoute(unoAPI uno.API, engine gotenberg.PDFEngine) api.Route {
}
// Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client.
// the Uno is able to send them as a response to the client.
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,379 +0,0 @@
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

@@ -1,431 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,42 +0,0 @@
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

@@ -1,512 +0,0 @@
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",
}
}
// 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

@@ -1,817 +0,0 @@
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) {
}
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 := 76
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,3 +1,3 @@
// Package logging provides a module which creates a zap.Logger for other
// modules.
// Package logging provides a module which creates a zap.Logger instance for
// other modules.
package logging

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
// Package pdfcpu provides a module which wraps the
// https://github.com/pdfcpu/pdfcpu library and implements the
// gotenberg.PDFEngine interface.
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine
// interface using the pdfcpu library. This package allows for the merging of
// PDF files but does not support conversion to specific PDF formats.
//
// See: https://github.com/pdfcpu/pdfcpu.
package pdfcpu

View File

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

View File

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

View File

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

View File

@@ -4,35 +4,30 @@ import (
"context"
"fmt"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// multiPDFEngines implements the gotenberg.PDFEngine interface and gathers one
// or more gotenberg.PDFEngine. It provides a sort of fallback mechanism: if an
// engine's method returns an error, it calls the same method from another
// engine.
type multiPDFEngines struct {
engines []gotenberg.PDFEngine
type multiPdfEngines struct {
engines []gotenberg.PdfEngine
}
// newMultiPDFEngines returns a multiPDFEngines. Arguments' order determines the
// order of the engines called.
func newMultiPDFEngines(engines ...gotenberg.PDFEngine) *multiPDFEngines {
return &multiPDFEngines{
func newMultiPdfEngines(engines ...gotenberg.PdfEngine) *multiPdfEngines {
return &multiPdfEngines{
engines: engines,
}
}
// Merge tries to merge the given PDFs into a unique PDF thanks to its
// children. If the context is done, it stops and returns an error.
func (multi multiPDFEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.engines {
go func(engine gotenberg.PDFEngine) {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
}(engine)
@@ -52,13 +47,13 @@ func (multi multiPDFEngines) Merge(ctx context.Context, logger *zap.Logger, inpu
// Convert converts the given PDF to a specific PDF format. thanks to its
// children. If the context is done, it stops and returns an error.
func (multi multiPDFEngines) Convert(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.engines {
go func(engine gotenberg.PDFEngine) {
errChan <- engine.Convert(ctx, logger, format, inputPath, outputPath)
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
}(engine)
select {
@@ -72,10 +67,10 @@ func (multi multiPDFEngines) Convert(ctx context.Context, logger *zap.Logger, fo
}
}
return fmt.Errorf("convert PDF to '%s' with multi PDF engines: %w", format, err)
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
}
// Interface guards.
var (
_ gotenberg.PDFEngine = (*multiPDFEngines)(nil)
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
)

View File

@@ -5,65 +5,68 @@ import (
"errors"
"testing"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestMultiPDFEngines_Merge(t *testing.T) {
tests := []struct {
name string
engine *multiPDFEngines
ctx context.Context
expectMergeErr bool
func TestMultiPdfEngines_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
name: "nominal behavior",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
scenario: "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.Background(),
expectError: false,
},
{
name: "at least one engine does not return an error",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
scenario: "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")
},
},
gotenberg.PDFEngineMock{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
),
ctx: context.Background(),
ctx: context.Background(),
expectError: false,
},
{
name: "all engines return an error",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
scenario: "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")
},
},
gotenberg.PDFEngineMock{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
),
ctx: context.Background(),
expectMergeErr: true,
ctx: context.Background(),
expectError: true,
},
{
name: "context expired",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
scenario: "context expired",
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
@@ -75,37 +78,35 @@ func TestMultiPDFEngines_Merge(t *testing.T) {
return ctx
}(),
expectMergeErr: true,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Merge(tc.ctx, zap.NewNop(), nil, "")
if tc.expectMergeErr && err == nil {
t.Errorf("expected engine.Merge() error, but got none")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectMergeErr && err != nil {
t.Errorf("expected no error from engine.Merge(), but got: %v", err)
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPDFEngines_Convert(t *testing.T) {
tests := []struct {
name string
engine *multiPDFEngines
ctx context.Context
expectConvertErr bool
func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
name: "nominal behavior",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
scenario: "nominal behavior",
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
@@ -113,15 +114,15 @@ func TestMultiPDFEngines_Convert(t *testing.T) {
ctx: context.Background(),
},
{
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 {
scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
@@ -129,27 +130,27 @@ func TestMultiPDFEngines_Convert(t *testing.T) {
ctx: context.Background(),
},
{
name: "all engines return an error",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
scenario: "all engines return an error",
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
),
ctx: context.Background(),
expectConvertErr: true,
ctx: context.Background(),
expectError: true,
},
{
name: "context expired",
engine: newMultiPDFEngines(
gotenberg.PDFEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, format, inputPath, outputPath string) error {
scenario: "context expired",
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
@@ -160,20 +161,18 @@ func TestMultiPDFEngines_Convert(t *testing.T) {
return ctx
}(),
expectConvertErr: true,
expectError: true,
},
}
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Convert(tc.ctx, zap.NewNop(), gotenberg.PdfFormats{}, "", "")
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := tc.engine.Convert(tc.ctx, zap.NewNop(), "", "", "")
if tc.expectConvertErr && err == nil {
t.Errorf("expected engine.Convert() error, but got none")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectConvertErr && err != nil {
t.Errorf("expected no error from engine.Convert(), but got: %v", err)
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}

View File

@@ -5,34 +5,35 @@ import (
"fmt"
"strings"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
flag "github.com/spf13/pflag"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func init() {
gotenberg.MustRegisterModule(PDFEngines{})
gotenberg.MustRegisterModule(new(PdfEngines))
}
// PDFEngines is a module which gathers available gotenberg.PDFEngine modules.
// The available gotenberg.PDFEngine modules can be either all
// gotenberg.PDFEngine modules or the modules selected by the user thanks to
// the "engines" flag.
// PdfEngines acts as an aggregator and manager for multiple PDF engine
// modules. It enables the selection and ordering of PDF engines based on user
// preferences passed via command-line flags. The [PdfEngines] module also
// implements the [gotenberg.PdfEngine] interface, providing a unified approach
// to PDF processing across the various engines it manages.
//
// PDFEngines wraps the gotenberg.PDFEngine modules in an internal struct which
// also implements gotenberg.PDFEngine. This struct provides a sort of fallback
// mechanism: if an engine's method returns an error, it calls the same method
// from another engine.
//
// This module implements the gotenberg.PDFEngineProvider interface.
type PDFEngines struct {
// When processing PDFs, [PdfEngines] will attempt to use the engines in the
// order they were defined. If the primary engine encounters an error,
// [PdfEngines] can fall back to the next available engine. It also implements
// the [api.Router] interface to expose relevant PDF processing routes if
// enabled.
type PdfEngines struct {
names []string
engines []gotenberg.PDFEngine
engines []gotenberg.PdfEngine
disableRoutes bool
}
// Descriptor returns a PDFEngines' module descriptor.
func (PDFEngines) Descriptor() gotenberg.ModuleDescriptor {
// Descriptor returns a PdfEngines' module descriptor.
func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
ID: "pdfengines",
FlagSet: func() *flag.FlagSet {
@@ -42,50 +43,39 @@ func (PDFEngines) Descriptor() gotenberg.ModuleDescriptor {
return fs
}(),
New: func() gotenberg.Module { return new(PDFEngines) },
New: func() gotenberg.Module { return new(PdfEngines) },
}
}
// Provision gets either all gotenberg.PDFEngine modules or the modules
// Provision gets either all [gotenberg.PdfEngine] modules or the modules
// selected by the user thanks to the "engines" flag.
func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error {
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
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))
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
if err != nil {
return fmt.Errorf("get PDF engines: %w", err)
}
mod.engines = make([]gotenberg.PDFEngine, len(engines))
mod.engines = make([]gotenberg.PdfEngine, len(engines))
for i, engine := range engines {
mod.engines[i] = engine.(gotenberg.PDFEngine)
mod.engines[i] = engine.(gotenberg.PdfEngine)
}
if len(names) > 0 {
// 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"
}
}
// Example in case of deprecated module name.
//for i, name := range names {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// mod.names[i] = "libreoffice-pdfengine"
// }
//}
return nil
}
@@ -100,10 +90,10 @@ func (mod *PDFEngines) Provision(ctx *gotenberg.Context) error {
return nil
}
// Validate validates there is at least one gotenberg.PDFEngine module
// available. It also validates that selected gotenberg.PDFEngine modules
// Validate validates there is at least one [gotenberg.PdfEngine] module
// available. It also validates that selected [gotenberg.PdfEngine] modules
// actually exist.
func (mod PDFEngines) Validate() error {
func (mod *PdfEngines) Validate() error {
if len(mod.engines) == 0 {
return errors.New("no PDF engine")
}
@@ -138,17 +128,17 @@ 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
// SystemMessages returns one message with the selected [gotenberg.PdfEngine]
// modules.
func (mod PDFEngines) SystemMessages() []string {
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.names))
// PdfEngine returns a [gotenberg.PdfEngine].
func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines := make([]gotenberg.PdfEngine, len(mod.names))
for i, name := range mod.names {
for _, engine := range mod.engines {
@@ -159,20 +149,20 @@ func (mod PDFEngines) PDFEngine() (gotenberg.PDFEngine, error) {
}
}
return newMultiPDFEngines(engines...), nil
return newMultiPdfEngines(engines...), nil
}
// Routes returns the HTTP routes.
func (mod PDFEngines) Routes() ([]api.Route, error) {
func (mod *PdfEngines) Routes() ([]api.Route, error) {
if mod.disableRoutes {
return nil, nil
}
engine, err := mod.PDFEngine()
engine, err := mod.PdfEngine()
if err != nil {
// Should not happen, unless our provider implementation
// changes in the future.
return nil, fmt.Errorf("get pdf engine: %w", err)
return nil, fmt.Errorf("get pdf mod: %w", err)
}
return []api.Route{
@@ -183,10 +173,10 @@ func (mod PDFEngines) Routes() ([]api.Route, error) {
// Interface guards.
var (
_ gotenberg.Module = (*PDFEngines)(nil)
_ gotenberg.Provisioner = (*PDFEngines)(nil)
_ gotenberg.Validator = (*PDFEngines)(nil)
_ gotenberg.SystemLogger = (*PDFEngines)(nil)
_ gotenberg.PDFEngineProvider = (*PDFEngines)(nil)
_ api.Router = (*PDFEngines)(nil)
_ gotenberg.Module = (*PdfEngines)(nil)
_ gotenberg.Provisioner = (*PdfEngines)(nil)
_ gotenberg.Validator = (*PdfEngines)(nil)
_ gotenberg.SystemLogger = (*PdfEngines)(nil)
_ gotenberg.PdfEngineProvider = (*PdfEngines)(nil)
_ api.Router = (*PdfEngines)(nil)
)

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