Compare commits

..

24 Commits

Author SHA1 Message Date
Julien Neuhart
b3c06fb8ae test(chromium): cover thenable waitForExpression 2026-08-14 12:00:57 +02:00
Julien Neuhart
f90684e165 fix(chromium): add missing import for awaited waitForExpression 2026-08-14 11:58:19 +02:00
Niklas
90e614afa4 feat(chromium): await thenable waitForExpression (#1617) 2026-08-14 11:57:29 +02:00
Julien Neuhart
6a087cb1f7 test(libreoffice): cover OOXML PowerPoint show conversion (#1626) 2026-08-14 11:49:46 +02:00
Ilya Fedorov
38db892552 fix(libreoffice): accept the OOXML PowerPoint show extensions (#1626) 2026-08-14 11:43:12 +02:00
Julien Neuhart
ca9603bdf4 chore(test): format optimize-images testdata and integration README 2026-08-14 11:07:16 +02:00
Julien Neuhart
0c1e82c885 chore(deps): update Go and npm dependencies 2026-08-14 11:07:16 +02:00
Julien Neuhart
1303e0ebc9 refactor(pdfengines): remove qpdf embed fallback (#1628) 2026-08-14 11:07:16 +02:00
Julien Neuhart
cc1341c3cf chore(deps): update pdfcpu to v0.15.0 (#1628) 2026-08-13 20:39:11 +02:00
Julien Neuhart
1ac1d9887e feat(pdfengines): optimize PDF images to reduce file size (#359) 2026-08-13 19:39:09 +02:00
Julien Neuhart
7a730cfdc2 feat(chromium): add --chromium-clear-storage to clear local storage between conversions (#919) 2026-08-13 14:56:46 +02:00
Julien Neuhart
b213f2ffed fix(chromium): fit the page to content for landscape single-page (#1390) 2026-08-13 14:14:48 +02:00
Julien Neuhart
8d1eeaa73a feat(chromium): screenshot a single element via the selector form field (#947) 2026-08-13 13:56:17 +02:00
Julien Neuhart
2f6818d3df fix(chromium): hint at --chromium-start-timeout when Chromium fails to start 2026-08-13 12:47:45 +02:00
Julien Neuhart
38f6e466d4 chore(libreoffice): drop the now-unneeded gosec nolint 2026-08-13 12:32:55 +02:00
Julien Neuhart
e39840d5cf ci: bump golangci-lint to v2.12.2 2026-08-13 12:31:28 +02:00
Julien Neuhart
9b48d3f84e chore(libreoffice): silence a gosec G703 false positive on temp-file cleanup 2026-08-13 12:28:46 +02:00
Julien Neuhart
05bde96334 refactor(qpdf): make embed-metadata logging context-aware 2026-08-13 08:21:35 +02:00
Julien Neuhart
8b2c15d5de fix(api): classify client-cancelled requests as 499 instead of 500 2026-08-13 08:21:35 +02:00
Julien Neuhart
8d327a5196 fix(libreoffice): render scrolled workbooks in full for SinglePageSheets 2026-08-13 08:21:35 +02:00
Julien Neuhart
dc61c3631e test(libreoffice): rename the linked-content tag to libreoffice-ssrf 2026-08-13 08:21:35 +02:00
Julien Neuhart
dc7c68152c fix(chromium): filter WebSocket handshakes against the outbound policy 2026-08-13 08:21:35 +02:00
Julien Neuhart
357c3b4a59 fix(outbound): return 403 instead of 500 for an unresolvable outbound host 2026-08-13 08:21:35 +02:00
Julien Neuhart
91f2587fef Merge commit from fork
Co-authored-by: Jacob Brackett <jbrackett@makenotion.com>
2026-08-13 08:08:28 +02:00
69 changed files with 2439 additions and 243 deletions

View File

@@ -47,6 +47,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -48,6 +48,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -47,6 +47,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -15,6 +15,7 @@ body:multipart-form {
~width: 800
~height: 600
~clip: false
~selector:
~format: png
~quality: 100
~optimizeForSpeed: false

View File

@@ -16,6 +16,7 @@ body:multipart-form {
~width: 800
~height: 600
~clip: false
~selector:
~format: png
~quality: 100
~optimizeForSpeed: false

View File

@@ -15,6 +15,7 @@ body:multipart-form {
~width: 800
~height: 600
~clip: false
~selector:
~format: png
~quality: 100
~optimizeForSpeed: false

View File

@@ -64,6 +64,8 @@ body:multipart-form {
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

@@ -17,6 +17,8 @@ body:multipart-form {
~autoIndexBookmarks: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
~userPassword:

View File

@@ -0,0 +1,26 @@
meta {
name: Optimize PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/optimize
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
~imageQuality: 80
}
headers {
~Gotenberg-Output-Filename: optimized
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -18,6 +18,8 @@ body:multipart-form {
~flatten: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false
~imageQuality: 80
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:

View File

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

View File

@@ -82,6 +82,7 @@ PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_OPTIMIZE_IMAGES_ENGINES=pdfcpu
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
@@ -90,7 +91,7 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
PDFENGINES_EMBED_ENGINES=pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PDFENGINES_FACTUR_X_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg
@@ -147,10 +148,12 @@ NO_CONCURRENCY=false
# chromium-screenshot-html
# chromium-screenshot-markdown
# chromium-screenshot-url
# chromium-ssrf
# debug
# health
# libreoffice
# libreoffice-convert
# libreoffice-ssrf
# output-filename
# pdfengines
# pdfengines-convert
@@ -160,6 +163,8 @@ NO_CONCURRENCY=false
# encrypt
# pdfengines-flatten
# flatten
# pdfengines-optimize
# optimize
# pdfengines-merge
# merge
# pdfengines-metadata

View File

@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.5
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.13.0
ARG PDFCPU_VERSION=v0.15.0
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).

View File

@@ -82,6 +82,7 @@ services:
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
- "--pdfengines-optimize-images-engines=${PDFENGINES_OPTIMIZE_IMAGES_ENGINES}"
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"

94
go.mod
View File

@@ -8,7 +8,7 @@ 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/cucumber/godog v0.16.0
github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.4
@@ -21,20 +21,20 @@ require (
github.com/shirou/gopsutil/v4 v4.26.7
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.43.0
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
github.com/testcontainers/testcontainers-go v0.44.0
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/log v0.21.0
go.opentelemetry.io/otel/metric v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/log v0.21.0
go.opentelemetry.io/otel/sdk/metric v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/net v0.57.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/text v0.40.0
golang.org/x/text v0.41.0
)
require (
@@ -42,11 +42,11 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.4 // indirect
github.com/bodgit/sevenzip v1.6.5 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@@ -57,16 +57,16 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
github.com/cucumber/messages/go/v34 v34.2.0 // indirect
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
github.com/cucumber/messages/go/v34 v34.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-connections v0.8.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -74,70 +74,70 @@ require (
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/magiconair/properties v1.18.11 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.1.1 // indirect
github.com/minio/minlz v1.2.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/go-archive v0.3.3 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/sys/user v0.4.1 // indirect
github.com/moby/sys/userns v0.2.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.5 // indirect
github.com/nwaples/rardecode/v2 v2.3.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pierrec/lz4/v4 v4.1.28 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sirupsen/logrus v1.10.0 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/stangelandcl/ppmd v0.1.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/ulikunitz/xz v0.5.15 // 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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.67.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

196
go.sum
View File

@@ -10,16 +10,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng=
github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -46,12 +46,12 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
github.com/cucumber/gherkin/go/v42 v42.0.1 h1:ao9TVJmBb8uNLEcjMDFbhsoL3yC7gMpRLHgP1ZGfFWA=
github.com/cucumber/gherkin/go/v42 v42.0.1/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
github.com/cucumber/messages/go/v34 v34.2.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -59,8 +59,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
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 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
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/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
@@ -72,8 +72,8 @@ 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/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -90,8 +90,8 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984 h1:6DE2PprLLZelQJu7AeebZLxjcENwC1plRZHDvd5uMCU=
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -99,8 +99,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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -121,8 +121,8 @@ github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
@@ -138,56 +138,60 @@ github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c
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/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.18.11 h1:j5ozYZl0zCjG7ahMDH0GWIobOvvUzT0BdAguG0ViKy0=
github.com/magiconair/properties v1.18.11/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.2.0 h1:6IOBuiHg04QxvbFfgFLT/9sMaO/UhL7S+ApW1mK8q5A=
github.com/minio/minlz v1.2.0/go.mod h1:Ls9H7nlkASeCcdl5thjVD5Eraj6z+zGa7xtq57jIKD4=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
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/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
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/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
github.com/moby/sys/userns v0.2.0 h1:nEtDtp7NCV/6dutSklNe8FrENPwFdc4mXnZqC/JWgXM=
github.com/moby/sys/userns v0.2.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode/v2 v2.3.0 h1:CtgyxWm8ClLcSh1u4M58fOz6lmeb/j4V7KpaEi/6UtM=
github.com/nwaples/rardecode/v2 v2.3.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -202,8 +206,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -222,15 +226,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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/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=
@@ -241,66 +245,66 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
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/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0 h1:oEl2Pw/i4OQwhAuda2pAHFAcOMivA+Xa+iTccBfab/g=
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0/go.mod h1:yMSQaiiq5dpfrSJCYLBcqFeJkFFI67seT4ngvx6jfVo=
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 h1:qU2CqTGdlstwoVhu1WfjJJ3z2ntcNjTJO0ksTsFKzPI=
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0/go.mod h1:Ekh3I2XXfhdWkqbRq4PrivJS4BS/se7Er9ZsbK6YEtQ=
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0 h1:wpCLEJ/4RHUadR11UOdznbmyyih5/OPYFcsehAh6PYI=
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0/go.mod h1:x7MbNOwoKV5Hj6uYMXQksHlQdTNOP3hoFPvqWISiu6s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0/go.mod h1:zyGrjRKL2B/6+Jc/m4/otPoZqV2MY9ZjC/aBraRO7zc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 h1:klTViGcsvLCd1xN3rZzfZ12NslC/OimbmR+k+A006RI=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0/go.mod h1:jRsK04CWmXuY8A0O+wMpSf+t90RHZ53o5Qmxn2PQPfk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 h1:pnxy6c/kvNBWdNNFzqpjuJLm9Hjhgk/Q0nY221rwuk0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0/go.mod h1:qw6YsFapotRwoDhXRZvljzaOvCQB7UfnafEJagpN2TA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E=
go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 h1:2lpf4hnrasYIsUyEXwnTZq5lsxrMm4T2Bwb06IctAZQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0/go.mod h1:YWOW6h7jwApz9Pl76ie/izUsSPj0s2MdIlpqbPqaf3U=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA=
go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc=
go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo=
go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ=
go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
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/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -312,21 +316,21 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
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=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:dWeMvEJ3JhYgqSCAHUZZJgMUyfniiiCvDc72x5EqJP0=
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

51
package-lock.json generated
View File

@@ -6,31 +6,29 @@
"": {
"devDependencies": {
"prettier": "3.9.6",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-gherkin": "^4.0.0",
"prettier-plugin-sh": "^0.19.0"
}
},
"node_modules/@cucumber/gherkin": {
"version": "32.2.0",
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz",
"integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==",
"version": "39.1.0",
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-39.1.0.tgz",
"integrity": "sha512-pqmSO2bUWxJm3TbNrKXlDaHjL6c77+ez9kWmfCd9oRPeTRPEVH3spZvpAqdXYWOZYSNYwWFCAAeZ4RGpkauNoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cucumber/messages": ">=19.1.4 <28"
"@cucumber/messages": ">=31.0.0 <33"
}
},
"node_modules/@cucumber/messages": {
"version": "27.2.0",
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz",
"integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==",
"version": "32.3.1",
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz",
"integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/uuid": "10.0.0",
"class-transformer": "0.5.1",
"reflect-metadata": "0.2.2",
"uuid": "11.0.5"
"reflect-metadata": "0.2.2"
}
},
"node_modules/@reteps/dockerfmt": {
@@ -108,13 +106,6 @@
"linux"
]
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
@@ -139,14 +130,14 @@
}
},
"node_modules/prettier-plugin-gherkin": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.3.tgz",
"integrity": "sha512-w9uB413NlSi8ZQwpexyu+ttriJJ88eZLV0x88ZTkzkLZyHYEX5wrNtaCx/yFYviIu/tuwsBqDPM47VODnIV/hw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-4.0.0.tgz",
"integrity": "sha512-EBDwV1Ou9rG+seoh7jcwZ6sXXyHwsbUks5TeSyrlrLaTdz+/qYvnHuwY8rk7mb6/+M1ZGuY4/SiVc68ybwvJOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cucumber/gherkin": "^32.0.0",
"@cucumber/messages": "^27.2.0",
"@cucumber/gherkin": "^39.1.0",
"@cucumber/messages": "^32.3.1",
"prettier": "^3.5.3"
}
},
@@ -189,20 +180,6 @@
"funding": {
"url": "https://opencollective.com/sh-syntax"
}
},
"node_modules/uuid": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
}
}
}

View File

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

View File

@@ -49,6 +49,7 @@ type PdfEngineMock struct {
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
OptimizeImagesMock func(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
@@ -80,6 +81,10 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, f
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
func (engine *PdfEngineMock) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
return engine.OptimizeImagesMock(ctx, logger, imageQuality, inputPath)
}
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return engine.ReadMetadataMock(ctx, logger, inputPath)
}

View File

@@ -338,8 +338,18 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
case errors.Is(err, ErrPublicIP):
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
default:
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
// A cancellation or timeout is not a policy decision; surface it
// as-is so callers do not report it as a filtered request.
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
default:
// The host could not be resolved, so its address class cannot be
// verified. Fail closed and treat it as filtered, the same as a
// host that resolves to a blocked address, so clients get a
// generic 403 rather than a 500. This also denies alternate IP
// encodings such as http://2130706433/ that the resolver rejects
// as a hostname but Chromium would read as a private IP.
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %v: %w", normalized, err, ErrFiltered)
}
}

View File

@@ -311,6 +311,39 @@ func TestFilterOutboundURL(t *testing.T) {
}
}
func TestDecideOutbound_UnresolvableHostFailsClosed(t *testing.T) {
withStubResolver(t, func(string) ([]netip.Addr, error) {
return nil, errors.New("no such host")
})
// An alternate IP encoding (decimal for 127.0.0.1) that the resolver
// rejects as a hostname must fail closed as filtered, not surface as a
// server error, so clients receive a generic 403.
_, err := DecideOutbound(context.Background(), "http://2130706433/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
if !errors.Is(err, ErrFiltered) {
t.Fatalf("expected ErrFiltered, got: %v", err)
}
}
func TestDecideOutbound_ResolverCancellationNotFiltered(t *testing.T) {
withStubResolver(t, func(string) ([]netip.Addr, error) {
return nil, context.Canceled
})
// A cancellation or timeout is not a policy decision and must not be
// reported as a filtered request.
_, err := DecideOutbound(context.Background(), "http://example.com/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
if err == nil {
t.Fatal("expected error, got nil")
}
if errors.Is(err, ErrFiltered) {
t.Fatalf("cancellation must not be filtered, got: %v", err)
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got: %v", err)
}
}
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)

View File

@@ -281,6 +281,12 @@ type PdfEngine interface {
// PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
// OptimizeImages re-encodes the raster images of a PDF in place to shrink
// the file, leaving text, vectors, fonts and structure untouched.
// imageQuality is the JPEG quality (1 to 100) applied to each re-encoded
// image.
OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
// ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)

View File

@@ -178,6 +178,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
}
defer func() {
err := form.RemoveAll()
if err != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("remove multipart temporary files: %s", err))
}
}()
// This will ensure we do not exceed the body limit.
var formValuesSize int64

View File

@@ -9,6 +9,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"sync"
"testing"
"time"
@@ -73,6 +74,64 @@ func TestNewContext_Cancellation(t *testing.T) {
}
}
func TestNewContext_RemovesMultipartTemporaryFiles(t *testing.T) {
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("files", "input.odt")
if err != nil {
t.Fatalf("create multipart file: %v", err)
}
_, err = part.Write(bytes.Repeat([]byte("x"), 1024))
if err != nil {
t.Fatalf("write multipart file: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
err = req.ParseMultipartForm(1)
if err != nil {
t.Fatalf("parse multipart form: %v", err)
}
defer func() {
_ = req.MultipartForm.RemoveAll()
}()
upload, err := req.MultipartForm.File["files"][0].Open()
if err != nil {
t.Fatalf("open disk-backed multipart file: %v", err)
}
temporaryFile, ok := upload.(*os.File)
if !ok {
_ = upload.Close()
t.Fatal("multipart upload is not disk-backed")
}
temporaryPath := temporaryFile.Name()
err = temporaryFile.Close()
if err != nil {
t.Fatalf("close disk-backed multipart file: %v", err)
}
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
downloadFromCfg := downloadFromConfig{disable: true}
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
if err != nil {
t.Fatalf("newContext returned error: %v", err)
}
defer cancel()
_, err = os.Stat(temporaryPath)
if !os.IsNotExist(err) {
t.Fatalf("multipart temporary file still exists: %s", temporaryPath)
}
}
// Concurrent downloadFrom entries must not race on the shared maps
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
// to catch the data race; without -race a sufficient number of entries

View File

@@ -86,13 +86,37 @@ func ParseError(err error) (int, string) {
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
}
// statusClientClosedRequest is the non-standard 499 status (nginx convention)
// recorded when the client aborts the request before it completes. It keeps
// such outcomes out of the 5xx range in the access log and Prometheus metrics.
const statusClientClosedRequest = 499
// requestCanceled reports whether err is the result of the client aborting the
// request rather than a server-side failure. It requires both that err wraps
// [context.Canceled] and that the request context itself was canceled, so a
// context.Canceled originating elsewhere still surfaces as an internal error.
// A server-side timeout is [context.DeadlineExceeded], mapped to 503 by
// [ParseError], and is deliberately not treated as a client abort.
// See https://github.com/gotenberg/gotenberg/issues/1627.
func requestCanceled(c echo.Context, err error) bool {
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,
// returns a response as "text/plain; charset=UTF-8".
func httpErrorHandler() echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
logger := c.Get("logger").(*slog.Logger)
status, message := ParseError(err)
if requestCanceled(c, err) {
// The client is gone, so writing a body would only fail and add
// noise. Record the status so the access log and metrics classify
// it as a client abort rather than an internal error.
c.Response().WriteHeader(statusClientClosedRequest)
return
}
status, message := ParseError(err)
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
err = c.String(status, message)
@@ -263,9 +287,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
finishTime := time.Now()
status := c.Response().Status
canceled := false
if err != nil {
parsedStatus, _ := ParseError(err)
status = parsedStatus
canceled = requestCanceled(c, err)
if canceled {
status = statusClientClosedRequest
} else {
parsedStatus, _ := ParseError(err)
status = parsedStatus
}
span.SetAttributes(attribute.String("error", err.Error()))
c.Error(err)
@@ -293,10 +323,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
With(slog.Int64("bytes_in", c.Request().ContentLength)).
With(slog.Int64("bytes_out", c.Response().Size))
if err != nil {
accessLogger.ErrorContext(ctx, err.Error())
} else {
switch {
case err == nil:
accessLogger.InfoContext(ctx, "request handled")
case canceled:
// A client abort is expected, not a server failure; keep it
// visible but out of the error stream.
accessLogger.InfoContext(ctx, err.Error())
default:
accessLogger.ErrorContext(ctx, err.Error())
}
additionalAttributes := []attribute.KeyValue{

View File

@@ -1,6 +1,10 @@
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
@@ -10,6 +14,70 @@ import (
"github.com/labstack/echo/v4"
)
// TestRequestCanceled pins the client-abort discriminator: only a
// context.Canceled that stems from the request context counts, so a server
// timeout or an unrelated cancellation still surfaces as an internal failure.
// See https://github.com/gotenberg/gotenberg/issues/1627.
func TestRequestCanceled(t *testing.T) {
canceled, cancel := context.WithCancel(context.Background())
cancel()
timedOut, cancelTimeout := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancelTimeout()
for _, tc := range []struct {
name string
reqCtx context.Context
err error
want bool
}{
{"client abort", canceled, context.Canceled, true},
{"wrapped client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), true},
{"canceled error but live request", context.Background(), context.Canceled, false},
{"canceled request but unrelated error", canceled, errors.New("boom"), false},
{"server timeout is not a client abort", timedOut, context.DeadlineExceeded, false},
{"no error", canceled, nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
c := echo.New().NewContext(req, httptest.NewRecorder())
if got := requestCanceled(c, tc.err); got != tc.want {
t.Fatalf("requestCanceled = %v, want %v", got, tc.want)
}
})
}
}
// TestHttpErrorHandler_ClientClosedRequest ensures a client abort is recorded
// as 499 rather than 500, and that a genuine failure keeps its status.
func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
canceled, cancel := context.WithCancel(context.Background())
cancel()
for _, tc := range []struct {
name string
reqCtx context.Context
err error
wantStatus int
}{
{"client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), statusClientClosedRequest},
{"internal failure", context.Background(), errors.New("boom"), http.StatusInternalServerError},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
c.Set("logger", slog.New(slog.DiscardHandler))
httpErrorHandler()(tc.err, c)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
})
}
}
// TestOutputFilenameMiddleware pins the sanitizing of the
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
// a Content-Disposition header, so a path separator must never survive it.

View File

@@ -49,6 +49,7 @@ type browserArguments struct {
denyPublicIPs bool
clearCache bool
clearCookies bool
clearStorage bool
disableJavaScript bool
}
@@ -179,6 +180,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
return fmt.Errorf("start pinning proxy: %w", err)
}
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs {
// Chromium implicitly bypasses the proxy for loopback and
// link-local destinations. A WebSocket handshake is never surfaced
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
// cannot filter it; the pinning proxy is the only layer that sees
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
// localhost, or the link-local cloud metadata endpoint
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
// the implicit bypass so those handshakes also traverse the pinning
// proxy and go through [gotenberg.DecideOutbound] like every other
// request.
//
// Gated on the IP-class policy: it is the control this closes, and
// under it loopback and link-local HTTP sub-resources are already
// blocked by listenForEventRequestPaused before they would reach
// the proxy, so this adds only the missing WebSocket coverage. When
// 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>"))
}
}
// See https://github.com/gotenberg/gotenberg/issues/524.
@@ -200,7 +223,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
if stopErr != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
}
return fmt.Errorf("run exec allocator: %w", err)
return fmt.Errorf("run exec allocator: %w; if Chromium is slow to start, raise --chromium-start-timeout (currently %s)", err, b.arguments.wsUrlReadTimeout)
}
b.ctxMu.Lock()
@@ -345,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
runtime.Enable(),
clearCacheActionFunc(logger, b.arguments.clearCache),
clearCookiesActionFunc(logger, b.arguments.clearCookies),
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
@@ -371,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
runtime.Enable(),
clearCacheActionFunc(logger, b.arguments.clearCache),
clearCookiesActionFunc(logger, b.arguments.clearCookies),
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
@@ -434,6 +459,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
extraHttpHeaders: options.ExtraHttpHeaders,
})
// WebSocket handshakes never surface as fetch.EventRequestPaused, so
// listenForEventRequestPaused above cannot filter them. Validate them
// against the same allow / deny lists and IP-class policy.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventWebSocketCreated(taskCtx, logger, eventWebSocketCreatedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
denyPrivateIPs: b.arguments.denyPrivateIPs,
denyPublicIPs: b.arguments.denyPublicIPs,
})
var (
invalidHttpStatusCode error
invalidHttpStatusCodeMu sync.RWMutex

View File

@@ -43,6 +43,10 @@ var (
// or undefined.
ErrInvalidSelectorQuery = errors.New("invalid selector query")
// ErrScreenshotSelectorNotFound happens when the CSS selector of a
// screenshot matches no element with a rendered box.
ErrScreenshotSelectorNotFound = errors.New("screenshot selector not found")
// ErrRpccMessageTooLarge happens when the messages received by
// ChromeDevTools are larger than 100 MB.
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
@@ -347,6 +351,11 @@ type ScreenshotOptions struct {
// dimensions.
Clip bool
// Selector clips the screenshot to the bounding box of the first element
// matching this CSS selector. Empty captures the whole page. Takes
// precedence over Clip.
Selector string
// Format is the image compression format, either "png" or "jpeg" or
// "webp".
Format string
@@ -370,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
Width: 800,
Height: 600,
Clip: false,
Selector: "",
Format: "png",
Quality: 100,
OptimizeForSpeed: false,
@@ -467,6 +477,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
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-clear-cookies", false, "Clear Chromium cookies between each conversion")
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
fs.Bool("chromium-disable-routes", false, "Disable the routes")
@@ -518,6 +529,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
clearCache: flags.MustBool("chromium-clear-cache"),
clearCookies: flags.MustBool("chromium-clear-cookies"),
clearStorage: flags.MustBool("chromium-clear-storage"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
}

View File

@@ -44,6 +44,54 @@ func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate)
})
}
type eventWebSocketCreatedOptions struct {
allowList, denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
}
// listenForEventWebSocketCreated validates the target of every WebSocket
// handshake against the same allow / deny lists and IP-class policy as
// [listenForEventRequestPaused]. Chromium never surfaces a WebSocket
// handshake as a fetch.EventRequestPaused, so without this listener a page
// could open a WebSocket to an address the outbound filter would otherwise
// block. See https://github.com/gotenberg/gotenberg/issues/1011.
//
// This listener records an operator-visible warning with the full ws:// URL.
// The connection itself is severed by the pinning proxy, which every
// WebSocket handshake traverses once the implicit loopback / link-local proxy
// bypass is removed (see the "<-loopback>" flag in browser.go). When the
// operator configures a custom proxy or host-resolver mappings, the pinning
// proxy is not started; the WebSocket then follows the operator's egress path
// and this warning is the remaining safeguard, since a WebSocket handshake
// cannot be aborted through the CDP Network domain.
func listenForEventWebSocketCreated(ctx context.Context, logger *slog.Logger, options eventWebSocketCreatedOptions) {
chromedp.ListenTarget(ctx, func(ev any) {
e, ok := ev.(*network.EventWebSocketCreated)
if !ok {
return
}
go func() {
logger.DebugContext(ctx, fmt.Sprintf("event EventWebSocketCreated fired for '%s'", e.URL))
deadline, ok := ctx.Deadline()
if !ok {
logger.ErrorContext(ctx, "context has no deadline, cannot filter WebSocket URL")
return
}
err := gotenberg.FilterOutboundURL(ctx, e.URL, options.allowList, options.denyList, deadline,
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
)
if err != nil {
logger.WarnContext(ctx, err.Error())
}
}()
})
}
type eventRequestPausedOptions struct {
allowList, denyList []*regexp2.Regexp
denyPrivateIPs bool

View File

@@ -366,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
var (
width, height int
clip bool
selector string
format string
quality int
optimizeForSpeed bool
@@ -376,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
Int("width", &width, defaultScreenshotOptions.Width).
Int("height", &height, defaultScreenshotOptions.Height).
Bool("clip", &clip, defaultScreenshotOptions.Clip).
String("selector", &selector, defaultScreenshotOptions.Selector).
Custom("format", func(value string) error {
if value == "" {
format = defaultScreenshotOptions.Format
@@ -420,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
Width: width,
Height: height,
Clip: clip,
Selector: selector,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
@@ -476,6 +479,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
@@ -501,7 +505,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate stamp: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -565,6 +569,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
@@ -587,7 +592,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -648,6 +653,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
@@ -679,7 +685,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -804,7 +810,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string, optimizeImages bool, imageQuality int) error {
outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath)
@@ -901,6 +907,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("rotate PDFs: %w", err)
}
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
@@ -963,7 +974,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
outputPath := ctx.GeneratePath(ext)
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, options.Options)
if errors.Is(err, ErrScreenshotSelectorNotFound) {
err = api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The selector '%s' (selector) matched no element with a visible box", options.Selector),
),
)
} else {
err = handleChromiumError(err, options.Options)
}
if err != nil {
return fmt.Errorf("screenshot: %w", err)
}

View File

@@ -6,13 +6,17 @@ import (
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strconv"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/cdproto/storage"
"github.com/chromedp/chromedp"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
@@ -54,6 +58,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
defer span.End()
err := func() error {
paperWidth := options.PaperWidth
paperHeight := options.PaperHeight
pageRanges := options.PageRanges
@@ -67,9 +72,20 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
if options.Landscape {
// Landscape swaps the paper dimensions, so the page is
// WithPaperHeight wide by WithPaperWidth tall. Size both to
// the content so the width expands to fit a wide document
// (e.g. a table) instead of the height-only expansion
// landing on the width axis and truncating it.
// See https://github.com/gotenberg/gotenberg/issues/1390.
paperWidth = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
paperHeight = (cssContentSize.Width / 96) + options.MarginLeft + options.MarginRight
} else {
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
}
pageRanges = "1" // little dirty hack to avoid leftovers.
}
@@ -78,7 +94,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
WithLandscape(options.Landscape).
WithPrintBackground(options.PrintBackground).
WithScale(options.Scale).
WithPaperWidth(options.PaperWidth).
WithPaperWidth(paperWidth).
WithPaperHeight(paperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
@@ -188,7 +204,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
WithOptimizeForSpeed(options.OptimizeForSpeed).
WithFormat(page.CaptureScreenshotFormat(options.Format))
if options.Clip {
switch {
case options.Selector != "":
clip, err := elementClip(ctx, options.Selector)
if err != nil {
return err
}
logger.DebugContext(ctx, fmt.Sprintf("clip screenshot to selector '%s'", options.Selector))
captureScreenshot = captureScreenshot.WithClip(clip)
case options.Clip:
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
Width: float64(options.Width),
Height: float64(options.Height),
@@ -229,6 +254,49 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
}
}
// elementClip resolves the first element matching selector to a page-space clip
// rectangle for Page.captureScreenshot.
//
// getBoundingClientRect reports viewport-relative CSS pixels; adding the scroll
// offset puts the rectangle in the document coordinate space that
// WithCaptureBeyondViewport expects. It fails with
// [ErrScreenshotSelectorNotFound] when nothing matches or the match has no
// rendered box (display:none or a zero area), so the caller can answer 400.
func elementClip(ctx context.Context, selector string) (*page.Viewport, error) {
var rect struct {
Found bool `json:"found"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
expr := fmt.Sprintf(`(() => {
const el = document.querySelector(%s);
if (!el) {
return { found: false };
}
const r = el.getBoundingClientRect();
return { found: true, x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
})()`, strconv.Quote(selector))
err := chromedp.Evaluate(expr, &rect).Do(ctx)
if err != nil {
return nil, fmt.Errorf("evaluate selector box: %v: %w", err, ErrScreenshotSelectorNotFound)
}
if !rect.Found || rect.Width <= 0 || rect.Height <= 0 {
return nil, fmt.Errorf("selector %q matched no element with a visible box: %w", selector, ErrScreenshotSelectorNotFound)
}
return &page.Viewport{
X: rect.X,
Y: rect.Y,
Width: rect.Width,
Height: rect.Height,
Scale: 1,
}, nil
}
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.DebugContext(ctx, "set device metrics override")
@@ -280,6 +348,54 @@ func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc
}
}
// clearStorageActionFunc clears the converted origin's local storage before the
// page loads, so state written by a previous conversion of the same origin does
// not leak into this one. See https://github.com/gotenberg/gotenberg/issues/919.
//
// Session storage is not touched: each conversion runs in its own browsing
// context (a fresh tab), so it is already isolated and cannot leak. Local
// storage is per-origin and shared across tabs of the long-lived browser, so it
// is the only web storage that carries over.
func clearStorageActionFunc(logger *slog.Logger, clear bool, rawURL string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if !clear {
logger.DebugContext(ctx, "local storage not cleared")
return nil
}
origin, ok := httpOrigin(rawURL)
if !ok {
// A file:// upload gets an opaque, per-request origin that is not
// shared between conversions, so there is nothing to clear.
logger.DebugContext(ctx, "local storage not cleared: non-http(s) origin is already isolated")
return nil
}
logger.DebugContext(ctx, fmt.Sprintf("clear local storage for %s", origin))
err := storage.ClearDataForOrigin(origin, string(storage.TypeLocalStorage)).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("clear local storage: %w", err)
}
}
// httpOrigin returns the http(s) security origin (scheme://host[:port]) of
// rawURL, and false when rawURL is not http(s). A non-http(s) URL such as a
// file:// upload has an opaque origin that no other conversion shares.
func httpOrigin(rawURL string) (string, bool) {
parsed, err := url.Parse(rawURL)
if err != nil {
return "", false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", false
}
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), true
}
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/175.
@@ -593,7 +709,13 @@ func waitForExpressionBeforePrintActionFunc(logger *slog.Logger, disableJavaScri
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
case <-ticker.C:
var ok bool
evaluate := chromedp.Evaluate(expression, &ok)
// Await the result so a thenable expression (an async function
// returning a Promise) resolves before its value is read. A
// non-promise result is unaffected.
// See https://github.com/gotenberg/gotenberg/pull/1617.
evaluate := chromedp.Evaluate(expression, &ok, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
return p.WithAwaitPromise(true)
})
err := evaluate.Do(ctx)
if err != nil {

View File

@@ -115,18 +115,29 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
tag := key
if key == "Trapped" {
// ExifTool writes the document info /Trapped entry as a malformed
// name-in-a-string, e.g. "(/Unknown)". pdfcpu's stricter validation
// (as of v0.15) rejects it, which breaks later pdfcpu operations on
// the file such as embedding. Writing Trapped to XMP keeps the value
// readable without the invalid document info entry.
// See https://github.com/gotenberg/gotenberg/issues/1628.
tag = "XMP-pdf:Trapped"
}
switch val := value.(type) {
case string:
if err := validateMetadataValue(key, val); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, val))
args = append(args, fmt.Sprintf("-%s=%s", tag, val))
case []string:
for _, s := range val {
if err := validateMetadataValue(key, s); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, s))
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
}
case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048.
@@ -138,18 +149,18 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
if err := validateMetadataValue(key, s); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, s))
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
}
case bool:
args = append(args, fmt.Sprintf("-%s=%t", key, val))
args = append(args, fmt.Sprintf("-%s=%t", tag, val))
case int:
args = append(args, fmt.Sprintf("-%s=%d", key, val))
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
case int64:
args = append(args, fmt.Sprintf("-%s=%d", key, val))
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
case float32:
args = append(args, fmt.Sprintf("-%s=%g", key, val))
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
case float64:
args = append(args, fmt.Sprintf("-%s=%g", key, val))
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
default:
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
@@ -296,6 +307,20 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
return err
}
// OptimizeImages is not available in this implementation.
func (engine *ExifTool) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
err := fmt.Errorf("optimize PDF images with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata extracts the metadata of a given PDF file by invoking
// the exiftool binary with "-j" (JSON output) and parsing the result.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {

View File

@@ -879,6 +879,8 @@ func (a *Api) Extensions() []string {
".potx",
".ppm",
".pps",
".ppsm",
".ppsx",
".ppt",
".pptm",
".pptx",

View File

@@ -293,6 +293,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
return errors.New("LibreOffice not started, cannot handle PDF conversion")
}
// SinglePageSheets starts each sheet's single page at the workbook's saved
// scroll position, truncating everything above and to the left of it.
// Render a copy with that position reset to the top-left cell instead.
// See https://github.com/gotenberg/gotenberg/issues/1222.
if options.SinglePageSheets {
inputPath = resetCalcScrollPosition(ctx, logger, inputPath)
}
args := []string{
"--no-launch",
"--format",

View File

@@ -0,0 +1,230 @@
package api
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
)
// topLeftCellAttr matches the topLeftCell attribute that an OOXML worksheet
// uses (on <sheetView> and, for frozen panes, <pane>) to store the cell that
// was at the top-left of the window when the workbook was saved.
var topLeftCellAttr = regexp.MustCompile(` topLeftCell="[^"]*"`)
// maxDecompressedWorksheet bounds how much a single worksheet may decompress to
// while rewriting it. It guards against a decompression bomb and keeps memory
// predictable. A worksheet larger than this is left untouched, so a pathological
// workbook falls back to the original file rather than being rewritten.
const maxDecompressedWorksheet = 128 << 20 // 128 MiB
// resetCalcScrollPosition returns a path to a copy of inputPath whose worksheet
// scroll positions have been reset to the top-left cell, or inputPath unchanged
// when the reset does not apply or cannot be performed safely.
//
// LibreOffice's SinglePageSheets export starts each single page at the sheet's
// saved topLeftCell, dropping every row and column above and to the left of it.
// A workbook saved scrolled away from A1 therefore renders truncated. Removing
// the attribute before the conversion makes the whole used range render.
// See https://github.com/gotenberg/gotenberg/issues/1222.
//
// The function never fails the conversion. On a non-xlsx input, a workbook that
// carries no scroll position, or any read, rewrite or validation error, it
// returns the original path so a malformed rewrite can never reach LibreOffice.
func resetCalcScrollPosition(ctx context.Context, logger *slog.Logger, inputPath string) string {
// Resolve the extension to a literal so the sanitized filename is never
// derived from the (user-controlled) upload name.
var ext string
switch strings.ToLower(filepath.Ext(inputPath)) {
case ".xlsx":
ext = ".xlsx"
case ".xlsm":
ext = ".xlsm"
default:
return inputPath
}
src, err := os.ReadFile(inputPath)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: read input: %s; using the original file", err))
return inputPath
}
out, changed, err := stripWorksheetScrollPosition(src)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
return inputPath
}
if !changed {
// The common case: nothing was saved scrolled, so nothing to do.
return inputPath
}
// A rewrite that dropped, renamed or corrupted an entry must never reach
// LibreOffice; fall back to the original workbook if it does not round-trip.
if err = validateWorkbook(src, out); err != nil {
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
return inputPath
}
// Write the sanitized copy alongside the input, inside the request working
// directory that LibreOffice already reads from. The pattern is constant,
// so the resulting name carries no user-controlled path component.
dst, err := os.CreateTemp(filepath.Dir(inputPath), "singlepagesheets-*"+ext)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: create sanitized file: %s; using the original file", err))
return inputPath
}
defer dst.Close()
_, err = dst.Write(out)
if err != nil {
_ = os.Remove(dst.Name())
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: write sanitized file: %s; using the original file", err))
return inputPath
}
logger.DebugContext(ctx, "reset calc scroll position: cleared worksheet topLeftCell for SinglePageSheets export")
return dst.Name()
}
// stripWorksheetScrollPosition rewrites the worksheet XML entries of an xlsx
// workbook, removing the topLeftCell attribute, and reports whether anything
// changed. Every non-worksheet entry, and every worksheet that does not carry
// the attribute, is copied byte-for-byte without recompression.
func stripWorksheetScrollPosition(src []byte) ([]byte, bool, error) {
reader, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return nil, false, fmt.Errorf("open workbook: %w", err)
}
var buf bytes.Buffer
writer := zip.NewWriter(&buf)
changed := false
for _, file := range reader.File {
rewritten, ok, err := rewriteWorksheet(file)
if err != nil {
return nil, false, err
}
if ok {
// Recompress only the worksheets that actually changed.
header := file.FileHeader
header.Method = zip.Deflate
w, err := writer.CreateHeader(&header)
if err != nil {
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
}
_, err = w.Write(rewritten)
if err != nil {
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
}
changed = true
continue
}
err = copyZipEntry(writer, file)
if err != nil {
return nil, false, err
}
}
err = writer.Close()
if err != nil {
return nil, false, fmt.Errorf("finalize workbook: %w", err)
}
if !changed {
return nil, false, nil
}
return buf.Bytes(), true, nil
}
// rewriteWorksheet returns file's contents with topLeftCell removed, and
// whether file is a worksheet that carried the attribute. A worksheet without
// the attribute, or any other entry, returns ok false so the caller copies it
// verbatim.
func rewriteWorksheet(file *zip.File) ([]byte, bool, error) {
if !strings.HasPrefix(file.Name, "xl/worksheets/") || !strings.HasSuffix(strings.ToLower(file.Name), ".xml") {
return nil, false, nil
}
rc, err := file.Open()
if err != nil {
return nil, false, fmt.Errorf("open worksheet %q: %w", file.Name, err)
}
defer rc.Close()
// Read at most maxDecompressedWorksheet+1 bytes so a decompression bomb
// cannot exhaust memory; a genuine overflow aborts the rewrite.
data, err := io.ReadAll(io.LimitReader(rc, maxDecompressedWorksheet+1))
if err != nil {
return nil, false, fmt.Errorf("read worksheet %q: %w", file.Name, err)
}
if len(data) > maxDecompressedWorksheet {
return nil, false, fmt.Errorf("worksheet %q exceeds %d bytes", file.Name, maxDecompressedWorksheet)
}
if !bytes.Contains(data, []byte("topLeftCell")) {
return nil, false, nil
}
return topLeftCellAttr.ReplaceAll(data, nil), true, nil
}
// copyZipEntry writes file into writer without decompressing and recompressing
// it, preserving its exact bytes.
func copyZipEntry(writer *zip.Writer, file *zip.File) error {
w, err := writer.CreateRaw(&file.FileHeader)
if err != nil {
return fmt.Errorf("copy entry %q: %w", file.Name, err)
}
rc, err := file.OpenRaw()
if err != nil {
return fmt.Errorf("open entry %q: %w", file.Name, err)
}
_, err = io.Copy(w, rc)
if err != nil {
return fmt.Errorf("copy entry %q: %w", file.Name, err)
}
return nil
}
// validateWorkbook checks that out reopens as a zip holding exactly the same
// entry names as src, rejecting a rewrite that lost, renamed or added an entry
// or produced a broken central directory. The entry payloads themselves are
// not re-read: unchanged entries are copied byte-for-byte from a workbook that
// already parsed, and rewritten worksheets are produced by the standard library
// writer, so re-decompressing everything would only add a decompression-bomb
// surface without catching a failure this transform can introduce.
func validateWorkbook(src, out []byte) error {
original, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
if err != nil {
return fmt.Errorf("reopen original workbook: %w", err)
}
rewritten, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
if err != nil {
return fmt.Errorf("reopen rewritten workbook: %w", err)
}
if len(rewritten.File) != len(original.File) {
return fmt.Errorf("entry count changed from %d to %d", len(original.File), len(rewritten.File))
}
names := make(map[string]struct{}, len(original.File))
for _, file := range original.File {
names[file.Name] = struct{}{}
}
for _, file := range rewritten.File {
_, ok := names[file.Name]
if !ok {
return fmt.Errorf("unexpected entry %q", file.Name)
}
}
return nil
}

View File

@@ -0,0 +1,216 @@
package api
import (
"archive/zip"
"bytes"
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
// buildWorkbook packs entries into an in-memory xlsx-like zip.
func buildWorkbook(t *testing.T, entries map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for name, content := range entries {
f, err := w.Create(name)
if err != nil {
t.Fatalf("create entry %q: %v", name, err)
}
_, err = f.Write([]byte(content))
if err != nil {
t.Fatalf("write entry %q: %v", name, err)
}
}
err := w.Close()
if err != nil {
t.Fatalf("close workbook: %v", err)
}
return buf.Bytes()
}
func readEntry(t *testing.T, workbook []byte, name string) string {
t.Helper()
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
if err != nil {
t.Fatalf("open workbook: %v", err)
}
for _, f := range r.File {
if f.Name != name {
continue
}
rc, err := f.Open()
if err != nil {
t.Fatalf("open entry %q: %v", name, err)
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("read entry %q: %v", name, err)
}
return string(data)
}
t.Fatalf("entry %q not found", name)
return ""
}
const scrolledSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
`<sheetViews><sheetView tabSelected="1" topLeftCell="A37" workbookViewId="0">` +
`<pane topLeftCell="A37"/><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>` +
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
const topSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
`<sheetViews><sheetView tabSelected="1" workbookViewId="0"/></sheetViews>` +
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
func TestStripWorksheetScrollPosition(t *testing.T) {
for _, tc := range []struct {
scenario string
workbook []byte
expectErr bool
expectChange bool
}{
{
scenario: "removes topLeftCell from sheetView and pane",
workbook: buildWorkbook(t, map[string]string{
"[Content_Types].xml": "<Types/>",
"xl/worksheets/sheet1.xml": scrolledSheet,
"xl/sharedStrings.xml": "<sst/>",
}),
expectChange: true,
},
{
scenario: "leaves a workbook without a saved scroll position untouched",
workbook: buildWorkbook(t, map[string]string{
"[Content_Types].xml": "<Types/>",
"xl/worksheets/sheet1.xml": topSheet,
}),
expectChange: false,
},
{
scenario: "only rewrites worksheet entries",
workbook: buildWorkbook(t, map[string]string{
"xl/worksheets/sheet1.xml": scrolledSheet,
// A stray topLeftCell elsewhere must not be touched.
"xl/workbook.xml": `<workbook topLeftCell="A9"/>`,
}),
expectChange: true,
},
{
scenario: "rejects a non-zip input",
workbook: []byte("not a zip file"),
expectErr: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
out, changed, err := stripWorksheetScrollPosition(tc.workbook)
if tc.expectErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if changed != tc.expectChange {
t.Fatalf("expected changed=%v, got %v", tc.expectChange, changed)
}
if !changed {
return
}
// The rewrite must round-trip and hold the same entries.
if err = validateWorkbook(tc.workbook, out); err != nil {
t.Fatalf("rewritten workbook did not validate: %v", err)
}
if strings.Contains(readEntry(t, out, "xl/worksheets/sheet1.xml"), "topLeftCell") {
t.Fatalf("worksheet still contains topLeftCell")
}
// Non-worksheet entries are copied verbatim.
if _, ok := entryNames(t, out)["xl/workbook.xml"]; ok {
if got := readEntry(t, out, "xl/workbook.xml"); got != `<workbook topLeftCell="A9"/>` {
t.Fatalf("non-worksheet entry was modified: %q", got)
}
}
})
}
}
func entryNames(t *testing.T, workbook []byte) map[string]struct{} {
t.Helper()
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
if err != nil {
t.Fatalf("open workbook: %v", err)
}
names := make(map[string]struct{}, len(r.File))
for _, f := range r.File {
names[f.Name] = struct{}{}
}
return names
}
func TestResetCalcScrollPosition(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()
writeFile := func(t *testing.T, name string, content []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
err := os.WriteFile(path, content, 0o600)
if err != nil {
t.Fatalf("write %q: %v", name, err)
}
return path
}
t.Run("non-xlsx input is returned unchanged", func(t *testing.T) {
path := writeFile(t, "input.docx", []byte("whatever"))
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
t.Fatalf("expected %q, got %q", path, got)
}
})
t.Run("workbook without a scroll position is returned unchanged", func(t *testing.T) {
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
"xl/worksheets/sheet1.xml": topSheet,
}))
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
t.Fatalf("expected original path %q, got %q", path, got)
}
})
t.Run("corrupt xlsx falls back to the original path", func(t *testing.T) {
path := writeFile(t, "input.xlsx", []byte("PK\x03\x04 not really a zip"))
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
t.Fatalf("expected fallback to %q, got %q", path, got)
}
})
t.Run("scrolled workbook yields a sanitized copy", func(t *testing.T) {
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
"[Content_Types].xml": "<Types/>",
"xl/worksheets/sheet1.xml": scrolledSheet,
}))
got := resetCalcScrollPosition(ctx, logger, path)
if got == path {
t.Fatalf("expected a sanitized copy, got the original path")
}
if filepath.Dir(got) != filepath.Dir(path) {
t.Fatalf("sanitized copy escaped the working directory: %q", got)
}
sanitized, err := os.ReadFile(got)
if err != nil {
t.Fatalf("read sanitized file: %v", err)
}
if strings.Contains(readEntry(t, sanitized, "xl/worksheets/sheet1.xml"), "topLeftCell") {
t.Fatalf("sanitized worksheet still contains topLeftCell")
}
})
}

View File

@@ -60,6 +60,11 @@ func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Lo
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// OptimizeImages is not available in this implementation.
func (engine *LibreOfficePdfEngine) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
return fmt.Errorf("optimize PDF images with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]

View File

@@ -44,6 +44,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -515,6 +516,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}
}
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
needsConvertStub := !nativePdfFormats ||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
(nativePdfFormats && hasPostProcessing)

View File

@@ -0,0 +1,321 @@
package pdfcpu
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
_ "image/png" // Register the PNG decoder: pdfcpu extracts FlateDecode images as PNG.
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// minOptimizeImageSize is the smallest encoded image worth re-encoding. Smaller
// images (thumbnails, icons, and line art that FlateDecode already keeps tiny)
// are left untouched: a JPEG pass would add artifacts for little or no gain.
const minOptimizeImageSize = 30 << 10 // 30 KiB
// pdfcpuListRowID matches the image Id (e.g. "X6") in a `pdfcpu images extract`
// filename such as "input_1_X6.png".
var pdfcpuListRowID = regexp.MustCompile(`_(X\d+)\.`)
// pdfcpuImage is one raster image XObject as reported by `pdfcpu images list`.
type pdfcpuImage struct {
obj int
id string
masked bool
comp int
bytes int64
filter string
}
// OptimizeImages re-encodes the raster images of inputPath to JPEG in place,
// shrinking image-heavy PDFs (a common case for Chromium output, which embeds
// non-JPEG images losslessly) while leaving text, vectors, fonts and structure
// untouched. See https://github.com/gotenberg/gotenberg/issues/359.
//
// Only lossless (FlateDecode), non-CMYK, non-masked images at or above
// [minOptimizeImageSize] are touched. Already-compressed, transparent, CMYK and
// small images are skipped so the pass never enlarges a file or corrupts
// transparency. It never fails the conversion for a single unreadable image; it
// logs and moves on, and returns the input unchanged when nothing qualifies.
func (engine *PdfCpu) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
fail := func(err error) error {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
images, err := engine.listImages(ctx, inputPath)
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
}
var targets []pdfcpuImage
for _, img := range images {
if optimizableImage(img) {
targets = append(targets, img)
}
}
if len(targets) == 0 {
logger.DebugContext(ctx, "no images to optimize")
span.SetStatus(codes.Ok, "")
return nil
}
workDir, err := os.MkdirTemp(filepath.Dir(inputPath), "optimize-images-*")
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: create work directory: %w", err))
}
defer func() {
if err := os.RemoveAll(workDir); err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("remove image optimization work directory: %v", err))
}
}()
extracted, err := engine.extractImages(ctx, logger, inputPath, workDir)
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
}
// Chain one update per image. Each update writes a fresh file; current holds
// the latest successful output, so a single failed image is skipped without
// discarding the ones already done. The input is only replaced on success.
current := inputPath
optimized := 0
for _, img := range targets {
src, ok := extracted[img.id]
if !ok {
logger.WarnContext(ctx, fmt.Sprintf("optimize images: image %s was not extracted, leaving it as is", img.id))
continue
}
reencoded := filepath.Join(workDir, img.id+".jpg")
err = reencodeToJpeg(src, reencoded, imageQuality)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("optimize images: re-encode %s: %v; leaving it as is", img.id, err))
continue
}
next := filepath.Join(workDir, fmt.Sprintf("optimized-%d.pdf", optimized))
err = engine.updateImage(ctx, logger, current, reencoded, next, img.obj)
if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("optimize images: update %s: %v; leaving it as is", img.id, err))
continue
}
current = next
optimized++
}
if optimized == 0 {
span.SetStatus(codes.Ok, "")
return nil
}
err = os.Rename(current, inputPath)
if err != nil {
return fail(fmt.Errorf("optimize PDF images with pdfcpu: replace input: %w", err))
}
logger.DebugContext(ctx, fmt.Sprintf("optimized %d image(s) at quality %d", optimized, imageQuality))
span.SetStatus(codes.Ok, "")
return nil
}
// optimizableImage reports whether an image is a safe, worthwhile target: a
// lossless (FlateDecode), non-CMYK, non-masked image at or above the size
// threshold. Everything else is left untouched.
func optimizableImage(img pdfcpuImage) bool {
switch {
case img.filter != "FlateDecode":
return false // Already compressed (JPEG/JPX); re-encoding would only add loss.
case img.comp == 4:
return false // CMYK; a JPEG round-trip is unsafe.
case img.masked:
return false // Soft mask, image mask or alpha; JPEG has no transparency.
case img.bytes < minOptimizeImageSize:
return false
default:
return true
}
}
// listImages runs `pdfcpu images list` and parses its table. The command writes
// to stdout, so it is run directly to capture the output.
func (engine *PdfCpu) listImages(ctx context.Context, inputPath string) ([]pdfcpuImage, error) {
cmd := exec.CommandContext(ctx, engine.binPath, "images", "list", inputPath) //nolint:gosec // binPath is validated at Provision; inputPath is a Gotenberg working file.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
var stdout bytes.Buffer
cmd.Stdout = &stdout
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("run pdfcpu images list: %w", err)
}
return parseImagesList(stdout.String()), nil
}
// parseImagesList parses the fixed-column table of `pdfcpu images list`. Columns
// are separated by U+2502; the header and separator rows are skipped because
// their second column is not a numeric object number.
func parseImagesList(output string) []pdfcpuImage {
var images []pdfcpuImage
for _, line := range strings.Split(output, "\n") {
cols := strings.Split(line, "│")
if len(cols) < 9 {
continue
}
obj, err := strconv.Atoi(strings.TrimSpace(cols[1]))
if err != nil {
continue
}
comp := 0
if fields := strings.Fields(cols[6]); len(fields) >= 2 {
comp, _ = strconv.Atoi(fields[1])
}
images = append(images, pdfcpuImage{
obj: obj,
id: strings.TrimSpace(cols[2]),
masked: strings.TrimSpace(cols[3]) != "image",
comp: comp,
bytes: parseHumanSize(cols[7]),
filter: strings.TrimSpace(cols[8]),
})
}
return images
}
// parseHumanSize converts a pdfcpu size cell such as "4.4 MB" or "194 KB" into
// a byte count.
func parseHumanSize(cell string) int64 {
fields := strings.Fields(cell)
if len(fields) == 0 {
return 0
}
value, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0
}
multiplier := float64(1)
if len(fields) > 1 {
switch strings.ToUpper(fields[1]) {
case "KB":
multiplier = 1 << 10
case "MB":
multiplier = 1 << 20
case "GB":
multiplier = 1 << 30
}
}
return int64(value * multiplier)
}
// extractImages extracts every image of inputPath into dir and returns a map of
// image Id (e.g. "X6") to the extracted file path.
func (engine *PdfCpu) extractImages(ctx context.Context, logger *slog.Logger, inputPath, dir string) (map[string]string, error) {
args := []string{"images", "extract", inputPath, dir}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("extract images: %w", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read extracted images: %w", err)
}
extracted := make(map[string]string, len(entries))
for _, entry := range entries {
if match := pdfcpuListRowID.FindStringSubmatch(entry.Name()); match != nil {
extracted[match[1]] = filepath.Join(dir, entry.Name())
}
}
return extracted, nil
}
// updateImage replaces the image object objNr of inFile with the image at
// imagePath, writing the result to outFile. The replacement must share the
// original image dimensions, which reencodeToJpeg preserves.
func (engine *PdfCpu) updateImage(ctx context.Context, logger *slog.Logger, inFile, imagePath, outFile string, objNr int) error {
args := []string{"images", "update", inFile, imagePath, outFile, strconv.Itoa(objNr)}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("update image: %w", err)
}
return nil
}
// reencodeToJpeg decodes the image at src and writes it to dst as JPEG at the
// given quality, keeping the original pixel dimensions (pdfcpu requires the
// replacement to match). quality is clamped to the valid 1 to 100 range.
func reencodeToJpeg(src, dst string, quality int) error {
if quality < 1 {
quality = 1
}
if quality > 100 {
quality = 100
}
in, err := os.Open(src) //nolint:gosec // src is a file this package extracted into its own temp dir.
if err != nil {
return fmt.Errorf("open image: %w", err)
}
defer in.Close()
img, _, err := image.Decode(in)
if err != nil {
return fmt.Errorf("decode image: %w", err)
}
out, err := os.Create(dst) //nolint:gosec // dst is a file in this package's own temp dir.
if err != nil {
return fmt.Errorf("create re-encoded image: %w", err)
}
defer out.Close()
err = jpeg.Encode(out, img, &jpeg.Options{Quality: quality})
if err != nil {
return fmt.Errorf("encode JPEG: %w", err)
}
return nil
}

View File

@@ -0,0 +1,83 @@
package pdfcpu
import "testing"
const sampleImagesList = `pages: all
/tmp/multi.pdf:
4 images available (8.9 MB)
Page │ Obj# │ Id │ Type SoftMask ImgMask │ Width │ Height │ ColorSpace Comp bpc Interp │ Size │ Filters
━━━━━┿━━━━━━┿━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━
1 │ 6 │ X6 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 5.5 MB │ FlateDecode
│ 8 │ X8 │ image * │ 1400 │ 1000 │ ICCBased 3 8 │ 63 KB │ FlateDecode
│ 9 │ X9 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 194 KB │ DCTDecode
│ 10 │ X10 │ image │ 120 │ 90 │ DeviceCMYK 4 8 │ 14 KB │ FlateDecode
`
func TestParseImagesList(t *testing.T) {
images := parseImagesList(sampleImagesList)
if len(images) != 4 {
t.Fatalf("expected 4 images, got %d", len(images))
}
for _, tc := range []struct {
index int
obj int
id string
masked bool
comp int
filter string
}{
{0, 6, "X6", false, 3, "FlateDecode"},
{1, 8, "X8", true, 3, "FlateDecode"},
{2, 9, "X9", false, 3, "DCTDecode"},
{3, 10, "X10", false, 4, "FlateDecode"},
} {
img := images[tc.index]
if img.obj != tc.obj || img.id != tc.id || img.masked != tc.masked || img.comp != tc.comp || img.filter != tc.filter {
t.Errorf("image %d = %+v, want obj=%d id=%s masked=%v comp=%d filter=%s",
tc.index, img, tc.obj, tc.id, tc.masked, tc.comp, tc.filter)
}
}
}
func TestParseHumanSize(t *testing.T) {
for _, tc := range []struct {
cell string
want int64
}{
{"5.5 MB", int64(5.5 * (1 << 20))},
{"194 KB", 194 << 10},
{" 14 KB ", 14 << 10},
{"512 B", 512},
{"2 GB", 2 << 30},
{"", 0},
{"garbage", 0},
} {
if got := parseHumanSize(tc.cell); got != tc.want {
t.Errorf("parseHumanSize(%q) = %d, want %d", tc.cell, got, tc.want)
}
}
}
func TestOptimizableImage(t *testing.T) {
base := pdfcpuImage{obj: 1, id: "X1", masked: false, comp: 3, bytes: 1 << 20, filter: "FlateDecode"}
for _, tc := range []struct {
scenario string
mutate func(pdfcpuImage) pdfcpuImage
want bool
}{
{"lossless RGB above threshold", func(i pdfcpuImage) pdfcpuImage { return i }, true},
{"already compressed", func(i pdfcpuImage) pdfcpuImage { i.filter = "DCTDecode"; return i }, false},
{"CMYK", func(i pdfcpuImage) pdfcpuImage { i.comp = 4; return i }, false},
{"masked", func(i pdfcpuImage) pdfcpuImage { i.masked = true; return i }, false},
{"below threshold", func(i pdfcpuImage) pdfcpuImage { i.bytes = minOptimizeImageSize - 1; return i }, false},
{"grayscale above threshold", func(i pdfcpuImage) pdfcpuImage { i.comp = 1; return i }, true},
} {
if got := optimizableImage(tc.mutate(base)); got != tc.want {
t.Errorf("%s: optimizableImage = %v, want %v", tc.scenario, got, tc.want)
}
}
}

View File

@@ -18,6 +18,7 @@ type multiPdfEngines struct {
splitEngines []gotenberg.PdfEngine
flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
optimizeImagesEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
@@ -36,6 +37,7 @@ func newMultiPdfEngines(
splitEngines,
flattenEngines,
convertEngines,
optimizeImagesEngines,
readMetadataEngines,
writeMetadataEngines,
passwordEngines,
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
splitEngines: splitEngines,
flattenEngines: flattenEngines,
convertEngines: convertEngines,
optimizeImagesEngines: optimizeImagesEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
@@ -189,6 +192,17 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger,
)
}
// OptimizeImages re-encodes the images of a PDF using the first available
// engine that supports image optimization.
func (multi *multiPdfEngines) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
return runWithFallbackVoid(ctx, "pdfengines.OptimizeImages", multi.optimizeImagesEngines,
func(ctx context.Context, engine gotenberg.PdfEngine) error {
return engine.OptimizeImages(ctx, logger, imageQuality, inputPath)
},
func(err error) error { return fmt.Errorf("optimize PDF images with multi PDF engines: %w", err) },
)
}
// Convert transforms the given PDF to a specific PDF format using the first
// available engine that supports PDF conversion.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {

View File

@@ -32,6 +32,7 @@ type PdfEngines struct {
splitNames []string
flattenNames []string
convertNames []string
optimizeImagesNames []string
readMetadataNames []string
writeMetadataNames []string
encryptNames []string
@@ -57,6 +58,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-optimize-images-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the image optimization feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
@@ -91,6 +93,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
splitNames := flags.MustStringSlice("pdfengines-split-engines")
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
optimizeImagesNames := flags.MustStringSlice("pdfengines-optimize-images-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
@@ -148,6 +151,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.convertNames = convertNames
}
mod.optimizeImagesNames = defaultNames
if len(optimizeImagesNames) > 0 {
mod.optimizeImagesNames = optimizeImagesNames
}
mod.readMetadataNames = defaultNames
if len(readMetadataNames) > 0 {
mod.readMetadataNames = readMetadataNames
@@ -247,6 +255,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.flattenNames)
findNonExistingEngines(mod.optimizeImagesNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMetadataNames)
@@ -275,6 +284,7 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")),
fmt.Sprintf("optimize images engines - %s", strings.Join(mod.optimizeImagesNames, " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames, " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.splitNames),
engines(mod.flattenNames),
engines(mod.convertNames),
engines(mod.optimizeImagesNames),
engines(mod.readMetadataNames),
engines(mod.writeMetadataNames),
engines(mod.encryptNames),
@@ -341,6 +352,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
mergeRoute(engine),
splitRoute(engine),
flattenRoute(engine),
optimizeRoute(engine),
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),

View File

@@ -344,6 +344,65 @@ func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []stri
return nil
}
// defaultImageQuality is the JPEG quality applied by the image optimization
// feature when the imageQuality form field is not set.
const defaultImageQuality = 80
// FormDataPdfOptimize extracts the image-optimization options from the form
// data: whether to optimize the images, and the JPEG quality (1 to 100) to
// apply to each re-encoded image.
func FormDataPdfOptimize(form *api.FormData) (bool, int) {
var (
optimizeImages bool
imageQuality int
)
form.
Bool("optimizeImages", &optimizeImages, false).
Custom("imageQuality", func(value string) error {
if value == "" {
imageQuality = defaultImageQuality
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
if intValue > 100 {
return errors.New("value is superior to 100")
}
imageQuality = intValue
return nil
})
return optimizeImages, imageQuality
}
// OptimizeStub re-encodes the images of each given PDF to shrink the file when
// optimizeImages is set, leaving text, vectors and structure untouched. It does
// nothing when optimizeImages is false.
func OptimizeStub(ctx *api.Context, engine gotenberg.PdfEngine, optimizeImages bool, imageQuality int, inputPaths []string) error {
if !optimizeImages {
return nil
}
for _, inputPath := range inputPaths {
err := engine.OptimizeImages(ctx, ctx.Log(), imageQuality, inputPath)
if err != nil {
return fmt.Errorf("optimize images of '%s': %w", inputPath, err)
}
}
return nil
}
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
@@ -931,6 +990,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
optimizeImages, imageQuality := FormDataPdfOptimize(form)
var inputPaths []string
var flatten bool
@@ -998,6 +1058,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
}
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
@@ -1108,6 +1173,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
optimizeImages, imageQuality := FormDataPdfOptimize(form)
var inputPaths []string
var flatten bool
@@ -1170,6 +1236,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
}
}
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
@@ -1262,6 +1333,42 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
// format.
func optimizeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/optimize",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
// This route optimizes unconditionally, so the optimizeImages toggle
// is ignored; only the image quality is read.
_, imageQuality := FormDataPdfOptimize(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = OptimizeStub(ctx, engine, true, imageQuality, inputPaths)
if err != nil {
return fmt.Errorf("optimize PDF images: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
func convertRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,

View File

@@ -219,6 +219,20 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
return err
}
// OptimizeImages is not available in this implementation.
func (engine *PdfTk) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
err := fmt.Errorf("optimize PDF images with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata is not available in this implementation.
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",

View File

@@ -255,6 +255,20 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go
return err
}
// OptimizeImages is not available in this implementation.
func (engine *QPdf) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.OptimizeImages",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
err := fmt.Errorf("optimize PDF images with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata is not available in this implementation.
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
@@ -454,7 +468,7 @@ func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger,
return err
}
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(ctx, logger, objects, metadata)
if len(filespecRefs) == 0 {
span.SetStatus(codes.Ok, "")
return nil
@@ -506,7 +520,7 @@ func parsePdfObjects(output []byte) (map[string]json.RawMessage, error) {
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
// and returns the catalog reference, catalog value, filespec references,
// and the update objects map.
func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
func patchFilespecMetadata(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
updateObjects := make(map[string]any)
var catalogRef string
var catalogValue map[string]any
@@ -556,7 +570,7 @@ func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessa
if ef, ok := value["/EF"].(map[string]any); ok {
efRef, _ := ef["/F"].(string)
if efRef != "" {
setStreamSubtype(logger, objects, updateObjects, efRef, mimeType)
setStreamSubtype(ctx, logger, objects, updateObjects, efRef, mimeType)
}
}
}
@@ -653,38 +667,38 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
// setStreamSubtype finds a stream object by reference and sets the /Subtype
// key in its dict.
func setStreamSubtype(logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
func setStreamSubtype(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
objKey := ref
if !strings.HasPrefix(objKey, "obj:") {
objKey = "obj:" + objKey
}
raw, ok := objects[objKey]
if !ok {
logger.Warn(fmt.Sprintf("set stream subtype on %s: object not found", ref))
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: object not found", ref))
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
return
}
streamRaw, ok := obj["stream"]
if !ok {
logger.Warn(fmt.Sprintf("set stream subtype on %s: no stream key", ref))
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: no stream key", ref))
return
}
var stream map[string]any
if err := json.Unmarshal(streamRaw, &stream); err != nil {
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
return
}
dict, ok := stream["dict"].(map[string]any)
if !ok {
logger.Warn(fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
return
}

View File

@@ -1,6 +1,7 @@
package qpdf
import (
"context"
"encoding/json"
"log/slog"
"os"
@@ -99,7 +100,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
"factur-x.xml": {"relationship": "Data"},
}
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
if catalogRef != "obj:1 0 R" {
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
@@ -125,7 +126,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
"factur-x.xml": {"relationship": "Data"},
}
_, _, filespecRefs, _ := patchFilespecMetadata(logger, objects, metadata)
_, _, filespecRefs, _ := patchFilespecMetadata(context.Background(), logger, objects, metadata)
if len(filespecRefs) != 0 {
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
}
@@ -139,7 +140,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
"factur-x.xml": {"relationship": "Alternative"},
}
_, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
_, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
if len(filespecRefs) != 1 {
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
}
@@ -158,7 +159,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
"factur-x.xml": {"mimeType": "text/xml"},
}
_, _, _, updateObjects := patchFilespecMetadata(logger, objects, metadata)
_, _, _, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
streamObj, ok := updateObjects["obj:3 0 R"]
if !ok {
t.Fatal("expected obj:3 0 R in updateObjects")
@@ -223,7 +224,7 @@ func TestSetStreamSubtype(t *testing.T) {
}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
dict := streamObj["dict"].(map[string]any)
@@ -238,7 +239,7 @@ func TestSetStreamSubtype(t *testing.T) {
}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "5 0 R", "application/pdf")
setStreamSubtype(context.Background(), logger, objects, updateObjects, "5 0 R", "application/pdf")
if _, ok := updateObjects["obj:5 0 R"]; !ok {
t.Error("expected obj:5 0 R in updateObjects")
@@ -249,7 +250,7 @@ func TestSetStreamSubtype(t *testing.T) {
objects := map[string]json.RawMessage{}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "obj:99 0 R", "text/xml")
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:99 0 R", "text/xml")
if len(updateObjects) != 0 {
t.Error("expected no updates for missing object")
@@ -262,7 +263,7 @@ func TestSetStreamSubtype(t *testing.T) {
}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
if len(updateObjects) != 0 {
t.Error("expected no updates for non-stream object")

View File

@@ -23,12 +23,12 @@ make test-integration PLATFORM=linux/arm64 # force a specific platform
Available 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` |
| LibreOffice | `libreoffice`, `libreoffice-convert` |
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `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` |
| 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` |
| 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` |
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
## Writing a new test

View File

@@ -62,6 +62,24 @@ Feature: /forms/chromium/convert/html
Page 12
"""
# A wide table on one landscape page: the page must expand its width to the
# content, so the rightmost column is not truncated and the page comes out
# landscape instead of the narrow-tall strip the height-only expansion used
# to produce. See https://github.com/gotenberg/gotenberg/issues/1390.
Scenario: POST /forms/chromium/convert/html (Single Page Landscape)
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/wide-table-html/index.html | file |
| singlePage | true | field |
| landscape | true | field |
| Gotenberg-Output-Filename | foo | 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 "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should be set to landscape orientation
Then the "foo.pdf" PDF should have content matching "Column 12" at page 1
Scenario: POST /forms/chromium/convert/html (Landscape)
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):
@@ -195,6 +213,26 @@ Feature: /forms/chromium/convert/html
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
# A thenable (async) expression is awaited and its resolved value gates the
# print. Without awaiting, the Promise object never evaluates to true and the
# request would time out. See https://github.com/gotenberg/gotenberg/pull/1617.
Scenario: POST /forms/chromium/convert/html (Wait For Thenable Expression)
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/feature-rich-html/index.html | file |
| waitForExpression | (async () => window.globalVar === 'ready')() | field |
| Gotenberg-Output-Filename | foo | 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 there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitForExpression)
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):
@@ -416,6 +454,87 @@ Feature: /forms/chromium/convert/html
Then the Gotenberg container should log the following entries:
| 'file:///etc/passwd' matches the expression from the denied list |
# Control for the WebSocket scenario below. An ordinary fetch to a loopback
# address is surfaced as a Fetch.requestPaused event, so it is blocked by
# CHROMIUM_DENY_PRIVATE_IPS and the block is logged. The allow-list is
# cleared because a matching allow-list entry bypasses the IP-based check.
@chromium-ssrf
Scenario: POST /forms/chromium/convert/html (Fetch to a non-public address is filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
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-fetch-html/index.html | file |
| waitDelay | 1s | field |
Then the response status code should be 200
Then the Gotenberg container should log the following entries:
| 'http://127.0.0.1:9999/ssrf-fetch' targets a non-public address |
# A WebSocket handshake is never surfaced as a Fetch.requestPaused event, so
# it escapes the filter in listenForEventRequestPaused. The page opens
# WebSockets to two non-public addresses (loopback and the link-local cloud
# metadata IP). listenForEventWebSocketCreated logs each disallowed handshake
# with its full ws:// URL (detection), and the pinning proxy severs the
# connection now that the implicit loopback bypass is removed (enforcement).
@chromium-ssrf
Scenario: POST /forms/chromium/convert/html (WebSocket to a non-public address is filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
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' targets a non-public address |
| CONNECT blocked for '127.0.0.1:9999' |
# A Web Worker is a separate CDP target, so its WebSocket handshake is not
# observed by listenForEventWebSocketCreated. Enforcement must not depend on
# that listener: the pinning proxy sees the handshake and severs it whatever
# the originating context. Only the proxy's block is asserted, since no
# detection log is produced for the worker target.
@chromium-ssrf
Scenario: POST /forms/chromium/convert/html (WebSocket from a Web Worker is filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
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-worker-html/index.html | file |
| waitDelay | 1s | field |
Then the response status code should be 200
Then the Gotenberg container should log the following entries:
| CONNECT blocked for '127.0.0.1:9999' |
# wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same path
# as ws://, and must be filtered identically.
@chromium-ssrf
Scenario: POST /forms/chromium/convert/html (Secure WebSocket to a non-public address is filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
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-tls-html/index.html | file |
| waitDelay | 1s | field |
Then the response status code should be 200
Then the Gotenberg container should log the following entries:
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
| CONNECT blocked for '127.0.0.1:9999' |
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
@chromium-ssrf
Scenario: POST /forms/chromium/convert/html (EventSource to a non-public address is filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
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-eventsource-html/index.html | file |
| waitDelay | 1s | field |
Then the response status code should be 200
Then the Gotenberg container should log the following entries:
| 'http://127.0.0.1:9999/sse' targets a non-public address |
Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
@@ -1088,6 +1207,26 @@ Feature: /forms/chromium/convert/html
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be flatten
# Post-processing image optimization re-encodes the embedded lossless image to
# JPEG. The same page is ~700 KB without it and well under 300 KB with it.
# See https://github.com/gotenberg/gotenberg/issues/359.
Scenario: POST /forms/chromium/convert/html (Optimize Images)
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/optimize-image-html/index.html | file |
| files | testdata/optimize-image-html/image.png | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the "foo.pdf" file size should be greater than 300 KB
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/optimize-image-html/index.html | file |
| files | testdata/optimize-image-html/image.png | file |
| optimizeImages | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the "foo.pdf" file size should be less than 300 KB
@encrypt
Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
Given I have a default Gotenberg container

View File

@@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url
Page 1
"""
# 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 without clearing)
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/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
# With --chromium-clear-storage every conversion clears the origin's
# localStorage first, so the counter never carries over.
# See https://github.com/gotenberg/gotenberg/issues/919.
Scenario: POST /forms/chromium/convert/url (Clear Storage)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_CLEAR_STORAGE | 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=1" at page 1
Scenario: POST /forms/chromium/convert/url (Single Page)
Given I have a default Gotenberg container
Given I have a static server
@@ -499,6 +534,7 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@chromium-ssrf
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
@@ -512,6 +548,55 @@ Feature: /forms/chromium/convert/url
Forbidden
"""
# IPv6 loopback literal is parsed as an IP and rejected by the IP-class check,
# like the IPv4 loopback literal above.
@chromium-ssrf
Scenario: POST /forms/chromium/convert/url (Main URL is an IPv6 loopback literal, deny-private-ips on)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
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://[::1]/ | field |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
# An alternate IP encoding (decimal for 127.0.0.1) that Chromium would read
# as loopback but the resolver rejects as a hostname. It must fail closed as
# filtered (a generic 403), not surface as a 500.
@chromium-ssrf
Scenario: POST /forms/chromium/convert/url (Main URL is a decimal-encoded loopback IP, deny-private-ips on)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
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://2130706433/ | field |
Then the response status code should be 403
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Forbidden
"""
# A classic SSRF vector: an allow-listed URL that redirects to an internal
# address. The redirected request must not inherit the initial URL's
# allow-list pass. listenForEventRequestPaused re-validates it; it does not
# match the allow-list, so it is blocked (Chromium reports ERR_ACCESS_DENIED
# and the conversion renders the resulting error page).
@chromium-ssrf
Scenario: POST /forms/chromium/convert/url (Redirect to a non-allow-listed address is re-filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | ^https?://host.docker.internal.* |
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/redirect-to-private | field |
Then the response status code should be 200
Then the Gotenberg container should log the following entries:
| 'http://127.0.0.1:9999/redirected' does not match any expression from the allowed list |
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | .+ |

View File

@@ -2,6 +2,22 @@
@chromium-screenshot-html
Feature: /forms/chromium/screenshot/html
# Route parity: the WebSocket outbound filter lives in the shared browser
# code path, so the screenshot route enforces it exactly like conversion.
@chromium-ssrf
Scenario: POST /forms/chromium/screenshot/html (WebSocket to a non-public address is filtered)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
| CHROMIUM_DENY_PRIVATE_IPS | true |
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/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 response header "Content-Type" should be "image/png"
Then the Gotenberg container should log the following entries:
| 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address |
| CONNECT blocked for '127.0.0.1:9999' |
Scenario: POST /forms/chromium/screenshot/html (Default)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
@@ -53,6 +69,32 @@ Feature: /forms/chromium/screenshot/html
Then the response status code should be 200
Then the response header "Content-Type" should be "image/png"
# The target element is 300x200 and sits below a spacer, so a correct clip
# proves both the element size and its page offset. See issue #947.
Scenario: POST /forms/chromium/screenshot/html (Selector)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
| files | testdata/screenshot-selector-html/index.html | file |
| selector | #target | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "image/png"
Then the "foo.png" image should be 300x200 pixels
# A centered red pixel proves the clip landed on the element, not on the
# white spacer above it, i.e. the page offset was applied.
Then the "foo.png" image pixel at 150,100 should be "#ff0000"
Scenario: POST /forms/chromium/screenshot/html (Selector Not Found)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
| files | testdata/screenshot-selector-html/index.html | file |
| selector | #does-not-exist | field |
Then the response status code should be 400
Then the response body should contain string:
"""
The selector '#does-not-exist' (selector) matched no element with a visible box
"""
Scenario: POST /forms/chromium/screenshot/html (Quality)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):

View File

@@ -83,6 +83,7 @@ Feature: /debug
"chromium-auto-start": "false",
"chromium-clear-cache": "false",
"chromium-clear-cookies": "false",
"chromium-clear-storage": "false",
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
"chromium-deny-private-ips": "false",
"chromium-deny-public-ips": "false",
@@ -221,6 +222,7 @@ Feature: /debug
"chromium-auto-start": "false",
"chromium-clear-cache": "false",
"chromium-clear-cookies": "false",
"chromium-clear-storage": "false",
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
"chromium-deny-private-ips": "false",
"chromium-deny-public-ips": "false",

View File

@@ -18,6 +18,33 @@ Feature: /forms/libreoffice/convert
Page 1
"""
# LibreOffice detects OOXML PowerPoint shows from content and converts them
# like .pptx; only Gotenberg's extension allow list gated them out.
# See https://github.com/gotenberg/gotenberg/pull/1626.
Scenario: POST /forms/libreoffice/convert (PowerPoint Show .ppsx)
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/slideshow.ppsx | file |
| Gotenberg-Output-Filename | foo | 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 there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Scenario: POST /forms/libreoffice/convert (PowerPoint Show .ppsm)
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/slideshow.ppsm | file |
| Gotenberg-Output-Filename | foo | 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 there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
# A CSV becomes a single Calc sheet named after the input file, and Calc's
# default page style prints that sheet name as a centered header. Uploads are
# stored under a UUID-based filename, so the UUID must not leak into the PDF.
@@ -942,7 +969,7 @@ Feature: /forms/libreoffice/convert
# An embedded image is stored inside the document, not linked, so blocking
# untrusted linked content leaves it untouched. Guards against over-blocking.
@libreoffice-linked-content
@libreoffice-ssrf
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
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):
@@ -955,7 +982,7 @@ Feature: /forms/libreoffice/convert
# An uploaded document always loads from an untrusted location, so soffice
# refuses to resolve any content it links (absolute file:// path or external
# URL). Closes the SSRF and local-file-read vector.
@libreoffice-linked-content
@libreoffice-ssrf
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
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):
@@ -964,3 +991,17 @@ Feature: /forms/libreoffice/convert
Then the response status code should be 200
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have 0 image(s)
# The workbook was saved scrolled down to row 37. Without the topLeftCell
# reset, SinglePageSheets would start the page there and drop the header rows,
# so the "Meteor" column header only appears when the whole sheet is rendered.
# See https://github.com/gotenberg/gotenberg/issues/1222.
Scenario: POST /forms/libreoffice/convert (SinglePageSheets renders a scrolled workbook in full)
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/singlepagesheets-scrolled.xlsx | file |
| singlePageSheets | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then there should be 1 PDF(s) in the response
Then the "foo.pdf" PDF should have content matching "Meteor" at page 1

View File

@@ -0,0 +1,61 @@
@pdfengines
@pdfengines-optimize
@optimize
Feature: /forms/pdfengines/optimize
# image-heavy.pdf is a ~710 KB PDF whose single image Chromium embedded
# losslessly (FlateDecode). Re-encoding it to JPEG shrinks the file well
# below this threshold while leaving the structure intact.
# See https://github.com/gotenberg/gotenberg/issues/359.
Scenario: POST /forms/pdfengines/optimize (Image-heavy PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
| Gotenberg-Output-Filename | foo | 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 "foo.pdf" file size should be less than 300 KB
Scenario: POST /forms/pdfengines/optimize (Custom Image Quality)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
| imageQuality | 40 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the "foo.pdf" file size should be less than 300 KB
Scenario: POST /forms/pdfengines/optimize (PDF Without Images)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
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
Scenario: POST /forms/pdfengines/optimize (Invalid Image Quality)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
| imageQuality | 200 | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Scenario: POST /forms/pdfengines/optimize (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: no form file found for extensions: [.pdf]
"""
Scenario: POST /forms/pdfengines/optimize (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
| files | testdata/image-heavy.pdf | file |
Then the response status code should be 404

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"image"
_ "image/png" // Register the PNG decoder for image.DecodeConfig.
"io"
"mime"
"net/http"
@@ -1010,6 +1012,74 @@ func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, imag
return nil
}
func (s *scenario) theImageShouldBePixels(_ context.Context, name string, width, height int) error {
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
if err != nil {
return fmt.Errorf("open image %q: %w", path, err)
}
defer file.Close()
config, format, err := image.DecodeConfig(file)
if err != nil {
return fmt.Errorf("decode image %q: %w", path, err)
}
if config.Width != width || config.Height != height {
return fmt.Errorf("expected %s image %dx%d, but actual is %dx%d", format, width, height, config.Width, config.Height)
}
return nil
}
func (s *scenario) theImagePixelShouldBe(_ context.Context, name string, x, y int, want string) error {
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
if err != nil {
return fmt.Errorf("open image %q: %w", path, err)
}
defer file.Close()
img, format, err := image.Decode(file)
if err != nil {
return fmt.Errorf("decode image %q: %w", path, err)
}
r, g, b, _ := img.At(x, y).RGBA()
// RGBA returns 16-bit channels; shift down to the 8-bit hex form.
got := fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
if !strings.EqualFold(got, want) {
return fmt.Errorf("expected %s pixel at %d,%d to be %s, but actual is %s", format, x, y, want, got)
}
return nil
}
func (s *scenario) theFileSizeShouldBe(_ context.Context, name, comparator string, sizeKB int) error {
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %q: %w", path, err)
}
limit := int64(sizeKB) * 1024
switch comparator {
case "less":
if info.Size() >= limit {
return fmt.Errorf("expected %s (%d bytes) to be smaller than %d KB", name, info.Size(), sizeKB)
}
case "greater":
if info.Size() <= limit {
return fmt.Errorf("expected %s (%d bytes) to be larger than %d KB", name, info.Size(), sizeKB)
}
}
return nil
}
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
var path string
if !strings.HasPrefix(name, "*_") {
@@ -1047,7 +1117,9 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
}
output = strings.ReplaceAll(output, " ", "")
re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`)
// The dimensions can be fractional (e.g. a content-sized single page),
// so match floats, not just integers.
re := regexp.MustCompile(`Pagesize:([\d.]+)x([\d.]+)`)
matches := re.FindStringSubmatch(output)
if len(matches) < 3 {
@@ -1056,22 +1128,22 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
invert := kind == "should NOT"
width, err := strconv.Atoi(matches[1])
width, err := strconv.ParseFloat(matches[1], 64)
if err != nil {
return fmt.Errorf("convert width value %q to integer: %w", matches[1], err)
return fmt.Errorf("convert width value %q to float: %w", matches[1], err)
}
height, err := strconv.Atoi(matches[2])
height, err := strconv.ParseFloat(matches[2], 64)
if err != nil {
return fmt.Errorf("convert height value %q to integer: %w", matches[2], err)
return fmt.Errorf("convert height value %q to float: %w", matches[2], err)
}
if invert && height < width {
return fmt.Errorf("expected height %d to be greater than width %d", height, width)
return fmt.Errorf("expected height %g to be greater than width %g", height, width)
}
if !invert && width < height {
return fmt.Errorf("expected width %d to be greater than height %d", width, height)
return fmt.Errorf("expected width %g to be greater than height %g", width, height)
}
return nil
@@ -1599,6 +1671,9 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
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 have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
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 "([^"]*)" file size should be (less|greater) than (\d+) KB$`, s.theFileSizeShouldBe)
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
if s.gotenbergContainer != nil {
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))

View File

@@ -181,6 +181,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
}
return c.HTML(http.StatusOK, string(b))
})
srv.GET("/redirect-to-private", func(c echo.Context) error {
s.req = c.Request()
// Redirect the browser to a non-public address so the outbound filter
// is exercised on the redirected request rather than on this URL.
return c.Redirect(http.StatusFound, "http://127.0.0.1:9999/redirected")
})
return s, nil
}

Binary file not shown.

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Local storage counter</title>
</head>
<body>
<!-- Increments a per-origin localStorage counter and renders it, so a value
above 1 means a previous conversion of the same origin leaked into this
one. See https://github.com/gotenberg/gotenberg/issues/919. -->
<pre id="out"></pre>
<script>
var n = (parseInt(localStorage.getItem("n"), 10) || 0) + 1;
localStorage.setItem("n", n);
document.getElementById("out").textContent = "localStorageCount=" + n;
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Image-heavy page</title>
<style>
@page {
margin: 0;
}
body {
margin: 0;
}
img {
width: 100%;
display: block;
}
</style>
</head>
<body>
<img src="image.png" />
</body>
</html>

View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Element screenshot</title>
<style>
body {
margin: 0;
padding: 40px;
background: #ffffff;
}
/* A spacer pushes the target away from the origin so the clip has to
account for the element offset, not just its size. */
.spacer {
height: 120px;
}
#target {
width: 300px;
height: 200px;
background: #ff0000;
}
</style>
</head>
<body>
<div class="spacer"></div>
<div id="target"></div>
</body>
</html>

Binary file not shown.

BIN
test/integration/testdata/slideshow.ppsm vendored Normal file

Binary file not shown.

BIN
test/integration/testdata/slideshow.ppsx vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>EventSource SSRF</title>
</head>
<body>
<h1>EventSource SSRF</h1>
<script type="application/javascript">
// EventSource issues an ordinary HTTP GET, so it is surfaced as a
// Fetch.requestPaused event and blocked by the outbound filter.
new EventSource("http://127.0.0.1:9999/sse");
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Fetch SSRF Control</title>
</head>
<body>
<h1>Fetch SSRF Control</h1>
<script type="application/javascript">
// Control: an ordinary fetch to a loopback address IS surfaced as a
// Fetch.requestPaused event and blocked by CHROMIUM_DENY_PRIVATE_IPS.
fetch("http://127.0.0.1:9999/ssrf-fetch").catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WebSocket SSRF</title>
</head>
<body>
<h1>WebSocket SSRF</h1>
<script type="application/javascript">
// Both targets resolve to non-public addresses, so CHROMIUM_DENY_PRIVATE_IPS
// must block them. Unlike fetch/XHR/sub-resources, a WebSocket handshake is
// never surfaced as a Fetch.requestPaused event, so it currently escapes the
// outbound filter entirely.
// 127.0.0.1 -> loopback
// 169.254.169.254 -> link-local (cloud metadata)
new WebSocket("ws://127.0.0.1:9999/ssrf-websocket");
new WebSocket("ws://169.254.169.254:80/ssrf-websocket");
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WSS SSRF</title>
</head>
<body>
<h1>WSS SSRF</h1>
<script type="application/javascript">
// wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same
// path as ws://, and must be filtered identically.
new WebSocket("wss://127.0.0.1:9999/wss-test");
</script>
</body>
</html>

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Worker WebSocket SSRF</title>
</head>
<body>
<h1>Worker WebSocket SSRF</h1>
<script type="application/javascript">
// A Web Worker is a separate CDP target, so its WebSocket handshake is
// not observed by listenForEventWebSocketCreated (which listens on the
// page target). Enforcement must therefore not rely on that listener:
// the pinning proxy sees every handshake regardless of the originating
// context and severs the connection.
const source =
'new WebSocket("ws://127.0.0.1:9999/ws-from-worker");' +
'new WebSocket("ws://169.254.169.254:80/ws-from-worker-metadata");';
const blob = new Blob([source], { type: "application/javascript" });
new Worker(URL.createObjectURL(blob));
</script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Wide table</title>
<style>
html,
body {
margin: 0;
padding: 0;
}
table {
border-collapse: collapse;
white-space: nowrap;
}
td {
border: 1px solid #000;
padding: 8px 24px;
}
</style>
</head>
<body>
<!-- A single wide row: much wider than tall, so a correct single page is
landscape, and the rightmost column only survives if the width was
expanded to the content instead of truncated. -->
<table>
<tr>
<td>Column 1</td>
<td>Column 2</td>
<td>Column 3</td>
<td>Column 4</td>
<td>Column 5</td>
<td>Column 6</td>
<td>Column 7</td>
<td>Column 8</td>
<td>Column 9</td>
<td>Column 10</td>
<td>Column 11</td>
<td>Column 12</td>
</tr>
</table>
</body>
</html>