Compare commits

...

52 Commits

Author SHA1 Message Date
Julien Neuhart
8ff9d3bcf1 feat(chromium): add scope to extraHttpHeaders 2024-10-11 13:45:07 +02:00
Julien Neuhart
99c328c302 fix(ci): LINUX_AMD64_RELEASE is no more ignored thanks to curly braces 2024-10-10 13:27:36 +02:00
Julien Neuhart
8a9f0a245e chore(api): improve --api-body-limit flag description to include examples 2024-10-08 19:58:08 +02:00
Julien Neuhart
9daecc127a chore(deps): update Go dependencies 2024-10-08 19:58:08 +02:00
Julien Neuhart
119bba4f04 feat(api): add flag --api-bind-ip 2024-10-08 19:58:08 +02:00
Julien Neuhart
bf205c579d fix(pdfcpu): switch to CLI 2024-10-06 18:32:09 +02:00
Julien Neuhart
aa57b17254 chore(deps): update Go dependencies 2024-10-06 09:52:48 +02:00
Julien Neuhart
7df786c5c6 fix(webhook): retrieve values from echo.Context before it get recycled 2024-10-05 14:24:22 +02:00
Julien Neuhart
b315464e98 chore(deps): update Noto Color Emoji to Unicode 16.0 2024-10-05 11:46:00 +02:00
Julien Neuhart
1252ea076b feat(chromium): skipNetworkIdleEvent is now true by default 2024-09-29 18:04:37 +02:00
Julien Neuhart
d970e446a8 chore(deps): update Go dependencies 2024-09-29 17:47:51 +02:00
Julien Neuhart
fe40f3727b feat(api): add --api-body-limit flag that set the body limit for multipart/form-data requests 2024-09-29 17:46:20 +02:00
Julien Neuhart
10a290b065 chore(libreoffice): improve HTTP error messages 2024-09-29 13:19:44 +02:00
Julien Neuhart
0abb11b42a chore(deps): update Go dependencies 2024-09-21 09:52:48 +02:00
Julien Neuhart
1eb6eb729a chore(downloadFrom): add godoc for issue https://github.com/golang/go/issues/69551 2024-09-20 21:06:11 +02:00
Julien Neuhart
e371a4a3c3 fix: add DOCKER_REGISTRY for tests image 2024-09-20 21:06:11 +02:00
Julien Neuhart
f2b6bd3d4b feat(api): add 'downloadForm' form field 2024-09-20 21:06:11 +02:00
Julien Neuhart
d21f87b543 feat(ci): add snapshot release 2024-09-20 21:06:11 +02:00
Julien Neuhart
b8cdee4f45 fix(cmd): failing test 2024-09-20 20:58:17 +02:00
Julien Neuhart
b06d7445d9 feat(libreoffice): add password form field for converting protected document 2024-09-20 20:36:45 +02:00
Julien Neuhart
2aa9cceb32 fix(README): wrong link to thecodingmachine/gotenberg repository 2024-09-18 07:57:22 +02:00
Julien Neuhart
ae2a2a8a07 chore: improve README 2024-09-17 16:47:52 +02:00
Julien Neuhart
286555411b chore(supervisor): add a comment about an unlikely issue 2024-09-17 16:47:41 +02:00
Julien Neuhart
3c42aec180 chore(deps): update Go dependencies 2024-09-13 16:47:31 +02:00
Julien Neuhart
a203ae00c6 fix(cmd): leaking error logs if reader already closed 2024-09-13 16:47:03 +02:00
Julien Neuhart
68784616a4 chore(godoc): removing dots 2024-09-13 16:44:12 +02:00
Julien Neuhart
5cd8c6a5da feat(api): add HEAD method for health route (#963) 2024-09-07 17:31:43 +02:00
Julien Neuhart
a3647fea8a fix(chromium): increase the scope of loading failed errors (#962) 2024-09-07 15:54:01 +02:00
Julien Neuhart
300e73f07d chore(deps): update Go dependencies 2024-09-05 09:23:33 +02:00
Gabriel Vasile
03ced23f05 fix(deps): download go1.23 toolchain not available (#957) 2024-09-05 09:22:59 +02:00
Julien Neuhart
da8eeb5d8f fix(golangci-lint): false positive and remove deprecated exportloopref linter 2024-09-03 09:59:40 +02:00
Julien Neuhart
16cf02896f chore(deps): update Go dependencies + Golang to 1.23 2024-09-03 09:45:36 +02:00
Julien Neuhart
c9f10d248e fix(libreoffice): use default options in PDF engines method - resolves #939 2024-08-11 14:53:05 +02:00
Julien Neuhart
3297e41289 chore(deps): update Golang dependencies 2024-07-29 10:22:08 +02:00
Julien Neuhart
4e1b347797 feat(libreoffice): retry on core dumped (#930) 2024-07-23 14:21:09 +02:00
Julien Neuhart
4602332dbb chore(Dockerfile): remove legacy alerts 2024-07-19 15:16:30 +02:00
Julien Neuhart
e1f69ee533 chore(deps): update Golang dependencies 2024-07-18 14:35:38 +02:00
Julien Neuhart
d90e431b5b fix(libreoffice): add PDFUACompliance arg 2024-07-11 15:14:50 +02:00
Julien Neuhart
45a9eca0ce chore(sponsors): new Zolsec logo 2024-07-04 15:58:24 +02:00
Julien Neuhart
af202f4871 chore(sponsors): move down sponsorship link 2024-07-04 15:04:57 +02:00
Julien Neuhart
7b32fa35f3 feat(sponsors): add Zolsec sponsorship 2024-07-04 15:03:30 +02:00
Julien Neuhart
0c40b230dc chore(libreoffice): fix wrong comment on quality option 2024-07-03 10:35:23 +02:00
Maxime Lapointe
2baba67bfc feat: eagerly auto restart processes (#916)
* Fix auto-restart's initial number of requests
It would need 1 less requests on the first restart
The number of request was technically off by one after the first restart

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

* Update pkg/gotenberg/supervisor.go

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

---------

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

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

* Resolve requested changes

Move ExportNotesInMargin above LosslessImageCompression
Remove overhead ExportNotes=true
2024-06-17 08:31:42 +02:00
53 changed files with 2515 additions and 713 deletions

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

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

4
.github/stale.yml vendored
View File

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

View File

@@ -23,4 +23,4 @@ jobs:
- name: Build and push Docker image for release - name: Build and push Docker image for release
run: | run: |
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REPOSITORY=thecodingmachine make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REGISTRY=thecodingmachine

View File

@@ -17,18 +17,18 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.22' go-version: '1.23'
cache: false cache: false
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Run linters - name: Run linters
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
with: with:
version: v1.59.0 version: v1.60.3
tests: tests:
needs: needs:
- Lint - lint
name: Tests name: Tests
# TODO: once arm64 actions are available, also run the tests on this architecture. # TODO: once arm64 actions are available, also run the tests on this architecture.
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000. # See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
@@ -50,11 +50,32 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
verbose: true verbose: true
multiarch_build: snapshot_release:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot release
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 snapshot Docker image (linux/amd64)
run: make release GOTENBERG_VERSION=${{ github.head_ref }} DOCKER_REPOSITORY=snapshot LINUX_AMD64_RELEASE=true
edge_release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: needs:
- Tests - tests
name: Multi-arch build name: Edge release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Set up QEMU - name: Set up QEMU
@@ -71,4 +92,4 @@ jobs:
- name: Build and push Docker image for main branch - name: Build and push Docker image for main branch
run: | run: |
make release GOTENBERG_VERSION=edge make release GOTENBERG_VERSION=edge
make release GOTENBERG_VERSION=edge DOCKER_REPOSITORY=thecodingmachine make release GOTENBERG_VERSION=edge DOCKER_REGISTRY=thecodingmachine

View File

@@ -6,6 +6,10 @@ linters-settings:
- prefix(github.com/gotenberg/gotenberg/v8) - prefix(github.com/gotenberg/gotenberg/v8)
skip-generated: true skip-generated: true
custom-order: true custom-order: true
# Until https://github.com/securego/gosec/issues/1187 is resolved.
gosec:
excludes:
- G115
linters: linters:
disable-all: true disable-all: true
@@ -19,10 +23,10 @@ linters:
- dupl - dupl
- dupword - dupword
- durationcheck - durationcheck
- copyloopvar
- errcheck - errcheck
- errname - errname
- exhaustive - exhaustive
- exportloopref
- gci - gci
- gofmt - gofmt
- goimports - goimports

View File

@@ -5,14 +5,16 @@ help: ## Show the help
.PHONY: it .PHONY: it
it: build build-tests ## Initialize the development environment it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.22 GOLANG_VERSION=1.23
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001 GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001 GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.042 # See https://github.com/googlefonts/noto-emoji/releases. NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package. PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.59.0 # See https://github.com/golangci/golangci-lint/releases. PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.60.3 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build .PHONY: build
build: ## Build the Gotenberg's Docker image build: ## Build the Gotenberg's Docker image
@@ -23,19 +25,26 @@ build: ## Build the Gotenberg's Docker image
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \ --build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \ --build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \ --build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \ --build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f build/Dockerfile . -f build/Dockerfile .
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_BIND_IP=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT=
API_ROOT_PATH=/ API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD= GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST=
API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0 CHROMIUM_RESTART_AFTER=0
CHROMIUM_MAX_QUEUE_SIZE=0 CHROMIUM_MAX_QUEUE_SIZE=0
@@ -84,16 +93,22 @@ run: ## Start a Gotenberg container
-p $(API_PORT):$(API_PORT) \ -p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \ -e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \ -e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \ $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \ gotenberg \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \ --api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \ --api-port-from-env=$(API_PORT_FROM_ENV) \
--api-bind-ip=$(API_BIND_IP) \
--api-start-timeout=$(API_START_TIMEOUT) \ --api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \ --api-timeout=$(API_TIMEOUT) \
--api-body-limit="$(API_BODY_LIMIT)" \
--api-root-path=$(API_ROOT_PATH) \ --api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \ --api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \ --api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \ --chromium-auto-start=$(CHROMIUM_AUTO_START) \
@@ -140,24 +155,25 @@ run: ## Start a Gotenberg container
build-tests: ## Build the tests' Docker image build-tests: ## Build the tests' Docker image
docker build \ docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \ --build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \ --build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \ --build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \ --build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \ -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile . -f test/Dockerfile .
.PHONY: tests .PHONY: tests
tests: ## Start the testing environment tests: ## Start the testing environment
docker run --rm -it \ docker run --rm -it \
-v $(PWD):/tests \ -v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \ $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
bash bash
.PHONY: tests-once .PHONY: tests-once
tests-once: ## Run the tests once (prefer the "tests" command while developing) tests-once: ## Run the tests once (prefer the "tests" command while developing)
docker run --rm \ docker run --rm \
-v $(PWD):/tests \ -v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \ $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
gotest gotest
# go install mvdan.cc/gofumpt@latest # go install mvdan.cc/gofumpt@latest
@@ -174,8 +190,10 @@ godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8) $(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060 godoc -http=:6060
LINUX_AMD64_RELEASE=false
.PHONY: release .PHONY: release
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository release: ## Build the Gotenberg's Docker image and push it to a Docker repository
./scripts/release.sh \ ./scripts/release.sh \
$(GOLANG_VERSION) \ $(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \ $(GOTENBERG_VERSION) \
@@ -183,4 +201,8 @@ release: ## Build the Gotenberg's Docker image for many platforms, then push it
$(GOTENBERG_USER_UID) \ $(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \ $(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \ $(PDFTK_VERSION) \
$(DOCKER_REPOSITORY) $(PDFCPU_VERSION) \
$(DOCKER_REGISTRY) \
$(DOCKER_REPOSITORY) \
$(LINUX_AMD64_RELEASE)

View File

@@ -2,12 +2,20 @@
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" /> <img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3> <h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p> <p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<br>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg"></a>
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
<a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></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 align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
</p> </p>
--- ---
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting **Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more! numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
## Quick Start ## Quick Start
@@ -32,14 +40,11 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
<p align="center"> <p align="center">
<a href="https://thecodingmachine.com"> <a href="https://thecodingmachine.com">
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" /> <img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
</a>
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
</a> </a>
</p> </p>
## Badges Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️
[![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/v8)
[![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg)

View File

@@ -3,13 +3,38 @@
# stage that uses them. # stage that uses them.
ARG GOLANG_VERSION ARG GOLANG_VERSION
# ----------------------------------------------
# pdfcpu binary build stage
# ----------------------------------------------
# Note: this stage is required as pdfcpu does not release an armhf variant by
# default.
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
ARG PDFCPU_VERSION
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
tar --strip-components=1 -xvzf pdfcpu.tar.gz
# Install module dependencies.
RUN go mod download &&\
go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
# Verify installation.
./pdfcpu version
# ---------------------------------------------- # ----------------------------------------------
# Gotenberg binary build stage # Gotenberg binary build stage
# ---------------------------------------------- # ----------------------------------------------
FROM golang:$GOLANG_VERSION AS binary-stage FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage
ARG GOTENBERG_VERSION ARG GOTENBERG_VERSION
ENV CGO_ENABLED 0 ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules). # Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home WORKDIR /home
@@ -187,16 +212,20 @@ RUN \
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d. # https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy the Gotenberg binary from the binary stage. # Copy the pdfcpu binary from the pdfcpu-binary-stage.
COPY --from=binary-stage /home/gotenberg /usr/bin/ COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
# Copy the Gotenberg binary from the gotenberg-binary-stage.
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else. # Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH /usr/bin/chromium ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH /usr/bin/unoconverter ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
ENV PDFTK_BIN_PATH /usr/bin/pdftk ENV PDFTK_BIN_PATH=/usr/bin/pdftk
ENV QPDF_BIN_PATH /usr/bin/qpdf ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH /usr/bin/exiftool ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
USER gotenberg USER gotenberg
WORKDIR /home/gotenberg WORKDIR /home/gotenberg

View File

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

41
go.mod
View File

@@ -1,43 +1,41 @@
module github.com/gotenberg/gotenberg/v8 module github.com/gotenberg/gotenberg/v8
go 1.21 go 1.23.0
require ( require (
github.com/alexliesenfeld/health v0.8.0 github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/barasher/go-exiftool v1.10.0 github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20240602235142-49d0e97b7881 github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df
github.com/chromedp/chromedp v0.9.5 github.com/chromedp/chromedp v0.10.0
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/compress v1.17.10 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.12.0 github.com/labstack/echo/v4 v4.12.0
github.com/labstack/gommon v0.4.2 github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1 github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.26 github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3 // indirect github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.8.0
github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_golang v1.20.4
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.24.0 // indirect golang.org/x/crypto v0.28.0 // indirect
golang.org/x/image v0.17.0 // indirect golang.org/x/net v0.30.0
golang.org/x/net v0.26.0 golang.org/x/sync v0.8.0
golang.org/x/sync v0.7.0 golang.org/x/sys v0.26.0 // indirect
golang.org/x/sys v0.21.0 // indirect golang.org/x/term v0.25.0
golang.org/x/term v0.21.0 golang.org/x/text v0.19.0
golang.org/x/text v0.16.0
) )
require github.com/dlclark/regexp2 v1.11.0 require github.com/dlclark/regexp2 v1.11.4
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
@@ -50,21 +48,16 @@ require (
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/css v1.0.1 // 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/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.54.0 // indirect github.com/prometheus/common v0.60.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.7.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )

99
go.sum
View File

@@ -11,18 +11,18 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.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-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20240602235142-49d0e97b7881 h1:RAUqkPvbEDGPgCYVc4GefBqAorWJAjKpVHgsRZyJmGE= github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df h1:cbtSn19AtqQha1cxmP2Qvgd3fFMz51AeAEKLJMyEUhc=
github.com/chromedp/cdproto v0.0.0-20240602235142-49d0e97b7881/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg= github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E=
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
@@ -32,7 +32,6 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
@@ -53,24 +52,18 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -84,39 +77,30 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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-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 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= 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 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.8.0 h1:SuEB4uVsPFz1nb802r38YpFpj9TtZh/oB0bGG34IRZw=
github.com/pdfcpu/pdfcpu v0.8.0/go.mod h1:jj03y/KKrwigt5xCi8t7px2mATcKuOzkIOoCX62yMho=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8= github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA=
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -125,8 +109,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
@@ -143,32 +127,25 @@ 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/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -156,7 +156,7 @@ func (cmd *Cmd) pipeOutput() error {
r := bufio.NewReader(reader) r := bufio.NewReader(reader)
defer func(reader io.ReadCloser) { defer func(reader io.ReadCloser) {
err := reader.Close() err := reader.Close()
if err != nil { if err != nil && !strings.Contains(err.Error(), "file already closed") {
logger.Error(fmt.Sprintf("close reader: %s", err)) logger.Error(fmt.Sprintf("close reader: %s", err))
} }
}(reader) }(reader)

View File

@@ -104,19 +104,8 @@ func TestCmd_Wait(t *testing.T) {
expectWaitError: false, expectWaitError: false,
}, },
{ {
scenario: "wait error", scenario: "wait error",
cmd: func() *Cmd { cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectWaitError: true, expectWaitError: true,
}, },
} }

View File

@@ -168,33 +168,37 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
return f.MustDuration(newName) return f.MustDuration(newName)
} }
// MustHumanReadableBytesString returns the human-readable bytes string of a // MustHumanReadableBytes returns the human-readable bytes string of a flag
// flag given by name. // given by name.
// It panics if an error occurs. // It panics if an error occurs.
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string { func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
val, err := f.GetString(name) val, err := f.GetString(name)
if err != nil { if err != nil {
panic(err) panic(err)
} }
_, err = bytes.Parse(val) if val == "" {
return 0
}
b, err := bytes.Parse(val)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return val return b
} }
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes // MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
// string of a deprecated flag if it was explicitly set or the human-readable // deprecated flag if it was explicitly set or the human-readable bytes string
// bytes string of the new flag. // of the new flag.
// It panics if an error occurs. // It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string { func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
if f.Changed(deprecated) { if f.Changed(deprecated) {
return f.MustHumanReadableBytesString(deprecated) return f.MustHumanReadableBytes(deprecated)
} }
return f.MustHumanReadableBytesString(newName) return f.MustHumanReadableBytes(newName)
} }
// MustRegexp returns the regular expression of a flag given by name. // MustRegexp returns the regular expression of a flag given by name.

View File

@@ -644,10 +644,11 @@ func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
} }
} }
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) { func TestParsedFlags_MustHumanReadableBytes(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "1MB", "") fs.String("foo", "1MB", "")
fs.String("bar", "1MB", "") fs.String("bar", "1MB", "")
fs.String("qux", "", "")
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"}) err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
if err != nil { if err != nil {
@@ -671,6 +672,11 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
{
scenario: "success: empty value",
name: "qux",
expectPanic: false,
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
@@ -689,31 +695,31 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
}() }()
} }
parsedFlags.MustHumanReadableBytesString(tc.name) parsedFlags.MustHumanReadableBytes(tc.name)
}) })
} }
} }
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) { func TestParsedFlags_MustDeprecatedHumanReadableBytes(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
rawFlags []string rawFlags []string
expectValue string expectValue int64
}{ }{
{ {
scenario: "deprecated flag value", scenario: "deprecated flag value",
rawFlags: []string{"--foo=1MB"}, rawFlags: []string{"--foo=1MB"},
expectValue: "1MB", expectValue: 1000000,
}, },
{ {
scenario: "non-deprecated flag value", scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2MB"}, rawFlags: []string{"--bar=2MB"},
expectValue: "2MB", expectValue: 2000000,
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value", scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1MB", "--bar=2MB"}, rawFlags: []string{"--foo=1MB", "--bar=2MB"},
expectValue: "1MB", expectValue: 1000000,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
@@ -728,9 +734,9 @@ func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar") actual := parsedFlags.MustDeprecatedHumanReadableBytes("foo", "bar")
if actual != tc.expectValue { if actual != tc.expectValue {
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual) t.Errorf("expected %d but got %d", tc.expectValue, actual)
} }
}) })
} }

View File

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

View File

@@ -1,4 +1,4 @@
package webhook package gotenberg
import ( import (
"testing" "testing"
@@ -7,17 +7,17 @@ import (
) )
func TestLeveledLogger_Error(t *testing.T) { func TestLeveledLogger_Error(t *testing.T) {
leveledLogger{logger: zap.NewNop()}.Error("foo") NewLeveledLogger(zap.NewNop()).Error("foo")
} }
func TestLeveledLogger_Warn(t *testing.T) { func TestLeveledLogger_Warn(t *testing.T) {
leveledLogger{logger: zap.NewNop()}.Warn("foo") NewLeveledLogger(zap.NewNop()).Warn("foo")
} }
func TestLeveledLogger_Info(t *testing.T) { func TestLeveledLogger_Info(t *testing.T) {
leveledLogger{logger: zap.NewNop()}.Info("foo") NewLeveledLogger(zap.NewNop()).Info("foo")
} }
func TestLeveledLogger_Debug(t *testing.T) { func TestLeveledLogger_Debug(t *testing.T) {
leveledLogger{logger: zap.NewNop()}.Debug("foo") NewLeveledLogger(zap.NewNop()).Debug("foo")
} }

View File

@@ -173,6 +173,20 @@ func (s *processSupervisor) Healthy() bool {
} }
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error { func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
// A user reported a potential issue:
//
// "Although the counting operation is atomic, nothing prevent 2 concurrent
// goroutines to retrieve the same 'currentQueueSize' and to compare its
// value against the max limit. Then, resulting queue size would be 1 above
// the allowed limit."
//
// However, he was unable to actually trigger this issue, even when sending
// a lot of requests.
//
// For now, the best option is to consider this issue to be unlikely to
// happen, and keep the code as it is because it is more readable this way.
//
// See https://github.com/gotenberg/gotenberg/issues/951.
currentQueueSize := s.reqQueueSize.Load() currentQueueSize := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize { if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded return ErrMaximumQueueSizeExceeded
@@ -187,10 +201,13 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
logger.Debug("process lock acquired") logger.Debug("process lock acquired")
s.reqQueueSize.Add(-1) s.reqQueueSize.Add(-1)
s.reqCounter.Add(1) s.reqCounter.Add(1)
releaseMutexChan := true
defer func() { defer func() {
logger.Debug("process lock released") if releaseMutexChan {
<-s.mutexChan logger.Debug("process lock released")
<-s.mutexChan
}
}() }()
if !s.firstStart.Load() { if !s.firstStart.Load() {
@@ -212,18 +229,26 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
} }
} }
err := s.runWithDeadline(ctx, task)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit { if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting...") s.logger.Debug("max request limit reached, restarting eagerly...")
err := s.runWithDeadline(ctx, func() error { releaseMutexChan = false
return s.restart()
}) go func() {
if err != nil { err := s.runWithDeadline(context.Background(), func() error {
return fmt.Errorf("process restart before task: %w", err) return s.restart()
} })
if err != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
}
logger.Debug("process lock released")
<-s.mutexChan
}()
} }
// Note: no error wrapping because it leaks on Chromium console exceptions output. // Note: no error wrapping because it leaks on Chromium console exceptions output.
return s.runWithDeadline(ctx, task) return err
case <-ctx.Done(): case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline") logger.Debug("failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1) s.reqQueueSize.Add(-1)

View File

@@ -325,7 +325,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
expectedStopCalls: 1, expectedStopCalls: 1,
}, },
{ {
scenario: "cannot restart after reaching max request limit", scenario: "auto-restart after reaching max request limit",
startError: errors.New("start error"), startError: errors.New("start error"),
initiallyStarted: true, initiallyStarted: true,
isRestarting: false, isRestarting: false,
@@ -451,6 +451,10 @@ func TestProcessSupervisor_Run(t *testing.T) {
return return
} }
// Making sure restarts are finished.
ps.mutexChan <- struct{}{}
<-ps.mutexChan
if startCalls.Load() != tc.expectedStartCalls { if startCalls.Load() != tc.expectedStartCalls {
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load()) t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
} }

View File

@@ -4,12 +4,14 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/multierr" "go.uber.org/multierr"
@@ -28,14 +30,17 @@ func init() {
// middlewares or health checks. // middlewares or health checks.
type Api struct { type Api struct {
port int port int
bindIp string
tlsCertFile string tlsCertFile string
tlsKeyFile string tlsKeyFile string
startTimeout time.Duration startTimeout time.Duration
bodyLimit int64
timeout time.Duration timeout time.Duration
rootPath string rootPath string
traceHeader string traceHeader string
basicAuthUsername string basicAuthUsername string
basicAuthPassword string basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool disableHealthCheckLogging bool
routes []Route routes []Route
@@ -47,6 +52,13 @@ type Api struct {
srv *echo.Echo srv *echo.Echo
} }
type downloadFromConfig struct {
allowList *regexp2.Regexp
denyList *regexp2.Regexp
maxRetry int
disable bool
}
// Router is a module interface which adds routes to the [Api]. // Router is a module interface which adds routes to the [Api].
type Router interface { type Router interface {
Routes() ([]Route, error) Routes() ([]Route, error)
@@ -161,13 +173,19 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("api", flag.ExitOnError) fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int("api-port", 3000, "Set the port on which the API should listen") fs.Int("api-port", 3000, "Set the port on which the API should listen")
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port") fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
fs.String("api-bind-ip", "", "Set the IP address the API should bind to for incoming connections")
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support") fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support") fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start") fs.Duration("api-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.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
return fs return fs
}(), }(),
@@ -179,12 +197,20 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
func (a *Api) Provision(ctx *gotenberg.Context) error { func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port") a.port = flags.MustInt("api-port")
a.bindIp = flags.MustString("api-bind-ip")
a.tlsCertFile = flags.MustString("api-tls-cert-file") a.tlsCertFile = flags.MustString("api-tls-cert-file")
a.tlsKeyFile = flags.MustString("api-tls-key-file") a.tlsKeyFile = flags.MustString("api-tls-key-file")
a.startTimeout = flags.MustDuration("api-start-timeout") a.startTimeout = flags.MustDuration("api-start-timeout")
a.timeout = flags.MustDuration("api-timeout") a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path") a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header") a.traceHeader = flags.MustString("api-trace-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging") a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
// Port from env? // Port from env?
@@ -307,6 +333,10 @@ func (a *Api) Validate() error {
) )
} }
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") { if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = multierr.Append(err, err = multierr.Append(err,
errors.New("both TLS certificate and key files must be set"), errors.New("both TLS certificate and key files must be set"),
@@ -411,13 +441,6 @@ func (a *Api) Start() error {
loggerMiddleware(a.logger, disableLoggingForPaths), loggerMiddleware(a.logger, disableLoggingForPaths),
) )
// Basic auth?
if a.basicAuthUsername != "" {
a.srv.Pre(
basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword),
)
}
// Add the modules' middlewares in their respective stacks. // Add the modules' middlewares in their respective stacks.
var externalMultipartMiddlewares []Middleware var externalMultipartMiddlewares []Middleware
for _, externalMiddleware := range a.externalMiddlewares { for _, externalMiddleware := range a.externalMiddlewares {
@@ -437,8 +460,13 @@ func (a *Api) Start() error {
for _, route := range a.routes { for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc var middlewares []echo.MiddlewareFunc
// Basic auth?
if a.basicAuthUsername != "" {
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
}
if route.IsMultipart { if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout)) middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
for _, externalMultipartMiddleware := range externalMultipartMiddlewares { for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler) middlewares = append(middlewares, externalMultipartMiddleware.Handler)
@@ -455,13 +483,22 @@ func (a *Api) Start() error {
) )
} }
// Let's not forget the health check route... // Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
healthCheckHandler := health.NewHandler(checker)
a.srv.GET( a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "health"), fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc { func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout)) return echo.WrapHandler(healthCheckHandler)
checker := health.NewChecker(checks...) }(),
return echo.WrapHandler(health.NewHandler(checker)) hardTimeoutMiddleware(hardTimeout),
)
a.srv.HEAD(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
return echo.WrapHandler(healthCheckHandler)
}(), }(),
hardTimeoutMiddleware(hardTimeout), hardTimeoutMiddleware(hardTimeout),
) )
@@ -493,11 +530,11 @@ func (a *Api) Start() error {
var err error var err error
if a.tlsCertFile != "" && a.tlsKeyFile != "" { if a.tlsCertFile != "" && a.tlsKeyFile != "" {
// Start an HTTPS server (supports HTTP/2). // Start an HTTPS server (supports HTTP/2).
err = a.srv.StartTLS(fmt.Sprintf(":%d", a.port), a.tlsCertFile, a.tlsKeyFile) err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile)
} else { } else {
// Start an HTTP/2 Cleartext (non-HTTPS) server. // Start an HTTP/2 Cleartext (non-HTTPS) server.
server := &http2.Server{} server := &http2.Server{}
err = a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server) err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
} }
if !errors.Is(err, http.ErrServerClosed) { if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error()) a.logger.Fatal(err.Error())
@@ -509,7 +546,11 @@ func (a *Api) Start() error {
// StartupMessage returns a custom startup message. // StartupMessage returns a custom startup message.
func (a *Api) StartupMessage() string { func (a *Api) StartupMessage() string {
return fmt.Sprintf("server listening on port %d", a.port) ip := a.bindIp
if a.bindIp == "" {
ip = "[::]"
}
return fmt.Sprintf("server started on %s:%d", ip, a.port)
} }
// Stop stops the HTTP server. // Stop stops the HTTP server.

View File

@@ -57,6 +57,30 @@ func TestApi_Provision(t *testing.T) {
}(), }(),
expectError: true, expectError: true,
}, },
{
scenario: "port from env: invalid environment variable value",
ctx: func() *gotenberg.Context {
fs := new(Api).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func() {
err := os.Setenv("PORT", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectError: true,
},
{ {
scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable", scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
@@ -99,30 +123,6 @@ func TestApi_Provision(t *testing.T) {
}, },
expectError: true, expectError: true,
}, },
{
scenario: "port from env: invalid environment variable value",
ctx: func() *gotenberg.Context {
fs := new(Api).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func() {
err := os.Setenv("PORT", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectError: true,
},
{ {
scenario: "no valid routers", scenario: "no valid routers",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
@@ -462,6 +462,7 @@ func TestApi_Validate(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
port int port int
bindIp string
tlsCertFile string tlsCertFile string
tlsKeyFile string tlsKeyFile string
rootPath string rootPath string
@@ -473,6 +474,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid port (< 1)", scenario: "invalid port (< 1)",
port: 0, port: 0,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -482,6 +484,17 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid port (> 65535)", scenario: "invalid port (> 65535)",
port: 65536, port: 65536,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
middlewares: nil,
expectError: true,
},
{
scenario: "invalid IP",
port: 10,
bindIp: "foo",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -491,6 +504,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid TLS files: only cert file provided", scenario: "invalid TLS files: only cert file provided",
port: 10, port: 10,
bindIp: "127.0.0.1",
tlsCertFile: "cert.pem", tlsCertFile: "cert.pem",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
@@ -501,6 +515,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid TLS files: only key file provided", scenario: "invalid TLS files: only key file provided",
port: 10, port: 10,
bindIp: "127.0.0.1",
tlsKeyFile: "key.pem", tlsKeyFile: "key.pem",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
@@ -511,6 +526,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid root path: missing / prefix", scenario: "invalid root path: missing / prefix",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "foo/", rootPath: "foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -520,6 +536,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid root path: missing / suffix", scenario: "invalid root path: missing / suffix",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo", rootPath: "/foo",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -529,6 +546,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid trace header", scenario: "invalid trace header",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "", traceHeader: "",
routes: nil, routes: nil,
@@ -538,6 +556,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: empty path", scenario: "invalid route: empty path",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -551,6 +570,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: missing / prefix in path", scenario: "invalid route: missing / prefix in path",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -564,6 +584,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid multipart route: no /forms prefix in path", scenario: "invalid multipart route: no /forms prefix in path",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -578,6 +599,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: no method", scenario: "invalid route: no method",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -592,6 +614,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: nil handler", scenario: "invalid route: nil handler",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -607,6 +630,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: path already existing", scenario: "invalid route: path already existing",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -627,6 +651,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid middleware: nil handler", scenario: "invalid middleware: nil handler",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -641,6 +666,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "success", scenario: "success",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -694,6 +720,7 @@ func TestApi_Validate(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
mod := Api{ mod := Api{
port: tc.port, port: tc.port,
bindIp: tc.bindIp,
tlsCertFile: tc.tlsCertFile, tlsCertFile: tc.tlsCertFile,
tlsKeyFile: tc.tlsKeyFile, tlsKeyFile: tc.tlsKeyFile,
rootPath: tc.rootPath, rootPath: tc.rootPath,
@@ -839,20 +866,23 @@ func TestApi_Start(t *testing.T) {
return return
} }
// health request. // health requests.
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
healthRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, healthRequest) healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthGetRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil)
mod.srv.ServeHTTP(recorder, healthHeadRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
} }
// version request. // version request.
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil) versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
versionRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, versionRequest) mod.srv.ServeHTTP(recorder, versionRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
@@ -861,7 +891,6 @@ func TestApi_Start(t *testing.T) {
// "multipart/form-data" request. // "multipart/form-data" request.
multipartRequest := func(url string) *http.Request { multipartRequest := func(url string) *http.Request {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
defer func() { defer func() {
@@ -916,15 +945,36 @@ func TestApi_Start(t *testing.T) {
} }
func TestApi_StartupMessage(t *testing.T) { func TestApi_StartupMessage(t *testing.T) {
mod := Api{ for _, tc := range []struct {
port: 3000, scenario string
} port int
bindIp string
expectMessage string
}{
{
scenario: "no custom IP",
port: 3000,
bindIp: "",
expectMessage: "server started on [::]:3000",
},
{
scenario: "custom IP",
port: 3000,
bindIp: "127.0.0.1",
expectMessage: "server started on 127.0.0.1:3000",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := Api{
port: tc.port,
bindIp: tc.bindIp,
}
actual := mod.StartupMessage() actual := mod.StartupMessage()
expect := "server listening on port 3000" if actual != tc.expectMessage {
t.Errorf("expected '%s' but got '%s'", tc.expectMessage, actual)
if actual != expect { }
t.Errorf("expected '%s' but got '%s'", expect, actual) })
} }
} }

View File

@@ -3,20 +3,25 @@ package api
import ( import (
"compress/flate" "compress/flate"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3" "github.com/mholt/archiver/v3"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -46,6 +51,36 @@ type Context struct {
context.Context context.Context
} }
type trackingReader struct {
R io.Reader
AddReadBytes func(n int64) error
}
func (t *trackingReader) Read(p []byte) (int, error) {
n, err := t.R.Read(p)
if n > 0 {
errAddRead := t.AddReadBytes(int64(n))
if errAddRead != nil {
return n, fmt.Errorf("add read bytes: %w", errAddRead)
}
}
if err != nil {
// It's a common practice in Go to return io.EOF unwrapped to signal
// the end of a data stream. Wrapping it can lead to unexpected
// behavior in standard library functions.
return n, err
}
return n, nil
}
type downloadFrom struct {
// Url is the URL to download a file from.
Url string `json:"url"`
// ExtraHttpHeaders are the HTTP headers to send alongside.
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
}
type osPathRename struct{} type osPathRename struct{}
func (o *osPathRename) Rename(oldpath, newpath string) error { func (o *osPathRename) Rename(oldpath, newpath string) error {
@@ -53,9 +88,25 @@ func (o *osPathRename) Rename(oldpath, newpath string) error {
} }
// newContext returns a [Context] by parsing a "multipart/form-data" request. // 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) { func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout) processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
// We want to make sure the multipart/form-data does not exceed a given
// limit. We consider: form fields (keys, values, files) and files
// downloaded remotely ("download from" feature).
var totalBytesRead atomic.Int64
addReadBytes := func(n int64) error {
newTotal := totalBytesRead.Add(n)
if bodyLimit != 0 && newTotal > bodyLimit {
return WrapError(
fmt.Errorf("body limit reached (> %d)", bodyLimit),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
)
}
return nil
}
ctx := &Context{ ctx := &Context{
outputPaths: make([]string, 0), outputPaths: make([]string, 0),
cancelled: false, cancelled: false,
@@ -117,6 +168,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return nil, cancel, fmt.Errorf("get multipart form: %w", err) return nil, cancel, fmt.Errorf("get multipart form: %w", err)
} }
// This will ensure we do not exceed the body limit.
var formValuesSize int64
for key, valArray := range form.Value {
formValuesSize += int64(len(key))
for _, val := range valArray {
formValuesSize += int64(len(val))
}
}
err = addReadBytes(formValuesSize)
if err != nil {
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
}
dirPath, err := fs.MkdirAll() dirPath, err := fs.MkdirAll()
if err != nil { if err != nil {
return nil, cancel, fmt.Errorf("create working directory: %w", err) return nil, cancel, fmt.Errorf("create working directory: %w", err)
@@ -126,6 +190,150 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.values = form.Value ctx.values = form.Value
ctx.files = make(map[string]string) ctx.files = make(map[string]string)
// First, try to download files listed in the "downloadFrom" form field, if
// any.
raw, ok := ctx.values["downloadFrom"]
if !downloadFromCfg.disable && ok {
var dls []downloadFrom
err = json.Unmarshal([]byte(raw[0]), &dls)
if err != nil {
return nil, cancel, WrapError(
fmt.Errorf("unmarshal json: %w", err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: %s", err)),
)
}
eg, _ := errgroup.WithContext(ctx)
for i, dl := range dls {
eg.Go(func() error {
deadline, ok := ctx.Deadline()
if !ok {
// Should not happen, as context is created with a timeout.
return errors.New("context has no deadline")
}
if strings.TrimSpace(dl.Url) == "" {
return WrapError(
errors.New("empty download from URL"),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field entry %d: URL must be set", i)),
)
}
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
if err != nil {
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
}
req.Header.Set("User-Agent", "Gotenberg")
for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(traceHeader, trace)
client := &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: time.Until(deadline),
},
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),
Logger: gotenberg.NewLeveledLogger(logger),
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
}
resp, err := client.Do(req)
if err != nil {
return WrapError(
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
)
}
defer func() {
err := resp.Body.Close()
if err != nil {
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
}
}()
if resp.StatusCode != http.StatusOK {
return WrapError(
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
)
}
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
return WrapError(
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
)
}
// FIXME: the implementation of this method might not be
// complete, as it fails to parse an empty mediatype.
// See: https://github.com/golang/go/issues/69551.
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
return WrapError(
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
)
}
filename, ok := params["filename"]
if !ok {
return WrapError(
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
)
}
// Avoid directory traversal and make sure filename characters are
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
out, err := os.Create(path)
if err != nil {
return fmt.Errorf("create local file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err))
}
}()
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: resp.Body, AddReadBytes: addReadBytes}
_, err = io.Copy(out, reader)
if err != nil {
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
}
ctx.files[filename] = path
return nil
})
}
err = eg.Wait()
if err != nil {
return ctx, cancel, err
}
}
copyToDisk := func(fh *multipart.FileHeader) error { copyToDisk := func(fh *multipart.FileHeader) error {
in, err := fh.Open() in, err := fh.Open()
if err != nil { if err != nil {
@@ -139,6 +347,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
}() }()
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
// Avoid directory traversal and make sure filename characters are // Avoid directory traversal and make sure filename characters are
// normalized. // normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662. // See: https://github.com/gotenberg/gotenberg/issues/662.
@@ -149,7 +360,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
if err != nil { if err != nil {
return fmt.Errorf("create local file: %w", err) return fmt.Errorf("create local file: %w", err)
} }
defer func() { defer func() {
err := out.Close() err := out.Close()
if err != nil { if err != nil {
@@ -157,7 +367,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
}() }()
_, err = io.Copy(out, in) _, err = io.Copy(out, reader)
if err != nil { if err != nil {
return fmt.Errorf("copy multipart file to local file: %w", err) return fmt.Errorf("copy multipart file to local file: %w", err)
} }
@@ -167,6 +377,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return nil return nil
} }
// Then, copy the form files, if any.
for _, files := range form.File { for _, files := range form.File {
for _, fh := range files { for _, fh := range files {
err = copyToDisk(fh) err = copyToDisk(fh)
@@ -178,6 +389,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values)) ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files)) ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err return ctx, cancel, err
} }

View File

@@ -2,6 +2,7 @@ package api
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -15,6 +16,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
@@ -75,9 +77,28 @@ func TestOsPathRename_Rename(t *testing.T) {
} }
func TestNewContext(t *testing.T) { func TestNewContext(t *testing.T) {
defaultAllowList, err := regexp2.Compile("", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
defaultDenyList, err := regexp2.Compile("", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
defaultDownloadFromCfg := downloadFromConfig{
allowList: defaultAllowList,
denyList: defaultDenyList,
maxRetry: 1,
disable: false,
}
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
request *http.Request request *http.Request
bodyLimit int64
downloadFromCfg downloadFromConfig
downloadFromSrv *echo.Echo
expectContext *Context
expectError bool expectError bool
expectHttpError bool expectHttpError bool
expectHttpStatus int expectHttpStatus int
@@ -123,6 +144,325 @@ func TestNewContext(t *testing.T) {
expectHttpError: true, expectHttpError: true,
expectHttpStatus: http.StatusBadRequest, expectHttpStatus: http.StatusBadRequest,
}, },
{
scenario: "request entity too large: form values",
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("key", "value")
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
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: downloadFrom",
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("downloadFrom", `[{"url":"http://localhost:80/"}]`)
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
}(),
bodyLimit: 45, // form values = 44 bytes.
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: form files",
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)
}
}()
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
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "invalid downloadFrom form field: cannot unmarshal",
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("downloadFrom", "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
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no URL",
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("downloadFrom", `[{}]`)
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
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: filtered URL",
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("downloadFrom", `[{"url":"https://foo.bar"}]`)
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
}(),
downloadFromCfg: func() downloadFromConfig {
denyList, err := regexp2.Compile("https://foo.bar", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return downloadFromConfig{allowList: defaultAllowList, denyList: denyList, maxRetry: 1, disable: false}
}(),
expectError: true,
},
{
scenario: "invalid downloadFrom form field: unreachable URL",
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("downloadFrom", `[{"url":"http://localhost:80/"}]`)
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
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: invalid status code",
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("downloadFrom", `[{"url":"http://localhost:80/"}]`)
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
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
return c.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no 'Content-Disposition' header",
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("downloadFrom", `[{"url":"http://localhost:80/"}]`)
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
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: malformed 'Content-Disposition' header",
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("downloadFrom", `[{"url":"http://localhost:80/"}]`)
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
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, ";;")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no filename parameter in 'Content-Disposition' header",
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("downloadFrom", `[{"url":"http://localhost:80/"}]`)
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
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, "inline;")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{ {
scenario: "success", scenario: "success",
request: func() *http.Request { request: func() *http.Request {
@@ -146,17 +486,69 @@ func TestNewContext(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
err = writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body) req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req return req
}(), }(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
if c.Request().Header.Get("User-Agent") != "Gotenberg" {
t.Fatalf("expected 'Gotenberg' from header 'User-Agent', but got '%s'", c.Request().Header.Get("User-Agent"))
}
if c.Request().Header.Get("X-Foo") != "Bar" {
t.Fatalf("expected 'Bar' from header 'X-Foo', but got '%s'", c.Request().Header.Get("X-Foo"))
}
if c.Request().Header.Get("Gotenberg-Trace") != "123" {
t.Fatalf("expected '123' from header 'Gotenberg-Trace', but got '%s'", c.Request().Header.Get("Gotenberg-Trace"))
}
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectContext: &Context{
values: map[string][]string{
"foo": {"foo"},
"downloadFrom": {
`[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`,
},
},
files: map[string]string{
"foo.txt": "foo.txt",
"bar.txt": "bar.txt", // downloadFrom.
},
},
expectError: false, expectError: false,
expectHttpError: false, expectHttpError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
if tc.downloadFromSrv != nil {
go func() {
err := tc.downloadFromSrv.Start(":80")
if !errors.Is(err, http.ErrServerClosed) {
t.Error(err)
return
}
}()
defer func() {
err := tc.downloadFromSrv.Shutdown(context.TODO())
if err != nil {
t.Error(err)
}
}()
}
handler := func(c echo.Context) error { handler := func(c echo.Context) error {
_, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second) ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
defer cancel() defer cancel()
// Context already cancelled. // Context already cancelled.
defer cancel() defer cancel()
@@ -165,6 +557,20 @@ func TestNewContext(t *testing.T) {
return err return err
} }
if tc.expectContext != nil {
if !reflect.DeepEqual(tc.expectContext.values, ctx.values) {
t.Fatalf("expected context.values to be %v but got %v", tc.expectContext.values, ctx.values)
}
if len(tc.expectContext.files) != len(ctx.files) {
t.Fatalf("expected context.files to contain %d items but got %d", len(tc.expectContext.files), len(ctx.files))
}
for key, value := range tc.expectContext.files {
if !strings.HasSuffix(ctx.files[key], value) {
t.Fatalf("expected context.files to contain '%s' but got '%s'", value, ctx.files[key])
}
}
}
return nil return nil
} }

View File

@@ -236,14 +236,16 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
// //
// ctx := c.Get("context").(*api.Context) // ctx := c.Get("context").(*api.Context)
// cancel := c.Get("cancel").(context.CancelFunc) // cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.MiddlewareFunc { func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger) logger := c.Get("logger").(*zap.Logger)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
// We create a context with a timeout so that underlying processes are // We create a context with a timeout so that underlying processes are
// able to stop early and handle correctly a timeout scenario. // able to stop early and handle correctly a timeout scenario.
ctx, cancel, err := newContext(c, logger, fs, timeout) ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
if err != nil { if err != nil {
cancel() cancel()

View File

@@ -458,10 +458,11 @@ func TestContextMiddleware(t *testing.T) {
c := srv.NewContext(tc.request, recorder) c := srv.NewContext(tc.request, recorder)
c.Set("logger", zap.NewNop()) c.Set("logger", zap.NewNop())
c.Set("traceHeader", "Gotenberg-Trace")
c.Set("trace", "foo") c.Set("trace", "foo")
c.Set("startTime", time.Now()) c.Set("startTime", time.Now())
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second)(tc.next)(c) err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
if tc.expectErr && err == nil { if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Errorf("test %d: expected error but got: %v", i, err)

View File

@@ -228,7 +228,6 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies), setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent), userAgentOverride(logger, options.UserAgent),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(), forceExactColorsActionFunc(),
@@ -252,7 +251,6 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies), setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent), userAgentOverride(logger, options.UserAgent),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(), forceExactColorsActionFunc(),
@@ -291,8 +289,14 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
defer taskCancel() defer taskCancel()
// We validate all others requests against our allow / deny lists. // We validate all others requests against our allow / deny lists.
// If a request does not pass the validation, we make it fail. // If a request does not pass the validation, we make it fail. It also set
listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList) // the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
extraHttpHeaders: options.ExtraHttpHeaders,
})
var ( var (
invalidHttpStatusCode error invalidHttpStatusCode error
@@ -314,6 +318,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu) listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
} }
var (
loadingFailed error
loadingFailedMu sync.RWMutex
)
// See:
// https://github.com/gotenberg/gotenberg/issues/913
// https://github.com/gotenberg/gotenberg/issues/959
listenForEventLoadingFailed(taskCtx, logger, &loadingFailed, &loadingFailedMu)
err = chromedp.Run(taskCtx, tasks...) err = chromedp.Run(taskCtx, tasks...)
if err != nil { if err != nil {
errMessage := err.Error() errMessage := err.Error()
@@ -349,6 +363,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions) return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
} }
// See:
// https://github.com/gotenberg/gotenberg/issues/913
// https://github.com/gotenberg/gotenberg/issues/959
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
return nil return nil
} }

View File

@@ -249,6 +249,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
browser browser browser browser
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
options PdfOptions options PdfOptions
url string
noDeadline bool noDeadline bool
start bool start bool
expectError bool expectError bool
@@ -378,7 +379,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
}, },
{ {
scenario: "skip networkIdle event", scenario: "do not skip networkIdle event",
browser: newChromiumBrowser( browser: newChromiumBrowser(
browserArguments{ browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"), binPath: os.Getenv("CHROMIUM_BIN_PATH"),
@@ -403,13 +404,13 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs return fs
}(), }(),
options: PdfOptions{ options: PdfOptions{
Options: Options{SkipNetworkIdleEvent: true}, Options: Options{SkipNetworkIdleEvent: false},
}, },
noDeadline: false, noDeadline: false,
start: true, start: true,
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"skipping network idle event", "event networkIdle fired",
}, },
}, },
{ {
@@ -478,6 +479,32 @@ func TestChromiumBrowser_pdf(t *testing.T) {
expectError: true, expectError: true,
expectedError: ErrConsoleExceptions, expectedError: ErrConsoleExceptions,
}, },
{
scenario: "ErrLoadingFailed",
browser: newChromiumBrowser(
browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
wsUrlReadTimeout: 5 * time.Second,
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
},
),
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))
}
return fs
}(),
url: "http://localhost:100",
noDeadline: false,
start: true,
expectError: true,
expectedError: ErrLoadingFailed,
},
{ {
scenario: "clear cache", scenario: "clear cache",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -675,8 +702,21 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs return fs
}(), }(),
options: PdfOptions{ options: PdfOptions{
Options: Options{ExtraHttpHeaders: map[string]string{ Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{
"X-Foo": "Bar", {
Name: "X-Foo",
Value: "foo",
},
{
Name: "X-Bar",
Value: "bar",
Scope: regexp2.MustCompile(`.*index\.html.*`, 0),
},
{
Name: "X-Baz",
Value: "baz",
Scope: regexp2.MustCompile(`.*another\.html.*`, 0),
},
}}, }},
}, },
noDeadline: false, noDeadline: false,
@@ -684,6 +724,10 @@ func TestChromiumBrowser_pdf(t *testing.T) {
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"extra HTTP headers:", "extra HTTP headers:",
"extra HTTP header 'X-Foo' will be set for request URL",
"extra HTTP header 'X-Bar' (scoped) will be set for request URL",
"extra HTTP header 'X-Baz' (scoped) will not be set for request URL",
"setting extra HTTP headers for request URL",
}, },
}, },
{ {
@@ -1198,6 +1242,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
"no cookies to set", "no cookies to set",
"no extra HTTP headers", "no extra HTTP headers",
"navigate to", "navigate to",
"skipping network idle event",
"default white background not hidden", "default white background not hidden",
"no emulated media type", "no emulated media type",
"no wait delay", "no wait delay",
@@ -1243,10 +1288,15 @@ func TestChromiumBrowser_pdf(t *testing.T) {
defer cancel() defer cancel()
} }
url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath())
if tc.url != "" {
url = tc.url
}
err := tc.browser.pdf( err := tc.browser.pdf(
ctx, ctx,
logger, logger,
fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()), url,
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options, tc.options,
) )
@@ -1286,6 +1336,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
browser browser browser browser
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
options ScreenshotOptions options ScreenshotOptions
url string
noDeadline bool noDeadline bool
start bool start bool
expectError bool expectError bool
@@ -1419,7 +1470,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
}, },
{ {
scenario: "skip networkIdle event", scenario: "do not skip networkIdle event",
browser: newChromiumBrowser( browser: newChromiumBrowser(
browserArguments{ browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"), binPath: os.Getenv("CHROMIUM_BIN_PATH"),
@@ -1444,13 +1495,13 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
return fs return fs
}(), }(),
options: ScreenshotOptions{ options: ScreenshotOptions{
Options: Options{SkipNetworkIdleEvent: true}, Options: Options{SkipNetworkIdleEvent: false},
}, },
noDeadline: false, noDeadline: false,
start: true, start: true,
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"skipping network idle event", "event networkIdle fired",
}, },
}, },
{ {
@@ -1519,6 +1570,33 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
expectError: true, expectError: true,
expectedError: ErrConsoleExceptions, expectedError: ErrConsoleExceptions,
}, },
{
scenario: "ErrLoadingFailed",
browser: newChromiumBrowser(
browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
wsUrlReadTimeout: 5 * time.Second,
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
},
),
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))
}
return fs
}(),
url: "http://localhost:100",
noDeadline: false,
start: true,
expectError: true,
expectedError: ErrLoadingFailed,
},
{ {
scenario: "clear cache", scenario: "clear cache",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -1655,6 +1733,41 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"set cookie", "set cookie",
}, },
}, },
{
scenario: "user agent override",
browser: newChromiumBrowser(
browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
wsUrlReadTimeout: 5 * time.Second,
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
},
),
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/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: ScreenshotOptions{
Options: Options{UserAgent: "foo"},
},
noDeadline: false,
start: true,
expectError: false,
expectedLogEntries: []string{
fmt.Sprintf("user agent override: foo"),
},
},
{ {
scenario: "extra HTTP headers", scenario: "extra HTTP headers",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -1681,8 +1794,21 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
return fs return fs
}(), }(),
options: ScreenshotOptions{ options: ScreenshotOptions{
Options: Options{ExtraHttpHeaders: map[string]string{ Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{
"X-Foo": "Bar", {
Name: "X-Foo",
Value: "foo",
},
{
Name: "X-Bar",
Value: "bar",
Scope: regexp2.MustCompile(`.*index\.html.*`, 0),
},
{
Name: "X-Baz",
Value: "baz",
Scope: regexp2.MustCompile(`.*another\.html.*`, 0),
},
}}, }},
}, },
noDeadline: false, noDeadline: false,
@@ -1690,6 +1816,10 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"extra HTTP headers:", "extra HTTP headers:",
"extra HTTP header 'X-Foo' will be set for request URL",
"extra HTTP header 'X-Bar' (scoped) will be set for request URL",
"extra HTTP header 'X-Baz' (scoped) will not be set for request URL",
"setting extra HTTP headers for request URL",
}, },
}, },
{ {
@@ -2124,6 +2254,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"no user agent override", "no user agent override",
"no extra HTTP headers", "no extra HTTP headers",
"navigate to", "navigate to",
"skipping network idle event",
"default white background not hidden", "default white background not hidden",
"no emulated media type", "no emulated media type",
"no wait delay", "no wait delay",
@@ -2169,10 +2300,15 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
defer cancel() defer cancel()
} }
url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath())
if tc.url != "" {
url = tc.url
}
err := tc.browser.screenshot( err := tc.browser.screenshot(
ctx, ctx,
logger, logger,
fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()), url,
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options, tc.options,
) )

View File

@@ -9,6 +9,7 @@ import (
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/zap" "go.uber.org/zap"
@@ -42,6 +43,9 @@ var (
// is set to true. // is set to true.
ErrConsoleExceptions = errors.New("console exceptions") ErrConsoleExceptions = errors.New("console exceptions")
// ErrLoadingFailed happens when a URL failed to load.
ErrLoadingFailed = errors.New("loading failed")
// PDF specific. // PDF specific.
// ErrOmitBackgroundWithoutPrintBackground happens if // ErrOmitBackgroundWithoutPrintBackground happens if
@@ -76,62 +80,51 @@ type Options struct {
// "networkIdle" event, drastically improving the conversion speed. It may // "networkIdle" event, drastically improving the conversion speed. It may
// not be suitable for all HTML documents, as some may not be fully // not be suitable for all HTML documents, as some may not be fully
// rendered until this event is fired. // rendered until this event is fired.
// Optional.
SkipNetworkIdleEvent bool SkipNetworkIdleEvent bool
// FailOnHttpStatusCodes sets if the conversion should fail if the status // FailOnHttpStatusCodes sets if the conversion should fail if the status
// code from the main page matches with one of its entries. // code from the main page matches with one of its entries.
// Optional.
FailOnHttpStatusCodes []int64 FailOnHttpStatusCodes []int64
// FailOnConsoleExceptions sets if the conversion should fail if there are // FailOnConsoleExceptions sets if the conversion should fail if there are
// exceptions in the Chromium console. // exceptions in the Chromium console.
// Optional.
FailOnConsoleExceptions bool FailOnConsoleExceptions bool
// WaitDelay is the duration to wait when loading an HTML document before // WaitDelay is the duration to wait when loading an HTML document before
// converting it. // converting it.
// Optional.
WaitDelay time.Duration WaitDelay time.Duration
// WaitWindowStatus is the window.status value to wait for before // WaitWindowStatus is the window.status value to wait for before
// converting an HTML document. // converting an HTML document.
// Optional.
WaitWindowStatus string WaitWindowStatus string
// WaitForExpression is the custom JavaScript expression to wait before // WaitForExpression is the custom JavaScript expression to wait before
// converting an HTML document until it returns true // converting an HTML document until it returns true
// Optional.
WaitForExpression string WaitForExpression string
// Cookies are the cookies to put in the Chromium cookies' jar. // Cookies are the cookies to put in the Chromium cookies' jar.
// Optional
Cookies []Cookie Cookies []Cookie
// UserAgent overrides the default 'User-Agent' HTTP header. // UserAgent overrides the default 'User-Agent' HTTP header.
// Optional.
UserAgent string UserAgent string
// ExtraHttpHeaders are extra HTTP headers to send by Chromium while // ExtraHttpHeaders are extra HTTP headers to send by Chromium while
// loading he HTML document. // loading he HTML document.
// Optional. ExtraHttpHeaders []ExtraHttpHeader
ExtraHttpHeaders map[string]string
// EmulatedMediaType is the media type to emulate, either "screen" or // EmulatedMediaType is the media type to emulate, either "screen" or
// "print". // "print".
// Optional.
EmulatedMediaType string EmulatedMediaType string
// OmitBackground hides default white background and allows generating PDFs // OmitBackground hides default white background and allows generating PDFs
// with transparency. // with transparency.
// Optional.
OmitBackground bool OmitBackground bool
} }
// DefaultOptions returns the default values for Options. // DefaultOptions returns the default values for Options.
func DefaultOptions() Options { func DefaultOptions() Options {
return Options{ return Options{
SkipNetworkIdleEvent: false, SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599}, FailOnHttpStatusCodes: []int64{499, 599},
FailOnConsoleExceptions: false, FailOnConsoleExceptions: false,
WaitDelay: 0, WaitDelay: 0,
@@ -150,48 +143,37 @@ type PdfOptions struct {
Options Options
// Landscape sets the paper orientation. // Landscape sets the paper orientation.
// Optional.
Landscape bool Landscape bool
// PrintBackground prints the background graphics. // PrintBackground prints the background graphics.
// Optional.
PrintBackground bool PrintBackground bool
// Scale is the scale of the page rendering. // Scale is the scale of the page rendering.
// Optional.
Scale float64 Scale float64
// SinglePage defines whether to print the entire content in one single // SinglePage defines whether to print the entire content in one single
// page. // page.
// Optional.
SinglePage bool SinglePage bool
// PaperWidth is the paper width, in inches. // PaperWidth is the paper width, in inches.
// Optional.
PaperWidth float64 PaperWidth float64
// PaperHeight is the paper height, in inches. // PaperHeight is the paper height, in inches.
// Optional.
PaperHeight float64 PaperHeight float64
// MarginTop is the top margin, in inches. // MarginTop is the top margin, in inches.
// Optional.
MarginTop float64 MarginTop float64
// MarginBottom is the bottom margin, in inches. // MarginBottom is the bottom margin, in inches.
// Optional.
MarginBottom float64 MarginBottom float64
// MarginLeft is the left margin, in inches. // MarginLeft is the left margin, in inches.
// Optional.
MarginLeft float64 MarginLeft float64
// MarginRight is the right margin, in inches. // MarginRight is the right margin, in inches.
// Optional.
MarginRight float64 MarginRight float64
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages. // Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
// Optional.
PageRanges string PageRanges string
// HeaderTemplate is the HTML template of the header. It should be valid // HeaderTemplate is the HTML template of the header. It should be valid
@@ -204,17 +186,14 @@ type PdfOptions struct {
// - totalPages: total pages in the document // - totalPages: total pages in the document
// For example, <span class=title></span> would generate span containing // For example, <span class=title></span> would generate span containing
// the title. // the title.
// Optional.
HeaderTemplate string HeaderTemplate string
// FooterTemplate is the HTML template of the footer. It should use the // FooterTemplate is the HTML template of the footer. It should use the
// same format as the HeaderTemplate. // same format as the HeaderTemplate.
// Optional.
FooterTemplate string FooterTemplate string
// PreferCssPageSize defines whether to prefer page size as defined by CSS. // PreferCssPageSize defines whether to prefer page size as defined by CSS.
// If false, the content will be scaled to fit the paper size. // If false, the content will be scaled to fit the paper size.
// Optional.
PreferCssPageSize bool PreferCssPageSize bool
} }
@@ -245,30 +224,24 @@ type ScreenshotOptions struct {
Options Options
// Width is the device screen width in pixels. // Width is the device screen width in pixels.
// Optional.
Width int Width int
// Height is the device screen height in pixels. // Height is the device screen height in pixels.
// Optional.
Height int Height int
// Clip defines whether to clip the screenshot according to the device // Clip defines whether to clip the screenshot according to the device
// dimensions. // dimensions.
// Optional.
Clip bool Clip bool
// Format is the image compression format, either "png" or "jpeg" or // Format is the image compression format, either "png" or "jpeg" or
// "webp". // "webp".
// Optional.
Format string Format string
// Quality is the compression quality from range [0..100] (jpeg only). // Quality is the compression quality from range [0..100] (jpeg only).
// Optional.
Quality int Quality int
// OptimizeForSpeed defines whether to optimize image encoding for speed, // OptimizeForSpeed defines whether to optimize image encoding for speed,
// not for resulting size. // not for resulting size.
// Optional.
OptimizeForSpeed bool OptimizeForSpeed bool
} }
@@ -317,6 +290,22 @@ type Cookie struct {
SameSite network.CookieSameSite `json:"sameSite,omitempty"` SameSite network.CookieSameSite `json:"sameSite,omitempty"`
} }
// ExtraHttpHeader are extra HTTP headers to send by Chromium.
type ExtraHttpHeader struct {
// Name is the header name.
// Required.
Name string
// Value is the header value.
// Required.
Value string
// Scope is the header scope. If nil, the header will be applied to ALL
// requests from the page.
// Optional.
Scope *regexp2.Regexp
}
// Api helps to interact with Chromium for converting HTML documents to PDF. // Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface { type Api interface {
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error

View File

@@ -15,6 +15,15 @@ import (
) )
func TestDefaultOptions(t *testing.T) { func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions()
notExpect := Options{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestDefaultPdfOptions(t *testing.T) {
actual := DefaultPdfOptions() actual := DefaultPdfOptions()
notExpect := PdfOptions{} notExpect := PdfOptions{}
@@ -23,6 +32,15 @@ func TestDefaultOptions(t *testing.T) {
} }
} }
func TestDefaultScreenshotOptions(t *testing.T) {
actual := DefaultScreenshotOptions()
notExpect := ScreenshotOptions{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestChromium_Descriptor(t *testing.T) { func TestChromium_Descriptor(t *testing.T) {
descriptor := new(Chromium).Descriptor() descriptor := new(Chromium).Descriptor()

View File

@@ -20,9 +20,22 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type eventRequestPausedOptions struct {
allowList, denyList *regexp2.Regexp
extraHttpHeaders []ExtraHttpHeader
}
// listenForEventRequestPaused listens for requests to check if they are // listenForEventRequestPaused listens for requests to check if they are
// allowed or not. // allowed or not. It also set the extra HTTP headers, if any.
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) { // See https://github.com/gotenberg/gotenberg/issues/1011.
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
if len(options.extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers")
} else {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
}
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) { switch e := ev.(type) {
case *fetch.EventRequestPaused: case *fetch.EventRequestPaused:
@@ -36,7 +49,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
return return
} }
err := gotenberg.FilterDeadline(allowList, denyList, e.Request.URL, deadline) err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil { if err != nil {
logger.Warn(err.Error()) logger.Warn(err.Error())
allow = false allow = false
@@ -45,27 +58,86 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
cctx := chromedp.FromContext(ctx) cctx := chromedp.FromContext(ctx)
executorCtx := cdp.WithExecutor(ctx, cctx.Target) executorCtx := cdp.WithExecutor(ctx, cctx.Target)
if allow { if !allow {
req := fetch.ContinueRequest(e.RequestID) req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("continue request: %s", err)) logger.Error(fmt.Sprintf("fail request: %s", err))
} }
return return
} }
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) req := fetch.ContinueRequest(e.RequestID)
var extraHttpHeadersToSet []ExtraHttpHeader
if len(options.extraHttpHeaders) > 0 {
// The user want to set extra HTTP headers.
// First, we have to check if at least one header has to be
// set for current request.
for _, header := range options.extraHttpHeaders {
if header.Scope == nil {
// Non-scoped header.
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
continue
}
ok, err := header.Scope.MatchString(e.Request.URL)
if err != nil {
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
} else if ok {
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
} else {
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
}
}
}
if len(extraHttpHeadersToSet) > 0 {
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
originalHeaders := e.Request.Headers
headers := make(map[string]string)
for key, value := range originalHeaders {
strValue, ok := value.(string)
if ok {
headers[key] = strValue
} else {
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
}
}
var headersEntries []*fetch.HeaderEntry
for key, value := range headers {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: key,
Value: value,
})
}
for _, header := range extraHttpHeadersToSet {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: header.Name,
Value: header.Value,
})
}
req.Headers = headersEntries
}
err = req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err)) logger.Error(fmt.Sprintf("continue request: %s", err))
} }
}() }()
} }
}) })
} }
// listenForEventResponseReceived listens for an invalid HTTP status code is // listenForEventResponseReceived listens for an invalid HTTP status code that
// returned by the main page. // is returned by the main page.
// See https://github.com/gotenberg/gotenberg/issues/613. // 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) { 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} { for _, code := range []int64{199, 299, 399, 499, 599} {
@@ -95,6 +167,52 @@ func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url
}) })
} }
// listenForEventLoadingFailed listens for an event indicating that the main
// page failed to load.
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadingFailed *error, loadingFailedMu *sync.RWMutex) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
if ev.Type != network.ResourceTypeDocument {
logger.Debug("skip EventLoadingFailed: is not resource type Document")
return
}
// Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page.
// We are looking for common errors.
// TODO: sufficient?
errors := []string{
"net::ERR_CONNECTION_CLOSED",
"net::ERR_CONNECTION_RESET",
"net::ERR_CONNECTION_REFUSED",
"net::ERR_CONNECTION_ABORTED",
"net::ERR_CONNECTION_FAILED",
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_INTERNET_DISCONNECTED",
"net::ERR_ADDRESS_UNREACHABLE",
"net::ERR_BLOCKED_BY_CLIENT",
"net::ERR_BLOCKED_BY_RESPONSE",
}
if !slices.Contains(errors, ev.ErrorText) {
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
return
}
loadingFailedMu.Lock()
defer loadingFailedMu.Unlock()
*loadingFailed = fmt.Errorf("%s", ev.ErrorText)
}
})
}
// listenForEventExceptionThrown listens for exceptions in the console and // listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer. // appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262. // See https://github.com/gotenberg/gotenberg/issues/262.

View File

@@ -13,6 +13,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2" "github.com/russross/blackfriday/v2"
@@ -36,7 +37,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
waitForExpression string waitForExpression string
cookies []Cookie cookies []Cookie
userAgent string userAgent string
extraHttpHeaders map[string]string extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string emulatedMediaType string
omitBackground bool omitBackground bool
) )
@@ -86,12 +87,59 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil return nil
} }
err := json.Unmarshal([]byte(value), &extraHttpHeaders) var headers map[string]string
err := json.Unmarshal([]byte(value), &headers)
if err != nil { if err != nil {
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err) return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
} }
return nil for k, v := range headers {
var scope string
var valueTokens []string
var invalidScopeToken bool
tokens := strings.Split(v, ";")
for _, token := range tokens {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
tokenNoSpaces := strings.Join(strings.Fields(token), "")
parts := strings.SplitN(tokenNoSpaces, "=", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
scope = parts[1]
} else {
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
invalidScopeToken = true
break
}
} else {
if token != "" {
valueTokens = append(valueTokens, token)
}
}
}
if invalidScopeToken {
continue
}
var scopeRegexp *regexp2.Regexp
if len(scope) > 0 {
p, errCompile := regexp2.Compile(scope, 0)
if errCompile != nil {
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
continue
}
scopeRegexp = p
}
extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{
Name: k,
Value: strings.Join(valueTokens, "; "),
Scope: scopeRegexp,
})
}
return err
}). }).
Custom("emulatedMediaType", func(value string) error { Custom("emulatedMediaType", func(value string) error {
if value == "" { if value == "" {
@@ -688,5 +736,15 @@ func handleChromiumError(err error, options Options) error {
) )
} }
if errors.Is(err, ErrLoadingFailed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Chromium returned %v", err),
),
)
}
return err return err
} }

View File

@@ -7,8 +7,10 @@ import (
"net/http" "net/http"
"os" "os"
"reflect" "reflect"
"sort"
"testing" "testing"
"github.com/dlclark/regexp2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
@@ -19,14 +21,18 @@ import (
func TestFormDataChromiumOptions(t *testing.T) { func TestFormDataChromiumOptions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedOptions Options expectedOptions Options
compareWithoutDeepEqual bool
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: false,
}, },
{ {
scenario: "invalid failOnHttpStatusCodes form field", scenario: "invalid failOnHttpStatusCodes form field",
@@ -44,6 +50,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.FailOnHttpStatusCodes = nil options.FailOnHttpStatusCodes = nil
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "valid failOnHttpStatusCodes form field", scenario: "valid failOnHttpStatusCodes form field",
@@ -61,6 +69,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.FailOnHttpStatusCodes = []int64{399, 499, 599} options.FailOnHttpStatusCodes = []int64{399, 499, 599}
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: false,
}, },
{ {
scenario: "invalid cookies form field", scenario: "invalid cookies form field",
@@ -73,7 +83,9 @@ func TestFormDataChromiumOptions(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "invalid cookies form field (missing required values)", scenario: "invalid cookies form field (missing required values)",
@@ -93,6 +105,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.Cookies = []Cookie{{}} options.Cookies = []Cookie{{}}
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "valid cookies form field", scenario: "valid cookies form field",
@@ -114,9 +128,11 @@ func TestFormDataChromiumOptions(t *testing.T) {
}} }}
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: false,
}, },
{ {
scenario: "invalid extraHttpHeaders form field", scenario: "invalid extraHttpHeaders form field: cannot unmarshall",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
@@ -126,7 +142,39 @@ func TestFormDataChromiumOptions(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
},
{
scenario: "invalid extraHttpHeaders form field: invalid scope",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
`{"foo":"bar;scope;;"}`,
},
})
return ctx
}(),
expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
},
{
scenario: "invalid extraHttpHeaders form field: invalid scope regex pattern",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
`{"foo":"bar;scope=*."}`,
},
})
return ctx
}(),
expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "valid extraHttpHeaders form field", scenario: "valid extraHttpHeaders form field",
@@ -134,18 +182,28 @@ func TestFormDataChromiumOptions(t *testing.T) {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"extraHttpHeaders": { "extraHttpHeaders": {
`{"foo":"bar"}`, `{"foo":"bar","baz":"qux;scope=https?:\\/\\/([a-zA-Z0-9-]+\\.)*qux\\.com\\/.*"}`,
}, },
}) })
return ctx return ctx
}(), }(),
expectedOptions: func() Options { expectedOptions: func() Options {
options := DefaultOptions() options := DefaultOptions()
options.ExtraHttpHeaders = map[string]string{ options.ExtraHttpHeaders = []ExtraHttpHeader{
"foo": "bar", {
Name: "foo",
Value: "bar",
},
{
Name: "baz",
Value: "qux",
Scope: regexp2.MustCompile(`https?:\/\/([a-zA-Z0-9-]+\.)*qux\.com\/.*`, 0),
},
} }
return options return options
}(), }(),
compareWithoutDeepEqual: true,
expectValidationError: false,
}, },
{ {
scenario: "invalid emulatedMediaType form field", scenario: "invalid emulatedMediaType form field",
@@ -158,7 +216,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
expectValidationError: true,
}, },
{ {
scenario: "valid emulatedMediaType form field", scenario: "valid emulatedMediaType form field",
@@ -176,14 +235,61 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.EmulatedMediaType = "screen" options.EmulatedMediaType = "screen"
return options return options
}(), }(),
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
_, actual := FormDataChromiumOptions(tc.ctx.Context) form, actual := FormDataChromiumOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.expectedOptions) { if tc.compareWithoutDeepEqual {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) if len(tc.expectedOptions.ExtraHttpHeaders) != len(actual.ExtraHttpHeaders) {
t.Fatalf("expected %d extra HTTP headers, but got %d", len(tc.expectedOptions.ExtraHttpHeaders), len(actual.ExtraHttpHeaders))
}
sort.Slice(tc.expectedOptions.ExtraHttpHeaders, func(i, j int) bool {
return tc.expectedOptions.ExtraHttpHeaders[i].Name < tc.expectedOptions.ExtraHttpHeaders[j].Name
})
sort.Slice(actual.ExtraHttpHeaders, func(i, j int) bool {
return actual.ExtraHttpHeaders[i].Name < actual.ExtraHttpHeaders[j].Name
})
for i := range tc.expectedOptions.ExtraHttpHeaders {
if tc.expectedOptions.ExtraHttpHeaders[i].Name != actual.ExtraHttpHeaders[i].Name {
t.Fatalf("expected '%s' extra HTTP header, but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Name, tc.expectedOptions.ExtraHttpHeaders[i].Name)
}
if tc.expectedOptions.ExtraHttpHeaders[i].Value != actual.ExtraHttpHeaders[i].Value {
t.Fatalf("expected '%s' as value for extra HTTP header '%s', but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Value, tc.expectedOptions.ExtraHttpHeaders[i].Name, actual.ExtraHttpHeaders[i].Value)
}
var expectedScope string
if tc.expectedOptions.ExtraHttpHeaders[i].Scope != nil {
expectedScope = tc.expectedOptions.ExtraHttpHeaders[i].Scope.String()
}
var actualScope string
if actual.ExtraHttpHeaders[i].Scope != nil {
actualScope = actual.ExtraHttpHeaders[i].Scope.String()
}
if expectedScope != actualScope {
t.Fatalf("expected '%s' as scope for extra HTTP header '%s', but got '%s'", expectedScope, tc.expectedOptions.ExtraHttpHeaders[i].Name, actualScope)
}
}
} else {
if !reflect.DeepEqual(actual, tc.expectedOptions) {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
}
}
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
} }
}) })
} }
@@ -191,14 +297,16 @@ func TestFormDataChromiumOptions(t *testing.T) {
func TestFormDataChromiumPdfOptions(t *testing.T) { func TestFormDataChromiumPdfOptions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedOptions PdfOptions expectedOptions PdfOptions
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedOptions: DefaultPdfOptions(), expectedOptions: DefaultPdfOptions(),
expectValidationError: false,
}, },
{ {
scenario: "custom form fields (Options & PdfOptions)", scenario: "custom form fields (Options & PdfOptions)",
@@ -220,29 +328,42 @@ func TestFormDataChromiumPdfOptions(t *testing.T) {
options.EmulatedMediaType = "screen" options.EmulatedMediaType = "screen"
return options return options
}(), }(),
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
_, actual := FormDataChromiumPdfOptions(tc.ctx.Context) form, actual := FormDataChromiumPdfOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.expectedOptions) { if !reflect.DeepEqual(actual, tc.expectedOptions) {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }
func TestFormDataChromiumScreenshotOptions(t *testing.T) { func TestFormDataChromiumScreenshotOptions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedOptions ScreenshotOptions expectedOptions ScreenshotOptions
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedOptions: DefaultScreenshotOptions(), expectedOptions: DefaultScreenshotOptions(),
expectValidationError: false,
}, },
{ {
scenario: "invalid format form field", scenario: "invalid format form field",
@@ -260,6 +381,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "" options.Format = ""
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "valid png format form field", scenario: "valid png format form field",
@@ -277,6 +399,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "png" options.Format = "png"
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "valid jpeg format form field", scenario: "valid jpeg format form field",
@@ -294,6 +417,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "jpeg" options.Format = "jpeg"
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "valid webp format form field", scenario: "valid webp format form field",
@@ -311,6 +435,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "webp" options.Format = "webp"
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "invalid quality form field (not an integer)", scenario: "invalid quality form field (not an integer)",
@@ -328,6 +453,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 0 options.Quality = 0
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "invalid quality form field (< 0)", scenario: "invalid quality form field (< 0)",
@@ -345,6 +471,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 0 options.Quality = 0
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "invalid quality form field (> 100)", scenario: "invalid quality form field (> 100)",
@@ -362,6 +489,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 0 options.Quality = 0
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "valid quality form field", scenario: "valid quality form field",
@@ -379,6 +507,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 50 options.Quality = 50
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "custom form fields (Options & ScreenshotOptions)", scenario: "custom form fields (Options & ScreenshotOptions)",
@@ -412,29 +541,42 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.EmulatedMediaType = "screen" options.EmulatedMediaType = "screen"
return options return options
}(), }(),
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
_, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context) form, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.expectedOptions) { if !reflect.DeepEqual(actual, tc.expectedOptions) {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }
func TestFormDataChromiumPdfFormats(t *testing.T) { func TestFormDataChromiumPdfFormats(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedPdfFormats gotenberg.PdfFormats expectedPdfFormats gotenberg.PdfFormats
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedPdfFormats: gotenberg.PdfFormats{}, expectedPdfFormats: gotenberg.PdfFormats{},
expectValidationError: false,
}, },
{ {
scenario: "pdfa and pdfua form fields", scenario: "pdfa and pdfua form fields",
@@ -450,30 +592,44 @@ func TestFormDataChromiumPdfFormats(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true}, expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true},
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
actual := FormDataChromiumPdfFormats(tc.ctx.Context.FormData()) form := tc.ctx.Context.FormData()
actual := FormDataChromiumPdfFormats(form)
if !reflect.DeepEqual(actual, tc.expectedPdfFormats) { if !reflect.DeepEqual(actual, tc.expectedPdfFormats) {
t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }
func TestFormDataPdfMetadata(t *testing.T) { func TestFormDataPdfMetadata(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedMetadata map[string]interface{} expectedMetadata map[string]interface{}
expectValidationError bool
}{ }{
{ {
scenario: "no metadata form field", scenario: "no metadata form field",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedMetadata: nil, expectedMetadata: nil,
expectValidationError: false,
}, },
{ {
scenario: "invalid metadata form field", scenario: "invalid metadata form field",
@@ -486,7 +642,8 @@ func TestFormDataPdfMetadata(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedMetadata: nil, expectedMetadata: nil,
expectValidationError: true,
}, },
{ {
scenario: "valid metadata form field", scenario: "valid metadata form field",
@@ -502,15 +659,27 @@ func TestFormDataPdfMetadata(t *testing.T) {
expectedMetadata: map[string]interface{}{ expectedMetadata: map[string]interface{}{
"foo": "bar", "foo": "bar",
}, },
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
actual := FormDataPdfMetadata(tc.ctx.Context.FormData()) form := tc.ctx.Context.FormData()
actual := FormDataPdfMetadata(form)
if !reflect.DeepEqual(actual, tc.expectedMetadata) { if !reflect.DeepEqual(actual, tc.expectedMetadata) {
t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }
@@ -1435,6 +1604,18 @@ func TestConvertUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict, expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "ErrLoadingFailed",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrLoadingFailed
}},
options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{ {
scenario: "error from Chromium", scenario: "error from Chromium",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1633,6 +1814,18 @@ func TestScreenshotUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict, expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "ErrLoadingFailed",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return ErrLoadingFailed
}},
options: DefaultScreenshotOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{ {
scenario: "error from Chromium", scenario: "error from Chromium",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},

View File

@@ -298,28 +298,33 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc
} }
} }
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { // This code has been replaced with the listenForEventRequestPaused function.
return func(ctx context.Context) error { // Indeed, the user may want to scope the headers per domain, but using
if len(extraHttpHeaders) == 0 { // network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
logger.Debug("no extra HTTP headers") // See https://github.com/gotenberg/gotenberg/issues/1011.
return nil //
} //func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders)) // if len(extraHttpHeaders) == 0 {
// logger.Debug("no extra HTTP headers")
headers := make(network.Headers, len(extraHttpHeaders)) // return nil
for key, value := range extraHttpHeaders { // }
headers[key] = value //
} // logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
//
err := network.SetExtraHTTPHeaders(headers).Do(ctx) // headers := make(network.Headers, len(extraHttpHeaders))
if err == nil { // for key, value := range extraHttpHeaders {
return nil // headers[key] = value
} // }
//
return fmt.Errorf("set extra HTTP headers: %w", err) // 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 { func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
return func(ctx context.Context) error { return func(ctx context.Context) error {

View File

@@ -25,9 +25,16 @@ var (
// by LibreOffice. // by LibreOffice.
ErrInvalidPdfFormats = errors.New("invalid PDF formats") ErrInvalidPdfFormats = errors.New("invalid PDF formats")
// ErrMalformedPageRanges happens if the page ranges option cannot be // ErrUnoException happens when unoconverter returns an exit code 5.
// interpreted by LibreOffice. ErrUnoException = errors.New("uno exception")
ErrMalformedPageRanges = errors.New("page ranges are malformed")
// ErrRuntimeException happens when unoconverter returns an exit code 6.
ErrRuntimeException = errors.New("uno exception")
// ErrCoreDumped happens randomly; sometime a conversion will work as
// expected, and some other time the same conversion will fail.
// See https://github.com/gotenberg/gotenberg/issues/639.
ErrCoreDumped = errors.New("core dumped")
) )
// Api is a module which provides a [Uno] to interact with LibreOffice. // Api is a module which provides a [Uno] to interact with LibreOffice.
@@ -41,40 +48,133 @@ type Api struct {
} }
// Options gathers available options when converting a document to PDF. // Options gathers available options when converting a document to PDF.
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
type Options struct { type Options struct {
// Password specifies the password for opening the source file.
Password string
// Landscape allows to change the orientation of the resulting PDF. // Landscape allows to change the orientation of the resulting PDF.
// Optional.
Landscape bool Landscape bool
// PageRanges allows to select the pages to convert. // PageRanges allows to select the pages to convert.
// Optional.
PageRanges string PageRanges string
// ExportFormFields allows to... export form fields in the resulting PDF. // ExportFormFields specifies whether form fields are exported as widgets
// Optional. // or only their fixed print representation is exported.
ExportFormFields bool ExportFormFields bool
// SinglePageSheets allows to output each sheet as a single page in the // AllowDuplicateFieldNames specifies whether multiple form fields exported
// resulting PDF. // are allowed to have the same field name.
// Optional AllowDuplicateFieldNames bool
// ExportBookmarks specifies if bookmarks are exported to PDF.
ExportBookmarks bool
// ExportBookmarksToPdfDestination specifies that the bookmarks contained
// in the source LibreOffice file should be exported to the PDF file as
// Named Destination.
ExportBookmarksToPdfDestination bool
// ExportPlaceholders exports the placeholders fields visual markings only.
// The exported placeholder is ineffective.
ExportPlaceholders bool
// ExportNotes specifies if notes are exported to PDF.
ExportNotes bool
// ExportNotesPages specifies if notes pages are exported to PDF.
// Notes pages are available in Impress documents only.
ExportNotesPages bool
// ExportOnlyNotesPages specifies, if the property ExportNotesPages is set
// to true, if only notes pages are exported to PDF.
ExportOnlyNotesPages bool
// ExportNotesInMargin specifies if notes in margin are exported to PDF.
ExportNotesInMargin bool
// ConvertOooTargetToPdfTarget specifies that the target documents with
// .od[tpgs] extension, will have that extension changed to .pdf when the
// link is exported to PDF. The source document remains untouched.
ConvertOooTargetToPdfTarget bool
// ExportLinksRelativeFsys specifies that the file system related
// hyperlinks (file:// protocol) present in the document will be exported
// as relative to the source document location.
ExportLinksRelativeFsys bool
// ExportHiddenSlides exports, for LibreOffice Impress, slides that are not
// included in slide shows.
ExportHiddenSlides bool
// SkipEmptyPages specifies that automatically inserted empty pages are
// suppressed. This option is active only if storing Writer documents.
SkipEmptyPages bool
// AddOriginalDocumentAsStream specifies that a stream is inserted to the
// PDF file which contains the original document for archiving purposes.
AddOriginalDocumentAsStream bool
// SinglePageSheets ignores each sheets paper size, print ranges and
// shown/hidden status and puts every sheet (even hidden sheets) on exactly
// one page.
SinglePageSheets bool SinglePageSheets bool
// LosslessImageCompression allows turning lossless compression on or off // LosslessImageCompression specifies if images are exported to PDF using
// to tweak image conversion performance. // a lossless compression format like PNG or compressed using the JPEG
// Optional // format.
LosslessImageCompression bool LosslessImageCompression bool
// ReduceImageResolution allows turning on or off image resolution // Quality specifies the quality of the JPG export. A higher value produces
// reduction to tweak image conversion performance. // a higher-quality image and a larger file. Between 1 and 100.
// Optional Quality int
// ReduceImageResolution specifies if the resolution of each image is
// reduced to the resolution specified by the property MaxImageResolution.
ReduceImageResolution bool ReduceImageResolution bool
// MaxImageResolution, if the property ReduceImageResolution is set to
// true, tells if all images will be reduced to the given value in DPI.
// Possible values are: 75, 150, 300, 600 and 1200.
MaxImageResolution int
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b, // PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA. // PDF/A-3b and PDF/UA.
// Optional.
PdfFormats gotenberg.PdfFormats PdfFormats gotenberg.PdfFormats
} }
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
Password: "",
Landscape: false,
PageRanges: "",
ExportFormFields: true,
AllowDuplicateFieldNames: false,
ExportBookmarks: true,
ExportBookmarksToPdfDestination: false,
ExportPlaceholders: false,
ExportNotes: false,
ExportNotesPages: false,
ExportOnlyNotesPages: false,
ExportNotesInMargin: false,
ConvertOooTargetToPdfTarget: false,
ExportLinksRelativeFsys: false,
ExportHiddenSlides: false,
SkipEmptyPages: false,
AddOriginalDocumentAsStream: false,
SinglePageSheets: false,
LosslessImageCompression: false,
Quality: 90,
ReduceImageResolution: false,
MaxImageResolution: 300,
PdfFormats: gotenberg.PdfFormats{
PdfA: "",
PdfUa: false,
},
}
}
// Uno is an abstraction on top of the Universal Network Objects API. // Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface { type Uno interface {
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
@@ -276,9 +376,21 @@ func (a *Api) LibreOffice() (Uno, error) {
// Pdf converts a document to PDF. // Pdf converts a document to PDF.
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return a.supervisor.Run(ctx, logger, func() error { err := a.supervisor.Run(ctx, logger, func() error {
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options) return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
}) })
if err == nil {
return nil
}
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options)
}
return fmt.Errorf("supervisor run task: %w", err)
} }
// Extensions returns the file extensions available for conversions. // Extensions returns the file extensions available for conversions.

View File

@@ -14,6 +14,15 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions()
notExpect := Options{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestApi_Descriptor(t *testing.T) { func TestApi_Descriptor(t *testing.T) {
descriptor := new(Api).Descriptor() descriptor := new(Api).Descriptor()
@@ -424,6 +433,13 @@ func TestApi_Pdf(t *testing.T) {
}}, }},
expectError: true, expectError: true,
}, },
{
scenario: "ErrCoreDumped",
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
}},
expectError: false,
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
a := new(Api) a := new(Api)

View File

@@ -8,6 +8,7 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -265,6 +266,10 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "-vvv") args = append(args, "-vvv")
} }
if options.Password != "" {
args = append(args, "--password", options.Password)
}
if options.Landscape { if options.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape") args = append(args, "--printer", "PaperOrientation=landscape")
} }
@@ -273,21 +278,26 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges)) args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
} }
if !options.ExportFormFields { args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
args = append(args, "--export", "ExportFormFields=false") args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
} args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
if options.SinglePageSheets { args = append(args, "--export", fmt.Sprintf("ExportBookmarksToPDFDestination=%t", options.ExportBookmarksToPdfDestination))
args = append(args, "--export", "SinglePageSheets=true") args = append(args, "--export", fmt.Sprintf("ExportPlaceholders=%t", options.ExportPlaceholders))
} args = append(args, "--export", fmt.Sprintf("ExportNotes=%t", options.ExportNotes))
args = append(args, "--export", fmt.Sprintf("ExportNotesPages=%t", options.ExportNotesPages))
if options.LosslessImageCompression { args = append(args, "--export", fmt.Sprintf("ExportOnlyNotesPages=%t", options.ExportOnlyNotesPages))
args = append(args, "--export", "UseLosslessCompression=true") args = append(args, "--export", fmt.Sprintf("ExportNotesInMargin=%t", options.ExportNotesInMargin))
} args = append(args, "--export", fmt.Sprintf("ConvertOOoTargetToPDFTarget=%t", options.ConvertOooTargetToPdfTarget))
args = append(args, "--export", fmt.Sprintf("ExportLinksRelativeFsys=%t", options.ExportLinksRelativeFsys))
if !options.ReduceImageResolution { args = append(args, "--export", fmt.Sprintf("ExportHiddenSlides=%t", options.ExportHiddenSlides))
args = append(args, "--export", "ReduceImageResolution=false") args = append(args, "--export", fmt.Sprintf("IsSkipEmptyPages=%t", options.SkipEmptyPages))
} args = append(args, "--export", fmt.Sprintf("IsAddStream=%t", options.AddOriginalDocumentAsStream))
args = append(args, "--export", fmt.Sprintf("SinglePageSheets=%t", options.SinglePageSheets))
args = append(args, "--export", fmt.Sprintf("UseLosslessCompression=%t", options.LosslessImageCompression))
args = append(args, "--export", fmt.Sprintf("Quality=%d", options.Quality))
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
switch options.PdfFormats.PdfA { switch options.PdfFormats.PdfA {
case "": case "":
@@ -304,12 +314,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
if options.PdfFormats.PdfUa { if options.PdfFormats.PdfUa {
args = append( args = append(
args, args,
"--export", "PDFUACompliance=true",
"--export", "UseTaggedPDF=true", "--export", "UseTaggedPDF=true",
"--export", "EnableTextAccessForAccessibilityTools=true", "--export", "EnableTextAccessForAccessibilityTools=true",
) )
} else { } else {
args = append( args = append(
args, args,
"--export", "PDFUACompliance=false",
"--export", "UseTaggedPDF=false", "--export", "UseTaggedPDF=false",
"--export", "EnableTextAccessForAccessibilityTools=false", "--export", "EnableTextAccessForAccessibilityTools=false",
) )
@@ -335,10 +347,22 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
} }
// LibreOffice's errors are not explicit. // LibreOffice's errors are not explicit.
// That's why we have to make an educated guess according to the exit code // For instance, an exit code 5 may be explained by a malformed page
// and given inputs. // ranges, but also by a not required password.
if exitCode == 5 && options.PageRanges != "" {
return ErrMalformedPageRanges // We may want to retry in case of a core dumped event.
// See https://github.com/gotenberg/gotenberg/issues/639.
if strings.Contains(err.Error(), "core dumped") {
return ErrCoreDumped
}
if exitCode == 5 {
// Potentially malformed page ranges or password not required.
return ErrUnoException
}
if exitCode == 6 {
// Password potentially required or invalid.
return ErrRuntimeException
} }
// Possible errors: // Possible errors:

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"testing" "testing"
"time" "time"
@@ -250,7 +251,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
expectedError: ErrInvalidPdfFormats, expectedError: ErrInvalidPdfFormats,
}, },
{ {
scenario: "ErrMalformedPageRanges", scenario: "ErrUnoException",
libreOffice: newLibreOfficeProcess( libreOffice: newLibreOfficeProcess(
libreOfficeArguments{ libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
@@ -267,7 +268,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
@@ -277,7 +278,61 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
cancelledCtx: false, cancelledCtx: false,
start: true, start: true,
expectError: true, expectError: true,
expectedError: ErrMalformedPageRanges, expectedError: ErrUnoException,
},
{
scenario: "ErrRuntimeException",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
options: Options{Password: "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))
}
in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx")
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
}()
out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath()))
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: true,
expectedError: ErrRuntimeException,
}, },
{ {
scenario: "context done", scenario: "context done",
@@ -336,7 +391,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
expectError: false, expectError: false,
}, },
{ {
scenario: "success (landscape)", scenario: "success (not default options)",
libreOffice: newLibreOfficeProcess( libreOffice: newLibreOfficeProcess(
libreOfficeArguments{ libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
@@ -352,159 +407,37 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
} }
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755) err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
return fs return fs
}(), }(),
options: Options{Landscape: true}, options: Options{
cancelledCtx: false, Password: "", // Ok, the only exception in this list.
start: true, Landscape: true,
expectError: false, PageRanges: "1",
}, ExportFormFields: false,
{ AllowDuplicateFieldNames: true,
scenario: "success (disable form fields)", ExportBookmarks: false,
libreOffice: newLibreOfficeProcess( ExportBookmarksToPdfDestination: true,
libreOfficeArguments{ ExportPlaceholders: true,
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), ExportNotes: true,
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"), ExportNotesPages: true,
startTimeout: 5 * time.Second, ExportOnlyNotesPages: true,
}, ExportNotesInMargin: true,
), ConvertOooTargetToPdfTarget: true,
fs: func() *gotenberg.FileSystem { ExportLinksRelativeFsys: true,
fs := gotenberg.NewFileSystem() ExportHiddenSlides: true,
SkipEmptyPages: true,
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) AddOriginalDocumentAsStream: true,
if err != nil { SinglePageSheets: true,
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err)) LosslessImageCompression: true,
} Quality: 100,
ReduceImageResolution: true,
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("DisableFormFields"), 0o755) MaxImageResolution: 600,
if err != nil { },
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{ExportFormFields: false},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (single page sheets)",
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("SinglePageSheets"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{SinglePageSheets: 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 LosslessImageCompression",
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("LosslessImageCompression"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{LosslessImageCompression: true},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success ReduceImageResolution",
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("ReduceImageResolution"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{ReduceImageResolution: false},
cancelledCtx: false, cancelledCtx: false,
start: true, start: true,
expectError: false, expectError: false,

View File

@@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"errors"
"go.uber.org/zap" "go.uber.org/zap"
@@ -33,12 +34,21 @@ func (provider *ProviderMock) LibreOffice() (Uno, error) {
// libreOfficeMock is a mock for the [libreOffice] interface. // libreOfficeMock is a mock for the [libreOffice] interface.
type libreOfficeMock struct { type libreOfficeMock struct {
errCoreDumpedCount int
gotenberg.ProcessMock gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error 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 { func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return b.pdfMock(ctx, logger, inputPath, outputPath, options) err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
if errors.Is(err, ErrCoreDumped) {
b.errCoreDumpedCount += 1
}
if b.errCoreDumpedCount > 1 {
return nil
}
return err
} }
// Interface guards. // Interface guards.

View File

@@ -42,14 +42,53 @@ func TestProviderMock(t *testing.T) {
} }
func TestLibreOfficeMock(t *testing.T) { func TestLibreOfficeMock(t *testing.T) {
mock := &libreOfficeMock{ for _, tc := range []struct {
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { scenario string
return nil mock *libreOfficeMock
expectError bool
}{
{
scenario: "success",
mock: &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return nil
},
},
expectError: false,
}, },
} {
scenario: "ErrCoreDumped (first call)",
mock: &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
},
},
expectError: true,
},
{
scenario: "ErrCoreDumped (second call)",
mock: func() *libreOfficeMock {
m := &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
},
}
m.pdf(context.Background(), zap.NewNop(), "", "", Options{})
return m
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{}) if !tc.expectError && err != nil {
if err != nil { t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err)
t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err) }
if tc.expectError && err == nil {
t.Fatal("expected error from libreOfficeMock.pdf but got none")
}
})
} }
} }

View File

@@ -56,9 +56,9 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
// error. // error.
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) 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{ opts := api.DefaultOptions()
PdfFormats: formats, opts.PdfFormats = formats
}) err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
if err == nil { if err == nil {
return nil return nil

View File

@@ -5,6 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strconv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -22,31 +24,102 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
defaultOptions := libreofficeapi.DefaultOptions()
// Let's get the data from the form and validate them. // Let's get the data from the form and validate them.
var ( var (
inputPaths []string inputPaths []string
landscape bool password string
nativePageRanges string landscape bool
exportFormFields bool nativePageRanges string
singlePageSheets bool exportFormFields bool
losslessImageCompression bool allowDuplicateFieldNames bool
reduceImageResolution bool exportBookmarks bool
pdfa string exportBookmarksToPdfDestination bool
pdfua bool exportPlaceholders bool
nativePdfFormats bool exportNotes bool
merge bool exportNotesPages bool
metadata map[string]interface{} exportOnlyNotesPages bool
exportNotesInMargin bool
convertOooTargetToPdfTarget bool
exportLinksRelativeFsys bool
exportHiddenSlides bool
skipEmptyPages bool
addOriginalDocumentAsStream bool
singlePageSheets bool
losslessImageCompression bool
quality int
reduceImageResolution bool
maxImageResolution int
pdfa string
pdfua bool
nativePdfFormats bool
merge bool
metadata map[string]interface{}
) )
err := ctx.FormData(). err := ctx.FormData().
MandatoryPaths(libreOffice.Extensions(), &inputPaths). MandatoryPaths(libreOffice.Extensions(), &inputPaths).
Bool("landscape", &landscape, false). String("password", &password, defaultOptions.Password).
String("nativePageRanges", &nativePageRanges, ""). Bool("landscape", &landscape, defaultOptions.Landscape).
Bool("exportFormFields", &exportFormFields, true). String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
Bool("singlePageSheets", &singlePageSheets, false). Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
Bool("losslessImageCompression", &losslessImageCompression, false). Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
Bool("reduceImageResolution", &reduceImageResolution, true). Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks).
Bool("exportBookmarksToPdfDestination", &exportBookmarksToPdfDestination, defaultOptions.ExportBookmarksToPdfDestination).
Bool("exportPlaceholders", &exportPlaceholders, defaultOptions.ExportPlaceholders).
Bool("exportNotes", &exportNotes, defaultOptions.ExportNotes).
Bool("exportNotesPages", &exportNotesPages, defaultOptions.ExportNotesPages).
Bool("exportOnlyNotesPages", &exportOnlyNotesPages, defaultOptions.ExportOnlyNotesPages).
Bool("exportNotesInMargin", &exportNotesInMargin, defaultOptions.ExportNotesInMargin).
Bool("convertOooTargetToPdfTarget", &convertOooTargetToPdfTarget, defaultOptions.ConvertOooTargetToPdfTarget).
Bool("exportLinksRelativeFsys", &exportLinksRelativeFsys, defaultOptions.ExportLinksRelativeFsys).
Bool("exportHiddenSlides", &exportHiddenSlides, defaultOptions.ExportHiddenSlides).
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
Bool("losslessImageCompression", &losslessImageCompression, defaultOptions.LosslessImageCompression).
Custom("quality", func(value string) error {
if value == "" {
quality = defaultOptions.Quality
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
if intValue > 100 {
return errors.New("value is superior to 100")
}
quality = intValue
return nil
}).
Bool("reduceImageResolution", &reduceImageResolution, defaultOptions.ReduceImageResolution).
Custom("maxImageResolution", func(value string) error {
if value == "" {
maxImageResolution = defaultOptions.MaxImageResolution
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if !slices.Contains([]int{75, 150, 300, 600, 1200}, intValue) {
return errors.New("value is not 75, 150, 300, 600 or 1200")
}
maxImageResolution = intValue
return nil
}).
String("pdfa", &pdfa, ""). String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false). Bool("pdfua", &pdfua, false).
Bool("nativePdfFormats", &nativePdfFormats, true). Bool("nativePdfFormats", &nativePdfFormats, true).
@@ -75,12 +148,28 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf") outputPaths[i] = ctx.GeneratePath(".pdf")
options := libreofficeapi.Options{ options := libreofficeapi.Options{
Landscape: landscape, Password: password,
PageRanges: nativePageRanges, Landscape: landscape,
ExportFormFields: exportFormFields, PageRanges: nativePageRanges,
SinglePageSheets: singlePageSheets, ExportFormFields: exportFormFields,
LosslessImageCompression: losslessImageCompression, AllowDuplicateFieldNames: allowDuplicateFieldNames,
ReduceImageResolution: reduceImageResolution, ExportBookmarks: exportBookmarks,
ExportBookmarksToPdfDestination: exportBookmarksToPdfDestination,
ExportPlaceholders: exportPlaceholders,
ExportNotes: exportNotes,
ExportNotesPages: exportNotesPages,
ExportOnlyNotesPages: exportOnlyNotesPages,
ExportNotesInMargin: exportNotesInMargin,
ConvertOooTargetToPdfTarget: convertOooTargetToPdfTarget,
ExportLinksRelativeFsys: exportLinksRelativeFsys,
ExportHiddenSlides: exportHiddenSlides,
SkipEmptyPages: skipEmptyPages,
AddOriginalDocumentAsStream: addOriginalDocumentAsStream,
SinglePageSheets: singlePageSheets,
LosslessImageCompression: losslessImageCompression,
Quality: quality,
ReduceImageResolution: reduceImageResolution,
MaxImageResolution: maxImageResolution,
} }
if nativePdfFormats { if nativePdfFormats {
@@ -99,10 +188,17 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
) )
} }
if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) { if errors.Is(err, libreofficeapi.ErrUnoException) {
return api.WrapError( return api.WrapError(
fmt.Errorf("convert to PDF: %w", err), fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)), api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
)
}
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
) )
} }

View File

@@ -39,6 +39,116 @@ func TestConvertRoute(t *testing.T) {
expectHttpStatus: http.StatusBadRequest, expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "invalid quality form field (not an integer)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (< 1)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"0",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (> 100)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"101",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid maxImageResolution form field (not an integer)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"maxImageResolution": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid maxImageResolution form field (not in range)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"maxImageResolution": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{ {
scenario: "invalid metadata form field", scenario: "invalid metadata form field",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
@@ -84,14 +194,14 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "ErrMalformedPageRanges", scenario: "ErrUnoException",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"document.docx": "/document.docx", "document.docx": "/document.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "nativePageRanges": {
"foo", "foo",
}, },
}) })
@@ -99,7 +209,34 @@ func TestConvertRoute(t *testing.T) {
}(), }(),
libreOffice: &libreofficeapi.ApiMock{ libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error { PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrMalformedPageRanges return libreofficeapi.ErrUnoException
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrRuntimeException",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"password": {
"invalid",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrRuntimeException
}, },
ExtensionsMock: func() []string { ExtensionsMock: func() []string {
return []string{".docx"} return []string{".docx"}
@@ -285,6 +422,12 @@ func TestConvertRoute(t *testing.T) {
"document2.docx": "/document2.docx", "document2.docx": "/document2.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"quality": {
"100",
},
"maxImageResolution": {
"1200",
},
"merge": { "merge": {
"true", "true",
}, },

View File

@@ -1,5 +1,5 @@
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine // Package pdfcpu provides an implementation of the gotenberg.PdfEngine
// interface using the pdfcpu library. This package allows for: // interface using the pdfcpu command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// //

View File

@@ -2,11 +2,10 @@ package pdfcpu
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os"
pdfcpuAPI "github.com/pdfcpu/pdfcpu/pkg/api"
pdfcpuLog "github.com/pdfcpu/pdfcpu/pkg/log"
pdfcpuConfig "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -16,10 +15,10 @@ func init() {
gotenberg.MustRegisterModule(new(PdfCpu)) gotenberg.MustRegisterModule(new(PdfCpu))
} }
// PdfCpu abstracts the pdfcpu library and implements the [gotenberg.PdfEngine] // PdfCpu abstracts the CLI tool pdfcpu and implements the
// interface. // [gotenberg.PdfEngine] interface.
type PdfCpu struct { type PdfCpu struct {
conf *pdfcpuConfig.Configuration binPath string
} }
// Descriptor returns a [PdfCpu]'s module descriptor. // Descriptor returns a [PdfCpu]'s module descriptor.
@@ -32,16 +31,38 @@ func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor {
// Provision sets the engine properties. // Provision sets the engine properties.
func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error { func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error {
pdfcpuConfig.ConfigPath = "disable" binPath, ok := os.LookupEnv("PDFCPU_BIN_PATH")
pdfcpuLog.DisableLoggers() if !ok {
engine.conf = pdfcpuConfig.NewDefaultConfiguration() return errors.New("PDFCPU_BIN_PATH environment variable is not set")
}
engine.binPath = binPath
return nil
}
// Validate validates the module properties.
func (engine *PdfCpu) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("pdfcpu binary path does not exist: %w", err)
}
return nil return nil
} }
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, false, engine.conf) var args []string
args = append(args, "merge", outputPath)
args = append(args, inputPaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err == nil { if err == nil {
return nil return nil
} }
@@ -68,5 +89,6 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met
var ( var (
_ gotenberg.Module = (*PdfCpu)(nil) _ gotenberg.Module = (*PdfCpu)(nil)
_ gotenberg.Provisioner = (*PdfCpu)(nil) _ gotenberg.Provisioner = (*PdfCpu)(nil)
_ gotenberg.Validator = (*PdfCpu)(nil)
_ gotenberg.PdfEngine = (*PdfCpu)(nil) _ gotenberg.PdfEngine = (*PdfCpu)(nil)
) )

View File

@@ -33,14 +33,59 @@ func TestPdfCpu_Provision(t *testing.T) {
} }
} }
func TestPdfCpu_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("PDFTK_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
engine.binPath = tc.binPath
err := engine.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfCpu_Merge(t *testing.T) { func TestPdfCpu_Merge(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx context.Context
inputPaths []string inputPaths []string
expectError bool expectError bool
}{ }{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{ {
scenario: "invalid input path", scenario: "invalid input path",
ctx: context.TODO(),
inputPaths: []string{ inputPaths: []string{
"foo", "foo",
}, },
@@ -48,6 +93,7 @@ func TestPdfCpu_Merge(t *testing.T) {
}, },
{ {
scenario: "single file success", scenario: "single file success",
ctx: context.TODO(),
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
}, },
@@ -55,10 +101,12 @@ func TestPdfCpu_Merge(t *testing.T) {
}, },
{ {
scenario: "many files success", scenario: "many files success",
ctx: context.TODO(),
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf", "/tests/test/testdata/pdfengines/sample2.pdf",
}, },
expectError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
@@ -81,7 +129,7 @@ func TestPdfCpu_Merge(t *testing.T) {
} }
}() }()
err = engine.Merge(nil, nil, tc.inputPaths, outputDir+"/foo.pdf") err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
if !tc.expectError && err != nil { if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)

View File

@@ -45,7 +45,7 @@ func (engine *PdfTk) Provision(ctx *gotenberg.Context) error {
func (engine *PdfTk) Validate() error { func (engine *PdfTk) Validate() error {
_, err := os.Stat(engine.binPath) _, err := os.Stat(engine.binPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("PdfTk binary path does not exist: %w", err) return fmt.Errorf("PDFtk binary path does not exist: %w", err)
} }
return nil return nil

View File

@@ -97,6 +97,7 @@ func TestPdfTk_Merge(t *testing.T) {
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
}, },
expectError: false,
}, },
{ {
scenario: "many files success", scenario: "many files success",
@@ -105,6 +106,7 @@ func TestPdfTk_Merge(t *testing.T) {
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf", "/tests/test/testdata/pdfengines/sample2.pdf",
}, },
expectError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {

View File

@@ -97,6 +97,7 @@ func TestQPdf_Merge(t *testing.T) {
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
}, },
expectError: false,
}, },
{ {
scenario: "many files success", scenario: "many files success",
@@ -105,6 +106,7 @@ func TestQPdf_Merge(t *testing.T) {
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf", "/tests/test/testdata/pdfengines/sample2.pdf",
}, },
expectError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {

View File

@@ -27,9 +27,9 @@ type client struct {
// send call the webhook either to send the success response or the error response. // send call the webhook either to send the success response or the error response.
func (c client) send(body io.Reader, headers map[string]string, erroed bool) error { func (c client) send(body io.Reader, headers map[string]string, erroed bool) error {
URL := c.url url := c.url
if erroed { if erroed {
URL = c.errorUrl url = c.errorUrl
} }
method := c.method method := c.method
@@ -37,9 +37,9 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
method = c.errorMethod method = c.errorMethod
} }
req, err := retryablehttp.NewRequest(method, URL, body) req, err := retryablehttp.NewRequest(method, url, body)
if err != nil { if err != nil {
return fmt.Errorf("create '%s' request to '%s': %w", method, URL, err) return fmt.Errorf("create '%s' request to '%s': %w", method, url, err)
} }
req.Header.Set("User-Agent", "Gotenberg") req.Header.Set("User-Agent", "Gotenberg")
@@ -75,17 +75,17 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("send '%s' request to '%s': %w", method, URL, err) return fmt.Errorf("send '%s' request to '%s': %w", method, url, err)
} }
if resp.StatusCode >= http.StatusBadRequest { if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, URL, resp.Status) return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
} }
defer func() { defer func() {
err := resp.Body.Close() err := resp.Body.Close()
if err != nil { if err != nil {
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", URL, err)) c.logger.Error(fmt.Sprintf("close response body from '%s': %s", url, err))
} }
}() }()
@@ -94,7 +94,7 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
// Now let's log! // Now let's log!
fields := make([]zap.Field, 5) fields := make([]zap.Field, 5)
fields[0] = zap.String("webhook_url", URL) fields[0] = zap.String("webhook_url", url)
fields[1] = zap.String("method", method) fields[1] = zap.String("method", method)
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime))) fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String()) fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
@@ -110,34 +110,3 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
return nil return nil
} }
// leveledLogger is wrapper around a [zap.Logger] which is used by the
// [retryablehttp.Client].
type leveledLogger struct {
logger *zap.Logger
}
// Error logs a message at error level using the wrapped zap.Logger.
func (leveled leveledLogger) Error(msg string, keysAndValues ...interface{}) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at warning level using the wrapped zap.Logger.
func (leveled leveledLogger) Warn(msg string, keysAndValues ...interface{}) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at info level using the wrapped zap.Logger.
func (leveled leveledLogger) Info(msg string, keysAndValues ...interface{}) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at debug level using the wrapped zap.Logger.
func (leveled leveledLogger) Debug(msg string, keysAndValues ...interface{}) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*leveledLogger)(nil)
)

View File

@@ -100,11 +100,11 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
// What about extra HTTP headers? // What about extra HTTP headers?
var extraHTTPHeaders map[string]string var extraHttpHeaders map[string]string
extraHTTPHeadersJSON := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers") extraHttpHeadersJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHTTPHeadersJSON != "" { if extraHttpHeadersJson != "" {
err = json.Unmarshal([]byte(extraHTTPHeadersJSON), &extraHTTPHeaders) err = json.Unmarshal([]byte(extraHttpHeadersJson), &extraHttpHeaders)
if err != nil { if err != nil {
return api.WrapError( return api.WrapError(
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err), fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
@@ -113,13 +113,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
} }
// Retrieve values from echo.Context before it get recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
client := &client{ client := &client{
url: webhookUrl, url: webhookUrl,
method: webhookMethod, method: webhookMethod,
errorUrl: webhookErrorUrl, errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod, errorMethod: webhookErrorMethod,
extraHttpHeaders: extraHTTPHeaders, extraHttpHeaders: extraHttpHeaders,
startTime: c.Get("startTime").(time.Time), startTime: startTime,
client: &retryablehttp.Client{ client: &retryablehttp.Client{
HTTPClient: &http.Client{ HTTPClient: &http.Client{
@@ -128,11 +134,9 @@ func webhookMiddleware(w *Webhook) api.Middleware {
RetryMax: w.maxRetry, RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait, RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait, RetryWaitMax: w.retryMaxWait,
Logger: leveledLogger{ Logger: gotenberg.NewLeveledLogger(ctx.Log()),
logger: ctx.Log(), CheckRetry: retryablehttp.DefaultRetryPolicy,
}, Backoff: retryablehttp.DefaultBackoff,
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
}, },
logger: ctx.Log(), logger: ctx.Log(),
} }
@@ -159,8 +163,8 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
headers := map[string]string{ headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSON, echo.HeaderContentType: echo.MIMEApplicationJSON,
c.Get("traceHeader").(string): c.Get("trace").(string), traceHeader: trace,
} }
err = client.send(bytes.NewReader(b), headers, true) err = client.send(bytes.NewReader(b), headers, true)
@@ -238,7 +242,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)), echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
echo.HeaderContentType: http.DetectContentType(fileHeader), echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
c.Get("traceHeader").(string): c.Get("trace").(string), traceHeader: trace,
} }
// Send the output file to the webhook. // Send the output file to the webhook.

View File

@@ -2,50 +2,61 @@
set -e set -e
# Args.
GOLANG_VERSION="$1" GOLANG_VERSION="$1"
GOTENBERG_VERSION="$2" GOTENBERG_VERSION="$2"
GOTENBERG_USER_GID="$3" GOTENBERG_USER_GID="$3"
GOTENBERG_USER_UID="$4" GOTENBERG_USER_UID="$4"
NOTO_COLOR_EMOJI_VERSION="$5" NOTO_COLOR_EMOJI_VERSION="$5"
PDFTK_VERSION="$6" PDFTK_VERSION="$6"
DOCKER_REPOSITORY="$7" PDFCPU_VERSION="$7"
DOCKER_REGISTRY="$8"
if [ "$GOTENBERG_VERSION" == "edge" ]; then DOCKER_REPOSITORY="$9"
docker buildx build \ LINUX_AMD64_RELEASE="${10}"
--build-arg GOLANG_VERSION="$GOLANG_VERSION" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--build-arg GOTENBERG_USER_GID="$GOTENBERG_USER_GID" \
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \
--platform linux/amd64 \
--platform linux/arm64 \
--platform linux/386 \
--platform linux/arm/v7 \
-t "$DOCKER_REPOSITORY/gotenberg:edge" \
--push \
-f build/Dockerfile .
# Cloud Run variant.
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
docker buildx build \
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--platform linux/amd64 \
-t "$DOCKER_REPOSITORY/gotenberg:edge-cloudrun" \
--push \
-f build/Dockerfile.cloudrun .
exit 0
fi
# Find out if given version is "semver".
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}" GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION" IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
VERSION_LENGTH=${#SEMVER[@]} VERSION_LENGTH=${#SEMVER[@]}
TAGS=()
TAGS_CLOUD_RUN=()
if [ "$VERSION_LENGTH" -ne 3 ]; then if [ "$VERSION_LENGTH" -eq 3 ]; then
echo "$VERSION is not semver." MAJOR="${SEMVER[0]}"
exit 1 MINOR="${SEMVER[1]}"
PATCH="${SEMVER[2]}"
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH-cloudrun")
else
# Normalizes version.
GOTENBERG_VERSION="${GOTENBERG_VERSION// /-}"
GOTENBERG_VERSION="$(echo "$GOTENBERG_VERSION" | tr -cd '[:alnum:]._\-')"
if [[ "$GOTENBERG_VERSION" =~ ^[\.\-] ]]; then
GOTENBERG_VERSION="_${GOTENBERG_VERSION#?}"
fi
if [ "${#GOTENBERG_VERSION}" -gt 128 ]; then
GOTENBERG_VERSION="${GOTENBERG_VERSION:0:128}"
fi
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION-cloudrun")
fi
# Multi-arch build takes a lot of time.
if [ "$LINUX_AMD64_RELEASE" = true ]; then
PLATFORM_FLAG="--platform linux/amd64"
else
PLATFORM_FLAG="--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7"
fi fi
docker buildx build \ docker buildx build \
@@ -55,26 +66,19 @@ docker buildx build \
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \ --build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \ --build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \ --build-arg PDFTK_VERSION="$PDFTK_VERSION" \
--platform linux/amd64 \ --build-arg PDFCPU_VERSION="$PDFCPU_VERSION" \
--platform linux/arm64 \ $PLATFORM_FLAG \
--platform linux/386 \ "${TAGS[@]}" \
--platform linux/arm/v7 \
-t "$DOCKER_REPOSITORY/gotenberg:latest" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}" \
--push \ --push \
-f build/Dockerfile . -f build/Dockerfile .
# Cloud Run variant. # Cloud Run variant.
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278. # Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
docker buildx build \ docker buildx build \
--build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \ --build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \ --build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--platform linux/amd64 \ --platform linux/amd64 \
-t "$DOCKER_REPOSITORY/gotenberg:latest-cloudrun" \ "${TAGS_CLOUD_RUN[@]}" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}-cloudrun" \
--push \ --push \
-f build/Dockerfile.cloudrun . -f build/Dockerfile.cloudrun .

View File

@@ -1,19 +1,20 @@
ARG GOLANG_VERSION ARG GOLANG_VERSION
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION ARG GOTENBERG_VERSION
ARG GOLANGCI_LINT_VERSION ARG GOLANGCI_LINT_VERSION
FROM golang:$GOLANG_VERSION-bookworm as golang FROM golang:$GOLANG_VERSION-bookworm AS golang
# We're extending the Gotenberg's Docker image because our code relies on external # We're extending the Gotenberg's Docker image because our code relies on external
# dependencies like Google Chrome, LibreOffice, etc. # dependencies like Google Chrome, LibreOffice, etc.
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root USER root
ENV GOPATH /go ENV GOPATH=/go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
ENV CGO_ENABLED 1 ENV CGO_ENABLED=1
COPY --from=golang /usr/local/go /usr/local/go COPY --from=golang /usr/local/go /usr/local/go

BIN
test/testdata/libreoffice/protected.docx vendored Normal file

Binary file not shown.