Compare commits

...

22 Commits

Author SHA1 Message Date
Johannes Klein
e4ee0f29d6 feat(libreoffice): add generateTaggedPdf form field
Tags the PDF without declaring PDF/UA conformance. Today tagging is only
available through pdfua, which also writes pdfuaid:part=1 into the XMP
metadata. Mirrors the Chromium routes, where generateTaggedPdf and pdfua
are separate fields. Defaults to false, so existing requests are
unchanged.

Closes #1676
2026-10-15 16:56:07 +02:00
Julien Neuhart
db150b7733 fix(chromium): report unreachable resources behind the pinning proxy as loading failures 2026-10-13 19:45:06 +02:00
Julien Neuhart
e1d714e5e0 fix(chromium): route loopback WebSockets through the pinning proxy when an outbound policy is set 2026-10-13 19:39:50 +02:00
Julien Neuhart
7b604da4f1 fix(chromium)!: isolate each conversion in its own browser context 2026-10-13 18:53:41 +02:00
Julien Neuhart
887727b40e test(chromium): wait for network idle in resource status code scenarios 2026-10-13 18:31:35 +02:00
Julien Neuhart
dd76e79443 fix(api): enforce api-body-limit before parsing multipart requests 2026-10-13 18:15:00 +02:00
dependabot[bot]
0a337e97b8 chore(deps): bump github.com/moby/moby/client from 0.6.0 to 0.6.2
Bumps [github.com/moby/moby/client](https://github.com/moby/moby) from 0.6.0 to 0.6.2.
- [Release notes](https://github.com/moby/moby/releases)
- [Changelog](https://github.com/moby/moby/blob/v0.6.2/CHANGELOG.md)
- [Commits](https://github.com/moby/moby/compare/v0.6.0...v0.6.2)

---
updated-dependencies:
- dependency-name: github.com/moby/moby/client
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-13 18:03:43 +02:00
Julien Neuhart
5af64d2124 chore(deps): bump OpenTelemetry to v1.47.0 and contrib to v0.72.0
The Logs API is now stable (go.opentelemetry.io/otel/log v1.47.0) and
go.opentelemetry.io/otel/log/global is deprecated. Register the logger
provider via otel.SetLoggerProvider instead.

Supersedes #1680, #1681 and #1683.
2026-10-13 17:51:58 +02:00
dependabot[bot]
2f895d9313 chore(deps): bump github.com/moby/moby/api from 1.56.0 to 1.56.1
Bumps [github.com/moby/moby/api](https://github.com/moby/moby) from 1.56.0 to 1.56.1.
- [Release notes](https://github.com/moby/moby/releases)
- [Commits](https://github.com/moby/moby/compare/api/v1.56.0...api/v1.56.1)

---
updated-dependencies:
- dependency-name: github.com/moby/moby/api
  dependency-version: 1.56.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-13 17:47:38 +02:00
Antonio Pauletich
c83dbd7baf test(chromium): cover header and footer image deduplication 2026-10-05 13:32:59 +02:00
dependabot[bot]
cc9cd5d82d chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.8 to 4.26.9
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.8 to 4.26.9.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.8...v4.26.9)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 18:59:45 +02:00
dependabot[bot]
768f87bb1c chore(deps): bump github.com/labstack/echo/v5 from 5.3.1 to 5.4.0
Bumps [github.com/labstack/echo/v5](https://github.com/labstack/echo) from 5.3.1 to 5.4.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v5.3.1...v5.4.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v5
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 18:59:37 +02:00
dependabot[bot]
a5bc10b9d8 chore(deps-dev): bump prettier-plugin-sh from 0.19.0 to 0.20.2
Bumps [prettier-plugin-sh](https://github.com/un-ts/prettier) from 0.19.0 to 0.20.2.
- [Release notes](https://github.com/un-ts/prettier/releases)
- [Changelog](https://github.com/un-ts/prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/un-ts/prettier/compare/prettier-plugin-sh@0.19.0...prettier-plugin-sh@0.20.2)

---
updated-dependencies:
- dependency-name: prettier-plugin-sh
  dependency-version: 0.20.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 18:59:20 +02:00
dependabot[bot]
ffc3cb98af chore(deps-dev): bump prettier from 3.9.8 to 3.9.9
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.8 to 3.9.9.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.8...3.9.9)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 18:59:08 +02:00
dependabot[bot]
8b46090eac chore(deps-dev): bump prettier from 3.9.6 to 3.9.8
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.6 to 3.9.8.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-25 06:46:33 +02:00
dependabot[bot]
39e43205b1 chore(deps): bump golang.org/x/net from 0.58.0 to 0.59.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.58.0 to 0.59.0.
- [Commits](https://github.com/golang/net/compare/v0.58.0...v0.59.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 09:27:35 +02:00
dependabot[bot]
c4c9ea12e5 chore(deps): bump golang.org/x/text from 0.41.0 to 0.42.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.41.0 to 0.42.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 07:02:04 +02:00
Julien Neuhart
ff817f6cf8 chore(deps)!: migrate from regexp2 v1 to regexp2 v2 2026-09-17 15:33:55 +02:00
Julien Neuhart
ab18384a51 build(dockerfile): update AWS Lambda Web Adapter to 1.0.1 2026-09-17 15:33:55 +02:00
Julien Neuhart
70783a01c6 fix(gotenberg): retry pattern matches aborted by a process pause 2026-09-16 21:23:43 +02:00
Julien Neuhart
2f9020455a chore(deps)!: migrate from echo v4 to echo v5 2026-09-16 19:38:45 +02:00
Julien Neuhart
8e850b1c93 fix(outbound): ignore IPv6 zone identifiers when classifying addresses 2026-09-13 10:15:07 +02:00
59 changed files with 1625 additions and 412 deletions

View File

@@ -31,6 +31,7 @@ body:multipart-form {
~skipEmptyPages: false ~skipEmptyPages: false
~addOriginalDocumentAsStream: false ~addOriginalDocumentAsStream: false
~singlePageSheets: false ~singlePageSheets: false
~generateTaggedPdf: false
~initialView: 0 ~initialView: 0
~initialPage: 1 ~initialPage: 1
~magnification: 0 ~magnification: 0

View File

@@ -66,6 +66,7 @@ CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).* CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_DENY_PRIVATE_IPS=false CHROMIUM_DENY_PRIVATE_IPS=false
CHROMIUM_DENY_PUBLIC_IPS=false CHROMIUM_DENY_PUBLIC_IPS=false
CHROMIUM_SHARED_BROWSER_CONTEXT=false
CHROMIUM_CLEAR_CACHE=false CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DISABLE_JAVASCRIPT=false CHROMIUM_DISABLE_JAVASCRIPT=false
@@ -154,6 +155,7 @@ NO_CONCURRENCY=false
# chromium # chromium
# chromium-concurrent # chromium-concurrent
# chromium-convert-html # chromium-convert-html
# chromium-image-deduplication
# chromium-convert-markdown # chromium-convert-markdown
# chromium-convert-url # chromium-convert-url
# chromium-screenshot-html # chromium-screenshot-html

View File

@@ -498,7 +498,7 @@ FROM gotenberg AS gotenberg-aws-lambda
USER root USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000 ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health ENV AWS_LWA_READINESS_CHECK_PATH=/health
@@ -516,7 +516,7 @@ FROM gotenberg-chromium AS gotenberg-aws-lambda-chromium
USER root USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000 ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health ENV AWS_LWA_READINESS_CHECK_PATH=/health
@@ -534,7 +534,7 @@ FROM gotenberg-libreoffice AS gotenberg-aws-lambda-libreoffice
USER root USER root
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
ENV AWS_LWA_PORT=3000 ENV AWS_LWA_PORT=3000
ENV AWS_LWA_READINESS_CHECK_PATH=/health ENV AWS_LWA_READINESS_CHECK_PATH=/health

View File

@@ -60,6 +60,7 @@ services:
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}" - "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
- "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}" - "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}"
- "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}" - "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}"
- "--chromium-shared-browser-context=${CHROMIUM_SHARED_BROWSER_CONTEXT}"
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}" - "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}" - "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}" - "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"

86
go.mod
View File

@@ -8,40 +8,39 @@ require (
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.21.0 github.com/coreos/go-oidc/v3 v3.21.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/v2 v2.8.0
github.com/gomarkdown/markdown v0.0.0-20260824154242-13c5cf49db8d github.com/gomarkdown/markdown v0.0.0-20260824154242-13c5cf49db8d
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.4 github.com/labstack/echo/v5 v5.4.0
github.com/labstack/gommon v0.5.0 github.com/labstack/gommon v0.5.0
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/moby/moby/api v1.56.0 github.com/moby/moby/api v1.56.1
github.com/moby/moby/client v0.6.0 github.com/moby/moby/client v0.6.2
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/shirou/gopsutil/v4 v4.26.8 github.com/shirou/gopsutil/v4 v4.26.9
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.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.1 go.opentelemetry.io/contrib/bridges/otelslog v0.21.0
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0 go.opentelemetry.io/contrib/exporters/autoexport v0.72.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0
go.opentelemetry.io/otel v1.46.0 go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/log v0.22.0 go.opentelemetry.io/otel/metric v1.47.0
go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk v1.47.0
go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk/log v1.47.0
go.opentelemetry.io/otel/sdk/log v0.22.0 go.opentelemetry.io/otel/sdk/metric v1.47.0
go.opentelemetry.io/otel/sdk/metric v1.46.0 go.opentelemetry.io/otel/trace v1.47.0
go.opentelemetry.io/otel/trace v1.46.0 golang.org/x/net v0.59.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.23.0
golang.org/x/term v0.45.0 golang.org/x/term v0.46.0
golang.org/x/text v0.41.0 golang.org/x/text v0.42.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-20261006220739-8c912ac31dd4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.3 // indirect github.com/andybalholm/brotli v1.2.3 // indirect
@@ -62,10 +61,10 @@ require (
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/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.2 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.11.0 // indirect github.com/ebitengine/purego v0.11.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
@@ -76,7 +75,7 @@ require (
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect
@@ -104,7 +103,7 @@ require (
github.com/pierrec/lz4/v4 v4.1.29 // indirect github.com/pierrec/lz4/v4 v4.1.29 // 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.3 // indirect github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect github.com/prometheus/procfs v0.22.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect github.com/sirupsen/logrus v1.10.2 // indirect
@@ -114,31 +113,30 @@ require (
github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/ulikunitz/xz v0.5.16 // indirect github.com/ulikunitz/xz v0.5.16 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.68.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.69.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // 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.56.0 // indirect golang.org/x/crypto v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.48.0 // indirect
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-20260831171406-18b4a7587f8a // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/grpc v1.83.2 // indirect google.golang.org/grpc v1.84.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
) )

181
go.sum
View File

@@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
@@ -58,17 +58,17 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
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=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -102,8 +102,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -131,8 +131,8 @@ 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/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/v5 v5.4.0 h1:iY674460IvSmUcj7MziL3YrgyTClghz5BpdZoKiGxR4=
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g= github.com/labstack/echo/v5 v5.4.0/go.mod h1:4iEGNQiPPZnkfYpNR/L6fINd3NLiGWUD5+eBotFALas=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
@@ -157,10 +157,10 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c=
github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4=
github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00= github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
@@ -194,14 +194,14 @@ github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59u
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
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.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo= github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig=
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
@@ -231,66 +231,62 @@ github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyU
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 h1:5sHc4ToTFjfSZCtGAAM6jPunICAmJX73htv372T4ipc= go.opentelemetry.io/contrib/bridges/otelslog v0.21.0 h1:/CdgE52i1OEPHhsGUqrggYNRaZy4v4YgVenGd52dRBI=
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1/go.mod h1:oa6kgvyz/3GYW04dohd0++xJIH4xdQY8PAbpeCMaM8M= go.opentelemetry.io/contrib/bridges/otelslog v0.21.0/go.mod h1:9pBdbVbuehIDzxN4accnzzxEUPT8Nq7grbZMo+J1VN4=
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 h1:9qgxsFLskbDMXl8WMqThoF6w8yGJgCumn9qRc67OmnI= go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 h1:OUIUu7ovHCEvG4IE5LQd2PNBH2eVYh50w/1ROwHEM7M=
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0/go.mod h1:2rCjF4F2siiTeLCzJsaGZ3CK0XIoimCSKXEBPdv+Je0= go.opentelemetry.io/contrib/bridges/prometheus v0.72.0/go.mod h1:pCrnkC07AZ5+h+0A0Z/1GYyqB+xu3A8LaEEhxvv9s6o=
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0 h1:VCsJbp0YLyPtx2tu5Vgv2a2/qLoaMCj8hT2uZ34+Mx0= go.opentelemetry.io/contrib/exporters/autoexport v0.72.0 h1:TPXrl7vEkmh69tp7dHTpz+Y6AUenFg1Du3Nx3a6dlxY=
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0/go.mod h1:qxZqn7e10f6ajmMCkg/47rMS7qQYfaOl2nj/4aytHUQ= go.opentelemetry.io/contrib/exporters/autoexport v0.72.0/go.mod h1:0jE6K8nsOgeiF53rcEhy71cgakek+XEWi4OPROUqLoI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 h1:5Ek4qQ5XX0PSQfY7lMW3CvcR36yy8YFDfGnYnaku8zE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0/go.mod h1:NUKIMOKSexQdTmS1pSxOQBDiVes3DHRier2ePsGW/gQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 h1:lYk7RmxdLK865qLwibroNGldHa1U7SWKYYvNjlK7PIo= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 h1:5WVp40k2fJHuc5BtNTgKk+EugKFT8Eg3lywFfCPI/nE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0/go.mod h1:6GvlND0H0xdUJanOtIAn0xfwLkauh1tmsYEEVSMDdqY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0/go.mod h1:59nH0RYCGHRlwt15WyioeSYINBbuObeBpTowPhEKWHY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 h1:LaLVGJtIzHcvHjVs2OftmKcc+M9mdOqS8vZ1fBD1Yes=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0/go.mod h1:vF6H5sNDuQBJWgSX00iZgbIqk2MZZoVMH0ar5t5bOpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0/go.mod h1:CLzakYy8jOdkBfcveCWYkeKJxujcF4xPhh2Z7IfE98U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38=
go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= go.opentelemetry.io/otel/exporters/prometheus v0.69.0 h1:LQVBTGnHvzrfGJpDoCSVTY7eaLZArvaTSq5l84Skxew=
go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= go.opentelemetry.io/otel/exporters/prometheus v0.69.0/go.mod h1:moif0nlgj/M16NRT8lDZVt8MC52+EOAJfR8EKRJ8LyU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 h1:kvMAiLEudKmk+CSG+iYbU8vTUGNNDaf/V09OO5lrTwI= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 h1:PW/E9Tw6ZykC88pey0MZY8hKOUeEw+hX6PU+oXA4A9o=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0/go.mod h1:L9Dlksri+MdT1cb2gIiA1cJJYW3Y92ipvDjNxYEyaDI= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0/go.mod h1:hUw4VNfiOnQ/yzGgy6ZdWVnudXw7iRfImhd6FjXOnvI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 h1:PR9eAf7o0dQs3hshZNZpE9aW2dXWX/KdDf6pJilVD3U= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 h1:ww5DYBpMKcqLdxjWrhGUIh6O+ZfvZUaMk4niVyNQPEQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0/go.mod h1:2Z4KyNdH1uuzivdinyfGsxzNNT/Rl45pwtVwfYVI0xk= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0/go.mod h1:Av5t+Ezx5S/Ks2dsEfWhny+GK+wrZ1K7NZ6aHSWnX3U=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA=
go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc= go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM=
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk= go.opentelemetry.io/otel/sdk/log v1.47.0 h1:W4N/ZfgpkRRnmARnqYoQ6BrWA0mrj8IzVDNLlpmUJZY=
go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g= go.opentelemetry.io/otel/sdk/log v1.47.0/go.mod h1:Wu74Z2EmOKXi2FleSNAb0Hyau2G+r5kubSEMJw5aAMs=
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg= go.opentelemetry.io/otel/sdk/log/logtest v0.23.0 h1:j3pm42QCrKYYLLkI6OrV1QHFkUfDA6/4EP+Y1PzggLg=
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s= go.opentelemetry.io/otel/sdk/log/logtest v0.23.0/go.mod h1:zt0gEnuTZgDvqvczHwYswz93eqMaIr6upTTsdV/HUlU=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 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=
@@ -299,36 +295,35 @@ 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= 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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
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=

24
package-lock.json generated
View File

@@ -5,9 +5,9 @@
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"prettier": "3.9.6", "prettier": "3.9.9",
"prettier-plugin-gherkin": "^4.0.0", "prettier-plugin-gherkin": "^4.0.0",
"prettier-plugin-sh": "^0.19.0" "prettier-plugin-sh": "^0.20.2"
} }
}, },
"node_modules/@cucumber/gherkin": { "node_modules/@cucumber/gherkin": {
@@ -114,9 +114,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.9.6", "version": "3.9.9",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -142,14 +142,14 @@
} }
}, },
"node_modules/prettier-plugin-sh": { "node_modules/prettier-plugin-sh": {
"version": "0.19.0", "version": "0.20.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz", "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.20.2.tgz",
"integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==", "integrity": "sha512-4QVyY7Vav0f5i6AVa80SFwUJXaWsAv9t0yUZIivwI0ucxTei6Jwhso/khWqaKDj4QGdwjP2py1nxQrDUeZ0jyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@reteps/dockerfmt": "^0.5.4", "@reteps/dockerfmt": "^0.5.4",
"sh-syntax": "^0.6.0" "sh-syntax": "^0.7.0"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@@ -169,9 +169,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/sh-syntax": { "node_modules/sh-syntax": {
"version": "0.6.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz", "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.7.0.tgz",
"integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==", "integrity": "sha512-sgMyAxpHiGgAaNg+gD3ad41vha24CzIjjghD4mdiON1lUafZPLJNYknyjcXmU4geilPstd69zCKkpccY0YvajA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@@ -1,7 +1,7 @@
{ {
"devDependencies": { "devDependencies": {
"prettier": "3.9.6", "prettier": "3.9.9",
"prettier-plugin-gherkin": "^4.0.0", "prettier-plugin-gherkin": "^4.0.0",
"prettier-plugin-sh": "^0.19.0" "prettier-plugin-sh": "^0.20.2"
} }
} }

View File

@@ -3,7 +3,7 @@ package gotenberg
import ( import (
"testing" "testing"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
) )
func TestAuditAllowList(t *testing.T) { func TestAuditAllowList(t *testing.T) {
@@ -93,7 +93,7 @@ func TestAuditAllowList_FlaggedPatternsAreActuallyExploitable(t *testing.T) {
t.Fatalf("pattern %q was not flagged", tc.pattern) t.Fatalf("pattern %q was not flagged", tc.pattern)
} }
ok, err := regexp2.MustCompile(tc.pattern, 0).MatchString(tc.attack) ok, err := regexp2.MustCompile(tc.pattern, regexp2.None).MatchString(tc.attack)
if err != nil { if err != nil {
t.Fatalf("match %q: %v", tc.attack, err) t.Fatalf("match %q: %v", tc.attack, err)
} }
@@ -129,7 +129,7 @@ func TestAuditAllowList_SafePatternsRejectTheAttacks(t *testing.T) {
t.Fatalf("safe pattern %q was flagged as %q", pattern, findings[0].Risk) t.Fatalf("safe pattern %q was flagged as %q", pattern, findings[0].Risk)
} }
re := regexp2.MustCompile(pattern, 0) re := regexp2.MustCompile(pattern, regexp2.None)
for _, attack := range attacks { for _, attack := range attacks {
ok, err := re.MatchString(attack) ok, err := re.MatchString(attack)
if err != nil { if err != nil {

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/labstack/gommon/bytes" "github.com/labstack/gommon/bytes"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
@@ -222,6 +222,10 @@ func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newNam
// built. Patterns compiled any other way keep regexp2's default of // built. Patterns compiled any other way keep regexp2's default of
// math.MaxInt64, which it treats as no timeout at all, so a hand-built slice // math.MaxInt64, which it treats as no timeout at all, so a hand-built slice
// must set this itself before reaching [DecideOutbound]. // must set this itself before reaching [DecideOutbound].
//
// The ceiling is wall-clock. Match through [MatchPattern] rather than calling
// regexp2 directly: a match that never approaches the ceiling still aborts if
// the process loses the CPU at the wrong moment.
const PatternMatchTimeout = 250 * time.Millisecond const PatternMatchTimeout = 250 * time.Millisecond
// MustRegexp returns the regular expression of a flag given by name. // MustRegexp returns the regular expression of a flag given by name.
@@ -235,7 +239,7 @@ func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
panic(err) panic(err)
} }
re := regexp2.MustCompile(val, 0) re := regexp2.MustCompile(val, regexp2.None)
re.MatchTimeout = PatternMatchTimeout re.MatchTimeout = PatternMatchTimeout
return re return re
@@ -272,7 +276,7 @@ func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
continue continue
} }
re := regexp2.MustCompile(val, 0) re := regexp2.MustCompile(val, regexp2.None)
re.MatchTimeout = PatternMatchTimeout re.MatchTimeout = PatternMatchTimeout
regexps = append(regexps, re) regexps = append(regexps, re)

View File

@@ -7,7 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
) )

View File

@@ -11,7 +11,6 @@ import (
"go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/exporters/autoexport" "go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric"
@@ -190,7 +189,7 @@ func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string)
loggerProvider := log.NewLoggerProvider(logOpts...) loggerProvider := log.NewLoggerProvider(logOpts...)
otelHandler := otelslog.NewHandler(serviceName, otelslog.WithLoggerProvider(loggerProvider)) otelHandler := otelslog.NewHandler(serviceName, otelslog.WithLoggerProvider(loggerProvider))
global.SetLoggerProvider(loggerProvider) otel.SetLoggerProvider(loggerProvider)
return loggerProvider.Shutdown, otelHandler, nil return loggerProvider.Shutdown, otelHandler, nil
} }

View File

@@ -15,7 +15,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"golang.org/x/net/http/httpproxy" "golang.org/x/net/http/httpproxy"
) )
@@ -109,11 +109,18 @@ var nonPublicIPv4Prefixes = []netip.Prefix{
// the prefixes themselves are deprecated or translation-only. See // the prefixes themselves are deprecated or translation-only. See
// [nonPublicIPv6Prefixes] and [nonPublicIPv4Prefixes] for the full lists // [nonPublicIPv6Prefixes] and [nonPublicIPv4Prefixes] for the full lists
// and rationale. // and rationale.
//
// An IPv6 zone identifier is ignored, so [::%1] classifies the same as [::].
func IsPublicIP(addr netip.Addr) bool { func IsPublicIP(addr netip.Addr) bool {
if !addr.IsValid() { if !addr.IsValid() {
return false return false
} }
addr = addr.Unmap() // A zone does not change where a non-link-local address routes, but
// [netip.Prefix.Contains] never matches a zoned address and
// [netip.Addr.IsUnspecified] compares the zone too. Keeping it would let
// http://[::%251]/ or any zoned address in the prefixes below pass as
// public.
addr = addr.WithZone("").Unmap()
switch { switch {
case addr.IsLoopback(), case addr.IsLoopback(),
addr.IsPrivate(), addr.IsPrivate(),
@@ -329,12 +336,16 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
allowMatched := false allowMatched := false
if len(allowList) > 0 { if len(allowList) > 0 {
for _, pattern := range allowList { for _, pattern := range allowList {
ok, err := pattern.MatchString(normalized) ok, err := MatchPattern(pattern, normalized)
if err != nil { if err != nil {
if time.Now().After(deadline) { if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded return OutboundDecision{}, context.DeadlineExceeded
} }
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", pattern.String(), normalized, err) // The pattern could not be evaluated, so the URL cannot be
// cleared for the IP-check bypass an allow-list match grants.
// Fail closed like an unresolvable host does below, so the
// client gets a generic 403 rather than a 500.
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %v: %w", pattern.String(), normalized, err, ErrFiltered)
} }
if ok { if ok {
@@ -349,12 +360,15 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
} }
for _, pattern := range denyList { for _, pattern := range denyList {
ok, err := pattern.MatchString(normalized) ok, err := MatchPattern(pattern, normalized)
if err != nil { if err != nil {
if time.Now().After(deadline) { if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded return OutboundDecision{}, context.DeadlineExceeded
} }
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", pattern.String(), normalized, err) // The pattern could not be evaluated, so the URL cannot be proven
// to fall outside the deny-list. Fail closed rather than letting a
// deny-list that never ran pass the request through.
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %v: %w", pattern.String(), normalized, err, ErrFiltered)
} }
if ok { if ok {

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
) )
func TestIsPublicIP(t *testing.T) { func TestIsPublicIP(t *testing.T) {
@@ -103,6 +103,23 @@ func TestIsPublicIP(t *testing.T) {
// Discard prefix (RFC 6666). // Discard prefix (RFC 6666).
{"100::1", false}, {"100::1", false},
// A zone identifier must not change the classification.
{"::%1", false},
{"::%lo", false},
{"::1%1", false},
{"fe80::1%eth0", false},
{"fc00::1%1", false},
{"::ffff:127.0.0.1%1", false},
{"fec0::1%eth0", false},
{"2002:a9fe:a9fe::%1", false},
{"2001:0:abcd:ef12:3456:7890:a9fe:a9fe%1", false},
{"64:ff9b::a9fe:a9fe%1", false},
{"64:ff9b:1::a9fe:a9fe%1", false},
{"::a9fe:a9fe%1", false},
{"2001:db8::1%1", false},
{"100::1%1", false},
{"2606:4700:4700::1111%1", true},
} { } {
t.Run(tc.addr, func(t *testing.T) { t.Run(tc.addr, func(t *testing.T) {
addr, err := netip.ParseAddr(tc.addr) addr, err := netip.ParseAddr(tc.addr)
@@ -147,10 +164,10 @@ func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
func TestFilterOutboundURL(t *testing.T) { func TestFilterOutboundURL(t *testing.T) {
defaultDeny := []*regexp2.Regexp{ defaultDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0), regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, regexp2.None),
} }
chromiumDeny := []*regexp2.Regexp{ chromiumDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0), regexp2.MustCompile(`^file:(?!//\/tmp/).*`, regexp2.None),
} }
for _, tc := range []struct { for _, tc := range []struct {
@@ -229,7 +246,7 @@ func TestFilterOutboundURL(t *testing.T) {
{ {
scenario: "allow-list match bypasses IP check", scenario: "allow-list match bypasses IP check",
rawURL: "http://internal.service/api", rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)}, allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, regexp2.None)},
deny: defaultDeny, deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)}, opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: false, expectErr: false,
@@ -237,15 +254,15 @@ func TestFilterOutboundURL(t *testing.T) {
{ {
scenario: "deny-list still wins over allow-list match", scenario: "deny-list still wins over allow-list match",
rawURL: "http://internal.service/api", rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)}, allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, regexp2.None)},
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)}, deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, regexp2.None)},
expectErr: true, expectErr: true,
expectIs: ErrFiltered, expectIs: ErrFiltered,
}, },
{ {
scenario: "allow-list non-empty and no match rejects", scenario: "allow-list non-empty and no match rejects",
rawURL: "https://other.example/", rawURL: "https://other.example/",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)}, allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, regexp2.None)},
expectErr: true, expectErr: true,
expectIs: ErrFiltered, expectIs: ErrFiltered,
}, },
@@ -304,6 +321,22 @@ func TestFilterOutboundURL(t *testing.T) {
expectErr: true, expectErr: true,
expectIs: ErrFiltered, expectIs: ErrFiltered,
}, },
{
scenario: "zoned unspecified address blocked with deny-private-ips",
rawURL: "http://[::%251]:9999/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "zoned site-local address blocked with deny-private-ips",
rawURL: "http://[fec0:1234::3%25eth0]:8080/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
if tc.stub != nil { if tc.stub != nil {
@@ -506,7 +539,7 @@ func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
// The regex deny-list fires before any resolution; verifies that // The regex deny-list fires before any resolution; verifies that
// operator-supplied deny patterns remain effective regardless of // operator-supplied deny patterns remain effective regardless of
// IP-class options. // IP-class options.
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)} deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, regexp2.None)}
_, err := DecideOutbound( _, err := DecideOutbound(
context.Background(), context.Background(),
@@ -572,7 +605,7 @@ func TestDecideOutbound_UserinfoDoesNotEvadeDenyList(t *testing.T) {
context.Background(), context.Background(),
rawURL, rawURL,
nil, nil,
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, 0)}, []*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, regexp2.None)},
time.Now().Add(5*time.Second), time.Now().Add(5*time.Second),
) )
if !errors.Is(err, ErrFiltered) { if !errors.Is(err, ErrFiltered) {
@@ -584,7 +617,7 @@ func TestDecideOutbound_UserinfoDoesNotEvadeDenyList(t *testing.T) {
func TestDecideOutbound_UserinfoDoesNotSatisfyAllowList(t *testing.T) { func TestDecideOutbound_UserinfoDoesNotSatisfyAllowList(t *testing.T) {
// A host-terminated allow-list, the shape the documentation recommends. // A host-terminated allow-list, the shape the documentation recommends.
allowList := []*regexp2.Regexp{regexp2.MustCompile(`^https://trusted\.example\.com(:[0-9]+)?(/|$)`, 0)} allowList := []*regexp2.Regexp{regexp2.MustCompile(`^https://trusted\.example\.com(:[0-9]+)?(/|$)`, regexp2.None)}
for _, rawURL := range []string{ for _, rawURL := range []string{
"https://trusted.example.com@169.254.169.254/latest/meta-data/", "https://trusted.example.com@169.254.169.254/latest/meta-data/",
@@ -623,7 +656,7 @@ func TestDecideOutbound_UserinfoKeptOutOfErrorMessages(t *testing.T) {
context.Background(), context.Background(),
"http://alice:hunter2@127.0.0.1:9999/", "http://alice:hunter2@127.0.0.1:9999/",
nil, nil,
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, 0)}, []*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, regexp2.None)},
time.Now().Add(5*time.Second), time.Now().Add(5*time.Second),
) )
if err == nil { if err == nil {
@@ -647,7 +680,7 @@ func TestDecideOutbound_LegitimateCredentialsStillReachTheHost(t *testing.T) {
decision, err := DecideOutbound( decision, err := DecideOutbound(
context.Background(), context.Background(),
"https://alice:hunter2@example.com/report.pdf", "https://alice:hunter2@example.com/report.pdf",
[]*regexp2.Regexp{regexp2.MustCompile(`^https://example\.com(:[0-9]+)?(/|$)`, 0)}, []*regexp2.Regexp{regexp2.MustCompile(`^https://example\.com(:[0-9]+)?(/|$)`, regexp2.None)},
nil, nil,
time.Now().Add(5*time.Second), time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true), WithDenyPrivateIPs(true),
@@ -741,7 +774,7 @@ func TestDecideOutboundBoundsCatastrophicPatterns(t *testing.T) {
// caller's whole budget, so a 30s API_TIMEOUT bought a 30s CPU burn. // caller's whole budget, so a 30s API_TIMEOUT bought a 30s CPU burn.
// The trailing "!" makes the match fail only after the nested quantifier // The trailing "!" makes the match fail only after the nested quantifier
// has explored every way to split the run of "a"s. // has explored every way to split the run of "a"s.
pattern := regexp2.MustCompile(`^https://example\.com/(a+)+$`, 0) pattern := regexp2.MustCompile(`^https://example\.com/(a+)+$`, regexp2.None)
pattern.MatchTimeout = PatternMatchTimeout pattern.MatchTimeout = PatternMatchTimeout
rawURL := "https://example.com/" + strings.Repeat("a", 40) + "!" rawURL := "https://example.com/" + strings.Repeat("a", 40) + "!"
@@ -760,6 +793,13 @@ func TestDecideOutboundBoundsCatastrophicPatterns(t *testing.T) {
t.Fatal("expected an error from a catastrophic deny-list pattern") t.Fatal("expected an error from a catastrophic deny-list pattern")
} }
// A deny-list that could not be evaluated cannot clear the URL, so the
// decision fails closed and the client gets a generic 403 rather than a
// 500 naming the pattern.
if !errors.Is(err, ErrFiltered) {
t.Fatalf("expected ErrFiltered from an unevaluable deny-list pattern but got: %v", err)
}
// Generous headroom over the 250ms ceiling, still far below the 30s // Generous headroom over the 250ms ceiling, still far below the 30s
// deadline the match would otherwise have been allowed to consume. // deadline the match would otherwise have been allowed to consume.
if elapsed > 5*time.Second { if elapsed > 5*time.Second {

55
pkg/gotenberg/pattern.go Normal file
View File

@@ -0,0 +1,55 @@
package gotenberg
import (
"github.com/dlclark/regexp2/v2"
)
// patternMatchAttempts caps how many times [MatchPattern] runs one pattern
// against one string. It is what keeps a pattern that is genuinely out of
// budget from retrying forever: three attempts bound its cost at three
// [PatternMatchTimeout], which is still two orders of magnitude below the
// --api-timeout (env API_TIMEOUT) the ceiling exists to protect.
const patternMatchAttempts = 3
// MatchPattern reports whether s matches pattern. It bounds the match by the
// pattern's MatchTimeout without the false timeouts that the bound alone
// produces.
//
// regexp2 does not time a match against [time.Now]. It derives the deadline
// from a process-global clock that a background goroutine advances every
// 100ms, and it tests that deadline on the very first step of the match.
// Anything that stops the whole process, a cgroup CPU-quota throttle or a long
// stop-the-world pause, also stops that goroutine, which then advances the
// clock by the full pause in a single write. A match holding a deadline from
// before that jump aborts whatever work it had done: a 366ns match against a
// short URL reports "match timeout after 250ms". The abort lands on whichever
// match straddles the jump rather than on a match that was slow, which is why
// it fires on an idle instance and against Gotenberg's own file:///tmp/ URLs.
// See https://github.com/gotenberg/gotenberg/issues/1659.
//
// Retrying separates the two cases. Catastrophic backtracking is
// deterministic: the same pattern against the same string exhausts the same
// budget on every attempt, so a genuine runaway still aborts, and costs at
// most patternMatchAttempts ceilings to prove it. A clock-induced abort needs
// the process to lose the CPU inside one specific match, which the next
// attempt does not reproduce.
//
// Elapsed time cannot make that call instead. A match frozen mid-flight
// reports the freeze as its own cost, 806ms against a 250ms ceiling in one
// measured run, so it is indistinguishable by wall clock from a match that
// really did spend its budget. Go exposes no per-goroutine CPU time, and
// process CPU time counts every other request in flight.
func MatchPattern(pattern *regexp2.Regexp, s string) (bool, error) {
var err error
for range patternMatchAttempts {
var ok bool
ok, err = pattern.MatchString(s)
if err == nil {
return ok, nil
}
}
return false, err
}

View File

@@ -0,0 +1,99 @@
package gotenberg
import (
"strings"
"testing"
"time"
"github.com/dlclark/regexp2/v2"
)
// mustPattern compiles a pattern the way the production lists are built.
func mustPattern(t *testing.T, expr string) *regexp2.Regexp {
t.Helper()
re := regexp2.MustCompile(expr, regexp2.None)
re.MatchTimeout = PatternMatchTimeout
return re
}
func TestMatchPattern(t *testing.T) {
// The abort [MatchPattern] absorbs cannot be staged here: it needs the
// whole process to lose the CPU around one specific match, which no test
// can schedule. What is testable is the other half of the contract, that
// retrying never turns a genuine runaway into a pass.
// See https://github.com/gotenberg/gotenberg/issues/1659.
for _, tc := range []struct {
scenario string
pattern *regexp2.Regexp
s string
expectMatch bool
expectError bool
}{
{
scenario: "deny-list match",
pattern: mustPattern(t, `^file:(?!//\/tmp/).*`),
s: "file:///etc/passwd",
expectMatch: true,
},
{
scenario: "no match against Gotenberg's own working directory",
pattern: mustPattern(t, `^file:(?!//\/tmp/).*`),
s: "file:///tmp/1a2b3c4d/5e6f7a8b/9c0d1e2f.html",
expectMatch: false,
},
{
scenario: "no match",
pattern: mustPattern(t, `^https://example\.com/`),
s: "https://example.org/",
expectMatch: false,
},
{
scenario: "catastrophic backtracking still aborts",
pattern: mustPattern(t, `^https://example\.com/(a+)+$`),
s: "https://example.com/" + strings.Repeat("a", 40) + "!",
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ok, err := MatchPattern(tc.pattern, tc.s)
if tc.expectError && err == nil {
t.Fatal("expected an error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if ok != tc.expectMatch {
t.Fatalf("expected match %t but got %t", tc.expectMatch, ok)
}
})
}
}
func TestMatchPatternBoundsCatastrophicPattern(t *testing.T) {
// Proving a runaway is genuine costs one ceiling per attempt, so the
// worst case is patternMatchAttempts of them plus regexp2's clock period
// on each, roughly a second. The bound that matters is the one this
// replaced: before the ceiling existed, the same match was allowed to
// burn a core for the caller's whole 30s budget.
pattern := mustPattern(t, `^https://example\.com/(a+)+$`)
s := "https://example.com/" + strings.Repeat("a", 40) + "!"
start := time.Now()
_, err := MatchPattern(pattern, s)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected an error from a catastrophic pattern")
}
// Generous headroom over the expected second keeps this stable on a
// loaded CI box while still failing if the bound is gone.
if elapsed > 5*time.Second {
t.Fatalf("match took %s, want at most %d ceilings of %s", elapsed, patternMatchAttempts, PatternMatchTimeout)
}
}

View File

@@ -12,10 +12,9 @@ import (
"time" "time"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"golang.org/x/net/http2"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -58,6 +57,12 @@ type Api struct {
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
logger *slog.Logger logger *slog.Logger
srv *echo.Echo srv *echo.Echo
// shutdownCancel triggers the graceful shutdown of the server started by
// [echo.StartConfig]. Echo v5 drives shutdown from a context instead of an
// Echo.Shutdown method. serveDone closes once that shutdown completed.
shutdownCancel context.CancelFunc
serveDone chan struct{}
} }
type downloadFromConfig struct { type downloadFromConfig struct {
@@ -134,7 +139,7 @@ const (
// middleware := Middleware{ // middleware := Middleware{
// Handler: func() echo.MiddlewareFunc { // Handler: func() echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc { // return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error { // return func(c *echo.Context) error {
// rootPath := c.Get("rootPath").(string) // rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath) // healthURI := fmt.Sprintf("%shealth", rootPath)
// //
@@ -536,16 +541,29 @@ func (a *Api) Validate() error {
return nil return nil
} }
// newEchoServer builds the [echo.Echo] instance with the settings Gotenberg
// relies on, independently of the routes and middlewares added by [Api.Start].
func newEchoServer() *echo.Echo {
srv := echo.New()
srv.HTTPErrorHandler = httpErrorHandler()
// Echo v5 serves files through Echo.Filesystem, an [fs.FS] rooted at the
// working directory, and [fs.FS] rejects absolute names. Every output file
// lives under the request's temporary directory, so Context.Attachment gets
// an absolute path and the default filesystem answers 404 for every
// conversion. Rooting at "/" restores the v4 behavior, where the path was
// opened as-is.
srv.Filesystem = echo.NewDefaultFS("/")
// Echo v5 dropped the X-Forwarded-For and X-Real-IP fallbacks from
// Context.RealIP. Keep the previous behavior so that the access log still
// reports the client IP when Gotenberg sits behind a reverse proxy.
srv.IPExtractor = echo.LegacyIPExtractor()
return srv
}
// Start starts the HTTP server. // Start starts the HTTP server.
func (a *Api) Start() error { func (a *Api) Start() error {
a.srv = echo.New() a.srv = newEchoServer()
a.srv.HideBanner = true
a.srv.HidePort = true
a.srv.Server.ReadTimeout = a.timeout
a.srv.Server.IdleTimeout = a.timeout
// See https://github.com/gotenberg/gotenberg/issues/396.
a.srv.Server.WriteTimeout = a.timeout + a.timeout
a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes. // Let's prepare the modules' routes.
var disableTelemetryForPaths []string var disableTelemetryForPaths []string
@@ -609,7 +627,7 @@ func (a *Api) Start() error {
securityMiddleware = oidcAuthMiddleware(verifier) securityMiddleware = oidcAuthMiddleware(verifier)
default: default:
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc { securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
return next(c) return next(c)
} }
} }
@@ -641,7 +659,7 @@ func (a *Api) Start() error {
// Root route. // Root route.
a.srv.GET( a.srv.GET(
a.rootPath, a.rootPath,
func(c echo.Context) error { func(c *echo.Context) error {
return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the <a href="https://gotenberg.dev">documentation</a> to learn how to interact with it 🚀`) return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the <a href="https://gotenberg.dev">documentation</a> to learn how to interact with it 🚀`)
}, },
securityMiddleware, securityMiddleware,
@@ -650,7 +668,7 @@ func (a *Api) Start() error {
// Favicon route. // Favicon route.
a.srv.GET( a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"), fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"),
func(c echo.Context) error { func(c *echo.Context) error {
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
}, },
securityMiddleware, securityMiddleware,
@@ -681,7 +699,7 @@ func (a *Api) Start() error {
// ...the version route. // ...the version route.
a.srv.GET( a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "version"), fmt.Sprintf("%s%s", a.rootPath, "version"),
func(c echo.Context) error { func(c *echo.Context) error {
return c.String(http.StatusOK, gotenberg.Version) return c.String(http.StatusOK, gotenberg.Version)
}, },
securityMiddleware, securityMiddleware,
@@ -691,7 +709,7 @@ func (a *Api) Start() error {
if a.enableDebugRoute { if a.enableDebugRoute {
a.srv.GET( a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "debug"), fmt.Sprintf("%s%s", a.rootPath, "debug"),
func(c echo.Context) error { func(c *echo.Context) error {
return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ") return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ")
}, },
securityMiddleware, securityMiddleware,
@@ -712,18 +730,50 @@ func (a *Api) Start() error {
return fmt.Errorf("waiting for modules readiness: %w", err) return fmt.Errorf("waiting for modules readiness: %w", err)
} }
startConfig := echo.StartConfig{
Address: fmt.Sprintf("%s:%d", a.bindIp, a.port),
HideBanner: true,
HidePort: true,
BeforeServeFunc: func(s *http.Server) error {
s.ReadTimeout = a.timeout
s.IdleTimeout = a.timeout
// See https://github.com/gotenberg/gotenberg/issues/396.
s.WriteTimeout = a.timeout + a.timeout
if a.tlsCertFile == "" || a.tlsKeyFile == "" {
// Serve HTTP/2 Cleartext (h2c). Echo v5 dropped
// StartH2CServer and golang.org/x/net/http2/h2c is deprecated,
// so the standard library serves h2c through Server.Protocols
// instead.
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
s.Protocols = protocols
}
return nil
},
}
// Not named cancel: that would reassign the readiness timeout's cancel from
// above, whose deferred call is already bound to the old value.
serveCtx, serveCancel := context.WithCancel(context.Background())
a.shutdownCancel = serveCancel
a.serveDone = make(chan struct{})
// As the following code is blocking, run it in a goroutine. // As the following code is blocking, run it in a goroutine.
go func() { go func() {
defer close(a.serveDone)
var err error var err error
if a.tlsCertFile != "" && a.tlsKeyFile != "" { if a.tlsCertFile != "" && a.tlsKeyFile != "" {
// Start an HTTPS server (supports HTTP/2). // Start an HTTPS server (supports HTTP/2).
err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile) err = startConfig.StartTLS(serveCtx, a.srv, a.tlsCertFile, a.tlsKeyFile)
} else { } else {
// Start an HTTP/2 Cleartext (non-HTTPS) server. // Start an HTTP/2 Cleartext (non-HTTPS) server.
server := &http2.Server{} err = startConfig.Start(serveCtx, a.srv)
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
} }
if !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
a.logger.ErrorContext(context.Background(), err.Error()) a.logger.ErrorContext(context.Background(), err.Error())
} }
}() }()
@@ -740,6 +790,26 @@ func (a *Api) StartupMessage() string {
return fmt.Sprintf("server started on %s:%d", ip, a.port) return fmt.Sprintf("server started on %s:%d", ip, a.port)
} }
// shutdown triggers the server's graceful shutdown and waits for it to
// complete, or for ctx to be done. Cancelling the serve context is what Echo v5
// exposes in place of Echo.Shutdown: [echo.StartConfig] shuts the server down
// on its own deadline, so the shutdown proceeds even when ctx is already done.
func (a *Api) shutdown(ctx context.Context) error {
if a.shutdownCancel == nil {
// Start never ran, so there is nothing to shut down.
return nil
}
a.shutdownCancel()
select {
case <-a.serveDone:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Stop stops the HTTP server. // Stop stops the HTTP server.
func (a *Api) Stop(ctx context.Context) error { func (a *Api) Stop(ctx context.Context) error {
for { for {
@@ -749,7 +819,7 @@ func (a *Api) Stop(ctx context.Context) error {
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
return a.srv.Shutdown(ctx) return a.shutdown(ctx)
default: default:
a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count)) a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count))
if count > 0 { if count > 0 {
@@ -757,7 +827,7 @@ func (a *Api) Stop(ctx context.Context) error {
continue continue
} }
a.logger.DebugContext(ctx, "no more asynchronous requests, continue with shutdown") a.logger.DebugContext(ctx, "no more asynchronous requests, continue with shutdown")
err := a.srv.Shutdown(ctx) err := a.shutdown(ctx)
if err != nil { if err != nil {
return fmt.Errorf("shutdown: %w", err) return fmt.Errorf("shutdown: %w", err)
} }

View File

@@ -18,7 +18,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/mholt/archives" "github.com/mholt/archives"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/codes"
@@ -70,7 +70,7 @@ type Context struct {
outputFilename string outputFilename string
logger *slog.Logger logger *slog.Logger
echoCtx echo.Context echoCtx *echo.Context
mkdirAll gotenberg.MkdirAll mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename pathRename gotenberg.PathRename
context.Context context.Context
@@ -158,7 +158,7 @@ type downloadFrom struct {
} }
// newContext returns a [Context] by parsing a "multipart/form-data" request. // newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) { func newContext(echoCtx *echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout) processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
// We want to make sure the multipart/form-data does not exceed a given // We want to make sure the multipart/form-data does not exceed a given
@@ -166,14 +166,18 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
// downloaded remotely ("download from" feature). // downloaded remotely ("download from" feature).
var totalBytesRead atomic.Int64 var totalBytesRead atomic.Int64
addReadBytes := func(n int64) error { bodyLimitErr := func() error {
newTotal := totalBytesRead.Add(n)
if bodyLimit != 0 && newTotal > bodyLimit {
return WrapError( return WrapError(
fmt.Errorf("body limit reached (> %d)", bodyLimit), fmt.Errorf("body limit reached (> %d)", bodyLimit),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."), NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
) )
} }
addReadBytes := func(n int64) error {
newTotal := totalBytesRead.Add(n)
if bodyLimit != 0 && newTotal > bodyLimit {
return bodyLimitErr()
}
return nil return nil
} }
@@ -217,8 +221,25 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
} }
}() }()
// The multipart parser reads the whole request and spools file parts to
// disk before the counter above sees a single byte, so the limit must
// also bound the raw body itself. Content-Length allows an early
// rejection, but it may be absent or lie, hence the reader.
if bodyLimit > 0 {
req := echoCtx.Request()
if req.ContentLength > bodyLimit {
return nil, cancel, fmt.Errorf("check content length: %w", bodyLimitErr())
}
req.Body = http.MaxBytesReader(echoCtx.Response(), req.Body, bodyLimit)
}
form, err := echoCtx.MultipartForm() form, err := echoCtx.MultipartForm()
if err != nil { if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
return nil, cancel, fmt.Errorf("get multipart form: %w", bodyLimitErr())
}
if errors.Is(err, http.ErrNotMultipart) { if errors.Is(err, http.ErrNotMultipart) {
return nil, cancel, WrapError( return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err), fmt.Errorf("get multipart form: %w", err),

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -19,8 +20,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -875,7 +876,7 @@ func TestNewContext_DownloadFromRedirectVerdictStaysGeneric(t *testing.T) {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
// The first hop is allowed, the redirect target is denied by the deny-list. // The first hop is allowed, the redirect target is denied by the deny-list.
denyList := []*regexp2.Regexp{regexp2.MustCompile("^"+regexp.QuoteMeta(private.URL), 0)} denyList := []*regexp2.Regexp{regexp2.MustCompile("^"+regexp.QuoteMeta(private.URL), regexp2.None)}
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{ _, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{
denyList: denyList, denyList: denyList,
@@ -902,3 +903,100 @@ func TestNewContext_DownloadFromRedirectVerdictStaysGeneric(t *testing.T) {
} }
} }
} }
type countingReadCloser struct {
r io.Reader
n int64
}
func (c *countingReadCloser) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func (*countingReadCloser) Close() error { return nil }
// The body limit must stop the multipart parser itself. It used to apply
// only after the parser had read the whole request and spooled file parts
// to disk.
func TestNewContext_BodyLimitBoundsIngress(t *testing.T) {
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err := writer.WriteField("foo", "bar")
if err != nil {
t.Fatalf("write multipart field: %v", err)
}
part, err := writer.CreateFormFile("files", "large.bin")
if err != nil {
t.Fatalf("create multipart file: %v", err)
}
_, err = part.Write(bytes.Repeat([]byte("A"), 2<<20))
if err != nil {
t.Fatalf("write multipart file: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
size := int64(body.Len())
for _, tc := range []struct {
scenario string
bodyLimit int64
knownLength bool
expectErr bool
maxBytesConsumed int64
}{
{scenario: "known length over the limit is rejected before reading", bodyLimit: 1024, knownLength: true, expectErr: true, maxBytesConsumed: 0},
{scenario: "unknown length over the limit stops at the limit", bodyLimit: 1024, knownLength: false, expectErr: true, maxBytesConsumed: 1024 + 1},
{scenario: "unknown length one byte over the limit", bodyLimit: size - 1, knownLength: false, expectErr: true, maxBytesConsumed: size},
{scenario: "body exactly at the limit", bodyLimit: size, knownLength: true, expectErr: false},
{scenario: "body exactly at the limit with unknown length", bodyLimit: size, knownLength: false, expectErr: false},
{scenario: "no limit", bodyLimit: 0, knownLength: false, expectErr: false},
} {
t.Run(tc.scenario, func(t *testing.T) {
counted := &countingReadCloser{r: bytes.NewReader(body.Bytes())}
req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/merge", nil)
req.Body = counted
req.ContentLength = -1
if tc.knownLength {
req.ContentLength = size
}
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))
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, tc.bodyLimit, downloadFromConfig{disable: true})
if cancel != nil {
defer cancel()
}
if !tc.expectErr {
if err != nil {
t.Fatalf("newContext returned error: %v", err)
}
if counted.n != size {
t.Fatalf("consumed %d bytes, want %d", counted.n, size)
}
return
}
if err == nil {
t.Fatal("expected the body limit to reject the request")
}
var httpErr HttpError
if !errors.As(err, &httpErr) {
t.Fatalf("expected an HttpError, got: %v", err)
}
if status, _ := httpErr.HttpError(); status != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d", status, http.StatusRequestEntityTooLarge)
}
if counted.n > tc.maxBytesConsumed {
t.Fatalf("consumed %d/%d bytes before rejection, want at most %d", counted.n, size, tc.maxBytesConsumed)
}
})
}
}

View File

@@ -12,8 +12,8 @@ import (
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v5/middleware"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
@@ -37,10 +37,12 @@ var (
// ParseError parses an error and returns the corresponding HTTP status and // ParseError parses an error and returns the corresponding HTTP status and
// HTTP message. // HTTP message.
func ParseError(err error) (int, string) { func ParseError(err error) (int, string) {
var echoErr *echo.HTTPError // [echo.StatusCode] also matches the router's ErrNotFound and
ok := errors.As(err, &echoErr) // ErrMethodNotAllowed sentinels, which Echo v5 no longer models as
if ok { // [echo.HTTPError]. Matching that type alone would let every unrouted
return echoErr.Code, http.StatusText(echoErr.Code) // request fall through to a 500.
if code := echo.StatusCode(err); code != 0 {
return code, http.StatusText(code)
} }
if errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.DeadlineExceeded) {
@@ -100,14 +102,14 @@ const statusClientClosedRequest = 499
// A server-side timeout is [context.DeadlineExceeded], mapped to 503 by // A server-side timeout is [context.DeadlineExceeded], mapped to 503 by
// [ParseError], and is deliberately not treated as a client abort. // [ParseError], and is deliberately not treated as a client abort.
// See https://github.com/gotenberg/gotenberg/issues/1627. // See https://github.com/gotenberg/gotenberg/issues/1627.
func requestCanceled(c echo.Context, err error) bool { func requestCanceled(c *echo.Context, err error) bool {
return errors.Is(err, context.Canceled) && errors.Is(c.Request().Context().Err(), context.Canceled) return errors.Is(err, context.Canceled) && errors.Is(c.Request().Context().Err(), context.Canceled)
} }
// httpErrorHandler is the centralized HTTP error handler. It parses the error, // httpErrorHandler is the centralized HTTP error handler. It parses the error,
// returns a response as "text/plain; charset=UTF-8". // returns a response as "text/plain; charset=UTF-8".
func httpErrorHandler() echo.HTTPErrorHandler { func httpErrorHandler() echo.HTTPErrorHandler {
return func(err error, c echo.Context) { return func(c *echo.Context, err error) {
logger := c.Get("logger").(*slog.Logger) logger := c.Get("logger").(*slog.Logger)
if requestCanceled(c, err) { if requestCanceled(c, err) {
@@ -134,7 +136,7 @@ func httpErrorHandler() echo.HTTPErrorHandler {
// startTime := c.Get("startTime").(time.Time) // startTime := c.Get("startTime").(time.Time)
func latencyMiddleware() echo.MiddlewareFunc { func latencyMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
// First piece for calculating the latency. // First piece for calculating the latency.
startTime := time.Now() startTime := time.Now()
c.Set("startTime", startTime) c.Set("startTime", startTime)
@@ -159,7 +161,7 @@ func latencyMiddleware() echo.MiddlewareFunc {
// } // }
func rootPathMiddleware(rootPath string) echo.MiddlewareFunc { func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
c.Set("rootPath", rootPath) c.Set("rootPath", rootPath)
// Call the next middleware in the chain. // Call the next middleware in the chain.
return next(c) return next(c)
@@ -173,7 +175,7 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
// outputFilename := c.Get("outputFilename").(string) // outputFilename := c.Get("outputFilename").(string)
func outputFilenameMiddleware() echo.MiddlewareFunc { func outputFilenameMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
filename := c.Request().Header.Get("Gotenberg-Output-Filename") filename := c.Request().Header.Get("Gotenberg-Output-Filename")
// Keep only the last path segment, so that a caller cannot name an // Keep only the last path segment, so that a caller cannot name an
// output file after a path. // output file after a path.
@@ -203,10 +205,18 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
semconvSrv := semconvutil.NewHTTPServer(meter) semconvSrv := semconvutil.NewHTTPServer(meter)
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
startTime := c.Get("startTime").(time.Time) startTime := c.Get("startTime").(time.Time)
rootPath := c.Get("rootPath").(string) rootPath := c.Get("rootPath").(string)
// Echo v5 returns the bare [http.ResponseWriter] from
// Context.Response, so unwrap it to read the recorded status and
// size below.
response, errUnwrap := echo.UnwrapResponse(c.Response())
if errUnwrap != nil {
return fmt.Errorf("unwrap response: %w", errUnwrap)
}
request := c.Request() request := c.Request()
savedCtx := request.Context() savedCtx := request.Context()
defer func() { defer func() {
@@ -239,7 +249,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
err := next(c) err := next(c)
if err != nil { if err != nil {
c.Error(err) c.Echo().HTTPErrorHandler(c, err)
} }
return nil return nil
} }
@@ -288,7 +298,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
err := next(c) err := next(c)
finishTime := time.Now() finishTime := time.Now()
status := c.Response().Status status := response.Status
canceled := false canceled := false
if err != nil { if err != nil {
canceled = requestCanceled(c, err) canceled = requestCanceled(c, err)
@@ -300,13 +310,13 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
} }
span.SetAttributes(attribute.String("error", err.Error())) span.SetAttributes(attribute.String("error", err.Error()))
c.Error(err) c.Echo().HTTPErrorHandler(c, err)
} }
span.SetStatus(semconvSrv.Status(status)) span.SetStatus(semconvSrv.Status(status))
span.SetAttributes(semconvSrv.ResponseTraceAttrs(semconvutil.ResponseTelemetry{ span.SetAttributes(semconvSrv.ResponseTraceAttrs(semconvutil.ResponseTelemetry{
StatusCode: status, StatusCode: status,
WriteBytes: c.Response().Size, WriteBytes: response.Size,
})...) })...)
// Pick the level and message before building the record: err.Error // Pick the level and message before building the record: err.Error
@@ -343,11 +353,11 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
slog.String("path", routePath), slog.String("path", routePath),
slog.String("referer", c.Request().Referer()), slog.String("referer", c.Request().Referer()),
slog.String("user_agent", c.Request().UserAgent()), slog.String("user_agent", c.Request().UserAgent()),
slog.Int("status", c.Response().Status), slog.Int("status", response.Status),
slog.Int64("latency", int64(latency)), slog.Int64("latency", int64(latency)),
slog.String("latency_human", latency.String()), slog.String("latency_human", latency.String()),
slog.Int64("bytes_in", c.Request().ContentLength), slog.Int64("bytes_in", c.Request().ContentLength),
slog.Int64("bytes_out", c.Response().Size), slog.Int64("bytes_out", response.Size),
) )
additionalAttributes := []attribute.KeyValue{ additionalAttributes := []attribute.KeyValue{
@@ -356,7 +366,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{ semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{
ServerName: serverName, ServerName: serverName,
ResponseSize: c.Response().Size, ResponseSize: response.Size,
Req: request, Req: request,
StatusCode: status, StatusCode: status,
AdditionalAttributes: additionalAttributes, AdditionalAttributes: additionalAttributes,
@@ -371,7 +381,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
// basicAuthMiddleware manages basic authentication. // basicAuthMiddleware manages basic authentication.
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc { func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) { return middleware.BasicAuth(func(c *echo.Context, u string, p string) (bool, error) {
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 && if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 { subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
return true, nil return true, nil
@@ -417,7 +427,7 @@ func (a *Api) buildOidcVerifier() (*oidc.IDTokenVerifier, error) {
// it to the client. // it to the client.
func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc { func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ") rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
if !ok || rawToken == "" { if !ok || rawToken == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header") return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header")
@@ -446,7 +456,7 @@ func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
// cancel := c.Get("cancel").(context.CancelFunc) // cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc { func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
logger, _ := c.Get("logger").(*slog.Logger) logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil { if logger == nil {
return errors.New("no logger in context (possible pool reuse)") return errors.New("no logger in context (possible pool reuse)")
@@ -507,7 +517,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
// handler fails to timeout as expected. // handler fails to timeout as expected.
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc { func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c *echo.Context) error {
// Guard the type assertion so a pooled [echo.Context] whose // Guard the type assertion so a pooled [echo.Context] whose
// store has been recycled under us does not crash the process. // store has been recycled under us does not crash the process.
// See the webhook async handler for the race this protects // See the webhook async handler for the race this protects

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
@@ -9,13 +10,15 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest" "github.com/coreos/go-oidc/v3/oidc/oidctest"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
) )
// TestRequestCanceled pins the client-abort discriminator: only a // TestRequestCanceled pins the client-abort discriminator: only a
@@ -73,7 +76,7 @@ func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
c := echo.New().NewContext(req, rec) c := echo.New().NewContext(req, rec)
c.Set("logger", slog.New(slog.DiscardHandler)) c.Set("logger", slog.New(slog.DiscardHandler))
httpErrorHandler()(tc.err, c) httpErrorHandler()(c, tc.err)
if rec.Code != tc.wantStatus { if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus) t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
@@ -105,7 +108,7 @@ func TestOutputFilenameMiddleware(t *testing.T) {
{"control characters", "fo\x01o\x7f", "foo"}, {"control characters", "fo\x01o\x7f", "foo"},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil }) handler := outputFilenameMiddleware()(func(c *echo.Context) error { return nil })
req := httptest.NewRequest(http.MethodPost, "/", nil) req := httptest.NewRequest(http.MethodPost, "/", nil)
if tc.header != "" { if tc.header != "" {
@@ -131,7 +134,7 @@ func TestOutputFilenameMiddleware(t *testing.T) {
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) { func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
mw := hardTimeoutMiddleware(100 * time.Millisecond) mw := hardTimeoutMiddleware(100 * time.Millisecond)
handler := mw(func(c echo.Context) error { return nil }) handler := mw(func(c *echo.Context) error { return nil })
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -220,7 +223,7 @@ func TestOidcAuthMiddleware(t *testing.T) {
} }
c := echo.New().NewContext(req, httptest.NewRecorder()) c := echo.New().NewContext(req, httptest.NewRecorder())
handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error { handler := oidcAuthMiddleware(verifier)(func(c *echo.Context) error {
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusOK)
}) })
@@ -243,3 +246,114 @@ func TestOidcAuthMiddleware(t *testing.T) {
}) })
} }
} }
// TestParseError_StatusMapping pins the statuses [ParseError] derives from the
// errors Echo and Gotenberg produce.
//
// Echo v5 models the router's ErrNotFound and ErrMethodNotAllowed as an
// unexported type rather than [echo.HTTPError], so matching that type alone
// would turn every unrouted request into a 500. It also guards the ordering:
// Gotenberg's own [SentinelHttpError] carries a client-facing message and must
// not be shadowed by the generic status lookup.
func TestParseError_StatusMapping(t *testing.T) {
for _, tc := range []struct {
scenario string
err error
wantStatus int
wantMessage string
}{
{"router not found", echo.ErrNotFound, http.StatusNotFound, http.StatusText(http.StatusNotFound)},
{"router method not allowed", echo.ErrMethodNotAllowed, http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed)},
{"explicit HTTP error", echo.NewHTTPError(http.StatusUnauthorized, "nope"), http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)},
{"wrapped HTTP error", fmt.Errorf("authenticate request: %w", echo.NewHTTPError(http.StatusUnauthorized, "nope")), http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)},
{"sentinel keeps its message", NewSentinelHttpError(http.StatusBadRequest, "Invalid 'foo' form field value"), http.StatusBadRequest, "Invalid 'foo' form field value"},
{"unknown error", errors.New("boom"), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)},
} {
t.Run(tc.scenario, func(t *testing.T) {
status, message := ParseError(tc.err)
if status != tc.wantStatus {
t.Fatalf("status = %d, want %d", status, tc.wantStatus)
}
if message != tc.wantMessage {
t.Fatalf("message = %q, want %q", message, tc.wantMessage)
}
})
}
}
// TestNewEchoServer_RealIP pins the client IP extraction that the access log's
// "remote_ip" field depends on.
//
// Echo v5.1.0 dropped the X-Forwarded-For and X-Real-IP fallbacks from
// Context.RealIP, so without an explicit extractor a Gotenberg behind a reverse
// proxy would log the proxy's address for every request. [newEchoServer]
// restores the previous behavior.
func TestNewEchoServer_RealIP(t *testing.T) {
srv := newEchoServer()
if srv.IPExtractor == nil {
t.Fatal("no IPExtractor configured: remote_ip would report the proxy address")
}
for _, tc := range []struct {
scenario string
headers map[string]string
want string
}{
{"x-forwarded-for keeps the client, not the proxy", map[string]string{"X-Forwarded-For": "203.0.113.7, 70.41.3.18"}, "203.0.113.7"},
{"single x-forwarded-for", map[string]string{"X-Forwarded-For": "203.0.113.7"}, "203.0.113.7"},
{"bracketed IPv6 is unwrapped", map[string]string{"X-Forwarded-For": "[2001:db8::1], 70.41.3.18"}, "2001:db8::1"},
{"x-real-ip when no x-forwarded-for", map[string]string{"X-Real-IP": "203.0.113.9"}, "203.0.113.9"},
{"no headers falls back to the remote address", nil, "192.0.2.1"},
} {
t.Run(tc.scenario, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.1:1234"
for key, value := range tc.headers {
req.Header.Set(key, value)
}
c := srv.NewContext(req, httptest.NewRecorder())
if got := c.RealIP(); got != tc.want {
t.Fatalf("RealIP = %q, want %q", got, tc.want)
}
})
}
}
// TestNewEchoServer_AttachmentServesAbsolutePath pins the filesystem that every
// conversion response is sent through.
//
// Echo v5 serves files through Echo.Filesystem, an [fs.FS] rooted at the working
// directory, and [fs.FS] rejects absolute names. Gotenberg builds every output
// file under the request's temporary directory and hands Context.Attachment an
// absolute path, so with the default filesystem every conversion route answers
// 404 while still reading the whole upload.
func TestNewEchoServer_AttachmentServesAbsolutePath(t *testing.T) {
path := filepath.Join(t.TempDir(), "output.pdf")
want := []byte("%PDF-1.7 not really a PDF")
err := os.WriteFile(path, want, 0o600)
if err != nil {
t.Fatalf("write fixture: %v", err)
}
srv := newEchoServer()
rec := httptest.NewRecorder()
c := srv.NewContext(httptest.NewRequest(http.MethodPost, "/", nil), rec)
err = c.Attachment(path, "output.pdf")
if err != nil {
t.Fatalf("Attachment(%q) = %v, want nil", path, err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Body.Bytes(); !bytes.Equal(got, want) {
t.Fatalf("body = %q, want %q", got, want)
}
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, `filename="output.pdf"`) {
t.Fatalf("Content-Disposition = %q, want it to carry filename=\"output.pdf\"", got)
}
}

View File

@@ -4,7 +4,7 @@ import (
"log/slog" "log/slog"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -83,7 +83,7 @@ func (ctx *ContextMock) SetLogger(logger *slog.Logger) {
// //
// ctx := &api.ContextMock{Context: &api.Context{}} // ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.setEchoContext(c) // ctx.setEchoContext(c)
func (ctx *ContextMock) SetEchoContext(c echo.Context) { func (ctx *ContextMock) SetEchoContext(c *echo.Context) {
ctx.echoCtx = c ctx.echoCtx = c
} }

View File

@@ -12,12 +12,14 @@ import (
"time" "time"
cdprotobrowser "github.com/chromedp/cdproto/browser" cdprotobrowser "github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/fetch" "github.com/chromedp/cdproto/fetch"
"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"
"github.com/chromedp/cdproto/target"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/shirou/gopsutil/v4/process" "github.com/shirou/gopsutil/v4/process"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -66,12 +68,27 @@ type browserArguments struct {
denyList []*regexp2.Regexp denyList []*regexp2.Regexp
denyPrivateIPs bool denyPrivateIPs bool
denyPublicIPs bool denyPublicIPs bool
sharedBrowserContext bool
clearCache bool clearCache bool
clearCookies bool clearCookies bool
clearStorage bool clearStorage bool
disableJavaScript bool disableJavaScript bool
} }
// restrictsNetwork reports whether the outbound policy can reject a network
// URL. The default deny-list only rejects file:// URLs, so it does not count.
func (a browserArguments) restrictsNetwork() bool {
if a.denyPrivateIPs || a.denyPublicIPs || len(a.allowList) > 0 {
return true
}
for _, pattern := range a.denyList {
if pattern.String() != defaultDenyListPattern {
return true
}
}
return false
}
type chromiumBrowser struct { type chromiumBrowser struct {
initialCtx context.Context initialCtx context.Context
ctx context.Context ctx context.Context
@@ -202,7 +219,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
} }
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL())) opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs { if b.arguments.restrictsNetwork() {
// Chromium implicitly bypasses the proxy for loopback and // Chromium implicitly bypasses the proxy for loopback and
// link-local destinations. A WebSocket handshake is never surfaced // link-local destinations. A WebSocket handshake is never surfaced
// as a fetch.EventRequestPaused, so listenForEventRequestPaused // as a fetch.EventRequestPaused, so listenForEventRequestPaused
@@ -210,17 +227,14 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1, // it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
// localhost, or the link-local cloud metadata endpoint // localhost, or the link-local cloud metadata endpoint
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes // (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
// the implicit bypass so those handshakes also traverse the pinning // the implicit bypass so those handshakes also traverse the
// proxy and go through [gotenberg.DecideOutbound] like every other // pinning proxy and go through [gotenberg.DecideOutbound] like
// request. // every other request.
// //
// Gated on the IP-class policy: it is the control this closes, and // Gated on a policy that can reject a network URL: without one,
// under it loopback and link-local HTTP sub-resources are already // loopback is not restricted, and routing it through the proxy
// blocked by listenForEventRequestPaused before they would reach // would only turn a refused loopback sub-resource into a 502
// the proxy, so this adds only the missing WebSocket coverage. When // response instead of a loading failure.
// the policy is off, loopback is not restricted, and routing it
// through the proxy would merely change how an unreachable loopback
// sub-resource reports its failure.
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>")) opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
} }
} }
@@ -435,6 +449,55 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
}) })
} }
// newIsolatedTarget creates a browser context and opens a blank tab in it, so
// cookies, cache, and storage never leak to another conversion, even a
// concurrent one. The caller must dispose the browser context.
//
// chromedp.WithNewBrowserContext does not work here: it opens the tab without
// a new window, which headless Chromium rejects with "no browser is open", and
// it leaks the browser context on that failure.
func (b *chromiumBrowser) newIsolatedTarget(ctx context.Context, logger *slog.Logger) (cdp.BrowserContextID, target.ID, error) {
c := chromedp.FromContext(b.ctx)
if c == nil || c.Browser == nil {
return "", "", errors.New("browser not allocated")
}
executor := cdp.WithExecutor(ctx, c.Browser)
browserContextID, err := target.CreateBrowserContext().WithDisposeOnDetach(true).Do(executor)
if err != nil {
return "", "", fmt.Errorf("create browser context: %w", err)
}
targetID, err := target.CreateTarget("about:blank").
WithBrowserContextID(browserContextID).
WithNewWindow(true).
Do(executor)
if err != nil {
b.disposeBrowserContext(logger, browserContextID)
return "", "", fmt.Errorf("create target: %w", err)
}
return browserContextID, targetID, nil
}
// disposeBrowserContext disposes a browser context created by
// [chromiumBrowser.newIsolatedTarget]. It uses a fresh context, as the
// conversion's one may already be done.
func (b *chromiumBrowser) disposeBrowserContext(logger *slog.Logger, browserContextID cdp.BrowserContextID) {
c := chromedp.FromContext(b.ctx)
if c == nil || c.Browser == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := target.DisposeBrowserContext(browserContextID).Do(cdp.WithExecutor(ctx, c.Browser))
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("dispose browser context: %s", err))
}
}
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error { func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
if !b.isStarted.Load() { if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks") return errors.New("browser not started, cannot handle tasks")
@@ -461,7 +524,18 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Until(deadline)) timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Until(deadline))
defer timeoutCancel() defer timeoutCancel()
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx) var taskOpts []chromedp.ContextOption
if !b.arguments.sharedBrowserContext {
browserContextID, targetID, err := b.newIsolatedTarget(timeoutCtx, logger)
if err != nil {
return fmt.Errorf("create isolated browser context: %w", err)
}
// Deferred before taskCancel, so it runs after the tab is closed.
defer b.disposeBrowserContext(logger, browserContextID)
taskOpts = append(taskOpts, chromedp.WithTargetID(targetID))
}
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx, taskOpts...)
defer taskCancel() defer taskCancel()
// Accumulate per-conversion network activity for telemetry. // Accumulate per-conversion network activity for telemetry.

View File

@@ -6,6 +6,8 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"github.com/dlclark/regexp2/v2"
) )
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch // TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
@@ -59,3 +61,36 @@ func TestChromiumDisableFeatures(t *testing.T) {
} }
} }
} }
// A WebSocket the outbound policy rejects must reach the pinning proxy, so
// the implicit loopback proxy bypass is removed whenever a policy can reject
// a network URL. See GHSA-73vq-mh8c-27wg.
func TestBrowserArguments_restrictsNetwork(t *testing.T) {
re := func(patterns ...string) []*regexp2.Regexp {
var res []*regexp2.Regexp
for _, p := range patterns {
res = append(res, regexp2.MustCompile(p, regexp2.None))
}
return res
}
for _, tc := range []struct {
scenario string
args browserArguments
expect bool
}{
{scenario: "default deny-list only", args: browserArguments{denyList: re(defaultDenyListPattern)}, expect: false},
{scenario: "no lists", args: browserArguments{}, expect: false},
{scenario: "allow-list", args: browserArguments{allowList: re(`^https://example\.com`), denyList: re(defaultDenyListPattern)}, expect: true},
{scenario: "custom deny-list", args: browserArguments{denyList: re(`^http://internal`)}, expect: true},
{scenario: "default and custom deny-list", args: browserArguments{denyList: re(defaultDenyListPattern, `^http://internal`)}, expect: true},
{scenario: "deny private IPs", args: browserArguments{denyList: re(defaultDenyListPattern), denyPrivateIPs: true}, expect: true},
{scenario: "deny public IPs", args: browserArguments{denyPublicIPs: true}, expect: true},
} {
t.Run(tc.scenario, func(t *testing.T) {
if got := tc.args.restrictsNetwork(); got != tc.expect {
t.Fatalf("restrictsNetwork() = %t, want %t", got, tc.expect)
}
})
}
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/codes"
@@ -97,6 +97,10 @@ var (
ErrPageRangesExceedsPageCount = errors.New("page ranges exceeds page count") ErrPageRangesExceedsPageCount = errors.New("page ranges exceeds page count")
) )
// defaultDenyListPattern is the default --chromium-deny-list entry. It only
// rejects file:// URLs outside /tmp.
const defaultDenyListPattern = `^file:(?!//\/tmp/).*`
// Chromium is a module that provides both an [Api] and routes for converting // Chromium is a module that provides both an [Api] and routes for converting
// an HTML document to PDF. // an HTML document to PDF.
type Chromium struct { type Chromium struct {
@@ -476,12 +480,13 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests") fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset") fs.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset")
fs.StringSlice("chromium-allow-list", []string{}, `Set the allowed URLs for Chromium using regular expressions - supports multiple values. A match bypasses --chromium-deny-private-ips (CHROMIUM_DENY_PRIVATE_IPS) and --chromium-deny-public-ips (CHROMIUM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`) fs.StringSlice("chromium-allow-list", []string{}, `Set the allowed URLs for Chromium using regular expressions - supports multiple values. A match bypasses --chromium-deny-private-ips (CHROMIUM_DENY_PRIVATE_IPS) and --chromium-deny-public-ips (CHROMIUM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`)
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values") fs.StringSlice("chromium-deny-list", []string{defaultDenyListPattern}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services") fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network") fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion") fs.Bool("chromium-shared-browser-context", false, "Run every conversion in one shared browser context, so cookies, cache, and storage carry over between conversions. Only enable it when all callers trust each other: a conversion can then reuse cookies another one set")
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion") fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion - only applies with --chromium-shared-browser-context")
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)") fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion - only applies with --chromium-shared-browser-context")
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion - only applies with --chromium-shared-browser-context")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript") fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
fs.Bool("chromium-disable-routes", false, "Disable the routes") fs.Bool("chromium-disable-routes", false, "Disable the routes")
@@ -515,6 +520,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)") return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
} }
sharedBrowserContext := flags.MustBool("chromium-shared-browser-context")
mod.args = browserArguments{ mod.args = browserArguments{
binPath: binPath, binPath: binPath,
allowInsecureLocalhost: flags.MustBool("chromium-allow-insecure-localhost"), allowInsecureLocalhost: flags.MustBool("chromium-allow-insecure-localhost"),
@@ -531,9 +538,12 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
denyList: flags.MustRegexpSlice("chromium-deny-list"), denyList: flags.MustRegexpSlice("chromium-deny-list"),
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"), denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"), denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
clearCache: flags.MustBool("chromium-clear-cache"), sharedBrowserContext: sharedBrowserContext,
clearCookies: flags.MustBool("chromium-clear-cookies"), // An isolated conversion starts from an empty browser context that
clearStorage: flags.MustBool("chromium-clear-storage"), // is disposed afterward, so there is nothing to clear.
clearCache: sharedBrowserContext && flags.MustBool("chromium-clear-cache"),
clearCookies: sharedBrowserContext && flags.MustBool("chromium-clear-cookies"),
clearStorage: sharedBrowserContext && flags.MustBool("chromium-clear-storage"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"), disableJavaScript: flags.MustBool("chromium-disable-javascript"),
} }

View File

@@ -19,7 +19,7 @@ import (
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -206,7 +206,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
} }
matchStart := time.Now() matchStart := time.Now()
ok, err := header.Scope.MatchString(e.Request.URL) ok, err := gotenberg.MatchPattern(header.Scope, e.Request.URL)
budget.consume(time.Since(matchStart)) budget.consume(time.Since(matchStart))
switch { switch {
@@ -468,6 +468,8 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
"net::ERR_CONNECTION_REFUSED", "net::ERR_CONNECTION_REFUSED",
"net::ERR_CONNECTION_ABORTED", "net::ERR_CONNECTION_ABORTED",
"net::ERR_CONNECTION_FAILED", "net::ERR_CONNECTION_FAILED",
// What a pinning proxy CONNECT that cannot reach its target reports.
"net::ERR_TUNNEL_CONNECTION_FAILED",
"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED",
"net::ERR_INTERNET_DISCONNECTED", "net::ERR_INTERNET_DISCONNECTED",
"net::ERR_ADDRESS_UNREACHABLE", "net::ERR_ADDRESS_UNREACHABLE",

View File

@@ -14,7 +14,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"golang.org/x/net/http/httpproxy" "golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -522,7 +522,7 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
} else { } else {
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err)) p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
} }
http.Error(w, "upstream error", http.StatusBadGateway) resetClient(w)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -585,3 +585,23 @@ func isClientCancellation(ctx context.Context, err error) bool {
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) { func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass) return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
} }
// resetClient aborts the client connection with a TCP reset instead of
// answering. Chromium treats a proxy response as the origin's own, so a 502
// would reach the page as an ordinary HTTP error; a reset surfaces as a
// network failure, as it would without the proxy.
func resetClient(w http.ResponseWriter) {
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
conn, _, err := hj.Hijack()
if err != nil {
return
}
if tcpConn, ok := conn.(*net.TCPConn); ok {
_ = tcpConn.SetLinger(0)
}
_ = conn.Close()
}

View File

@@ -15,10 +15,11 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -764,6 +765,47 @@ func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
} }
} }
// A forward request whose target cannot be reached resets the client
// connection rather than answering 502. Chromium treats a proxy response as
// the origin's own, so a 502 would hide the failure from
// failOnResourceLoadingFailed.
func TestPinningProxy_Forward_DialFailure_ResetsClient(t *testing.T) {
p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
return nil, errors.New("connection refused")
}
logger := slog.New(slog.DiscardHandler)
err := p.Start(logger)
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(logger) })
conn, err := net.Dial("tcp", strings.TrimPrefix(p.URL(), "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "GET http://real.example/ HTTP/1.1\r\nHost: real.example\r\n\r\n")
if err != nil {
t.Fatalf("write request: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err == nil {
_ = resp.Body.Close()
t.Fatalf("got status %d, want the connection reset", resp.StatusCode)
}
if !errors.Is(err, syscall.ECONNRESET) && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("read response: %v, want a connection reset", err)
}
}
// TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies // TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies
// the handleForward dial-cancellation path: when the inner Transport's // the handleForward dial-cancellation path: when the inner Transport's
// dial returns a canceled error (client hung up mid-dial), the proxy // dial returns a canceled error (client hung up mid-dial), the proxy
@@ -790,12 +832,9 @@ func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T)
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
} }
resp, err := client.Get("http://www.google.com/") resp, err := client.Get("http://www.google.com/")
if err != nil { if err == nil {
t.Fatalf("GET via proxy: %v", err) _ = resp.Body.Close()
} t.Fatalf("GET via proxy: got status %d, want the connection reset", resp.StatusCode)
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", resp.StatusCode)
} }
records := rec.snapshot() records := rec.snapshot()

View File

@@ -14,7 +14,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )

View File

@@ -14,9 +14,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -466,7 +466,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/chromium/convert/url", Path: "/forms/chromium/convert/url",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false) mode := pdfengines.FormDataPdfSplitMode(form, false)
@@ -528,7 +528,7 @@ func screenshotUrlRoute(chromium Api) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/chromium/screenshot/url", Path: "/forms/chromium/screenshot/url",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx) form, options := FormDataChromiumScreenshotOptions(ctx)
@@ -562,7 +562,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/chromium/convert/html", Path: "/forms/chromium/convert/html",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false) mode := pdfengines.FormDataPdfSplitMode(form, false)
@@ -621,7 +621,7 @@ func screenshotHtmlRoute(chromium Api) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/chromium/screenshot/html", Path: "/forms/chromium/screenshot/html",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx) form, options := FormDataChromiumScreenshotOptions(ctx)
@@ -652,7 +652,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/chromium/convert/markdown", Path: "/forms/chromium/convert/markdown",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false) mode := pdfengines.FormDataPdfSplitMode(form, false)
@@ -720,7 +720,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/chromium/screenshot/markdown", Path: "/forms/chromium/screenshot/markdown",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx) form, options := FormDataChromiumScreenshotOptions(ctx)

View File

@@ -6,7 +6,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestScopeMatchBudget(t *testing.T) { func TestScopeMatchBudget(t *testing.T) {
@@ -90,7 +92,7 @@ func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
break break
} }
matchStart := time.Now() matchStart := time.Now()
_, _ = pattern.MatchString(url) _, _ = gotenberg.MatchPattern(pattern, url)
b.consume(time.Since(matchStart)) b.consume(time.Since(matchStart))
matched++ matched++
} }
@@ -100,10 +102,13 @@ func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
t.Errorf("all %d headers were matched, want the budget to stop matching early", headers) t.Errorf("all %d headers were matched, want the budget to stop matching early", headers)
} }
// Each match is separately capped at extraHttpHeaderScopeMatchTimeout, so // A match that is genuinely out of budget costs a few
// the worst case is the budget plus one final match that started with the // extraHttpHeaderScopeMatchTimeout rather than one:
// last of the credit. Generous slack keeps this stable on a loaded CI box. // [gotenberg.MatchPattern] retries an abort to tell a real runaway from
ceiling := budget + extraHttpHeaderScopeMatchTimeout + time.Second // one caused by the process losing the CPU. The worst case is the budget
// plus one final match that started with the last of the credit. Generous
// slack keeps this stable on a loaded CI box.
ceiling := budget + 4*extraHttpHeaderScopeMatchTimeout + time.Second
if elapsed > ceiling { if elapsed > ceiling {
t.Errorf("matching took %s, want at most %s", elapsed, ceiling) t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
} }

View File

@@ -266,6 +266,10 @@ type Options struct {
// NativeTiledWatermarkText specifies the tiled watermark text. // NativeTiledWatermarkText specifies the tiled watermark text.
NativeTiledWatermarkText string NativeTiledWatermarkText string
// GenerateTaggedPdf specifies whether to generate a tagged (accessible)
// PDF without declaring PDF/UA conformance. PdfFormats.PdfUa implies it.
GenerateTaggedPdf bool
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b, // PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA. // PDF/A-3b and PDF/UA.
PdfFormats gotenberg.PdfFormats PdfFormats gotenberg.PdfFormats
@@ -318,6 +322,7 @@ func DefaultOptions() Options {
NativeWatermarkRotateAngle: 0, NativeWatermarkRotateAngle: 0,
NativeWatermarkFontName: "Helvetica", NativeWatermarkFontName: "Helvetica",
NativeTiledWatermarkText: "", NativeTiledWatermarkText: "",
GenerateTaggedPdf: false,
PdfFormats: gotenberg.PdfFormats{ PdfFormats: gotenberg.PdfFormats{
PdfA: "", PdfA: "",
PdfUa: false, PdfUa: false,

View File

@@ -437,7 +437,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
args = append( args = append(
args, args,
"--export", "PDFUACompliance=false", "--export", "PDFUACompliance=false",
"--export", "UseTaggedPDF=false", "--export", fmt.Sprintf("UseTaggedPDF=%t", options.GenerateTaggedPdf),
"--export", "EnableTextAccessForAccessibilityTools=false", "--export", "EnableTextAccessForAccessibilityTools=false",
) )
} }

View File

@@ -14,7 +14,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
"golang.org/x/net/http/httpproxy" "golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"

View File

@@ -17,14 +17,14 @@ import (
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
) )
func compileRegexes(t *testing.T, patterns ...string) []*regexp2.Regexp { func compileRegexes(t *testing.T, patterns ...string) []*regexp2.Regexp {
t.Helper() t.Helper()
out := make([]*regexp2.Regexp, 0, len(patterns)) out := make([]*regexp2.Regexp, 0, len(patterns))
for _, p := range patterns { for _, p := range patterns {
r, err := regexp2.Compile(p, 0) r, err := regexp2.Compile(p, regexp2.None)
if err != nil { if err != nil {
t.Fatalf("compile %q: %v", p, err) t.Fatalf("compile %q: %v", p, err)
} }

View File

@@ -7,7 +7,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -27,7 +27,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/libreoffice/convert", Path: "/forms/libreoffice/convert",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
defaultOptions := libreofficeapi.DefaultOptions() defaultOptions := libreofficeapi.DefaultOptions()
@@ -75,6 +75,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
skipEmptyPages bool skipEmptyPages bool
addOriginalDocumentAsStream bool addOriginalDocumentAsStream bool
singlePageSheets bool singlePageSheets bool
generateTaggedPdf bool
losslessImageCompression bool losslessImageCompression bool
quality int quality int
reduceImageResolution bool reduceImageResolution bool
@@ -126,6 +127,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages). Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream). Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets). Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
Bool("generateTaggedPdf", &generateTaggedPdf, defaultOptions.GenerateTaggedPdf).
Custom("initialView", func(value string) error { Custom("initialView", func(value string) error {
if value == "" { if value == "" {
initialView = defaultOptions.InitialView initialView = defaultOptions.InitialView
@@ -396,6 +398,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
NativeWatermarkRotateAngle: nativeWatermarkRotateAngle, NativeWatermarkRotateAngle: nativeWatermarkRotateAngle,
NativeWatermarkFontName: nativeWatermarkFontName, NativeWatermarkFontName: nativeWatermarkFontName,
NativeTiledWatermarkText: nativeTiledWatermarkText, NativeTiledWatermarkText: nativeTiledWatermarkText,
GenerateTaggedPdf: generateTaggedPdf,
} }
if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing { if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing {

View File

@@ -12,7 +12,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"

View File

@@ -11,7 +11,7 @@ import (
"strings" "strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -1011,7 +1011,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/merge", Path: "/forms/pdfengines/merge",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1212,7 +1212,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/split", Path: "/forms/pdfengines/split",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1364,7 +1364,7 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/flatten", Path: "/forms/pdfengines/flatten",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1399,7 +1399,7 @@ func optimizeRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/optimize", Path: "/forms/pdfengines/optimize",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1435,7 +1435,7 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/convert", Path: "/forms/pdfengines/convert",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1492,7 +1492,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/metadata/read", Path: "/forms/pdfengines/metadata/read",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
var inputPaths []string var inputPaths []string
@@ -1546,7 +1546,7 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/metadata/write", Path: "/forms/pdfengines/metadata/write",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1581,7 +1581,7 @@ func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/read", Path: "/forms/pdfengines/bookmarks/read",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
var inputPaths []string var inputPaths []string
@@ -1634,7 +1634,7 @@ func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/write", Path: "/forms/pdfengines/bookmarks/write",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1669,7 +1669,7 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/encrypt", Path: "/forms/pdfengines/encrypt",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1714,7 +1714,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/embed", Path: "/forms/pdfengines/embed",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1776,7 +1776,7 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/watermark", Path: "/forms/pdfengines/watermark",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1833,7 +1833,7 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/stamp", Path: "/forms/pdfengines/stamp",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1892,7 +1892,7 @@ func rotateRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/rotate", Path: "/forms/pdfengines/rotate",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
@@ -1929,7 +1929,7 @@ func facturXRoute(engine gotenberg.PdfEngine) api.Route {
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/factur-x", Path: "/forms/pdfengines/factur-x",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c *echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()

View File

@@ -7,7 +7,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"

View File

@@ -11,7 +11,7 @@ import (
"time" "time"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/propagation"

View File

@@ -14,7 +14,7 @@ import (
"time" "time"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -95,7 +95,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}) })
} }
return func(c echo.Context) error { return func(c *echo.Context) error {
webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url") webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url")
if webhookUrl == "" { if webhookUrl == "" {
// No webhook URL, call the next middleware in the chain. // No webhook URL, call the next middleware in the chain.
@@ -208,7 +208,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
} }
// Retrieve values from echo.Context before it gets recycled. // Retrieve values from [echo.Context] before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000. // See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time) startTime := c.Get("startTime").(time.Time)
correlationIdHeader := c.Get("correlationIdHeader").(string) correlationIdHeader := c.Get("correlationIdHeader").(string)
@@ -323,16 +323,16 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// As a webhook URL has been given, we handle the request in a // As a webhook URL has been given, we handle the request in a
// goroutine and return immediately. // goroutine and return immediately.
// //
// Echo returns the echo.Context back to its sync.Pool as // Echo returns the [echo.Context] back to its sync.Pool as
// soon as this synchronous handler returns ErrAsyncProcess. // soon as this synchronous handler returns ErrAsyncProcess.
// A concurrent request can then claim the recycled context // A concurrent request can then claim the recycled context
// and c.Reset() wipes the shared store, which would cause // and c.Reset() wipes the shared store, which would cause
// any c.Get("...").(T) assertion downstream of the webhook // any c.Get("...").(T) assertion downstream of the webhook
// goroutine to panic on a nil value and crash the process. // goroutine to panic on a nil value and crash the process.
// Snapshot the keys downstream reads onto a detached // Snapshot the keys downstream reads onto a detached
// wrapper before spawning the goroutine so pool reuse // context before spawning the goroutine so pool reuse
// cannot reach into our async work. // cannot reach into our async work.
detached := newPoolSafeContext(c, "logger", "context", "correlationId", "correlationIdHeader", "startTime") detached := newDetachedContext(c, "logger", "context", "correlationId", "correlationIdHeader", "startTime")
w.asyncCount.Add(1) w.asyncCount.Add(1)
go func() { go func() {

View File

@@ -1,58 +1,34 @@
package webhook package webhook
import ( import (
"sync" "github.com/labstack/echo/v5"
"github.com/labstack/echo/v4"
) )
// poolSafeContext wraps an [echo.Context] and keeps a private snapshot of // newDetachedContext returns an [echo.Context] carrying a snapshot of the given
// the values that downstream middleware and route handlers read from the // keys, detached from Echo's context pool.
// store. Echo returns an [echo.Context] to its sync.Pool as soon as the
// synchronous handler returns, including when the webhook middleware
// returns [api.ErrAsyncProcess]. A concurrent request can then claim the
// recycled context and c.Reset() wipes the shared store out from under
// the webhook goroutine, which causes any
// `c.Get("logger").(*slog.Logger)`-style assertion further down the
// chain to panic on a nil value.
// //
// Wrapping c before handing it to the goroutine insulates the async work // Echo returns an [echo.Context] to its sync.Pool as soon as the synchronous
// from pool reuse: Get/Set read and write the private store while every // handler returns, including when the webhook middleware returns
// other [echo.Context] method delegates to the embedded context for // [api.ErrAsyncProcess]. A concurrent request can then claim the recycled
// anything the downstream might still need. // context and c.Reset() wipes the shared store out from under the webhook
type poolSafeContext struct { // goroutine, which causes any `c.Get("logger").(*slog.Logger)`-style assertion
echo.Context // further down the chain to panic on a nil value.
mu sync.RWMutex //
store map[string]any // [echo.NewContext] allocates outside the pool, so recycling cannot reach the
} // returned context. Keys absent from c are omitted; the returned context still
// newPoolSafeContext snapshots the given keys from c into a detached
// store and returns a wrapper whose Get/Set operate on that store
// exclusively. Keys absent from c are omitted; the wrapper still
// returns nil for them, matching [echo.Context.Get] behavior. // returns nil for them, matching [echo.Context.Get] behavior.
func newPoolSafeContext(c echo.Context, keys ...string) *poolSafeContext { //
store := make(map[string]any, len(keys)) // Only the asynchronous path uses this. Nothing downstream of the webhook
// middleware writes to the response: contextMiddleware sits upstream and has
// already answered 204 by the time the goroutine runs.
func newDetachedContext(c *echo.Context, keys ...string) *echo.Context {
detached := echo.NewContext(c.Request(), c.Response(), c.Echo())
for _, key := range keys { for _, key := range keys {
if v := c.Get(key); v != nil { if v := c.Get(key); v != nil {
store[key] = v detached.Set(key, v)
} }
} }
return &poolSafeContext{Context: c, store: store}
}
// Get returns the value stored in the detached store, not the embedded return detached
// context's pooled store.
func (p *poolSafeContext) Get(key string) any {
p.mu.RLock()
defer p.mu.RUnlock()
return p.store[key]
}
// Set writes to the detached store, not the embedded context's pooled
// store. This prevents downstream middleware writes from leaking into a
// later request that claims the same pooled context.
func (p *poolSafeContext) Set(key string, val any) {
p.mu.Lock()
defer p.mu.Unlock()
p.store[key] = val
} }

View File

@@ -6,10 +6,10 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
) )
func TestPoolSafeContext_SurvivesUnderlyingReset(t *testing.T) { func TestNewDetachedContext_SurvivesUnderlyingReset(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil) req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -19,10 +19,10 @@ func TestPoolSafeContext_SurvivesUnderlyingReset(t *testing.T) {
c.Set("logger", logger) c.Set("logger", logger)
c.Set("correlationId", "abc-123") c.Set("correlationId", "abc-123")
detached := newPoolSafeContext(c, "logger", "correlationId", "missing") detached := newDetachedContext(c, "logger", "correlationId", "missing")
// Simulate Echo recycling c for a concurrent request. Reset wipes the // Simulate Echo recycling c for a concurrent request. Reset clears the
// shared store, which is exactly the crash scenario the wrapper // pooled store, which is exactly the crash scenario the detached context
// guards against. // guards against.
c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder()) c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder())
@@ -38,17 +38,17 @@ func TestPoolSafeContext_SurvivesUnderlyingReset(t *testing.T) {
// Underlying c must remain clean. // Underlying c must remain clean.
if c.Get("logger") != nil { if c.Get("logger") != nil {
t.Fatalf("underlying c.Get(\"logger\") leaked wrapper state after reset") t.Fatalf("underlying c.Get(\"logger\") leaked detached state after reset")
} }
} }
func TestPoolSafeContext_SetDoesNotTouchUnderlying(t *testing.T) { func TestNewDetachedContext_SetDoesNotTouchUnderlying(t *testing.T) {
e := echo.New() e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil) req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
detached := newPoolSafeContext(c) detached := newDetachedContext(c)
detached.Set("foo", "bar") detached.Set("foo", "bar")
if got, _ := detached.Get("foo").(string); got != "bar" { if got, _ := detached.Get("foo").(string); got != "bar" {

View File

@@ -5,7 +5,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2/v2"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"

View File

@@ -25,7 +25,7 @@ Available tags:
| Group | Tags | | Group | Tags |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` | | Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-image-deduplication`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` |
| LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` | | LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-optimize`, `optimize`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` | | PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-optimize`, `optimize`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` | | Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
@@ -67,6 +67,7 @@ Available tags:
- `there should be the following file(s) in the (response|webhook request):` (table of filenames) - `there should be the following file(s) in the (response|webhook request):` (table of filenames)
- `the "<name>" PDF should have <N> page(s)` - `the "<name>" PDF should have <N> page(s)`
- `the "<name>" PDF (should|should NOT) be set to landscape orientation` - `the "<name>" PDF (should|should NOT) be set to landscape orientation`
- `the "<name>" PDF (should|should NOT) be tagged`
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring) - `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
- `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>` - `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>`
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`) - `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
@@ -75,6 +76,7 @@ Available tags:
- `the (response|webhook request) PDF(s) (should|should NOT) allow "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`) - `the (response|webhook request) PDF(s) (should|should NOT) allow "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`)
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded` - `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
- `the "<name>" PDF should have <N> image(s)` - `the "<name>" PDF should have <N> image(s)`
- `the "<name>" PDF should reuse <N> image XObject(s) across <P> page(s)` (checks image appearances on every page and distinct PDF object IDs)
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings) - `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
- `all concurrent response status codes should be <code>` - `all concurrent response status codes should be <code>`
- `all concurrent responses should have <N> PDF(s)` - `all concurrent responses should have <N> PDF(s)`

View File

@@ -521,6 +521,22 @@ Feature: /forms/chromium/convert/html
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address | | 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
| CONNECT blocked for '127.0.0.1:9999' | | CONNECT blocked for '127.0.0.1:9999' |
# An allow-list is enforced on its own, without the IP-class flags. Chromium
# skips the proxy for loopback and link-local destinations unless told
# otherwise, so a WebSocket the allow-list rejects must still reach the
# pinning proxy to be severed.
@chromium-ssrf
Scenario: POST /forms/chromium/convert/html (WebSocket not in the allow-list is filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^file:///tmp/.* |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/ssrf-websocket-html/index.html | file |
| waitDelay | 1s | field |
Then the response status code should be 200
Then the Gotenberg container should log the following entries:
| 'ws://127.0.0.1:9999/ssrf-websocket' does not match any expression from the allowed list |
| CONNECT blocked for '127.0.0.1:9999' |
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced # EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused. # as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
@chromium-ssrf @chromium-ssrf
@@ -609,6 +625,7 @@ Feature: /forms/chromium/convert/html
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file | | files | testdata/feature-rich-html/index.html | file |
| failOnResourceHttpStatusCodes | [499,599] | field | | failOnResourceHttpStatusCodes | [499,599] | field |
| skipNetworkIdleEvent | false | field |
Then the response status code should be 409 Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string: Then the response body should match string:
@@ -627,12 +644,29 @@ Feature: /forms/chromium/convert/html
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
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed) # Through the pinning proxy, an unreachable resource must still fail the
# conversion. The proxy resets the connection instead of answering 502, which
# Chromium would treat as an ordinary HTTP error.
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed through the pinning proxy)
Given I have a default Gotenberg container Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file | | files | testdata/feature-rich-html/index.html | file |
| failOnResourceLoadingFailed | true | field | | failOnResourceLoadingFailed | true | field |
Then the response status code should be 409 Then the response status code should be 409
Then the response body should contain string:
"""
resource Stylesheet: net::ERR_CONNECTION_RESET
"""
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
# A configured allow-list routes loopback through the pinning proxy, which
# reports a refused connection as a reset. See the scenario below.
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| failOnResourceLoadingFailed | true | field |
Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string: Then the response body should contain string:
""" """

View File

@@ -400,6 +400,7 @@ Feature: /forms/chromium/convert/markdown
| files | testdata/feature-rich-markdown/index.html | file | | files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file | | files | testdata/feature-rich-markdown/table.md | file |
| failOnResourceHttpStatusCodes | [499,599] | field | | failOnResourceHttpStatusCodes | [499,599] | field |
| skipNetworkIdleEvent | false | field |
Then the response status code should be 409 Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string: Then the response body should match string:
@@ -409,7 +410,11 @@ Feature: /forms/chromium/convert/markdown
""" """
Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed) Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed)
Given I have a default Gotenberg container # A configured allow-list routes loopback through the pinning proxy, which
# reports a refused connection as a reset. See the convert/html
# scenario.
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/feature-rich-markdown/index.html | file | | files | testdata/feature-rich-markdown/index.html | file |
| files | testdata/feature-rich-markdown/table.md | file | | files | testdata/feature-rich-markdown/table.md | file |

View File

@@ -22,10 +22,9 @@ Feature: /forms/chromium/convert/url
Page 1 Page 1
""" """
# localStorage is per-origin and shared by the long-lived browser, so without # Each conversion runs in its own browser context, so localStorage never
# clearing it accumulates across same-origin conversions. # carries over.
# See https://github.com/gotenberg/gotenberg/issues/919. Scenario: POST /forms/chromium/convert/url (localStorage isolated per conversion)
Scenario: POST /forms/chromium/convert/url (localStorage leaks without clearing)
Given I have a default Gotenberg container Given I have a default Gotenberg container
Given I have a static server Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
@@ -37,13 +36,33 @@ Feature: /forms/chromium/convert/url
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field | | url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
| Gotenberg-Output-Filename | foo | header | | Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200 Then the response status code should be 200
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
# With --chromium-shared-browser-context, localStorage is per-origin and
# shared by the long-lived browser, so without clearing it accumulates
# across same-origin conversions.
# See https://github.com/gotenberg/gotenberg/issues/919.
Scenario: POST /forms/chromium/convert/url (localStorage leaks in a shared browser context)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_SHARED_BROWSER_CONTEXT | true |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1 Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1
# With --chromium-clear-storage every conversion clears the origin's # With --chromium-clear-storage every conversion in a shared browser context
# localStorage first, so the counter never carries over. # clears the origin's localStorage first, so the counter never carries over.
# See https://github.com/gotenberg/gotenberg/issues/919. # See https://github.com/gotenberg/gotenberg/issues/919.
Scenario: POST /forms/chromium/convert/url (Clear Storage) Scenario: POST /forms/chromium/convert/url (Clear Storage)
Given I have a Gotenberg container with the following environment variable(s): Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_SHARED_BROWSER_CONTEXT | true |
| CHROMIUM_CLEAR_STORAGE | true | | CHROMIUM_CLEAR_STORAGE | true |
Given I have a static server Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
@@ -210,6 +229,36 @@ Feature: /forms/chromium/convert/url
Then the server request cookie "cookie_1" should be "foo" Then the server request cookie "cookie_1" should be "foo"
Then the server request cookie "cookie_2" should be "" Then the server request cookie "cookie_2" should be ""
# A cookie set by one conversion must not reach the next one.
Scenario: POST /forms/chromium/convert/url (Cookies isolated per conversion)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"}] | field |
Then the response status code should be 200
Then the server request cookie "cookie_1" should be "foo"
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
Then the response status code should be 200
Then the server request cookie "cookie_1" should be ""
# With --chromium-shared-browser-context, cookies carry over between
# conversions.
Scenario: POST /forms/chromium/convert/url (Cookies in a shared browser context)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_SHARED_BROWSER_CONTEXT | true |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"}] | field |
Then the response status code should be 200
Then the server request cookie "cookie_1" should be "foo"
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
Then the response status code should be 200
Then the server request cookie "cookie_1" should be "foo"
# See https://github.com/gotenberg/gotenberg/issues/1130. # See https://github.com/gotenberg/gotenberg/issues/1130.
Scenario: POST /forms/chromium/convert/url (case-insensitive sameSite) Scenario: POST /forms/chromium/convert/url (case-insensitive sameSite)
Given I have a default Gotenberg container Given I have a default Gotenberg container
@@ -649,6 +698,7 @@ Feature: /forms/chromium/convert/url
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| failOnResourceHttpStatusCodes | [499,599] | field | | failOnResourceHttpStatusCodes | [499,599] | field |
| skipNetworkIdleEvent | false | field |
Then the response status code should be 409 Then the response status code should be 409
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should contain string: Then the response body should contain string:
@@ -661,7 +711,11 @@ Feature: /forms/chromium/convert/url
""" """
Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed) Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed)
Given I have a default Gotenberg container # A configured allow-list routes loopback through the pinning proxy, which
# reports a refused connection as a reset. See the convert/html
# scenario.
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
Given I have a static server Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |

View File

@@ -0,0 +1,57 @@
@chromium
@chromium-convert-html
@chromium-image-deduplication
Feature: Chromium header and footer image deduplication
# See: https://github.com/gotenberg/gotenberg/issues/1077.
# See: https://github.com/gotenberg/gotenberg/issues/1669.
# The PNG must appear on every page, but its image XObject must be shared.
Scenario: POST /forms/chromium/convert/html (Header image reused across pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| files | testdata/header-footer-image/header.html | file |
| marginTop | 1 | field |
| marginBottom | 1 | field |
| Gotenberg-Output-Filename | header | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "header.pdf" PDF should have 1 page(s)
Then the "header.pdf" PDF should reuse 1 image XObject(s) across 1 page(s)
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-3-html/index.html | file |
| files | testdata/header-footer-image/header.html | file |
| marginTop | 1 | field |
| marginBottom | 1 | field |
| Gotenberg-Output-Filename | header | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "header.pdf" PDF should have 3 page(s)
Then the "header.pdf" PDF should reuse 1 image XObject(s) across 3 page(s)
Scenario: POST /forms/chromium/convert/html (Footer image reused across pages)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| files | testdata/header-footer-image/footer.html | file |
| marginTop | 1 | field |
| marginBottom | 1 | field |
| Gotenberg-Output-Filename | footer | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "footer.pdf" PDF should have 1 page(s)
Then the "footer.pdf" PDF should reuse 1 image XObject(s) across 1 page(s)
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/pages-3-html/index.html | file |
| files | testdata/header-footer-image/footer.html | file |
| marginTop | 1 | field |
| marginBottom | 1 | field |
| Gotenberg-Output-Filename | footer | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "footer.pdf" PDF should have 3 page(s)
Then the "footer.pdf" PDF should reuse 1 image XObject(s) across 3 page(s)

View File

@@ -98,6 +98,7 @@ Feature: /debug
"chromium-max-queue-size": "0", "chromium-max-queue-size": "0",
"chromium-proxy-server": "", "chromium-proxy-server": "",
"chromium-restart-after": "100", "chromium-restart-after": "100",
"chromium-shared-browser-context": "false",
"chromium-start-timeout": "20s", "chromium-start-timeout": "20s",
"gotenberg-build-debug-data": "true", "gotenberg-build-debug-data": "true",
"gotenberg-graceful-shutdown-duration": "30s", "gotenberg-graceful-shutdown-duration": "30s",
@@ -237,6 +238,7 @@ Feature: /debug
"chromium-max-concurrency": "6", "chromium-max-concurrency": "6",
"chromium-proxy-server": "", "chromium-proxy-server": "",
"chromium-restart-after": "100", "chromium-restart-after": "100",
"chromium-shared-browser-context": "false",
"chromium-start-timeout": "20s", "chromium-start-timeout": "20s",
"gotenberg-build-debug-data": "true", "gotenberg-build-debug-data": "true",
"gotenberg-graceful-shutdown-duration": "30s", "gotenberg-graceful-shutdown-duration": "30s",

View File

@@ -142,6 +142,24 @@ Feature: /forms/libreoffice/convert
| foo.pdf | | foo.pdf |
Then the "foo.pdf" PDF should be set to landscape orientation Then the "foo.pdf" PDF should be set to landscape orientation
Scenario: POST /forms/libreoffice/convert (Tagged PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| Gotenberg-Output-Filename | foo | header |
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should NOT be tagged
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| generateTaggedPdf | true | field |
| Gotenberg-Output-Filename | foo | header |
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should be tagged
Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document) Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document)
Given I have a default Gotenberg container Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
@@ -215,6 +233,7 @@ Feature: /forms/libreoffice/convert
| skipEmptyPages | foo | field | | skipEmptyPages | foo | field |
| addOriginalDocumentAsStream | foo | field | | addOriginalDocumentAsStream | foo | field |
| singlePageSheets | foo | field | | singlePageSheets | foo | field |
| generateTaggedPdf | foo | field |
| initialView | 5 | field | | initialView | 5 | field |
| initialPage | -1 | field | | initialPage | -1 | field |
| magnification | 9 | field | | magnification | 9 | field |
@@ -255,6 +274,7 @@ Feature: /forms/libreoffice/convert
form field 'skipEmptyPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) form field 'skipEmptyPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
form field 'addOriginalDocumentAsStream' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) form field 'addOriginalDocumentAsStream' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
form field 'singlePageSheets' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) form field 'singlePageSheets' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
form field 'initialView' is invalid (got '5', resulting to value is not 0, 1 or 2) form field 'initialView' is invalid (got '5', resulting to value is not 0, 1 or 2)
form field 'initialPage' is invalid (got '-1', resulting to value is inferior to 1) form field 'initialPage' is invalid (got '-1', resulting to value is inferior to 1)
form field 'magnification' is invalid (got '9', resulting to value is not 0, 1, 2, 3 or 4) form field 'magnification' is invalid (got '9', resulting to value is not 0, 1, 2, 3 or 4)

View File

@@ -0,0 +1,72 @@
package scenario
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
func (s *scenario) thePdfShouldReuseImageXObjects(ctx context.Context, name string, images, pages int) error {
path := filepath.Join(s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("stat PDF %q: %w", path, err)
}
cmd := []string{"pdfimages", "-list", filepath.Base(path)}
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
if err != nil {
return fmt.Errorf("exec %q: %w", cmd, err)
}
return checkImageXObjectReuse(output, images, pages)
}
func checkImageXObjectReuse(output string, images, pages int) error {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) < 2 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "page") {
return fmt.Errorf("invalid pdfimages -list output: %q", output)
}
objects := make(map[[2]int]struct{})
imagesPerPage := make(map[int]int)
for _, line := range lines[2:] {
fields := strings.Fields(line)
if len(fields) < 12 {
return fmt.Errorf("invalid pdfimages -list row: %q", line)
}
page, err := strconv.Atoi(fields[0])
if err != nil {
return fmt.Errorf("parse image page %q: %w", fields[0], err)
}
if page < 1 || page > pages {
return fmt.Errorf("image on page %d outside expected range 1-%d", page, pages)
}
// pdfimages lists appearances, including repeated references to the
// same image. Columns 11 and 12 identify the underlying PDF object.
var ref [2]int
for i := range ref {
ref[i], err = strconv.Atoi(fields[10+i])
if err != nil {
return fmt.Errorf("parse image object ID in %q: %w", line, err)
}
}
objects[ref] = struct{}{}
imagesPerPage[page]++
}
for page := 1; page <= pages; page++ {
if imagesPerPage[page] != images {
return fmt.Errorf("expected %d image appearance(s) on page %d, but actual is %d", images, page, imagesPerPage[page])
}
}
if len(objects) != images {
return fmt.Errorf("expected %d unique image XObject(s) shared across %d page(s), but actual is %d", images, pages, len(objects))
}
return nil
}

View File

@@ -0,0 +1,106 @@
package scenario
import (
"fmt"
"strings"
"testing"
)
func TestCheckImageXObjectReuse(t *testing.T) {
t.Parallel()
const header = `page num type width height color comp bpc enc interp object ID x-ppi y-ppi size ratio
--------------------------------------------------------------------------------------------
`
imageRow := func(page, object, generation int) string {
return fmt.Sprintf("%d %d image 32 32 rgb 3 8 image no %d %d 96 96 3104B 101%%\n", page, page-1, object, generation)
}
tests := []struct {
name string
output string
pages int
wantErr string
}{
{
name: "one page baseline",
output: header + imageRow(1, 7, 0),
pages: 1,
},
{
name: "three pages share one image object",
output: header + imageRow(1, 7, 0) + imageRow(2, 7, 0) + imageRow(3, 7, 0),
pages: 3,
},
{
name: "regression embeds a different object per page",
output: header + imageRow(1, 7, 0) + imageRow(2, 8, 0) + imageRow(3, 9, 0),
pages: 3,
wantErr: "expected 1 unique image XObject(s) shared across 3 page(s), but actual is 3",
},
{
name: "object generation is part of its identity",
output: header + imageRow(1, 7, 0) + imageRow(2, 7, 1) + imageRow(3, 7, 0),
pages: 3,
wantErr: "expected 1 unique image XObject(s) shared across 3 page(s), but actual is 2",
},
{
name: "image missing from a page",
output: header + imageRow(1, 7, 0) + imageRow(3, 7, 0),
pages: 3,
wantErr: "expected 1 image appearance(s) on page 2, but actual is 0",
},
{
name: "no image cannot pass",
output: header,
pages: 1,
wantErr: "expected 1 image appearance(s) on page 1, but actual is 0",
},
{
name: "repeated image only on first page cannot pass",
output: header + imageRow(1, 7, 0) + imageRow(1, 7, 0) + imageRow(1, 7, 0),
pages: 3,
wantErr: "expected 1 image appearance(s) on page 1, but actual is 3",
},
{
name: "image on unexpected page",
output: header + imageRow(1, 7, 0) + imageRow(2, 7, 0),
pages: 1,
wantErr: "image on page 2 outside expected range 1-1",
},
{
name: "tool error cannot pass",
output: "Syntax Error: Couldn't find trailer dictionary",
pages: 1,
wantErr: "invalid pdfimages -list output:",
},
{
name: "malformed image row",
output: header + "1 0 image\n",
pages: 1,
wantErr: "invalid pdfimages -list row:",
},
{
name: "inline image has no reusable object ID",
output: header + "1 0 image 32 32 rgb 3 8 image no [inline] - 96 96 3104B 101%\n",
pages: 1,
wantErr: "parse image object ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := checkImageXObjectReuse(tt.output, 1, tt.pages)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

View File

@@ -1795,10 +1795,12 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel) ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages) ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be tagged$`, s.thePdfShouldBeTagged)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have a document outline$`, s.thePdfShouldHaveADocumentOutline) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have a document outline$`, s.thePdfShouldHaveADocumentOutline)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages) ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
ctx.Then(`^the "([^"]*)" PDF should reuse (\d+) image XObject\(s\) across (\d+) page\(s\)$`, s.thePdfShouldReuseImageXObjects)
ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels) ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels)
ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe) ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe)
ctx.Then(`^the "([^"]*)" file size should be (less|greater) than (\d+) KB$`, s.theFileSizeShouldBe) ctx.Then(`^the "([^"]*)" file size should be (less|greater) than (\d+) KB$`, s.theFileSizeShouldBe)

View File

@@ -15,7 +15,7 @@ import (
"github.com/cucumber/godog" "github.com/cucumber/godog"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v5"
"github.com/mholt/archives" "github.com/mholt/archives"
) )
@@ -26,12 +26,17 @@ type server struct {
errChan chan error errChan chan error
eventBody []byte eventBody []byte
eventMu sync.Mutex eventMu sync.Mutex
shutdown context.CancelFunc
done chan struct{}
} }
func newServer(ctx context.Context, workdir string) (*server, error) { func newServer(ctx context.Context, workdir string) (*server, error) {
srv := echo.New() srv := echo.New()
srv.HideBanner = true // The static file handlers below serve absolute paths, which Echo v5's
srv.HidePort = true // default working-directory filesystem rejects. See newEchoServer in
// pkg/modules/api.
srv.Filesystem = echo.NewDefaultFS("/")
s := &server{ s := &server{
srv: srv, srv: srv,
errChan: make(chan error, 1), errChan: make(chan error, 1),
@@ -47,7 +52,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
return err return err
} }
webhookHandler := func(c echo.Context) error { webhookHandler := func(c *echo.Context) error {
s.req = c.Request() s.req = c.Request()
body, err := io.ReadAll(s.req.Body) body, err := io.ReadAll(s.req.Body)
@@ -131,7 +136,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
return webhookErr(c.String(http.StatusOK, http.StatusText(http.StatusOK))) return webhookErr(c.String(http.StatusOK, http.StatusText(http.StatusOK)))
} }
webhookErrorHandler := func(c echo.Context) error { webhookErrorHandler := func(c *echo.Context) error {
s.req = c.Request() s.req = c.Request()
body, err := io.ReadAll(s.req.Body) body, err := io.ReadAll(s.req.Body)
if err != nil { if err != nil {
@@ -148,7 +153,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
srv.PATCH("/webhook/error", webhookErrorHandler) srv.PATCH("/webhook/error", webhookErrorHandler)
srv.PUT("/webhook/error", webhookErrorHandler) srv.PUT("/webhook/error", webhookErrorHandler)
webhookEventsHandler := func(c echo.Context) error { webhookEventsHandler := func(c *echo.Context) error {
body, err := io.ReadAll(c.Request().Body) body, err := io.ReadAll(c.Request().Body)
if err != nil { if err != nil {
return c.String(http.StatusInternalServerError, err.Error()) return c.String(http.StatusInternalServerError, err.Error())
@@ -159,7 +164,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
return c.String(http.StatusOK, http.StatusText(http.StatusOK)) return c.String(http.StatusOK, http.StatusText(http.StatusOK))
} }
srv.POST("/webhook/events", webhookEventsHandler) srv.POST("/webhook/events", webhookEventsHandler)
srv.GET("/static/:path", func(c echo.Context) error { srv.GET("/static/:path", func(c *echo.Context) error {
s.req = c.Request() s.req = c.Request()
path := c.Param("path") path := c.Param("path")
if strings.Contains(path, "teststore") { if strings.Contains(path, "teststore") {
@@ -167,7 +172,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
} }
return c.Attachment(fmt.Sprintf("%s/%s", wd, path), filepath.Base(path)) return c.Attachment(fmt.Sprintf("%s/%s", wd, path), filepath.Base(path))
}) })
srv.GET("/html/:path", func(c echo.Context) error { srv.GET("/html/:path", func(c *echo.Context) error {
s.req = c.Request() s.req = c.Request()
path := fmt.Sprintf("%s/%s", wd, c.Param("path")) path := fmt.Sprintf("%s/%s", wd, c.Param("path"))
f, err := os.Open(path) f, err := os.Open(path)
@@ -181,7 +186,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
} }
return c.HTML(http.StatusOK, string(b)) return c.HTML(http.StatusOK, string(b))
}) })
srv.GET("/redirect-to-private", func(c echo.Context) error { srv.GET("/redirect-to-private", func(c *echo.Context) error {
s.req = c.Request() s.req = c.Request()
// Redirect the browser to a non-public address so the outbound filter // Redirect the browser to a non-public address so the outbound filter
// is exercised on the redirected request rather than on this URL. // is exercised on the redirected request rather than on this URL.
@@ -206,9 +211,20 @@ func (s *server) start(ctx context.Context) (int, error) {
port := ln.Addr().(*net.TCPAddr).Port port := ln.Addr().(*net.TCPAddr).Port
startConfig := echo.StartConfig{
Listener: ln,
HideBanner: true,
HidePort: true,
}
serveCtx, cancel := context.WithCancel(context.Background())
s.shutdown = cancel
s.done = make(chan struct{})
go func() { go func() {
s.srv.Listener = ln defer close(s.done)
err = s.srv.Start("")
err := startConfig.Start(serveCtx, s.srv)
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && !errors.Is(err, http.ErrServerClosed) {
godog.Log(ctx, err.Error()) godog.Log(ctx, err.Error())
} }
@@ -219,5 +235,17 @@ func (s *server) start(ctx context.Context) (int, error) {
func (s *server) stop(ctx context.Context) error { func (s *server) stop(ctx context.Context) error {
close(s.errChan) close(s.errChan)
return s.srv.Shutdown(ctx)
if s.shutdown == nil {
return nil
}
s.shutdown()
select {
case <-s.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
} }

View File

@@ -0,0 +1,44 @@
package scenario
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
func (s *scenario) thePdfShouldBeTagged(ctx context.Context, name, kind string) error {
path := filepath.Join(s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("stat PDF %q: %w", path, err)
}
cmd := []string{"pdfinfo", filepath.Base(path)}
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
if err != nil {
return fmt.Errorf("exec %q: %w", cmd, err)
}
return checkTagged(output, kind != "should NOT")
}
// checkTagged reads the "Tagged" line of pdfinfo, which reflects the Marked
// entry of the document catalog's MarkInfo dictionary.
func checkTagged(output string, want bool) error {
for line := range strings.SplitSeq(output, "\n") {
value, found := strings.CutPrefix(strings.TrimSpace(line), "Tagged:")
if !found {
continue
}
tagged := strings.TrimSpace(value) == "yes"
if tagged != want {
return fmt.Errorf("expected tagged PDF to be %t, but actual is %t", want, tagged)
}
return nil
}
return fmt.Errorf("invalid pdfinfo output, no Tagged line: %q", output)
}

View File

@@ -0,0 +1,67 @@
package scenario
import (
"strings"
"testing"
)
func TestCheckTagged(t *testing.T) {
t.Parallel()
pdfinfo := func(tagged string) string {
return "Producer: LibreOffice 26.8\nTagged: " + tagged + "\nPages: 1\n"
}
tests := []struct {
name string
output string
want bool
wantErr string
}{
{
name: "tagged PDF expected and found",
output: pdfinfo("yes"),
want: true,
},
{
name: "untagged PDF expected and found",
output: pdfinfo("no"),
want: false,
},
{
name: "tagged PDF expected but untagged",
output: pdfinfo("no"),
want: true,
wantErr: "expected tagged PDF to be true, but actual is false",
},
{
name: "untagged PDF expected but tagged",
output: pdfinfo("yes"),
want: false,
wantErr: "expected tagged PDF to be false, but actual is true",
},
{
name: "no Tagged line",
output: "Pages: 1\n",
want: true,
wantErr: "invalid pdfinfo output, no Tagged line:",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := checkTagged(tc.output, tc.want)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err)
}
})
}
}

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body style="margin: 0; width: 100%">
<!-- An opaque RGB PNG avoids a separate alpha-mask image XObject. -->
<img
alt="Regression test logo"
width="32"
height="32"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAMK0lEQVR4nAEgDN/zAHFeV9yZjwo6SF080rU79JVR0qeuIuYqIIkxwf4rwT7KASBVtxNyEiAxJ4grbwObwdwYZ6P0T7ZyaHzTFJ005RAb+dDFcre2+xqwdWMu6K2qYgD5LRL11Gbts9TyrnGOXwDlHG5y4w4MwrW50xeDinDuoJTl8/S8A8ZLkQMA1u8ezIEjb2uuymxnltx/FBavvETNXZziNgILdKHlL8eh4arzO57gogg1pmz87SbQ68LuEWliRgfzVbw5zKP0DV+iMwMA/Uo3EaY4a9y1Gb5+wwZvfwZoLxNRqlG/ihDl7ZuePDIBwSIq9uDtvTmlFmt51h+uwN/P0/JA8TD79MeLDcLyfWrOiHYWgmZfIIRQ55No5JDAhmwwdhdSUULvyfYHfRYUAA87FNMkQJMhwONa9zyeAZpvUbc3YYjl7PXdmWt9TjIOR9KLlr/oYX4v0p2gwqBEpFuU8rztON0/kzogZ6AyV/Ii59Y1U1XmBQOZc1ZuB0GZdNFLd7YEl5xZ0RoQBng9MACeb3oQG5av5e+OeVd0EBSwTVjj2LIeVDPjC6K3pzm71SWZlQnzGva6q8yRXNgnwCWqhw7il9UPqutAY92KZbAAWXRPSghQMcfJXKhMhTk9IKc00BUIwWMrAM/Iiag0BUsAJ28WinyHUJ+Z7i6kY3mRkpTElQVtxb9PkzzJEkiDNBM2zvI213tqQbms1p3uDAJl7ZhfxXdzN0GUN35tW41YJHqKAx+goQH2joWt3KjyZ1EU0MjktAvsQPLI4aMGNLLLAHjgm2bT2agbLgwuHy/s8zu4MsitoBUFrkFaapQimZrqQe7rk2mE1CE+m7nR/T0TIqzLZ8FfY/kw08XIks4pw63W2xPi039Ixpm4GWd9Dw/XvkgqeKvWtIm+SrK+/hVfEwAwAx4vZY4b9ludgZxTSo3GCP+eJ56dnvDxstPd/iRjbWOLlzMO9RIyHQsQiR6GyAHaYMd10nVDTURBLBRh/vOIVgqW/y5sFLWAT/JwsYFrKSqq+aFD5Qi1vzVrNPbFSRYAeUpOpmKQMUpXRGFn2121iPDhkQtxMuhgIV3GLdj7N2D8jvFitz1ArnJAmbaLXAhUsqvaFVW8uQIl9/RnuS5/nTNZVyzGPpn6/DdR+ilEVioeoJbnMc2dsaHMV3dMX6o6AMtgzHv6tqdLzj1e1JNd6NO7kLp3hJgMZdA6QCjZZTta7F6kUkn+50IrgZKvYSV/OAvJlp5R0hYnIZxYT/GjnG1NX46skoyZYLVEaAbta6Ilt0hT2tJm/k+OW20J7nHXbgCgdIDlXjHrwu0j7Lhb9YlCw3+FY4FZax6fq8ito7hwB1P1HRTXppGkpRcKrNf/RG/98IblVELbH5AvahR5o+fsAVkAMVF2ikK3Jb9QcWdyZuz9+adExsJl2ThBU3fMf5AAQnkPbPrOi7S/aiHfuFP/elFTtZCb9d0XGvEe2KW608dbRIitQrF7CG75lg7kTtuo5MKyBjkdG4ZhtXvAVCbP46MkxLTp/IJacHf9n876CzrC5n09UzQ/WiLD0KmtWyHYAJZArRFStklX9WzAcLi416r/QDof5aShd4zM0K0WwmQFr35OgAmN78LDxglaRbQLAbG8YWb45XwrVfXF/8jCS/wCfaID5TutbSLRVUFPchzq+NeQP0RXVHipz7Bl4b59xwAXI/gQ3FN+tlw/+vQpGgfeMX0QBbI3Q6mu8GaiDZyBoSlDa9jNKmdjM+XcpJAnNLIHXWZ2ZvHZkbOHWw4rer7JWffwPYhKlbbLiCpEBTRWquJRfFL2OHYtKYYpEdd0mJgAkwRzNP3YQg99kB1QNJnQfaejmXSrokCxVMbnbmdW4T2gCN8JjGxUO+ZT1MaSNTpzviDfWBvr44z5bBL5PFPsjNx8AEEDYq91v5qew+43n8sat3dEytOM6aZmQnxjBmYtAEqN9xFdkB1/z0qCCA3fuCN44ERMy7Jow6p0wotUsMJg96xE6bult59nhteUqGUEgtj0X1z/xhEx9WII6tuUJIW6BeTT3FfEegZbXzKBjkZi9BqeCmVCOIzTfTrCJxDLHQBEMri8mSZWdzrQoZTLSjEtRu4WJpIFcMnd/acdues9mbUB7qcfmZURJiPP6SD9byHKkfM+Kp6jCbicn0+Pmyzd9Rpzui25NaoJ9o7hetV4wOB803SYynbu1R8TjRovHNMAqfNrqBjFCS2++XVHB367TUdL1rnyL1Gl3LIZ7ypYhsESIPv/eHkDoi47rtoCuohtsy2QHcmmGqB2GaJYhyyPdVUWYDJvOAH59qRjNMPMkUCGsTBurwug/aTzaF5AmdVmAH3e7o+C5ANUCCxTpHMory8yI8BtJoeKbZbwsYF4RuhzHnlFrRiixiCoGp0HpWwaTkasHjJT2JfVFLtEOqauCQlKYWZgxN+o5lyG7GPt6B1sRfK9BcBesrMPbMuBFjGgbADdq1xYYWJre/Iku1F3q7S3X9CIbwQ5HndUQcTZqotqll9orYPbstFytlAHbl+hCAyVvmtbFHi5HCkKSOtdy3I2we8ziuW+w2keW0cxb3hTvW3LDl6+KrxYfvfp1a6rr3oAZhd+aTUupdRJnkMTiLM0YARjYKkxsvIeJ+rXscGEa9+uKzUjHfCM8MCRqJuDMLJOYQZJMOy8/gESh/r2df6zjwu0rOPK2g3mQN24HQpf4NHA3Js0eJgW8x6H21nn3xbWACB/7AHIBaIXx29qFx9Vc+2Mn1EjMpupjzL5gdKvFACuszoKYQFGLonvisV1+rSGhvCBsWZjL+zNfEbhSmD5RgBdVMsAzZUGCm+Hc2jHk0sasbmh9jb4YX9Od4iy7BjjQwCXObO/HyL9I3eokGoAXQL7Cv5oflCMnzOw0kg0JhPvZdAfAqUNGba4sTAWQ79aNhmXYON7wb6yr+47G9gR6pLgdeWwLZO0fuFUy8G33hlagsilZKpgZZSs2ZKcozp0UC8AeMqdXC2TLaMHmdqzCZ/677p9TlCINu86p7LB8cmH8jzFEnirZmg/t8GClPin9DkttqoLW1mb8xsBy5/Mkv5+E06p+YUsrpoFU971XdOr6hlft10OqQmZcHpGBUcLZqjdAJR4uCC1SWcHO5/yT00LTyKtib1NDjIw3TgVlTt48gkn/Ssl7ScPCch3Wd4JYVIfy8FBCVPAklz6FZqdn7WbsVsgRIm0h2V6952GTBWtSOFwBsCoikWEO7uqRc2czrF0KQAKtLj6OhcofSTdbVMjSc7+H1OoZYl0Qaf4qFc1ElGZTjFS3JQ9dHuTc927mzDXYbCFmE5e8iMQ30pDlO2xUKAvXw3XceMY6e6708a/Amumxlppr1OMH2nQrLTPV1RuQ3YAtV7Nz6wAZf53b3umPpauivw+BRxmrGdw4xxx8IFZ2Xsg0bjjOq4uF2ce3qRhaEEibWNJlq8LiykF7/jMfNbC75T20pEBauCau7zJ0yLZABmlLDoLEznqnNtdUa4c8/VEANZCgXnsKm56WwPCP2QnMyKSSzgNg8wFISFXn5I3aUEs8Qgzo8lwzeHGwL3NDaX4yhbqHjHk4TRZWReQqpOFZ8xEtkNVYyuqfWwXmyoBO0rSKNU/36G+/LL7vkdRETQjfgBD64rnfYLXwSR0MEEJL9WQQImW58JbcDFpoiZ2TTCpzj3iX9p2X9fH2a5gHqjIyuVuHu+BXRU3bDZGdBzEBlMHk9n7zG+95PNXWO23ri3d0rwmL1IWrGfJBkNOIs7dkwsAjgR1a7R+EpPEPm0lTkcKEzLYCpHRN3jKkNJrqahi0nM7nOXLy+IKIp6lywIbwmP4vgOYS/kkEDlT6Rf3AN77C8B7RubVMiHcS54ZlAgVxLN9GCsFuUjX1ixxO3SqhEE5AKTJGK+E6+eWiOunUeL+xbQH+/ai6TEuNd0/CF80seyg32ozQvPiVytrPFPwWW5Zm0BejcYhdrD5LP/VfZoxnpTSsYd2JE84ox8M8XjXhhn1ijeGXTCIX9aG7IMfWfZQgQAbd1xNL6atgyHE6lwQwB2rZ42OEk7tkkRjr9DiWKMQmDOKb2WYoW9vi6AEenhJXcgT6Lr/h6PFXyMtF/aCej/FJq4i9/U+F8W5CVfmBAUdOk91ozn4HQFhLrSAGZrcaa5BZe5R1Of8LAAAAABJRU5ErkJggg=="
/>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
</head>
<body style="margin: 0; width: 100%">
<!-- An opaque RGB PNG avoids a separate alpha-mask image XObject. -->
<img
alt="Regression test logo"
width="32"
height="32"
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAMK0lEQVR4nAEgDN/zAHFeV9yZjwo6SF080rU79JVR0qeuIuYqIIkxwf4rwT7KASBVtxNyEiAxJ4grbwObwdwYZ6P0T7ZyaHzTFJ005RAb+dDFcre2+xqwdWMu6K2qYgD5LRL11Gbts9TyrnGOXwDlHG5y4w4MwrW50xeDinDuoJTl8/S8A8ZLkQMA1u8ezIEjb2uuymxnltx/FBavvETNXZziNgILdKHlL8eh4arzO57gogg1pmz87SbQ68LuEWliRgfzVbw5zKP0DV+iMwMA/Uo3EaY4a9y1Gb5+wwZvfwZoLxNRqlG/ihDl7ZuePDIBwSIq9uDtvTmlFmt51h+uwN/P0/JA8TD79MeLDcLyfWrOiHYWgmZfIIRQ55No5JDAhmwwdhdSUULvyfYHfRYUAA87FNMkQJMhwONa9zyeAZpvUbc3YYjl7PXdmWt9TjIOR9KLlr/oYX4v0p2gwqBEpFuU8rztON0/kzogZ6AyV/Ii59Y1U1XmBQOZc1ZuB0GZdNFLd7YEl5xZ0RoQBng9MACeb3oQG5av5e+OeVd0EBSwTVjj2LIeVDPjC6K3pzm71SWZlQnzGva6q8yRXNgnwCWqhw7il9UPqutAY92KZbAAWXRPSghQMcfJXKhMhTk9IKc00BUIwWMrAM/Iiag0BUsAJ28WinyHUJ+Z7i6kY3mRkpTElQVtxb9PkzzJEkiDNBM2zvI213tqQbms1p3uDAJl7ZhfxXdzN0GUN35tW41YJHqKAx+goQH2joWt3KjyZ1EU0MjktAvsQPLI4aMGNLLLAHjgm2bT2agbLgwuHy/s8zu4MsitoBUFrkFaapQimZrqQe7rk2mE1CE+m7nR/T0TIqzLZ8FfY/kw08XIks4pw63W2xPi039Ixpm4GWd9Dw/XvkgqeKvWtIm+SrK+/hVfEwAwAx4vZY4b9ludgZxTSo3GCP+eJ56dnvDxstPd/iRjbWOLlzMO9RIyHQsQiR6GyAHaYMd10nVDTURBLBRh/vOIVgqW/y5sFLWAT/JwsYFrKSqq+aFD5Qi1vzVrNPbFSRYAeUpOpmKQMUpXRGFn2121iPDhkQtxMuhgIV3GLdj7N2D8jvFitz1ArnJAmbaLXAhUsqvaFVW8uQIl9/RnuS5/nTNZVyzGPpn6/DdR+ilEVioeoJbnMc2dsaHMV3dMX6o6AMtgzHv6tqdLzj1e1JNd6NO7kLp3hJgMZdA6QCjZZTta7F6kUkn+50IrgZKvYSV/OAvJlp5R0hYnIZxYT/GjnG1NX46skoyZYLVEaAbta6Ilt0hT2tJm/k+OW20J7nHXbgCgdIDlXjHrwu0j7Lhb9YlCw3+FY4FZax6fq8ito7hwB1P1HRTXppGkpRcKrNf/RG/98IblVELbH5AvahR5o+fsAVkAMVF2ikK3Jb9QcWdyZuz9+adExsJl2ThBU3fMf5AAQnkPbPrOi7S/aiHfuFP/elFTtZCb9d0XGvEe2KW608dbRIitQrF7CG75lg7kTtuo5MKyBjkdG4ZhtXvAVCbP46MkxLTp/IJacHf9n876CzrC5n09UzQ/WiLD0KmtWyHYAJZArRFStklX9WzAcLi416r/QDof5aShd4zM0K0WwmQFr35OgAmN78LDxglaRbQLAbG8YWb45XwrVfXF/8jCS/wCfaID5TutbSLRVUFPchzq+NeQP0RXVHipz7Bl4b59xwAXI/gQ3FN+tlw/+vQpGgfeMX0QBbI3Q6mu8GaiDZyBoSlDa9jNKmdjM+XcpJAnNLIHXWZ2ZvHZkbOHWw4rer7JWffwPYhKlbbLiCpEBTRWquJRfFL2OHYtKYYpEdd0mJgAkwRzNP3YQg99kB1QNJnQfaejmXSrokCxVMbnbmdW4T2gCN8JjGxUO+ZT1MaSNTpzviDfWBvr44z5bBL5PFPsjNx8AEEDYq91v5qew+43n8sat3dEytOM6aZmQnxjBmYtAEqN9xFdkB1/z0qCCA3fuCN44ERMy7Jow6p0wotUsMJg96xE6bult59nhteUqGUEgtj0X1z/xhEx9WII6tuUJIW6BeTT3FfEegZbXzKBjkZi9BqeCmVCOIzTfTrCJxDLHQBEMri8mSZWdzrQoZTLSjEtRu4WJpIFcMnd/acdues9mbUB7qcfmZURJiPP6SD9byHKkfM+Kp6jCbicn0+Pmyzd9Rpzui25NaoJ9o7hetV4wOB803SYynbu1R8TjRovHNMAqfNrqBjFCS2++XVHB367TUdL1rnyL1Gl3LIZ7ypYhsESIPv/eHkDoi47rtoCuohtsy2QHcmmGqB2GaJYhyyPdVUWYDJvOAH59qRjNMPMkUCGsTBurwug/aTzaF5AmdVmAH3e7o+C5ANUCCxTpHMory8yI8BtJoeKbZbwsYF4RuhzHnlFrRiixiCoGp0HpWwaTkasHjJT2JfVFLtEOqauCQlKYWZgxN+o5lyG7GPt6B1sRfK9BcBesrMPbMuBFjGgbADdq1xYYWJre/Iku1F3q7S3X9CIbwQ5HndUQcTZqotqll9orYPbstFytlAHbl+hCAyVvmtbFHi5HCkKSOtdy3I2we8ziuW+w2keW0cxb3hTvW3LDl6+KrxYfvfp1a6rr3oAZhd+aTUupdRJnkMTiLM0YARjYKkxsvIeJ+rXscGEa9+uKzUjHfCM8MCRqJuDMLJOYQZJMOy8/gESh/r2df6zjwu0rOPK2g3mQN24HQpf4NHA3Js0eJgW8x6H21nn3xbWACB/7AHIBaIXx29qFx9Vc+2Mn1EjMpupjzL5gdKvFACuszoKYQFGLonvisV1+rSGhvCBsWZjL+zNfEbhSmD5RgBdVMsAzZUGCm+Hc2jHk0sasbmh9jb4YX9Od4iy7BjjQwCXObO/HyL9I3eokGoAXQL7Cv5oflCMnzOw0kg0JhPvZdAfAqUNGba4sTAWQ79aNhmXYON7wb6yr+47G9gR6pLgdeWwLZO0fuFUy8G33hlagsilZKpgZZSs2ZKcozp0UC8AeMqdXC2TLaMHmdqzCZ/677p9TlCINu86p7LB8cmH8jzFEnirZmg/t8GClPin9DkttqoLW1mb8xsBy5/Mkv5+E06p+YUsrpoFU971XdOr6hlft10OqQmZcHpGBUcLZqjdAJR4uCC1SWcHO5/yT00LTyKtib1NDjIw3TgVlTt48gkn/Ssl7ScPCch3Wd4JYVIfy8FBCVPAklz6FZqdn7WbsVsgRIm0h2V6952GTBWtSOFwBsCoikWEO7uqRc2czrF0KQAKtLj6OhcofSTdbVMjSc7+H1OoZYl0Qaf4qFc1ElGZTjFS3JQ9dHuTc927mzDXYbCFmE5e8iMQ30pDlO2xUKAvXw3XceMY6e6708a/Amumxlppr1OMH2nQrLTPV1RuQ3YAtV7Nz6wAZf53b3umPpauivw+BRxmrGdw4xxx8IFZ2Xsg0bjjOq4uF2ce3qRhaEEibWNJlq8LiykF7/jMfNbC75T20pEBauCau7zJ0yLZABmlLDoLEznqnNtdUa4c8/VEANZCgXnsKm56WwPCP2QnMyKSSzgNg8wFISFXn5I3aUEs8Qgzo8lwzeHGwL3NDaX4yhbqHjHk4TRZWReQqpOFZ8xEtkNVYyuqfWwXmyoBO0rSKNU/36G+/LL7vkdRETQjfgBD64rnfYLXwSR0MEEJL9WQQImW58JbcDFpoiZ2TTCpzj3iX9p2X9fH2a5gHqjIyuVuHu+BXRU3bDZGdBzEBlMHk9n7zG+95PNXWO23ri3d0rwmL1IWrGfJBkNOIs7dkwsAjgR1a7R+EpPEPm0lTkcKEzLYCpHRN3jKkNJrqahi0nM7nOXLy+IKIp6lywIbwmP4vgOYS/kkEDlT6Rf3AN77C8B7RubVMiHcS54ZlAgVxLN9GCsFuUjX1ixxO3SqhEE5AKTJGK+E6+eWiOunUeL+xbQH+/ai6TEuNd0/CF80seyg32ozQvPiVytrPFPwWW5Zm0BejcYhdrD5LP/VfZoxnpTSsYd2JE84ox8M8XjXhhn1ijeGXTCIX9aG7IMfWfZQgQAbd1xNL6atgyHE6lwQwB2rZ42OEk7tkkRjr9DiWKMQmDOKb2WYoW9vi6AEenhJXcgT6Lr/h6PFXyMtF/aCej/FJq4i9/U+F8W5CVfmBAUdOk91ozn4HQFhLrSAGZrcaa5BZe5R1Of8LAAAAABJRU5ErkJggg=="
/>
</body>
</html>