Compare commits

...

43 Commits

Author SHA1 Message Date
Julien Neuhart
0abb11b42a chore(deps): update Go dependencies 2024-09-21 09:52:48 +02:00
Julien Neuhart
1eb6eb729a chore(downloadFrom): add godoc for issue https://github.com/golang/go/issues/69551 2024-09-20 21:06:11 +02:00
Julien Neuhart
e371a4a3c3 fix: add DOCKER_REGISTRY for tests image 2024-09-20 21:06:11 +02:00
Julien Neuhart
f2b6bd3d4b feat(api): add 'downloadForm' form field 2024-09-20 21:06:11 +02:00
Julien Neuhart
d21f87b543 feat(ci): add snapshot release 2024-09-20 21:06:11 +02:00
Julien Neuhart
b8cdee4f45 fix(cmd): failing test 2024-09-20 20:58:17 +02:00
Julien Neuhart
b06d7445d9 feat(libreoffice): add password form field for converting protected document 2024-09-20 20:36:45 +02:00
Julien Neuhart
2aa9cceb32 fix(README): wrong link to thecodingmachine/gotenberg repository 2024-09-18 07:57:22 +02:00
Julien Neuhart
ae2a2a8a07 chore: improve README 2024-09-17 16:47:52 +02:00
Julien Neuhart
286555411b chore(supervisor): add a comment about an unlikely issue 2024-09-17 16:47:41 +02:00
Julien Neuhart
3c42aec180 chore(deps): update Go dependencies 2024-09-13 16:47:31 +02:00
Julien Neuhart
a203ae00c6 fix(cmd): leaking error logs if reader already closed 2024-09-13 16:47:03 +02:00
Julien Neuhart
68784616a4 chore(godoc): removing dots 2024-09-13 16:44:12 +02:00
Julien Neuhart
5cd8c6a5da feat(api): add HEAD method for health route (#963) 2024-09-07 17:31:43 +02:00
Julien Neuhart
a3647fea8a fix(chromium): increase the scope of loading failed errors (#962) 2024-09-07 15:54:01 +02:00
Julien Neuhart
300e73f07d chore(deps): update Go dependencies 2024-09-05 09:23:33 +02:00
Gabriel Vasile
03ced23f05 fix(deps): download go1.23 toolchain not available (#957) 2024-09-05 09:22:59 +02:00
Julien Neuhart
da8eeb5d8f fix(golangci-lint): false positive and remove deprecated exportloopref linter 2024-09-03 09:59:40 +02:00
Julien Neuhart
16cf02896f chore(deps): update Go dependencies + Golang to 1.23 2024-09-03 09:45:36 +02:00
Julien Neuhart
c9f10d248e fix(libreoffice): use default options in PDF engines method - resolves #939 2024-08-11 14:53:05 +02:00
Julien Neuhart
3297e41289 chore(deps): update Golang dependencies 2024-07-29 10:22:08 +02:00
Julien Neuhart
4e1b347797 feat(libreoffice): retry on core dumped (#930) 2024-07-23 14:21:09 +02:00
Julien Neuhart
4602332dbb chore(Dockerfile): remove legacy alerts 2024-07-19 15:16:30 +02:00
Julien Neuhart
e1f69ee533 chore(deps): update Golang dependencies 2024-07-18 14:35:38 +02:00
Julien Neuhart
d90e431b5b fix(libreoffice): add PDFUACompliance arg 2024-07-11 15:14:50 +02:00
Julien Neuhart
45a9eca0ce chore(sponsors): new Zolsec logo 2024-07-04 15:58:24 +02:00
Julien Neuhart
af202f4871 chore(sponsors): move down sponsorship link 2024-07-04 15:04:57 +02:00
Julien Neuhart
7b32fa35f3 feat(sponsors): add Zolsec sponsorship 2024-07-04 15:03:30 +02:00
Julien Neuhart
0c40b230dc chore(libreoffice): fix wrong comment on quality option 2024-07-03 10:35:23 +02:00
Maxime Lapointe
2baba67bfc feat: eagerly auto restart processes (#916)
* Fix auto-restart's initial number of requests
It would need 1 less requests on the first restart
The number of request was technically off by one after the first restart

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

* Update pkg/gotenberg/supervisor.go

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

---------

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

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

* Resolve requested changes

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

* refactor: PR comment changes

* fix: change lossLessImageCompression default to false

---------

Co-authored-by: Gareth Judson <gareth.judson@delwp.vic.gov.au>
2024-06-13 10:28:36 +02:00
Julien Neuhart
b1d94a3845 feat(chromium): add user agent override 2024-06-12 20:51:10 +02:00
Julien Neuhart
06869cf491 chore(deps): update Golang dependencies 2024-06-12 20:51:10 +02:00
45 changed files with 1698 additions and 439 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
daysUntilStale: 15
daysUntilStale: 7
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
daysUntilClose: 3
# Issues with these labels will never be considered stale
exemptLabels:
- bug

View File

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

View File

@@ -17,18 +17,18 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version: '1.23'
cache: false
- name: Checkout source code
uses: actions/checkout@v4
- name: Run linters
uses: golangci/golangci-lint-action@v6
with:
version: v1.59.0
version: v1.60.3
tests:
needs:
- Lint
- lint
name: Tests
# TODO: once arm64 actions are available, also run the tests on this architecture.
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
@@ -50,11 +50,32 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
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'
needs:
- Tests
name: Multi-arch build
- tests
name: Edge release
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
@@ -71,4 +92,4 @@ jobs:
- name: Build and push Docker image for main branch
run: |
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)
skip-generated: true
custom-order: true
# Until https://github.com/securego/gosec/issues/1187 is resolved.
gosec:
excludes:
- G115
linters:
disable-all: true
@@ -19,10 +23,10 @@ linters:
- dupl
- dupword
- durationcheck
- copyloopvar
- errcheck
- errname
- exhaustive
- exportloopref
- gci
- gofmt
- goimports

View File

@@ -5,14 +5,15 @@ help: ## Show the help
.PHONY: it
it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.22
GOLANG_VERSION=1.23
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.042 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
GOLANGCI_LINT_VERSION=v1.59.0 # See https://github.com/golangci/golangci-lint/releases.
GOLANGCI_LINT_VERSION=v1.60.3 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build
build: ## Build the Gotenberg's Docker image
@@ -23,7 +24,7 @@ build: ## Build the Gotenberg's Docker image
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f build/Dockerfile .
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
@@ -36,6 +37,10 @@ API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST=
API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0
CHROMIUM_MAX_QUEUE_SIZE=0
@@ -84,7 +89,7 @@ run: ## Start a Gotenberg container
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \
@@ -94,6 +99,10 @@ run: ## Start a Gotenberg container
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
@@ -140,24 +149,25 @@ run: ## Start a Gotenberg container
build-tests: ## Build the tests' Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile .
.PHONY: tests
tests: ## Start the testing environment
docker run --rm -it \
-v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
bash
.PHONY: tests-once
tests-once: ## Run the tests once (prefer the "tests" command while developing)
docker run --rm \
-v $(PWD):/tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
gotest
# go install mvdan.cc/gofumpt@latest
@@ -174,8 +184,10 @@ godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060
LINUX_AMD64_RELEASE=false
.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 \
$(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \
@@ -183,4 +195,7 @@ release: ## Build the Gotenberg's Docker image for many platforms, then push it
$(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \
$(DOCKER_REPOSITORY)
$(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" />
<h3 align="center">Gotenberg</h3>
<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>
---
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
## Quick Start
@@ -32,14 +40,11 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
<p align="center">
<a href="https://thecodingmachine.com">
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" />
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
</a>
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
</a>
</p>
## Badges
[![Docker pulls](https://img.shields.io/docker/pulls/gotenberg/gotenberg)](https://hub.docker.com/r/gotenberg/gotenberg)
[![Docker pulls](https://img.shields.io/docker/pulls/thecodingmachine/gotenberg)](https://hub.docker.com/r/thecodingmachine/gotenberg)
[![Continuous Integration](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg)](https://pkg.go.dev/github.com/gotenberg/gotenberg/v8)
[![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg)
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️

View File

@@ -9,7 +9,7 @@ ARG GOLANG_VERSION
FROM golang:$GOLANG_VERSION AS binary-stage
ARG GOTENBERG_VERSION
ENV CGO_ENABLED 0
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
@@ -191,12 +191,12 @@ COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
COPY --from=binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH /usr/bin/unoconverter
ENV PDFTK_BIN_PATH /usr/bin/pdftk
ENV QPDF_BIN_PATH /usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH /usr/bin/exiftool
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
USER gotenberg
WORKDIR /home/gotenberg

View File

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

39
go.mod
View File

@@ -1,43 +1,43 @@
module github.com/gotenberg/gotenberg/v8
go 1.21
go 1.23.0
require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20240602235142-49d0e97b7881
github.com/chromedp/chromedp v0.9.5
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f
github.com/chromedp/chromedp v0.10.0
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.8 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.12.0
github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.26
github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.8.0
github.com/pdfcpu/pdfcpu v0.8.1
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/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/image v0.16.0 // indirect
golang.org/x/net v0.25.0
golang.org/x/sync v0.7.0
golang.org/x/sys v0.20.0 // indirect
golang.org/x/term v0.20.0
golang.org/x/text v0.15.0
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/image v0.20.0 // indirect
golang.org/x/net v0.29.0
golang.org/x/sync v0.8.0
golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.24.0
golang.org/x/text v0.18.0
)
require github.com/dlclark/regexp2 v1.11.0
require github.com/dlclark/regexp2 v1.11.4
require (
github.com/aymerick/douceur v0.2.0 // indirect
@@ -55,16 +55,17 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-runewidth v0.0.16 // 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/common v0.54.0 // indirect
github.com/prometheus/common v0.59.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/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
golang.org/x/time v0.6.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

85
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/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-20240602235142-49d0e97b7881/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f h1:dEjjp+iN34En5Pl9XIi978DmR2/CMwuOxoPWtiHixKQ=
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E=
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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
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/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
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/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
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/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
@@ -61,8 +60,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
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.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
@@ -71,6 +70,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -84,19 +85,21 @@ 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/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.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.8.0 h1:SuEB4uVsPFz1nb802r38YpFpj9TtZh/oB0bGG34IRZw=
github.com/pdfcpu/pdfcpu v0.8.0/go.mod h1:jj03y/KKrwigt5xCi8t7px2mATcKuOzkIOoCX62yMho=
github.com/pdfcpu/pdfcpu v0.8.1 h1:AiWUb8uXlrXqJ73OmiYXBjDF0Qxt4OuM281eAfkAOMA=
github.com/pdfcpu/pdfcpu v0.8.1/go.mod h1:M5SFotxdaw0fedxthpjbA/PADytAo6wJnGH0SSBWJ7s=
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/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
@@ -104,12 +107,12 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
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/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8=
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
github.com/prometheus/common v0.59.1 h1:LXb1quJHWm1P6wq/U824uxYi4Sg0oGvNeUm1z5dJoX0=
github.com/prometheus/common v0.59.1/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0=
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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -125,8 +128,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/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
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.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
@@ -143,28 +146,28 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.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.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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/alexliesenfeld/health"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
@@ -36,6 +37,7 @@ type Api struct {
traceHeader string
basicAuthUsername string
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool
routes []Route
@@ -47,6 +49,13 @@ type Api struct {
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].
type Router interface {
Routes() ([]Route, error)
@@ -168,6 +177,10 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
return fs
}(),
@@ -185,6 +198,12 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.timeout = flags.MustDuration("api-timeout")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
// Port from env?
@@ -411,13 +430,6 @@ func (a *Api) Start() error {
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.
var externalMultipartMiddlewares []Middleware
for _, externalMiddleware := range a.externalMiddlewares {
@@ -437,8 +449,13 @@ func (a *Api) Start() error {
for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc
// Basic auth?
if a.basicAuthUsername != "" {
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
}
if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout))
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.downloadFromCfg))
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
@@ -455,13 +472,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(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker))
return echo.WrapHandler(healthCheckHandler)
}(),
hardTimeoutMiddleware(hardTimeout),
)
a.srv.HEAD(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
return echo.WrapHandler(healthCheckHandler)
}(),
hardTimeoutMiddleware(hardTimeout),
)

View File

@@ -839,20 +839,23 @@ func TestApi_Start(t *testing.T) {
return
}
// health request.
// health requests.
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 {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
// version request.
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
versionRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, versionRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
@@ -861,7 +864,6 @@ func TestApi_Start(t *testing.T) {
// "multipart/form-data" request.
multipartRequest := func(url string) *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {

View File

@@ -3,9 +3,11 @@ package api
import (
"compress/flate"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
@@ -14,9 +16,11 @@ import (
"time"
"github.com/google/uuid"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -46,6 +50,14 @@ type Context struct {
context.Context
}
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{}
func (o *osPathRename) Rename(oldpath, newpath string) error {
@@ -53,7 +65,7 @@ func (o *osPathRename) Rename(oldpath, newpath string) error {
}
// newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration) (*Context, context.CancelFunc, error) {
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
ctx := &Context{
@@ -126,6 +138,147 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.values = form.Value
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))
}
}()
_, err = io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("copy downloaded file from '%s' to local file: %v", dl.Url, err)
}
ctx.files[filename] = path
return nil
})
}
err = eg.Wait()
if err != nil {
return ctx, cancel, err
}
}
copyToDisk := func(fh *multipart.FileHeader) error {
in, err := fh.Open()
if err != nil {
@@ -149,7 +302,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
if err != nil {
return fmt.Errorf("create local file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
@@ -167,6 +319,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return nil
}
// Then, copy the form files, if any.
for _, files := range form.File {
for _, fh := range files {
err = copyToDisk(fh)

View File

@@ -2,6 +2,7 @@ package api
import (
"bytes"
"context"
"errors"
"fmt"
"io"
@@ -15,6 +16,7 @@ import (
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
@@ -75,9 +77,27 @@ func TestOsPathRename_Rename(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 {
scenario string
request *http.Request
downloadFromCfg downloadFromConfig
downloadFromSrv *echo.Echo
expectContext *Context
expectError bool
expectHttpError bool
expectHttpStatus int
@@ -123,6 +143,236 @@ func TestNewContext(t *testing.T) {
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
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",
request: func() *http.Request {
@@ -146,17 +396,69 @@ func TestNewContext(t *testing.T) {
if err != nil {
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.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 {
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,
expectHttpError: false,
},
} {
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 {
_, 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.downloadFromCfg, "Gotenberg-Trace", "123")
defer cancel()
// Context already cancelled.
defer cancel()
@@ -165,6 +467,20 @@ func TestNewContext(t *testing.T) {
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
}

View File

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

View File

@@ -458,10 +458,11 @@ func TestContextMiddleware(t *testing.T) {
c := srv.NewContext(tc.request, recorder)
c.Set("logger", zap.NewNop())
c.Set("traceHeader", "Gotenberg-Trace")
c.Set("trace", "foo")
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, downloadFromConfig{})(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)

View File

@@ -227,6 +227,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
clearCookiesActionFunc(logger, b.arguments.clearCookies),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
@@ -250,6 +251,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
clearCookiesActionFunc(logger, b.arguments.clearCookies),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
@@ -312,6 +314,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
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...)
if err != nil {
errMessage := err.Error()
@@ -347,6 +359,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
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
}

View File

@@ -249,6 +249,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
browser browser
fs *gotenberg.FileSystem
options PdfOptions
url string
noDeadline bool
start bool
expectError bool
@@ -478,6 +479,32 @@ func TestChromiumBrowser_pdf(t *testing.T) {
expectError: true,
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",
browser: newChromiumBrowser(
@@ -614,6 +641,41 @@ func TestChromiumBrowser_pdf(t *testing.T) {
"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: PdfOptions{
Options: Options{UserAgent: "foo"},
},
noDeadline: false,
start: true,
expectError: false,
expectedLogEntries: []string{
fmt.Sprintf("user agent override: foo"),
},
},
{
scenario: "extra HTTP headers",
browser: newChromiumBrowser(
@@ -1208,10 +1270,15 @@ func TestChromiumBrowser_pdf(t *testing.T) {
defer cancel()
}
url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath())
if tc.url != "" {
url = tc.url
}
err := tc.browser.pdf(
ctx,
logger,
fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()),
url,
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options,
)
@@ -1251,6 +1318,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
browser browser
fs *gotenberg.FileSystem
options ScreenshotOptions
url string
noDeadline bool
start bool
expectError bool
@@ -1484,6 +1552,33 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
expectError: true,
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",
browser: newChromiumBrowser(
@@ -1990,6 +2085,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"cache not cleared",
"cookies not cleared",
"JavaScript not disabled",
"no user agent override",
"no extra HTTP headers",
"navigate to",
"default white background not hidden",
@@ -2037,6 +2133,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"cookies not cleared",
"JavaScript not disabled",
"no cookies to set",
"no user agent override",
"no extra HTTP headers",
"navigate to",
"default white background not hidden",
@@ -2084,6 +2181,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"cookies not cleared",
"JavaScript not disabled",
"no cookies to set",
"no user agent override",
"no extra HTTP headers",
"navigate to",
"default white background not hidden",
@@ -2131,10 +2229,15 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
defer cancel()
}
url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath())
if tc.url != "" {
url = tc.url
}
err := tc.browser.screenshot(
ctx,
logger,
fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()),
url,
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options,
)

View File

@@ -42,6 +42,9 @@ var (
// is set to true.
ErrConsoleExceptions = errors.New("console exceptions")
// ErrLoadingFailed happens when a URL failed to load.
ErrLoadingFailed = errors.New("loading failed")
// PDF specific.
// ErrOmitBackgroundWithoutPrintBackground happens if
@@ -76,51 +79,44 @@ type Options struct {
// "networkIdle" event, drastically improving the conversion speed. It may
// not be suitable for all HTML documents, as some may not be fully
// rendered until this event is fired.
// Optional.
SkipNetworkIdleEvent bool
// FailOnHttpStatusCodes sets if the conversion should fail if the status
// code from the main page matches with one of its entries.
// Optional.
FailOnHttpStatusCodes []int64
// FailOnConsoleExceptions sets if the conversion should fail if there are
// exceptions in the Chromium console.
// Optional.
FailOnConsoleExceptions bool
// WaitDelay is the duration to wait when loading an HTML document before
// converting it.
// Optional.
WaitDelay time.Duration
// WaitWindowStatus is the window.status value to wait for before
// converting an HTML document.
// Optional.
WaitWindowStatus string
// WaitForExpression is the custom JavaScript expression to wait before
// converting an HTML document until it returns true
// Optional.
WaitForExpression string
// Cookies are the cookies to put in the Chromium cookies' jar.
// Optional
Cookies []Cookie
// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
// the HTML document.
// Optional.
// UserAgent overrides the default 'User-Agent' HTTP header.
UserAgent string
// ExtraHttpHeaders are extra HTTP headers to send by Chromium while
// loading he HTML document.
ExtraHttpHeaders map[string]string
// EmulatedMediaType is the media type to emulate, either "screen" or
// "print".
// Optional.
EmulatedMediaType string
// OmitBackground hides default white background and allows generating PDFs
// with transparency.
// Optional.
OmitBackground bool
}
@@ -134,6 +130,7 @@ func DefaultOptions() Options {
WaitWindowStatus: "",
WaitForExpression: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
@@ -145,48 +142,37 @@ type PdfOptions struct {
Options
// Landscape sets the paper orientation.
// Optional.
Landscape bool
// PrintBackground prints the background graphics.
// Optional.
PrintBackground bool
// Scale is the scale of the page rendering.
// Optional.
Scale float64
// SinglePage defines whether to print the entire content in one single
// page.
// Optional.
SinglePage bool
// PaperWidth is the paper width, in inches.
// Optional.
PaperWidth float64
// PaperHeight is the paper height, in inches.
// Optional.
PaperHeight float64
// MarginTop is the top margin, in inches.
// Optional.
MarginTop float64
// MarginBottom is the bottom margin, in inches.
// Optional.
MarginBottom float64
// MarginLeft is the left margin, in inches.
// Optional.
MarginLeft float64
// MarginRight is the right margin, in inches.
// Optional.
MarginRight float64
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
// Optional.
PageRanges string
// HeaderTemplate is the HTML template of the header. It should be valid
@@ -199,17 +185,14 @@ type PdfOptions struct {
// - totalPages: total pages in the document
// For example, <span class=title></span> would generate span containing
// the title.
// Optional.
HeaderTemplate string
// FooterTemplate is the HTML template of the footer. It should use the
// same format as the HeaderTemplate.
// Optional.
FooterTemplate string
// PreferCssPageSize defines whether to prefer page size as defined by CSS.
// If false, the content will be scaled to fit the paper size.
// Optional.
PreferCssPageSize bool
}
@@ -240,30 +223,24 @@ type ScreenshotOptions struct {
Options
// Width is the device screen width in pixels.
// Optional.
Width int
// Height is the device screen height in pixels.
// Optional.
Height int
// Clip defines whether to clip the screenshot according to the device
// dimensions.
// Optional.
Clip bool
// Format is the image compression format, either "png" or "jpeg" or
// "webp".
// Optional.
Format string
// Quality is the compression quality from range [0..100] (jpeg only).
// Optional.
Quality int
// OptimizeForSpeed defines whether to optimize image encoding for speed,
// not for resulting size.
// Optional.
OptimizeForSpeed bool
}

View File

@@ -15,6 +15,15 @@ import (
)
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()
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) {
descriptor := new(Chromium).Descriptor()

View File

@@ -21,7 +21,8 @@ import (
)
// listenForEventRequestPaused listens for requests to check if they are
// allowed or not.
// allowed or not.network.SetBlockedURLS()
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) {
@@ -64,8 +65,8 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
})
}
// listenForEventResponseReceived listens for an invalid HTTP status code is
// returned by the main page.
// listenForEventResponseReceived listens for an invalid HTTP status code that
// is returned by the main page.
// See https://github.com/gotenberg/gotenberg/issues/613.
func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url string, failOnHttpStatusCodes []int64, invalidHttpStatusCode *error, invalidHttpStatusCodeMu *sync.RWMutex) {
for _, code := range []int64{199, 299, 399, 499, 599} {
@@ -95,6 +96,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
// appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262.

View File

@@ -35,6 +35,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
waitWindowStatus string
waitForExpression string
cookies []Cookie
userAgent string
extraHttpHeaders map[string]string
emulatedMediaType string
omitBackground bool
@@ -78,6 +79,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return err
}).
String("userAgent", &userAgent, defaultOptions.UserAgent).
Custom("extraHttpHeaders", func(value string) error {
if value == "" {
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
@@ -115,6 +117,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
@@ -685,5 +688,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
}

View File

@@ -1435,6 +1435,18 @@ func TestConvertUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict,
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",
ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1633,6 +1645,18 @@ func TestScreenshotUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict,
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",
ctx: &api.ContextMock{Context: new(api.Context)},

View File

@@ -281,6 +281,23 @@ func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionF
}
}
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(userAgent) == 0 {
logger.Debug("no user agent override")
return nil
}
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("set user agent override: %w", err)
}
}
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(extraHttpHeaders) == 0 {

View File

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

View File

@@ -14,6 +14,15 @@ import (
"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) {
descriptor := new(Api).Descriptor()
@@ -424,6 +433,13 @@ func TestApi_Pdf(t *testing.T) {
}},
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) {
a := new(Api)

View File

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

View File

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

View File

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

View File

@@ -42,14 +42,53 @@ func TestProviderMock(t *testing.T) {
}
func TestLibreOfficeMock(t *testing.T) {
mock := &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return nil
for _, tc := range []struct {
scenario string
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 err != nil {
t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err)
if !tc.expectError && err != nil {
t.Fatalf("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]
// error.
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, api.Options{
PdfFormats: formats,
})
opts := api.DefaultOptions()
opts.PdfFormats = formats
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
if err == nil {
return nil

View File

@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"github.com/labstack/echo/v4"
@@ -22,27 +24,102 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
defaultOptions := libreofficeapi.DefaultOptions()
// Let's get the data from the form and validate them.
var (
inputPaths []string
landscape bool
nativePageRanges string
exportFormFields bool
singlePageSheets bool
pdfa string
pdfua bool
nativePdfFormats bool
merge bool
metadata map[string]interface{}
inputPaths []string
password string
landscape bool
nativePageRanges string
exportFormFields bool
allowDuplicateFieldNames bool
exportBookmarks bool
exportBookmarksToPdfDestination bool
exportPlaceholders bool
exportNotes bool
exportNotesPages bool
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().
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
Bool("landscape", &landscape, false).
String("nativePageRanges", &nativePageRanges, "").
Bool("exportFormFields", &exportFormFields, true).
Bool("singlePageSheets", &singlePageSheets, false).
String("password", &password, defaultOptions.Password).
Bool("landscape", &landscape, defaultOptions.Landscape).
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
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, "").
Bool("pdfua", &pdfua, false).
Bool("nativePdfFormats", &nativePdfFormats, true).
@@ -71,10 +148,28 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
options := libreofficeapi.Options{
Landscape: landscape,
PageRanges: nativePageRanges,
ExportFormFields: exportFormFields,
SinglePageSheets: singlePageSheets,
Password: password,
Landscape: landscape,
PageRanges: nativePageRanges,
ExportFormFields: exportFormFields,
AllowDuplicateFieldNames: allowDuplicateFieldNames,
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 {
@@ -93,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(
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 the document: possible causes include malformed page ranges '%s' (nativePageRanges) or the document might not be password-protected, but 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 invalid or required, but the exact cause is uncertain"),
)
}

View File

@@ -39,6 +39,116 @@ func TestConvertRoute(t *testing.T) {
expectHttpStatus: http.StatusBadRequest,
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",
ctx: func() *api.ContextMock {
@@ -84,14 +194,14 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
scenario: "ErrMalformedPageRanges",
scenario: "ErrUnoException",
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{
"pdfa": {
"nativePageRanges": {
"foo",
},
})
@@ -99,7 +209,34 @@ func TestConvertRoute(t *testing.T) {
}(),
libreOffice: &libreofficeapi.ApiMock{
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 {
return []string{".docx"}
@@ -285,6 +422,12 @@ func TestConvertRoute(t *testing.T) {
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"100",
},
"maxImageResolution": {
"1200",
},
"merge": {
"true",
},

View File

@@ -27,9 +27,9 @@ type client struct {
// 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 {
URL := c.url
url := c.url
if erroed {
URL = c.errorUrl
url = c.errorUrl
}
method := c.method
@@ -37,9 +37,9 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
method = c.errorMethod
}
req, err := retryablehttp.NewRequest(method, URL, body)
req, err := retryablehttp.NewRequest(method, url, body)
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")
@@ -75,17 +75,17 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
resp, err := c.client.Do(req)
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 {
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() {
err := resp.Body.Close()
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!
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[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
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
}
// 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?
var extraHTTPHeaders map[string]string
var extraHttpHeaders map[string]string
extraHTTPHeadersJSON := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHTTPHeadersJSON != "" {
err = json.Unmarshal([]byte(extraHTTPHeadersJSON), &extraHTTPHeaders)
extraHttpHeadersJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHttpHeadersJson != "" {
err = json.Unmarshal([]byte(extraHttpHeadersJson), &extraHttpHeaders)
if err != nil {
return api.WrapError(
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
@@ -118,7 +118,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
method: webhookMethod,
errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod,
extraHttpHeaders: extraHTTPHeaders,
extraHttpHeaders: extraHttpHeaders,
startTime: c.Get("startTime").(time.Time),
client: &retryablehttp.Client{
@@ -128,11 +128,9 @@ func webhookMiddleware(w *Webhook) api.Middleware {
RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait,
Logger: leveledLogger{
logger: ctx.Log(),
},
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
Logger: gotenberg.NewLeveledLogger(ctx.Log()),
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
},
logger: ctx.Log(),
}

View File

@@ -2,50 +2,60 @@
set -e
# Args.
GOLANG_VERSION="$1"
GOTENBERG_VERSION="$2"
GOTENBERG_USER_GID="$3"
GOTENBERG_USER_UID="$4"
NOTO_COLOR_EMOJI_VERSION="$5"
PDFTK_VERSION="$6"
DOCKER_REPOSITORY="$7"
if [ "$GOTENBERG_VERSION" == "edge" ]; then
docker buildx build \
--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
DOCKER_REGISTRY="$7"
DOCKER_REPOSITORY="$8"
LINUX_AMD64_RELEASE="$9"
# Find out if given version is "semver".
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
VERSION_LENGTH=${#SEMVER[@]}
TAGS=()
TAGS_CLOUD_RUN=()
if [ "$VERSION_LENGTH" -ne 3 ]; then
echo "$VERSION is not semver."
exit 1
if [ "$VERSION_LENGTH" -eq 3 ]; then
MAJOR="${SEMVER[0]}"
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
docker buildx build \
@@ -55,26 +65,18 @@ docker buildx build \
--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: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]}" \
$PLATFORM_FLAG \
"${TAGS[@]}" \
--push \
-f build/Dockerfile .
# Cloud Run variant.
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
docker buildx build \
--build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--platform linux/amd64 \
-t "$DOCKER_REPOSITORY/gotenberg:latest-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}-cloudrun" \
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}-cloudrun" \
"${TAGS_CLOUD_RUN[@]}" \
--push \
-f build/Dockerfile.cloudrun .
-f build/Dockerfile.cloudrun .

View File

@@ -1,19 +1,20 @@
ARG GOLANG_VERSION
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_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
# dependencies like Google Chrome, LibreOffice, etc.
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
USER root
ENV GOPATH /go
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
ENV CGO_ENABLED 1
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
ENV CGO_ENABLED=1
COPY --from=golang /usr/local/go /usr/local/go

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

Binary file not shown.