Compare commits

...

14 Commits

Author SHA1 Message Date
Julien Neuhart
c0f487e333 feat(api): add OIDC bearer token authentication 2026-08-14 17:04:20 +02:00
Julien Neuhart
3de6932279 feat(pdfengines): apply multiple stamps and watermarks on every route 2026-08-14 16:36:40 +02:00
Julien Neuhart
e9a67132ec feat(pdfengines): apply multiple stamps in one request (#1601) 2026-08-14 15:52:14 +02:00
Julien Neuhart
41b33fd6ad test(integration): allow repeated form fields in requests 2026-08-14 15:52:14 +02:00
Julien Neuhart
2521485bf8 test(libreoffice): add .ppsm and .ppsx to the Bad Request extension list 2026-08-14 15:20:46 +02:00
Julien Neuhart
65e5699b71 feat(pdfengines): generate document-title bookmarks when merging (#867) 2026-08-14 12:36:13 +02:00
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
35 changed files with 1231 additions and 454 deletions

View File

@@ -15,6 +15,7 @@ body:multipart-form {
files: @file(../../test/integration/testdata/page_2.pdf)
~flatten: false
~autoIndexBookmarks: false
~titleBookmarks: false
~pdfa: PDF/A-1b
~pdfua: true
~optimizeImages: false

View File

@@ -28,6 +28,10 @@ API_CORRELATION_ID_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_ENABLE_OIDC_AUTH=false
API_OIDC_ISSUER=
API_OIDC_AUDIENCE=
API_OIDC_JWKS_URL=
API_DOWNLOAD_FROM_ALLOW_LIST=
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
@@ -91,7 +95,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=pdfcpu,qpdf
PDFENGINES_EMBED_ENGINES=pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PDFENGINES_FACTUR_X_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg

100
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,25 @@ 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 (
github.com/coreos/go-oidc/v3 v3.20.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
)
require (
@@ -42,11 +47,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 +62,17 @@ 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-jose/go-jose/v4 v4.1.4 // 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 +80,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/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/oauth2 v0.36.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
)

202
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=
@@ -42,16 +42,18 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
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 +61,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 +74,10 @@ 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-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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 +94,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 +103,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 +125,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 +142,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 +210,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 +230,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 +249,68 @@ 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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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 +322,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

@@ -39,6 +39,10 @@ type Api struct {
correlationIdHeader string
basicAuthUsername string
basicAuthPassword string
oidcEnabled bool
oidcIssuer string
oidcAudience string
oidcJwksUrl string
downloadFromCfg downloadFromConfig
disableHealthCheckRouteTelemetry bool
disableRootRouteTelemetry bool
@@ -198,6 +202,10 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.Bool("api-enable-oidc-auth", false, "Enable OIDC bearer token authentication - mutually exclusive with basic authentication")
fs.String("api-oidc-issuer", "", "Set the OIDC issuer URL, e.g. https://tenant.example.com/ - the token 'iss' claim must match")
fs.String("api-oidc-audience", "", "Set the expected OIDC audience - the token 'aud' claim must contain it")
fs.String("api-oidc-jwks-url", "", "Set the OIDC JWKS URL - discovered from the issuer's well-known configuration when empty")
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
@@ -280,6 +288,15 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.basicAuthPassword = basicAuthPassword
}
// Enable OIDC auth? The flags are populated from their API_OIDC_* env vars
// by the CLI, so no manual environment lookup is needed here.
a.oidcEnabled = flags.MustBool("api-enable-oidc-auth")
if a.oidcEnabled {
a.oidcIssuer = flags.MustString("api-oidc-issuer")
a.oidcAudience = flags.MustString("api-oidc-audience")
a.oidcJwksUrl = flags.MustString("api-oidc-jwks-url")
}
// Get routes from modules.
mods, err := ctx.Modules(new(Router))
if err != nil {
@@ -411,6 +428,25 @@ func (a *Api) Validate() error {
)
}
if a.basicAuthUsername != "" && a.oidcEnabled {
err = errors.Join(err,
errors.New("basic authentication and OIDC authentication cannot both be enabled"),
)
}
if a.oidcEnabled {
if a.oidcIssuer == "" {
err = errors.Join(err,
errors.New("OIDC issuer must not be empty when OIDC auth is enabled; set --api-oidc-issuer"),
)
}
if a.oidcAudience == "" {
err = errors.Join(err,
errors.New("OIDC audience must not be empty when OIDC auth is enabled; set --api-oidc-audience"),
)
}
}
if err != nil {
return err
}
@@ -517,11 +553,18 @@ func (a *Api) Start() error {
hardTimeout := a.timeout + (time.Duration(5) * time.Second)
// Basic auth?
// Authentication?
var securityMiddleware echo.MiddlewareFunc
if a.basicAuthUsername != "" {
switch {
case a.basicAuthUsername != "":
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
} else {
case a.oidcEnabled:
verifier, err := a.buildOidcVerifier()
if err != nil {
return fmt.Errorf("build OIDC verifier: %w", err)
}
securityMiddleware = oidcAuthMiddleware(verifier)
default:
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)

View File

@@ -0,0 +1,67 @@
package api
import (
"strings"
"testing"
)
func TestApi_Validate_Auth(t *testing.T) {
base := func() *Api {
return &Api{port: 3000, rootPath: "/", correlationIdHeader: "Gotenberg-Trace"}
}
for _, tc := range []struct {
scenario string
mutate func(*Api)
wantErr string // substring expected in the error, "" means no error
}{
{"no auth", func(*Api) {}, ""},
{"basic auth only", func(a *Api) { a.basicAuthUsername = "foo" }, ""},
{
"oidc auth valid",
func(a *Api) {
a.oidcEnabled = true
a.oidcIssuer = "https://tenant.example.com/"
a.oidcAudience = "gotenberg"
},
"",
},
{
"basic and oidc are mutually exclusive",
func(a *Api) {
a.basicAuthUsername = "foo"
a.oidcEnabled = true
a.oidcIssuer = "https://tenant.example.com/"
a.oidcAudience = "gotenberg"
},
"cannot both be enabled",
},
{
"oidc missing issuer",
func(a *Api) { a.oidcEnabled = true; a.oidcAudience = "gotenberg" },
"issuer must not be empty",
},
{
"oidc missing audience",
func(a *Api) { a.oidcEnabled = true; a.oidcIssuer = "https://tenant.example.com/" },
"audience must not be empty",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := base()
tc.mutate(a)
err := a.Validate()
if tc.wantErr == "" {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want a substring %q", err, tc.wantErr)
}
})
}
}

View File

@@ -479,6 +479,51 @@ func (form *FormData) Stamp(target *string) *FormData {
return form
}
// Stamps binds the absolute paths of every file uploaded with the "stamp"
// field name, in submission order. Unlike [FormData.Stamp], it keeps all of
// them so a route can apply several stamps in a single request.
func (form *FormData) Stamps(target *[]string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[StampFormField]; ok {
*target = paths
}
return form
}
// Watermarks binds the absolute paths of every file uploaded with the
// "watermark" field name, in submission order. Unlike [FormData.Watermark], it
// keeps all of them so a route can apply several watermarks in a single request.
func (form *FormData) Watermarks(target *[]string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[WatermarkFormField]; ok {
*target = paths
}
return form
}
// Strings binds every value submitted for key, in submission order. A field
// repeated in the multipart body (e.g. multiple "stampSource") contributes one
// entry per occurrence, which lets a route read parallel field arrays.
func (form *FormData) Strings(key string, target *[]string) *FormData {
if form.errors != nil {
return form
}
if values, ok := form.values[key]; ok {
*target = values
}
return form
}
// FacturXXml binds the absolute path of the uploaded Factur-X CII invoice
// XML. Only a file uploaded with the "facturxXml" field name is included.
func (form *FormData) FacturXXml(target *string) *FormData {

View File

@@ -1837,3 +1837,61 @@ func TestFormData_paths_excludesFacturXXml(t *testing.T) {
t.Errorf("expected only the non-Factur-X .xml document, got %+v", paths)
}
}
func TestFormData_Strings(t *testing.T) {
form := &FormData{
values: map[string][]string{
"foo": {"a", "b", "c"},
},
}
var got []string
form.Strings("foo", &got)
if want := []string{"a", "b", "c"}; !reflect.DeepEqual(got, want) {
t.Errorf("expected %+v, got %+v", want, got)
}
var missing []string
form.Strings("bar", &missing)
if missing != nil {
t.Errorf("expected nil for a missing key, got %+v", missing)
}
}
func TestFormData_Stamps(t *testing.T) {
form := &FormData{
filesByField: map[string][]string{
StampFormField: {"/tmp/abc/a.png", "/tmp/abc/b.pdf"},
},
}
var got []string
form.Stamps(&got)
if want := []string{"/tmp/abc/a.png", "/tmp/abc/b.pdf"}; !reflect.DeepEqual(got, want) {
t.Errorf("expected %+v, got %+v", want, got)
}
empty := &FormData{}
var none []string
empty.Stamps(&none)
if none != nil {
t.Errorf("expected nil when no stamp file was uploaded, got %+v", none)
}
}
func TestFormData_Watermarks(t *testing.T) {
form := &FormData{
filesByField: map[string][]string{
WatermarkFormField: {"/tmp/abc/a.png"},
},
}
var got []string
form.Watermarks(&got)
if want := []string{"/tmp/abc/a.png"}; !reflect.DeepEqual(got, want) {
t.Errorf("expected %+v, got %+v", want, got)
}
}

View File

@@ -10,9 +10,11 @@ import (
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
@@ -368,6 +370,63 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
})
}
// buildOidcVerifier constructs an OIDC ID token verifier. When oidcJwksUrl is
// set, the keys are fetched from that URL lazily, so there is no network call at
// startup; otherwise the provider is discovered from its issuer, which does one.
// Both paths use an OTEL-instrumented HTTP client, so the JWKS and discovery
// fetches produce client spans.
func (a *Api) buildOidcVerifier() (*oidc.IDTokenVerifier, error) {
httpClient := &http.Client{
Timeout: 10 * time.Second,
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
ctx := oidc.ClientContext(context.Background(), httpClient)
cfg := &oidc.Config{
ClientID: a.oidcAudience,
SupportedSigningAlgs: []string{oidc.RS256, oidc.ES256},
}
if a.oidcJwksUrl != "" {
keySet := oidc.NewRemoteKeySet(ctx, a.oidcJwksUrl)
return oidc.NewVerifier(a.oidcIssuer, keySet, cfg), nil
}
provider, err := oidc.NewProvider(ctx, a.oidcIssuer)
if err != nil {
return nil, fmt.Errorf("discover OIDC provider '%s': %w", a.oidcIssuer, err)
}
return provider.Verifier(cfg), nil
}
// oidcAuthMiddleware validates the Bearer token in the Authorization header with
// the OIDC verifier, which checks the signature against the provider's rotating
// JWKS and the issuer, audience and expiry claims. It answers 401 for a missing
// or invalid token, logging the underlying reason at debug level without leaking
// it to the client.
func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
if !ok || rawToken == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header")
}
_, err := verifier.Verify(c.Request().Context(), rawToken)
if err != nil {
if logger, ok := c.Get("logger").(*slog.Logger); ok && logger != nil {
logger.DebugContext(c.Request().Context(), "OIDC token verification failed", slog.Any("error", err))
}
return echo.NewHTTPError(http.StatusUnauthorized, "the Bearer token is invalid")
}
return next(c)
}
}
}
// contextMiddleware, middleware for "multipart/form-data" requests, sets the
// [Context] and related context.CancelFunc in the [echo.Context] under
// "context" and "cancel". If the process is synchronous, it also handles the

View File

@@ -2,6 +2,8 @@ package api
import (
"context"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"log/slog"
@@ -11,6 +13,8 @@ import (
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coreos/go-oidc/v3/oidc/oidctest"
"github.com/labstack/echo/v4"
)
@@ -152,3 +156,90 @@ func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *te
t.Fatalf("error = %q, want a message mentioning logger", err)
}
}
func TestOidcAuthMiddleware(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
const (
keyID = "test-key"
audience = "gotenberg"
)
oidcServer := &oidctest.Server{
PublicKeys: []oidctest.PublicKey{
{PublicKey: privateKey.Public(), KeyID: keyID, Algorithm: oidc.RS256},
},
}
srv := httptest.NewServer(oidcServer)
defer srv.Close()
oidcServer.SetIssuer(srv.URL)
// Building through the module's own helper exercises the discovery path too.
a := &Api{oidcIssuer: srv.URL, oidcAudience: audience}
verifier, err := a.buildOidcVerifier()
if err != nil {
t.Fatalf("build verifier: %v", err)
}
claims := func(issuer, aud string, expiresIn time.Duration) string {
now := time.Now()
return fmt.Sprintf(`{"iss":%q,"aud":%q,"sub":"user","exp":%d,"iat":%d}`,
issuer, aud, now.Add(expiresIn).Unix(), now.Unix())
}
sign := func(claims string) string {
return oidctest.SignIDToken(privateKey, keyID, oidc.RS256, claims)
}
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate other key: %v", err)
}
for _, tc := range []struct {
scenario string
authHeader string
wantStatus int
}{
{"valid token", "Bearer " + sign(claims(srv.URL, audience, time.Hour)), http.StatusOK},
{"missing header", "", http.StatusUnauthorized},
{"wrong scheme", "Basic Zm9vOmJhcg==", http.StatusUnauthorized},
{"empty bearer", "Bearer ", http.StatusUnauthorized},
{"malformed token", "Bearer not-a-jwt", http.StatusUnauthorized},
{"wrong issuer", "Bearer " + sign(claims("https://evil.example/", audience, time.Hour)), http.StatusUnauthorized},
{"wrong audience", "Bearer " + sign(claims(srv.URL, "someone-else", time.Hour)), http.StatusUnauthorized},
{"expired token", "Bearer " + sign(claims(srv.URL, audience, -time.Hour)), http.StatusUnauthorized},
{"unknown signing key", "Bearer " + oidctest.SignIDToken(otherKey, "unknown", oidc.RS256, claims(srv.URL, audience, time.Hour)), http.StatusUnauthorized},
} {
t.Run(tc.scenario, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
}
c := echo.New().NewContext(req, httptest.NewRecorder())
handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error {
return c.NoContent(http.StatusOK)
})
err := handler(c)
if tc.wantStatus == http.StatusOK {
if err != nil {
t.Fatalf("expected the request to pass, got error: %v", err)
}
return
}
var httpErr *echo.HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("expected an *echo.HTTPError, got %T (%v)", err, err)
}
if httpErr.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", httpErr.Code, tc.wantStatus)
}
})
}
}

View File

@@ -474,10 +474,16 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
metadata := pdfengines.FormDataPdfMetadata(form, false)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
if wErr != nil {
return fmt.Errorf("form data watermarks: %w", wErr)
}
stamps, sErr := pdfengines.FormDataPdfStamps(form)
if sErr != nil {
return fmt.Errorf("form data stamps: %w", sErr)
}
var watermarkFiles, stampFiles []string
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
@@ -496,16 +502,16 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("reject URL scheme: %w", err)
}
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
return fmt.Errorf("bind watermark files: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
err = pdfengines.BindStampFiles(stamps, stampFiles)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
return fmt.Errorf("bind stamp files: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -564,10 +570,16 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
metadata := pdfengines.FormDataPdfMetadata(form, false)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
if wErr != nil {
return fmt.Errorf("form data watermarks: %w", wErr)
}
stamps, sErr := pdfengines.FormDataPdfStamps(form)
if sErr != nil {
return fmt.Errorf("form data stamps: %w", sErr)
}
var watermarkFiles, stampFiles []string
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
@@ -581,18 +593,18 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
return fmt.Errorf("bind watermark files: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
err = pdfengines.BindStampFiles(stamps, stampFiles)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
return fmt.Errorf("bind stamp files: %w", err)
}
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, optimizeImages, imageQuality)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -648,10 +660,16 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
metadata := pdfengines.FormDataPdfMetadata(form, false)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
if wErr != nil {
return fmt.Errorf("form data watermarks: %w", wErr)
}
stamps, sErr := pdfengines.FormDataPdfStamps(form)
if sErr != nil {
return fmt.Errorf("form data stamps: %w", sErr)
}
var watermarkFiles, stampFiles []string
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
@@ -670,13 +688,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
return fmt.Errorf("bind watermark files: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
err = pdfengines.BindStampFiles(stamps, stampFiles)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
return fmt.Errorf("bind stamp files: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
@@ -685,7 +703,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, optimizeImages, imageQuality)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermarks, stamps, rotateAngle, rotatePages, optimizeImages, imageQuality)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -810,7 +828,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, optimizeImages bool, imageQuality int) 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, watermarks, stamps []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)
@@ -892,12 +910,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("split PDF: %w", err)
}
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}

View File

@@ -15,6 +15,7 @@ import (
"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"
@@ -708,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

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

View File

@@ -37,10 +37,16 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
metadata := pdfengines.FormDataPdfMetadata(form, false)
encrypt := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
watermarks, wErr := pdfengines.FormDataPdfWatermarks(form)
if wErr != nil {
return fmt.Errorf("form data watermarks: %w", wErr)
}
stamps, sErr := pdfengines.FormDataPdfStamps(form)
if sErr != nil {
return fmt.Errorf("form data stamps: %w", sErr)
}
var watermarkFiles, stampFiles []string
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
@@ -311,13 +317,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("validate form data: %w", err)
}
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
err = pdfengines.BindWatermarkFiles(watermarks, watermarkFiles)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
return fmt.Errorf("bind watermark files: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
err = pdfengines.BindStampFiles(stamps, stampFiles)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
return fmt.Errorf("bind stamp files: %w", err)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
@@ -339,7 +345,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
// requested. The conversion runs as a post-processing step below.
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
hasPostProcessing := len(watermarks) > 0 || len(stamps) > 0 || angle != 0 ||
len(embedPaths) > 0 || len(metadata) > 0 || flatten || facturX.ConformanceLevel != ""
outputPaths := make([]string, len(inputPaths))
@@ -494,12 +500,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}
}
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
err = pdfengines.WatermarkStub(ctx, engine, watermarks, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
err = pdfengines.StampStub(ctx, engine, stamps, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}

View File

@@ -62,7 +62,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
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")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu", "qpdf"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
fs.StringSlice("pdfengines-embed-metadata-engines", []string{"qpdf"}, "Set the PDF engines and their order for the embed metadata feature - empty means all")
fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all")
fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all")

View File

@@ -442,6 +442,27 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
return nil
}
// documentTitle returns the input PDF's Title metadata entry, falling back to
// the original filename without its extension when the entry is absent, blank,
// or cannot be read. It labels the per-document entries the merge route's
// titleBookmarks feature generates.
func documentTitle(ctx *api.Context, engine gotenberg.PdfEngine, inputPath, filename string) string {
fallback := strings.TrimSuffix(filename, filepath.Ext(filename))
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
if err != nil {
ctx.Log().WarnContext(ctx, fmt.Sprintf("read metadata of '%s' for title bookmark, using filename: %s", filename, err))
return fallback
}
title, ok := metadata["Title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return fallback
}
return title
}
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark {
if offset == 0 {
return bookmarks
@@ -808,160 +829,149 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s
return nil
}
// FormDataPdfWatermark creates a [gotenberg.Stamp] for watermarking from the
// form data.
func FormDataPdfWatermark(form *api.FormData, mandatory bool) gotenberg.Stamp {
return formDataPdfStampOrWatermark(form, "watermark", mandatory)
// FormDataPdfStamps builds the ordered list of stamps from the repeated stamp
// fields. See [formDataPdfStampsOrWatermarks].
func FormDataPdfStamps(form *api.FormData) ([]gotenberg.Stamp, error) {
return formDataPdfStampsOrWatermarks(form, "stamp")
}
// FormDataPdfStamp creates a [gotenberg.Stamp] for stamping from the form data.
func FormDataPdfStamp(form *api.FormData, mandatory bool) gotenberg.Stamp {
return formDataPdfStampOrWatermark(form, "stamp", mandatory)
// FormDataPdfWatermarks builds the ordered list of watermarks from the repeated
// watermark fields. See [formDataPdfStampsOrWatermarks].
func FormDataPdfWatermarks(form *api.FormData) ([]gotenberg.Stamp, error) {
return formDataPdfStampsOrWatermarks(form, "watermark")
}
func formDataPdfStampOrWatermark(form *api.FormData, prefix string, mandatory bool) gotenberg.Stamp {
var (
source string
expression string
pages string
options map[string]string
)
// formDataPdfStampsOrWatermarks builds the ordered list of stamps or watermarks
// from the repeated {prefix}Source, {prefix}Expression, {prefix}Pages and
// {prefix}Options fields. The number of entries equals the number of
// {prefix}Source values, so a single occurrence of each field yields one entry,
// preserving the single-stamp/watermark behavior. Fields are aligned by
// position; a missing expression, pages or options entry defaults to empty.
// Image and pdf entries take their file from the uploaded files, in order (see
// [bindStampOrWatermarkFiles]).
func formDataPdfStampsOrWatermarks(form *api.FormData, prefix string) ([]gotenberg.Stamp, error) {
var sources, expressions, pages, options []string
form.
Strings(prefix+"Source", &sources).
Strings(prefix+"Expression", &expressions).
Strings(prefix+"Pages", &pages).
Strings(prefix+"Options", &options)
sourceFunc := func(value string) error {
if value != "" && value != gotenberg.StampSourceText && value != gotenberg.StampSourceImage && value != gotenberg.StampSourcePDF {
return fmt.Errorf("wrong value, expected either '%s', '%s' or '%s'", gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF)
at := func(values []string, i int) string {
if i < len(values) {
return values[i]
}
source = value
return nil
return ""
}
optionsFunc := func(value string) error {
if value == "" {
return nil
stamps := make([]gotenberg.Stamp, 0, len(sources))
for i, source := range sources {
if source != gotenberg.StampSourceText && source != gotenberg.StampSourceImage && source != gotenberg.StampSourcePDF {
return nil, api.WrapError(
fmt.Errorf("wrong %sSource value '%s'", prefix, source),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Invalid form data: form field '%sSource' is invalid (got '%s', resulting to wrong value, expected either '%s', '%s' or '%s')", prefix, source, gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF),
),
)
}
err := json.Unmarshal([]byte(value), &options)
if err != nil {
return fmt.Errorf("unmarshal %s options: %w", prefix, err)
var opts map[string]string
if raw := at(options, i); raw != "" {
err := json.Unmarshal([]byte(raw), &opts)
if err != nil {
return nil, api.WrapError(
fmt.Errorf("unmarshal %sOptions: %w", prefix, err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Invalid form data: form field '%sOptions' is invalid", prefix),
),
)
}
}
return nil
stamps = append(stamps, gotenberg.Stamp{
Source: source,
Expression: at(expressions, i),
Pages: at(pages, i),
Options: opts,
})
}
if mandatory {
form.
MandatoryCustom(prefix+"Source", func(value string) error {
return sourceFunc(value)
}).
String(prefix+"Expression", &expression, "").
String(prefix+"Pages", &pages, "").
Custom(prefix+"Options", func(value string) error {
return optionsFunc(value)
})
} else {
form.
Custom(prefix+"Source", func(value string) error {
return sourceFunc(value)
}).
String(prefix+"Expression", &expression, "").
String(prefix+"Pages", &pages, "").
Custom(prefix+"Options", func(value string) error {
return optionsFunc(value)
})
}
return gotenberg.Stamp{
Source: source,
Expression: expression,
Pages: pages,
Options: options,
}
return stamps, nil
}
// FormDataPdfWatermarkFile extracts the watermark file path from form data.
func FormDataPdfWatermarkFile(form *api.FormData) string {
var path string
form.Watermark(&path)
return path
// BindStampFiles assigns each image or pdf stamp its uploaded stamp file. See
// [bindStampOrWatermarkFiles].
func BindStampFiles(stamps []gotenberg.Stamp, stampFiles []string) error {
return bindStampOrWatermarkFiles(stamps, stampFiles, "stamp")
}
// FormDataPdfStampFile extracts the stamp file path from form data.
func FormDataPdfStampFile(form *api.FormData) string {
var path string
form.Stamp(&path)
return path
// BindWatermarkFiles assigns each image or pdf watermark its uploaded watermark
// file. See [bindStampOrWatermarkFiles].
func BindWatermarkFiles(watermarks []gotenberg.Stamp, watermarkFiles []string) error {
return bindStampOrWatermarkFiles(watermarks, watermarkFiles, "watermark")
}
// EnsureStampFile validates that, when stamp.Source is image or pdf, an
// uploaded stamp file was supplied, and replaces stamp.Expression with
// uploadedFile in that case. Returning an [api] HTTP 400 error prevents
// an anonymous caller from passing an arbitrary filesystem path via
// stampExpression and having pdfcpu read it. Source values of text or
// empty are passed through unchanged.
func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error {
if stamp.Source != gotenberg.StampSourceImage && stamp.Source != gotenberg.StampSourcePDF {
return nil
// bindStampOrWatermarkFiles assigns each image or pdf entry its uploaded file,
// consuming files in order. Text entries take no file. It returns an [api] HTTP
// 400 error when an image or pdf entry has no file left to consume, which also
// prevents an anonymous caller from passing an arbitrary filesystem path via
// the expression field. kind is "stamp" or "watermark" and shapes the error.
func bindStampOrWatermarkFiles(stamps []gotenberg.Stamp, files []string, kind string) error {
fileIndex := 0
for i := range stamps {
if stamps[i].Source != gotenberg.StampSourceImage && stamps[i].Source != gotenberg.StampSourcePDF {
continue
}
if fileIndex >= len(files) {
return api.WrapError(
fmt.Errorf("not enough %s files for the image or pdf entries", kind),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Invalid form data: a %s file is required for image or pdf source", kind),
),
)
}
stamps[i].Expression = files[fileIndex]
fileIndex++
}
if uploadedFile == "" {
return api.WrapError(
errors.New("no stamp file provided for image or pdf source"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a stamp file is required for image or pdf source",
),
)
}
stamp.Expression = uploadedFile
return nil
}
// EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The
// shape is identical: image or pdf sources must be accompanied by an
// uploaded file, and the file path replaces watermark.Expression to
// prevent pdfcpu from reading an attacker-controlled path.
func EnsureWatermarkFile(watermark *gotenberg.Stamp, uploadedFile string) error {
if watermark.Source != gotenberg.StampSourceImage && watermark.Source != gotenberg.StampSourcePDF {
return nil
}
if uploadedFile == "" {
return api.WrapError(
errors.New("no watermark file provided for image or pdf source"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a watermark file is required for image or pdf source",
),
)
}
watermark.Expression = uploadedFile
return nil
}
// WatermarkStub applies each watermark to a list of PDF files, in order.
// Entries with no source are skipped, so an empty list does nothing.
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, watermarks []gotenberg.Stamp, inputPaths []string) error {
for _, watermark := range watermarks {
if watermark.Source == "" {
continue
}
// WatermarkStub applies a watermark to a list of PDF files. If the stamp has
// no source, it does nothing.
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
if stamp.Source == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Watermark(ctx, ctx.Log(), inputPath, stamp)
if err != nil {
return fmt.Errorf("watermark '%s': %w", inputPath, err)
for _, inputPath := range inputPaths {
err := engine.Watermark(ctx, ctx.Log(), inputPath, watermark)
if err != nil {
return fmt.Errorf("watermark '%s': %w", inputPath, err)
}
}
}
return nil
}
// StampStub applies a stamp to a list of PDF files. If the stamp has
// no source, it does nothing.
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
if stamp.Source == "" {
return nil
}
// StampStub applies each stamp to a list of PDF files, in order. Entries with
// no source are skipped, so an empty list does nothing.
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamps []gotenberg.Stamp, inputPaths []string) error {
for _, stamp := range stamps {
if stamp.Source == "" {
continue
}
for _, inputPath := range inputPaths {
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
if err != nil {
return fmt.Errorf("stamp '%s': %w", inputPath, err)
for _, inputPath := range inputPaths {
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
if err != nil {
return fmt.Errorf("stamp '%s': %w", inputPath, err)
}
}
}
@@ -983,10 +993,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
bookmarks := FormDataPdfBookmarks(form, false)
encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
watermarks, wErr := FormDataPdfWatermarks(form)
if wErr != nil {
return fmt.Errorf("form data watermarks: %w", wErr)
}
stamps, sErr := FormDataPdfStamps(form)
if sErr != nil {
return fmt.Errorf("form data stamps: %w", sErr)
}
var watermarkFiles, stampFiles []string
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
@@ -995,22 +1011,24 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
var inputPaths []string
var flatten bool
var autoIndexBookmarks bool
var titleBookmarks bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Bool("flatten", &flatten, false).
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
Bool("titleBookmarks", &titleBookmarks, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EnsureWatermarkFile(&watermark, watermarkFile)
err = BindWatermarkFiles(watermarks, watermarkFiles)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
return fmt.Errorf("bind watermark files: %w", err)
}
err = EnsureStampFile(&stamp, stampFile)
err = BindStampFiles(stamps, stampFiles)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
return fmt.Errorf("bind stamp files: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
@@ -1036,12 +1054,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
outputPaths := []string{outputPath}
err = WatermarkStub(ctx, engine, watermark, outputPaths)
err = WatermarkStub(ctx, engine, watermarks, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = StampStub(ctx, engine, stamp, outputPaths)
err = StampStub(ctx, engine, stamps, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
@@ -1078,7 +1096,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
finalBookmarks = b
} else {
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
if bMap != nil || autoIndexBookmarks {
if bMap != nil || autoIndexBookmarks || titleBookmarks {
offset := 0
for _, inputPath := range inputPaths {
filename := ctx.OriginalFilename(inputPath)
@@ -1088,7 +1106,9 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
fileBookmarks = bMap[filename]
}
if len(fileBookmarks) == 0 && autoIndexBookmarks {
// titleBookmarks nests each input's own outline under
// its title entry, so its outline is read here too.
if len(fileBookmarks) == 0 && (autoIndexBookmarks || titleBookmarks) {
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
@@ -1096,8 +1116,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
fileBookmarks = fb
}
if len(fileBookmarks) > 0 {
finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...)
fileBookmarks = shiftBookmarks(fileBookmarks, offset)
if titleBookmarks {
finalBookmarks = append(finalBookmarks, gotenberg.Bookmark{
Title: documentTitle(ctx, engine, inputPath, filename),
Page: offset + 1,
Children: fileBookmarks,
})
} else if len(fileBookmarks) > 0 {
finalBookmarks = append(finalBookmarks, fileBookmarks...)
}
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
@@ -1166,10 +1194,16 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
metadata := FormDataPdfMetadata(form, false)
encrypt := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
watermarks, wErr := FormDataPdfWatermarks(form)
if wErr != nil {
return fmt.Errorf("form data watermarks: %w", wErr)
}
stamps, sErr := FormDataPdfStamps(form)
if sErr != nil {
return fmt.Errorf("form data stamps: %w", sErr)
}
var watermarkFiles, stampFiles []string
form.Watermarks(&watermarkFiles).Stamps(&stampFiles)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
facturX, facturxXmlPath := FormDataPdfFacturX(form)
@@ -1185,13 +1219,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = EnsureWatermarkFile(&watermark, watermarkFile)
err = BindWatermarkFiles(watermarks, watermarkFiles)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
return fmt.Errorf("bind watermark files: %w", err)
}
err = EnsureStampFile(&stamp, stampFile)
err = BindStampFiles(stamps, stampFiles)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
return fmt.Errorf("bind stamp files: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths)
@@ -1214,12 +1248,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("split PDFs: %w", err)
}
err = WatermarkStub(ctx, engine, watermark, outputPaths)
err = WatermarkStub(ctx, engine, watermarks, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = StampStub(ctx, engine, stamp, outputPaths)
err = StampStub(ctx, engine, stamps, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
@@ -1697,23 +1731,37 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
stamp := FormDataPdfWatermark(form, true)
watermarkFile := FormDataPdfWatermarkFile(form)
watermarks, err := FormDataPdfWatermarks(form)
if err != nil {
return fmt.Errorf("form data watermarks: %w", err)
}
var inputPaths []string
err := form.
var watermarkFiles []string
err = form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Watermarks(&watermarkFiles).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EnsureWatermarkFile(&stamp, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
if len(watermarks) == 0 {
return api.WrapError(
errors.New("no watermark provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: form field 'watermarkSource' is required",
),
)
}
err = WatermarkStub(ctx, engine, stamp, inputPaths)
err = BindWatermarkFiles(watermarks, watermarkFiles)
if err != nil {
return fmt.Errorf("bind watermark files: %w", err)
}
err = WatermarkStub(ctx, engine, watermarks, inputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
@@ -1740,23 +1788,41 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
stamp := FormDataPdfStamp(form, true)
stampFile := FormDataPdfStampFile(form)
// Reading the stamp fields as parallel arrays applies several
// stamps in one request. A single occurrence of each field is the
// existing single-stamp behavior.
stamps, err := FormDataPdfStamps(form)
if err != nil {
return fmt.Errorf("form data stamps: %w", err)
}
var inputPaths []string
err := form.
var stampFiles []string
err = form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Stamps(&stampFiles).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
if len(stamps) == 0 {
return api.WrapError(
errors.New("no stamp provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: form field 'stampSource' is required",
),
)
}
err = StampStub(ctx, engine, stamp, inputPaths)
err = BindStampFiles(stamps, stampFiles)
if err != nil {
return fmt.Errorf("bind stamp files: %w", err)
}
err = StampStub(ctx, engine, stamps, inputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}

View File

@@ -0,0 +1,181 @@
package pdfengines
import (
"errors"
"net/http"
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
func TestFormDataPdfStamps(t *testing.T) {
for _, tc := range []struct {
scenario string
values map[string][]string
expect []gotenberg.Stamp
expectErr bool
expectCode int
}{
{
scenario: "single text stamp (backward compatible)",
values: map[string][]string{
"stampSource": {"text"},
"stampExpression": {"CONFIDENTIAL"},
"stampOptions": {`{"rot":"45"}`},
},
expect: []gotenberg.Stamp{
{Source: "text", Expression: "CONFIDENTIAL", Options: map[string]string{"rot": "45"}},
},
},
{
scenario: "multiple stamps aligned by position",
values: map[string][]string{
"stampSource": {"text", "image"},
"stampExpression": {"ONE"},
"stampPages": {"1-2", "3"},
"stampOptions": {`{"pos":"tl"}`, `{"pos":"br"}`},
},
expect: []gotenberg.Stamp{
{Source: "text", Expression: "ONE", Pages: "1-2", Options: map[string]string{"pos": "tl"}},
{Source: "image", Expression: "", Pages: "3", Options: map[string]string{"pos": "br"}},
},
},
{
scenario: "no stamp fields",
values: map[string][]string{},
expect: []gotenberg.Stamp{},
},
{
scenario: "invalid source",
values: map[string][]string{"stampSource": {"text", "foo"}},
expectErr: true,
expectCode: http.StatusBadRequest,
},
{
scenario: "invalid options JSON",
values: map[string][]string{
"stampSource": {"text"},
"stampOptions": {"{"},
},
expectErr: true,
expectCode: http.StatusBadRequest,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(tc.values)
form := ctx.FormData()
got, err := FormDataPdfStamps(form)
if tc.expectErr {
if err == nil {
t.Fatal("expected an error, got nil")
}
var httpErr api.HttpError
if !errors.As(err, &httpErr) {
t.Fatalf("expected an api.HttpError, got %T", err)
}
if status, _ := httpErr.HttpError(); status != tc.expectCode {
t.Fatalf("status = %d, want %d", status, tc.expectCode)
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !reflect.DeepEqual(got, tc.expect) {
t.Fatalf("stamps = %#v, want %#v", got, tc.expect)
}
})
}
}
func TestBindStampFiles(t *testing.T) {
for _, tc := range []struct {
scenario string
stamps []gotenberg.Stamp
files []string
expect []gotenberg.Stamp
expectErr bool
expectCode int
}{
{
scenario: "text stamps consume no files",
stamps: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}},
expect: []gotenberg.Stamp{{Source: "text", Expression: "FOO"}},
},
{
scenario: "image and pdf stamps consume files in order, overwriting expression",
stamps: []gotenberg.Stamp{
{Source: "image", Expression: "ignored"},
{Source: "text", Expression: "MIDDLE"},
{Source: "pdf"},
},
files: []string{"/a.png", "/b.pdf"},
expect: []gotenberg.Stamp{
{Source: "image", Expression: "/a.png"},
{Source: "text", Expression: "MIDDLE"},
{Source: "pdf", Expression: "/b.pdf"},
},
},
{
scenario: "not enough files for the image or pdf stamps",
stamps: []gotenberg.Stamp{{Source: "image"}, {Source: "image"}},
files: []string{"/a.png"},
expectErr: true,
expectCode: http.StatusBadRequest,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := BindStampFiles(tc.stamps, tc.files)
if tc.expectErr {
if err == nil {
t.Fatal("expected an error, got nil")
}
var httpErr api.HttpError
if !errors.As(err, &httpErr) {
t.Fatalf("expected an api.HttpError, got %T", err)
}
if status, _ := httpErr.HttpError(); status != tc.expectCode {
t.Fatalf("status = %d, want %d", status, tc.expectCode)
}
return
}
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !reflect.DeepEqual(tc.stamps, tc.expect) {
t.Fatalf("stamps = %#v, want %#v", tc.stamps, tc.expect)
}
})
}
}
func TestFormDataPdfWatermarks(t *testing.T) {
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetValues(map[string][]string{
"watermarkSource": {"text", "image"},
"watermarkExpression": {"DRAFT"},
"watermarkOptions": {`{"opacity":"0.5"}`, ""},
})
form := ctx.FormData()
got, err := FormDataPdfWatermarks(form)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
want := []gotenberg.Stamp{
{Source: "text", Expression: "DRAFT", Options: map[string]string{"opacity": "0.5"}},
{Source: "image"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("watermarks = %#v, want %#v", got, want)
}
}

View File

@@ -419,57 +419,17 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath
}
// EmbedFiles is not available in this implementation.
// EmbedFiles embeds the given files into a PDF as attachments using qpdf's
// --add-attachment. It is a fallback for pdfcpu: qpdf reads PDFs whose document
// info pdfcpu's stricter validation rejects (for example a non-conformant
// /Trapped value written by ExifTool). See
// https://github.com/gotenberg/gotenberg/issues/1628.
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(engine.spanAttrs()...),
)
defer span.End()
if len(filePaths) == 0 {
span.SetStatus(codes.Ok, "")
return nil
}
fail := func(err error) error {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Each --add-attachment group must be terminated by "--", so qpdf attaches
// one file per invocation. Chain them, writing to a temporary file and
// moving it back over the input each time.
outputPath := inputPath + "~embed"
for _, filePath := range filePaths {
args := make([]string, 0, 6+len(engine.globalArgs))
args = append(args, engine.globalArgs...)
args = append(args, "--add-attachment", filePath, "--key="+filepath.Base(filePath))
args = append(args, "--", inputPath, outputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fail(fmt.Errorf("create command: %w", err))
}
_, err = cmd.Exec()
if err != nil {
return fail(fmt.Errorf("embed files with QPDF: %w", err))
}
err = os.Rename(outputPath, inputPath)
if err != nil {
return fail(fmt.Errorf("embed files with QPDF: replace input: %w", err))
}
}
span.SetStatus(codes.Ok, "")
return nil
err := fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// EmbedFilesMetadata sets metadata on already-embedded files in a PDF using

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`, `chromium-ssrf` |
| LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
| 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` |
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
## Writing a new test

View File

@@ -213,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):

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.
@@ -211,7 +238,7 @@ Feature: /forms/libreoffice/convert
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: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]
Invalid form data: no form file found for extensions: [.123 .602 .abw .bib .bmp .cdr .cgm .cmx .csv .cwk .dbf .dif .doc .docm .docx .dot .dotm .dotx .dxf .emf .eps .epub .fodg .fodp .fods .fodt .fopd .gif .htm .html .hwp .jpeg .jpg .key .ltx .lwp .mcw .met .mml .mw .numbers .odd .odg .odm .odp .ods .odt .otg .oth .otp .ots .ott .pages .pbm .pcd .pct .pcx .pdb .pdf .pgm .png .pot .potm .potx .ppm .pps .ppsm .ppsx .ppt .pptm .pptx .psd .psw .pub .pwp .pxl .ras .rtf .sda .sdc .sdd .sdp .sdw .sgl .slk .smf .stc .std .sti .stw .svg .svm .swf .sxc .sxd .sxg .sxi .sxm .sxw .tga .tif .tiff .txt .uof .uop .uos .uot .vdx .vor .vsd .vsdm .vsdx .wb2 .wk1 .wks .wmf .wpd .wpg .wps .xbm .xhtml .xls .xlsb .xlsm .xlsx .xlt .xltm .xltx .xlw .xml .xpm .zabw]
form field 'landscape' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
form field 'exportFormFields' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
form field 'allowDuplicateFieldNames' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)

View File

@@ -291,6 +291,49 @@ Feature: /forms/pdfengines/merge
}
"""
# titleBookmarks adds a top-level bookmark per merged document, labeled by its
# Title metadata (falling back to the filename) and pointing to its first page,
# with the document's own outline nested underneath.
# See https://github.com/gotenberg/gotenberg/issues/867.
@bookmarks
Scenario: POST /forms/pdfengines/merge (Title Bookmarks)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/titled_alpha.pdf | file |
| files | testdata/titled_bravo.pdf | file |
| files | testdata/untitled_gamma.pdf | file |
| titleBookmarks | 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"
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Alpha",
"page": 1,
"children": [
{ "title": "A1", "page": 1 },
{ "title": "A2", "page": 2 }
]
},
{
"title": "Bravo",
"page": 3
},
{
"title": "untitled_gamma",
"page": 4
}
]
}
"""
@bookmarks
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
Given I have a default Gotenberg container

View File

@@ -51,6 +51,39 @@ Feature: /forms/pdfengines/stamp
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
# Repeating the stamp fields applies several stamps in one request, in order.
# Image and pdf stamps consume the uploaded stamp files in order; text stamps
# take none. Both text stamps must land, so their content is asserted (the
# options keep them unrotated and apart so pdftotext reads them cleanly).
# See https://github.com/gotenberg/gotenberg/pull/1601.
Scenario: POST /forms/pdfengines/stamp (Multiple Stamps - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | STAMPONE | field |
| stampOptions | {"rotation":"0","position":"tl","scale":"0.2 abs"} | field |
| stampSource | text | field |
| stampExpression | STAMPTWO | field |
| stampOptions | {"rotation":"0","position":"br","scale":"0.2 abs"} | field |
| stampSource | image | field |
| stamp | testdata/watermark.png | 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 the following content at page 1:
"""
STAMPONE
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
STAMPTWO
"""
Scenario: POST /forms/pdfengines/stamp (PDF - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |

View File

@@ -51,6 +51,39 @@ Feature: /forms/pdfengines/watermark
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
# Repeating the watermark fields applies several watermarks in one request, in
# order. Image and pdf watermarks consume the uploaded watermark files in
# order; text watermarks take none. Both text watermarks must land, so their
# content is asserted (the options keep them unrotated and apart so pdftotext
# reads them cleanly).
Scenario: POST /forms/pdfengines/watermark (Multiple Watermarks - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | MARKONE | field |
| watermarkOptions | {"rotation":"0","position":"tl","scale":"0.2 abs"} | field |
| watermarkSource | text | field |
| watermarkExpression | MARKTWO | field |
| watermarkOptions | {"rotation":"0","position":"br","scale":"0.2 abs"} | field |
| watermarkSource | image | field |
| watermark | testdata/watermark.png | 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 the following content at page 1:
"""
MARKONE
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
MARKTWO
"""
Scenario: POST /forms/pdfengines/watermark (PDF - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |

View File

@@ -29,6 +29,17 @@ Feature: /
When I make a "GET" request to Gotenberg at the "/" endpoint
Then the response status code should be 401
# A request without a Bearer token is rejected. The JWKS URL is never fetched
# here, so no OIDC provider needs to be reachable.
Scenario: GET / (OIDC Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_OIDC_AUTH | true |
| API_OIDC_ISSUER | https://gotenberg.test/ |
| API_OIDC_AUDIENCE | gotenberg |
| API_OIDC_JWKS_URL | https://gotenberg.test/jwks.json |
When I make a "GET" request to Gotenberg at the "/" endpoint
Then the response status code should be 401
Scenario: GET /foo/ (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ROOT_PATH | /foo/ |

View File

@@ -29,14 +29,16 @@ func doRequest(method, url string, headers map[string]string, body io.Reader) (*
return resp, nil
}
func doFormDataRequest(method, url string, fields map[string]string, files map[string][]string, headers map[string]string) (*http.Response, error) {
func doFormDataRequest(method, url string, fields map[string][]string, files map[string][]string, headers map[string]string) (*http.Response, error) {
var b bytes.Buffer
writer := multipart.NewWriter(&b)
for name, value := range fields {
err := writer.WriteField(name, value)
if err != nil {
return nil, fmt.Errorf("write field %q: %w", name, err)
for name, values := range fields {
for _, value := range values {
err := writer.WriteField(name, value)
if err != nil {
return nil, fmt.Errorf("write field %q: %w", name, err)
}
}
}

View File

@@ -208,7 +208,7 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
return errors.New("no Gotenberg container")
}
fields := make(map[string]string)
fields := make(map[string][]string)
files := make(map[string][]string)
headers := make(map[string]string)
@@ -220,10 +220,10 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
switch kind {
case "field":
if name == "downloadFrom" || name == "url" || name == "cookies" {
fields[name] = strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort))
fields[name] = append(fields[name], strings.ReplaceAll(value, "%d", fmt.Sprintf("%d", s.hostPort)))
continue
}
fields[name] = value
fields[name] = append(fields[name], value)
case "file":
if strings.Contains(value, "teststore") {
if s.teststoreDir == "" {
@@ -363,7 +363,7 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
return errors.New("no Gotenberg container")
}
fields := make(map[string]string)
fields := make(map[string][]string)
files := make(map[string][]string)
headers := make(map[string]string)
@@ -374,7 +374,7 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count
switch kind {
case "field":
fields[name] = value
fields[name] = append(fields[name], value)
case "file":
wd, err := os.Getwd()
if err != nil {

View File

@@ -4,9 +4,16 @@
<meta charset="utf-8" />
<title>Image-heavy page</title>
<style>
@page { margin: 0; }
body { margin: 0; }
img { width: 100%; display: block; }
@page {
margin: 0;
}
body {
margin: 0;
}
img {
width: 100%;
display: block;
}
</style>
</head>
<body>

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.