Compare commits

...

10 Commits

Author SHA1 Message Date
Julien Neuhart
8944db131c fix(api): bound downloadFrom concurrency and entry count 2026-09-02 14:57:29 +02:00
Johannes Klein
676570074a fix(Dockerfile): re-add libreoffice-math so DOCX math formulas render
The v8.30.0 image slimming replaced the libreoffice metapackage with an
explicit component list that omits libreoffice-math. Writer imports OMML
equations as Math objects, so without the component every formula is
silently dropped from converted PDFs.

Fixes #1644
2026-09-02 11:55:30 +02:00
dependabot[bot]
11179cb271 chore(deps): bump google.golang.org/grpc from 1.83.0 to 1.83.1 (#1646)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.0 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.0...v1.83.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-02 10:51:17 +02:00
Julien Neuhart
c2a85f92d0 chore(deps): update unoconverter to v0.5.0 2026-08-31 19:01:18 +02:00
Muhammad Haseeb
7dbff18e65 fix(chromium): fail fast with 503 when Chromium crashes (#1641) 2026-08-31 16:48:21 +02:00
Julien Neuhart
923e5f71eb style(api): remove redundant parentheses in type switch 2026-08-31 13:38:05 +02:00
Julien Neuhart
334f859d95 chore(deps): update golangci-lint to v2.13.2 2026-08-31 13:36:02 +02:00
Julien Neuhart
0819514b7b chore(deps): update Go to 1.27.0 2026-08-31 13:36:02 +02:00
Julien Neuhart
c636a52666 fix(otel): keep telemetry off unless an exporter is configured 2026-08-31 13:27:05 +02:00
dependabot[bot]
0b16b0ab34 chore(deps): bump github.com/stretchr/testify from 1.11.1 to 1.12.1 (#1636)
Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.1 to 1.12.1.
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.11.1...v1.12.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-21 11:57:55 +02:00
19 changed files with 431 additions and 54 deletions

View File

@@ -31,7 +31,7 @@ jobs:
- name: Run linters - name: Run linters
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.12.2 version: v2.13.2
lint-prettier: lint-prettier:
name: Lint non-Golang codebase name: Lint non-Golang codebase

View File

@@ -38,6 +38,8 @@ API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
API_DOWNLOAD_FROM_MAX_RETRY=4 API_DOWNLOAD_FROM_MAX_RETRY=4
API_DOWNLOAD_FROM_MAX_CONCURRENCY=10
API_DOWNLOAD_FROM_MAX_ENTRIES=0
API_DISABLE_DOWNLOAD_FROM=false API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
API_DISABLE_ROOT_ROUTE_TELEMETRY=true API_DISABLE_ROOT_ROUTE_TELEMETRY=true

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will # ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build # concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them. # stage that uses them.
ARG GOLANG_VERSION=1.26.5 ARG GOLANG_VERSION=1.27.0
# ---------------------------------------------- # ----------------------------------------------
# pdfcpu binary build stage # pdfcpu binary build stage
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
WORKDIR /downloads WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \ RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.5.0/unoconv -o unoconverter \
&& chmod +x unoconverter && chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \ RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
@@ -259,7 +259,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \ hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \ hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \ && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \ libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3. # unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \ && ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup. # Cleanup.
@@ -386,7 +386,7 @@ RUN echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/so
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \ hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \ hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \ && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno \ libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw libreoffice-math python3-uno \
# unoconverter will look for the Python binary, which has to be at version 3. # unoconverter will look for the Python binary, which has to be at version 3.
&& ln -s /usr/bin/python3 /usr/bin/python \ && ln -s /usr/bin/python3 /usr/bin/python \
# Cleanup. # Cleanup.

View File

@@ -35,6 +35,8 @@ services:
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}" - "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}" - "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}" - "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-download-from-max-concurrency=${API_DOWNLOAD_FROM_MAX_CONCURRENCY}"
- "--api-download-from-max-entries=${API_DOWNLOAD_FROM_MAX_ENTRIES}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}" - "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}" - "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}" - "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"

17
go.mod
View File

@@ -1,11 +1,12 @@
module github.com/gotenberg/gotenberg/v8 module github.com/gotenberg/gotenberg/v8
go 1.26.5 go 1.27.0
require ( require (
github.com/alexliesenfeld/health v0.8.1 github.com/alexliesenfeld/health v0.8.1
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535. github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
github.com/coreos/go-oidc/v3 v3.20.0
github.com/cucumber/godog v0.16.0 github.com/cucumber/godog v0.16.0
github.com/dlclark/regexp2 v1.12.0 github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984 github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984
@@ -20,10 +21,11 @@ require (
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/shirou/gopsutil/v4 v4.26.7 github.com/shirou/gopsutil/v4 v4.26.7
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.1
github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go v0.44.0
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0 go.opentelemetry.io/contrib/bridges/otelslog v0.20.0
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0 go.opentelemetry.io/contrib/exporters/autoexport v0.70.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/log v0.21.0 go.opentelemetry.io/otel/log v0.21.0
go.opentelemetry.io/otel/metric v1.45.0 go.opentelemetry.io/otel/metric v1.45.0
@@ -37,11 +39,6 @@ require (
golang.org/x/text v0.41.0 golang.org/x/text v0.41.0
) )
require (
github.com/coreos/go-oidc/v3 v3.20.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
)
require ( require (
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
@@ -64,7 +61,6 @@ require (
github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
github.com/cucumber/messages/go/v34 v34.2.1 // indirect github.com/cucumber/messages/go/v34 v34.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.8.1 // indirect github.com/docker/go-connections v0.8.1 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
@@ -106,7 +102,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.28 // indirect github.com/pierrec/lz4/v4 v4.1.28 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/common v0.70.1 // indirect
@@ -136,6 +131,7 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.55.0 // indirect golang.org/x/crypto v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
@@ -143,7 +139,6 @@ require (
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
google.golang.org/grpc v1.83.0 // indirect google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

21
go.sum
View File

@@ -55,7 +55,6 @@ github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgX
github.com/cucumber/messages/go/v34 v34.2.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE= github.com/cucumber/messages/go/v34 v34.2.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8= github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
@@ -130,10 +129,6 @@ github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
@@ -192,7 +187,6 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
@@ -206,8 +200,6 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc= github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc= github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
@@ -228,8 +220,8 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
@@ -303,6 +295,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
@@ -333,15 +327,12 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks= google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g= google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strings"
"sync/atomic" "sync/atomic"
"go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/bridges/otelslog"
@@ -74,6 +75,24 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic
return merged return merged
} }
// OTEL_*_EXPORTER select the exporter for each signal. autoexport treats an
// unset or empty value as a request for the OTLP exporter, which then fails
// against the default localhost:4318 endpoint when nothing listens there and,
// for metrics, keeps retrying on the periodic reader's timer. Gotenberg keeps
// telemetry opt-in: a signal with no exporter configured is built without one
// and stays inert. See https://github.com/gotenberg/gotenberg/issues/1643.
const (
tracesExporterEnvKey = "OTEL_TRACES_EXPORTER"
metricsExporterEnvKey = "OTEL_METRICS_EXPORTER"
logsExporterEnvKey = "OTEL_LOGS_EXPORTER"
)
// exporterConfigured reports whether the operator selected an exporter for the
// signal owning envKey. An unset or blank value keeps that signal off.
func exporterConfigured(envKey string) bool {
return strings.TrimSpace(os.Getenv(envKey)) != ""
}
// InitTracerProvider initializes the OpenTelemetry tracer provider. // InitTracerProvider initializes the OpenTelemetry tracer provider.
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) { func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger) initOtelLogger(logger)
@@ -86,13 +105,14 @@ func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string)
trace.WithResource(res), trace.WithResource(res),
} }
traceExporter, err := autoexport.NewSpanExporter(ctx) if exporterConfigured(tracesExporterEnvKey) {
if err != nil { traceExporter, err := autoexport.NewSpanExporter(ctx)
return nil, err if err != nil {
} return nil, err
}
if !autoexport.IsNoneSpanExporter(traceExporter) { if !autoexport.IsNoneSpanExporter(traceExporter) {
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter)) traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
}
} }
traceProvider := trace.NewTracerProvider(traceOpts...) traceProvider := trace.NewTracerProvider(traceOpts...)
@@ -119,13 +139,14 @@ func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string)
} }
metricOpts = append(metricOpts, exemplarFilterOptions()...) metricOpts = append(metricOpts, exemplarFilterOptions()...)
metricReader, err := autoexport.NewMetricReader(ctx) if exporterConfigured(metricsExporterEnvKey) {
if err != nil { metricReader, err := autoexport.NewMetricReader(ctx)
return nil, err if err != nil {
} return nil, err
}
if !autoexport.IsNoneMetricReader(metricReader) { if !autoexport.IsNoneMetricReader(metricReader) {
metricOpts = append(metricOpts, metric.WithReader(metricReader)) metricOpts = append(metricOpts, metric.WithReader(metricReader))
}
} }
meterProvider := metric.NewMeterProvider(metricOpts...) meterProvider := metric.NewMeterProvider(metricOpts...)
@@ -157,13 +178,14 @@ func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string)
log.WithResource(res), log.WithResource(res),
} }
logExporter, err := autoexport.NewLogExporter(ctx) if exporterConfigured(logsExporterEnvKey) {
if err != nil { logExporter, err := autoexport.NewLogExporter(ctx)
return nil, nil, err if err != nil {
} return nil, nil, err
}
if !autoexport.IsNoneLogExporter(logExporter) { if !autoexport.IsNoneLogExporter(logExporter) {
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter))) logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
}
} }
loggerProvider := log.NewLoggerProvider(logOpts...) loggerProvider := log.NewLoggerProvider(logOpts...)

View File

@@ -89,6 +89,49 @@ func TestInitTracerProvider_HonorsSamplerEnv(t *testing.T) {
} }
} }
// TestExporterConfigured pins the opt-in gate: an unset or blank
// OTEL_*_EXPORTER keeps the signal off, so Gotenberg never wires the OTLP
// exporter that autoexport would otherwise default to and fail to reach at
// localhost:4318. See https://github.com/gotenberg/gotenberg/issues/1643.
func TestExporterConfigured(t *testing.T) {
const key = "OTEL_METRICS_EXPORTER"
orig, had := os.LookupEnv(key)
t.Cleanup(func() {
if had {
os.Setenv(key, orig)
return
}
os.Unsetenv(key)
})
for _, tc := range []struct {
scenario string
unset bool
value string
want bool
}{
{"unset", true, "", false},
{"empty", false, "", false},
{"whitespace only", false, " ", false},
{"none", false, "none", true},
{"otlp", false, "otlp", true},
{"padded value", false, " otlp ", true},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.unset {
os.Unsetenv(key)
} else {
os.Setenv(key, tc.value)
}
if got := exporterConfigured(key); got != tc.want {
t.Errorf("exporterConfigured(%q) = %v, want %v", key, got, tc.want)
}
})
}
}
func TestExemplarFilterOptions(t *testing.T) { func TestExemplarFilterOptions(t *testing.T) {
t.Run("default pins trace-based", func(t *testing.T) { t.Run("default pins trace-based", func(t *testing.T) {
if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok { if v, ok := os.LookupEnv("OTEL_METRICS_EXEMPLAR_FILTER"); ok {

View File

@@ -67,6 +67,8 @@ type downloadFromConfig struct {
denyPublicIPs bool denyPublicIPs bool
enableEnvironmentProxy bool enableEnvironmentProxy bool
maxRetry int maxRetry int
maxConcurrency int
maxEntries int
disable bool disable bool
} }
@@ -212,6 +214,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet") fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials") fs.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Int("api-download-from-max-concurrency", 10, "Set the maximum number of downloadFrom entries fetched concurrently per request - bounds the outbound fan-out. Set to 0 to disable this feature")
fs.Int("api-download-from-max-entries", 0, "Set the maximum number of downloadFrom entries allowed per request. Set to 0 to disable this feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature") fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route") fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route") fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
@@ -255,6 +259,8 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"), denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"), enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
maxRetry: flags.MustInt("api-download-from-max-retry"), maxRetry: flags.MustInt("api-download-from-max-retry"),
maxConcurrency: flags.MustInt("api-download-from-max-concurrency"),
maxEntries: flags.MustInt("api-download-from-max-entries"),
disable: flags.MustBool("api-disable-download-from"), disable: flags.MustBool("api-disable-download-from"),
} }
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry") a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
@@ -404,6 +410,18 @@ func (a *Api) Validate() error {
} }
} }
if a.downloadFromCfg.maxConcurrency < 0 {
err = errors.Join(err,
fmt.Errorf("download from max concurrency must not be negative, got %d; set --api-download-from-max-concurrency (env API_DOWNLOAD_FROM_MAX_CONCURRENCY) to 0 to disable the limit", a.downloadFromCfg.maxConcurrency),
)
}
if a.downloadFromCfg.maxEntries < 0 {
err = errors.Join(err,
fmt.Errorf("download from max entries must not be negative, got %d; set --api-download-from-max-entries (env API_DOWNLOAD_FROM_MAX_ENTRIES) to 0 to disable the limit", a.downloadFromCfg.maxEntries),
)
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") { if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = errors.Join(err, err = errors.Join(err,
errors.New("both TLS certificate and key files must be set"), errors.New("both TLS certificate and key files must be set"),

View File

@@ -222,6 +222,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
) )
} }
// Reject oversized arrays at the trust boundary, before allocating
// the results slice or spawning any goroutine, so a compact request
// cannot inflate into an unbounded fan-out.
if downloadFromCfg.maxEntries > 0 && len(dls) > downloadFromCfg.maxEntries {
return nil, cancel, WrapError(
fmt.Errorf("too many downloadFrom entries: got %d, max %d", len(dls), downloadFromCfg.maxEntries),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: too many entries, the maximum is %d", downloadFromCfg.maxEntries)),
)
}
// Each goroutine writes to its own results slot. The main // Each goroutine writes to its own results slot. The main
// goroutine merges into ctx.files, ctx.diskToOriginal, and // goroutine merges into ctx.files, ctx.diskToOriginal, and
// ctx.filesByField after eg.Wait() to avoid concurrent map // ctx.filesByField after eg.Wait() to avoid concurrent map
@@ -232,6 +242,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
results := make([]downloadFromResult, len(dls)) results := make([]downloadFromResult, len(dls))
eg, _ := errgroup.WithContext(ctx) eg, _ := errgroup.WithContext(ctx)
// Bound the number of in-flight downloads. Each entry allocates a
// retryable client, an outbound transport, a span, and logger state,
// so an unbounded array would otherwise exhaust process memory. A
// value of 0 keeps the fan-out unbounded.
if downloadFromCfg.maxConcurrency > 0 {
eg.SetLimit(downloadFromCfg.maxConcurrency)
}
for i, dl := range dls { for i, dl := range dls {
eg.Go(func() error { eg.Go(func() error {
deadline, ok := ctx.Deadline() deadline, ok := ctx.Deadline()

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
@@ -11,6 +12,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -209,6 +211,137 @@ func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
} }
} }
// An oversized downloadFrom array must be rejected at the trust boundary with
// a 400, before any download goroutine is spawned.
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
func TestNewContext_DownloadFromMaxEntries(t *testing.T) {
var hits atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Content-Disposition", `attachment; filename="download.txt"`)
_, _ = w.Write([]byte("downloaded"))
}))
defer server.Close()
dls := make([]downloadFrom, 3)
for i := range dls {
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
}
payload, err := json.Marshal(dls)
if err != nil {
t.Fatalf("marshal downloadFrom payload: %v", err)
}
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err = writer.WriteField("downloadFrom", string(payload))
if err != nil {
t.Fatalf("write downloadFrom field: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
downloadFromCfg := downloadFromConfig{maxEntries: 2}
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
if cancel != nil {
defer cancel()
}
if err == nil {
t.Fatal("newContext returned no error, want a 400 for too many entries")
}
var httpErr HttpError
if !errors.As(err, &httpErr) {
t.Fatalf("error %v is not an HttpError", err)
}
if status, _ := httpErr.HttpError(); status != http.StatusBadRequest {
t.Fatalf("HTTP status = %d, want %d", status, http.StatusBadRequest)
}
if got := hits.Load(); got != 0 {
t.Fatalf("server hits = %d, want 0 (rejected before any download)", got)
}
}
// The number of in-flight downloadFrom fetches must never exceed the
// configured concurrency limit, regardless of array length.
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
func TestNewContext_DownloadFromMaxConcurrency(t *testing.T) {
const (
downloads = 8
maxConcurrency = 2
)
var current, peak atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
inFlight := current.Add(1)
for {
observed := peak.Load()
if inFlight <= observed || peak.CompareAndSwap(observed, inFlight) {
break
}
}
time.Sleep(20 * time.Millisecond)
current.Add(-1)
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte("downloaded"))
}))
defer server.Close()
dls := make([]downloadFrom, downloads)
for i := range dls {
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
}
payload, err := json.Marshal(dls)
if err != nil {
t.Fatalf("marshal downloadFrom payload: %v", err)
}
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err = writer.WriteField("downloadFrom", string(payload))
if err != nil {
t.Fatalf("write downloadFrom field: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
downloadFromCfg := downloadFromConfig{maxConcurrency: maxConcurrency}
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
if err != nil {
t.Fatalf("newContext returned error: %v", err)
}
defer cancel()
if got := len(ctx.files); got != downloads {
t.Fatalf("downloaded files = %d, want %d", got, downloads)
}
if got := peak.Load(); got > maxConcurrency {
t.Fatalf("peak concurrency = %d, want <= %d", got, maxConcurrency)
}
}
func TestSanitizeFilename(t *testing.T) { func TestSanitizeFilename(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string

View File

@@ -612,7 +612,7 @@ func (form *FormData) mustValue(key string, target any, defaultValue any) *FormD
val, ok := form.values[key] val, ok := form.values[key]
if !ok || val[0] == "" { if !ok || val[0] == "" {
switch t := (target).(type) { switch t := target.(type) {
case *string: case *string:
*t = defaultValue.(string) *t = defaultValue.(string)
case *bool: case *bool:
@@ -659,7 +659,7 @@ func (form *FormData) mustMandatoryField(key string, target any) *FormData {
func (form *FormData) mustAssign(key, value string, target any) *FormData { func (form *FormData) mustAssign(key, value string, target any) *FormData {
var err error var err error
switch t := (target).(type) { switch t := target.(type) {
case *string: case *string:
*t = value *t = value
case *bool: case *bool:

View File

@@ -523,8 +523,45 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
cancelOnMainPageError: taskCancel, cancelOnMainPageError: taskCancel,
}) })
var (
crashed error
crashedMu sync.RWMutex
)
// See https://github.com/gotenberg/gotenberg/issues/1640.
listenForEventTargetCrashed(taskCtx, logger, eventTargetCrashedOptions{
crashed: &crashed,
crashedMu: &crashedMu,
cancel: taskCancel,
})
runErr := chromedp.Run(taskCtx, tasks...) runErr := chromedp.Run(taskCtx, tasks...)
// A crashed renderer is the root cause of every other failure this
// conversion may have recorded, so check it first.
// See https://github.com/gotenberg/gotenberg/issues/1640.
crashedMu.RLock()
defer crashedMu.RUnlock()
if crashed != nil {
return fmt.Errorf("handle tasks: %w", crashed)
}
// The browser context is only ever canceled when the browser process
// dies or is stopped, never on a request timeout. If the run failed
// and the browser context is done, the conversion failed because the
// browser went away mid-flight; fail fast with the same crash error
// instead of letting the error fall through as a generic context
// cancellation. The check is gated on runErr so a successful
// conversion is never discarded by a browser death that lands right
// after it.
// See https://github.com/gotenberg/gotenberg/issues/1640.
if runErr != nil {
if err := b.ctx.Err(); err != nil {
return fmt.Errorf("handle tasks: %w", ErrChromiumCrashed)
}
}
// Check event-driven errors first — they take priority over chromedp.Run // Check event-driven errors first — they take priority over chromedp.Run
// errors because they carry the actual root cause (e.g., HTTP 500 from // errors because they carry the actual root cause (e.g., HTTP 500 from
// the main page). When we cancel taskCtx on a main page error, // the main page). When we cancel taskCtx on a main page error,

View File

@@ -71,6 +71,10 @@ var (
// ErrResourceLoadingFailed happens when one or more resources failed to load. // ErrResourceLoadingFailed happens when one or more resources failed to load.
ErrResourceLoadingFailed = errors.New("resource loading failed") ErrResourceLoadingFailed = errors.New("resource loading failed")
// ErrChromiumCrashed happens when the Chromium renderer crashes during a
// conversion.
ErrChromiumCrashed = errors.New("chromium crashed")
// PDF specific. // PDF specific.
// ErrOmitBackgroundWithoutPrintBackground happens if // ErrOmitBackgroundWithoutPrintBackground happens if
@@ -1106,6 +1110,8 @@ func chromiumErrorType(err error, queueReason string) string {
errors.Is(err, ErrInvalidEvaluationExpression), errors.Is(err, ErrInvalidEvaluationExpression),
errors.Is(err, ErrInvalidSelectorQuery): errors.Is(err, ErrInvalidSelectorQuery):
return gotenberg.ErrorTypeInvalidInput return gotenberg.ErrorTypeInvalidInput
case errors.Is(err, ErrChromiumCrashed):
return "chromium_unavailable"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded): case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
return queueReason return queueReason
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting): case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):

View File

@@ -21,6 +21,7 @@ func TestChromiumErrorType(t *testing.T) {
{"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"}, {"invalid resource http status", ErrInvalidResourceHttpStatusCode, "chromium_unavailable", "invalid_input"},
{"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"}, {"loading failed", ErrLoadingFailed, "chromium_unavailable", "invalid_input"},
{"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"}, {"resource loading failed", ErrResourceLoadingFailed, "chromium_unavailable", "invalid_input"},
{"crashed", ErrChromiumCrashed, "chromium_unavailable", "chromium_unavailable"},
{"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"}, {"invalid evaluation expression", ErrInvalidEvaluationExpression, "chromium_unavailable", "invalid_input"},
{"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"}, {"invalid selector query", ErrInvalidSelectorQuery, "chromium_unavailable", "invalid_input"},
{"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"}, {"pdf queue", gotenberg.ErrMaximumQueueSizeExceeded, "chromium_unavailable", "chromium_unavailable"},

View File

@@ -14,6 +14,7 @@ import (
"github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/fetch" "github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/inspector"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
@@ -521,6 +522,38 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
}) })
} }
type eventTargetCrashedOptions struct {
crashed *error
crashedMu *sync.RWMutex
cancel context.CancelFunc
}
// listenForEventTargetCrashed listens for the Inspector.targetCrashed event,
// which Chromium sends when the renderer serving the conversion's tab
// crashes. chromedp enables the Inspector domain on every target but does
// not handle this event: left alone, the in-flight CDP command never
// receives a response and the conversion blocks until the request deadline.
// Record the crash and cancel the task context so the conversion fails fast
// instead.
// See https://github.com/gotenberg/gotenberg/issues/1640.
func listenForEventTargetCrashed(ctx context.Context, logger *slog.Logger, options eventTargetCrashedOptions) {
chromedp.ListenTarget(ctx, func(ev any) {
if _, ok := ev.(*inspector.EventTargetCrashed); ok {
logger.DebugContext(ctx, "event EventTargetCrashed fired")
options.crashedMu.Lock()
defer options.crashedMu.Unlock()
*options.crashed = ErrChromiumCrashed
// Cancel the task context so the in-flight CDP command aborts
// immediately instead of waiting for a response the crashed
// renderer can never send.
options.cancel()
}
})
}
// waitForEventDomContentEventFired registers a listener for the // waitForEventDomContentEventFired registers a listener for the
// DomContentEventFired event and returns a waiter that blocks until the // DomContentEventFired event and returns a waiter that blocks until the
// event fires or ctx is done. The listener registers at call time, not // event fires or ctx is done. The listener registers at call time, not

View File

@@ -1020,6 +1020,16 @@ func handleChromiumError(err error, options Options) error {
return nil return nil
} }
if errors.Is(err, ErrChromiumCrashed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusServiceUnavailable,
"Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists.",
),
)
}
if errors.Is(err, ErrInvalidEvaluationExpression) { if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" { if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return // We do not expect the 'waitWindowStatus' form field to return

View File

@@ -0,0 +1,51 @@
package chromium
import (
"fmt"
"net/http"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
// TestHandleChromiumError_Crashed pins the mapping of a Chromium renderer
// crash to a 503 Service Unavailable. When the renderer crashes mid-conversion,
// the request must fail fast with 503 rather than hang until the deadline and
// surface as a generic timeout.
// See https://github.com/gotenberg/gotenberg/issues/1640.
func TestHandleChromiumError_Crashed(t *testing.T) {
// Mirror the wrapping done by [chromiumBrowser.do].
err := handleChromiumError(fmt.Errorf("handle tasks: %w", ErrChromiumCrashed), Options{})
if err == nil {
t.Fatal("expected an error, got none")
}
status, message := api.ParseError(err)
if status != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d (message: %s)", status, http.StatusServiceUnavailable, message)
}
want := "Chromium crashed while processing the request. Retry, or reduce the workload if the problem persists."
if message != want {
t.Errorf("message = %q, want %q", message, want)
}
}
// TestHandleChromiumError_CrashedTakesPrecedence guards the ordering in
// [handleChromiumError]: a crash is a server-side failure and must map to 503
// even when the error chain also carries a marker that another branch would
// map to a client-error status.
func TestHandleChromiumError_CrashedTakesPrecedence(t *testing.T) {
err := handleChromiumError(
fmt.Errorf("handle tasks: %w; %w", ErrChromiumCrashed, ErrInvalidHttpStatusCode),
Options{},
)
if err == nil {
t.Fatal("expected an error, got none")
}
status, _ := api.ParseError(err)
if status != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d", status, http.StatusServiceUnavailable)
}
}

View File

@@ -1401,3 +1401,19 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 1 page(s) Then the "foo.pdf" PDF should have 1 page(s)
# chrome://crash makes the renderer crash deterministically, the same
# failure class as a renderer crash triggered by the page content. The
# request must fail fast with a 503 instead of hanging until the API
# timeout.
# See https://github.com/gotenberg/gotenberg/issues/1640.
Scenario: POST /forms/chromium/convert/url (Chromium crash fails fast with 503)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | chrome://crash | field |
Then the response status code should be 503
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string:
"""
Chromium crashed while processing the request
"""