mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 11:52:14 +01:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0f487e333 | ||
|
|
3de6932279 | ||
|
|
e9a67132ec | ||
|
|
41b33fd6ad | ||
|
|
2521485bf8 | ||
|
|
65e5699b71 | ||
|
|
b3c06fb8ae | ||
|
|
f90684e165 | ||
|
|
90e614afa4 | ||
|
|
6a087cb1f7 | ||
|
|
38db892552 | ||
|
|
ca9603bdf4 | ||
|
|
0c1e82c885 | ||
|
|
1303e0ebc9 | ||
|
|
cc1341c3cf | ||
|
|
1ac1d9887e | ||
|
|
7a730cfdc2 | ||
|
|
b213f2ffed | ||
|
|
8d1eeaa73a | ||
|
|
2f6818d3df | ||
|
|
38f6e466d4 | ||
|
|
e39840d5cf | ||
|
|
9b48d3f84e | ||
|
|
05bde96334 | ||
|
|
8b2c15d5de | ||
|
|
8d327a5196 | ||
|
|
dc61c3631e | ||
|
|
dc7c68152c | ||
|
|
357c3b4a59 | ||
|
|
91f2587fef |
@@ -47,6 +47,8 @@ body:multipart-form {
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~optimizeImages: false
|
||||
~imageQuality: 80
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
|
||||
@@ -48,6 +48,8 @@ body:multipart-form {
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~optimizeImages: false
|
||||
~imageQuality: 80
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
|
||||
@@ -47,6 +47,8 @@ body:multipart-form {
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~optimizeImages: false
|
||||
~imageQuality: 80
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
|
||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~selector:
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
|
||||
@@ -16,6 +16,7 @@ body:multipart-form {
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~selector:
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
|
||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
||||
~width: 800
|
||||
~height: 600
|
||||
~clip: false
|
||||
~selector:
|
||||
~format: png
|
||||
~quality: 100
|
||||
~optimizeForSpeed: false
|
||||
|
||||
@@ -64,6 +64,8 @@ body:multipart-form {
|
||||
~splitUnify: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~optimizeImages: false
|
||||
~imageQuality: 80
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
|
||||
@@ -15,8 +15,11 @@ 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
|
||||
~imageQuality: 80
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||
~userPassword:
|
||||
|
||||
26
.bruno/PDF Engines/Optimize/Optimize PDF.bru
Normal file
26
.bruno/PDF Engines/Optimize/Optimize PDF.bru
Normal file
@@ -0,0 +1,26 @@
|
||||
meta {
|
||||
name: Optimize PDF
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/forms/pdfengines/optimize
|
||||
body: multipartForm
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
files: @file(../../test/integration/testdata/page_1.pdf)
|
||||
~imageQuality: 80
|
||||
}
|
||||
|
||||
headers {
|
||||
~Gotenberg-Output-Filename: optimized
|
||||
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
|
||||
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
|
||||
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
|
||||
~Gotenberg-Webhook-Method: POST
|
||||
~Gotenberg-Webhook-Error-Method: POST
|
||||
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ body:multipart-form {
|
||||
~flatten: false
|
||||
~pdfa: PDF/A-1b
|
||||
~pdfua: true
|
||||
~optimizeImages: false
|
||||
~imageQuality: 80
|
||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||
~userPassword:
|
||||
~ownerPassword:
|
||||
|
||||
2
.github/workflows/continuous-integration.yml
vendored
2
.github/workflows/continuous-integration.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.10.1
|
||||
version: v2.12.2
|
||||
|
||||
lint-prettier:
|
||||
name: Lint non-Golang codebase
|
||||
|
||||
11
Makefile
11
Makefile
@@ -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
|
||||
@@ -82,6 +86,7 @@ PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||
PDFENGINES_FLATTEN_ENGINES=qpdf
|
||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||
PDFENGINES_OPTIMIZE_IMAGES_ENGINES=pdfcpu
|
||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
||||
@@ -90,7 +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=qpdf,pdfcpu
|
||||
PDFENGINES_EMBED_ENGINES=pdfcpu
|
||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||
PROMETHEUS_NAMESPACE=gotenberg
|
||||
@@ -147,10 +152,12 @@ NO_CONCURRENCY=false
|
||||
# chromium-screenshot-html
|
||||
# chromium-screenshot-markdown
|
||||
# chromium-screenshot-url
|
||||
# chromium-ssrf
|
||||
# debug
|
||||
# health
|
||||
# libreoffice
|
||||
# libreoffice-convert
|
||||
# libreoffice-ssrf
|
||||
# output-filename
|
||||
# pdfengines
|
||||
# pdfengines-convert
|
||||
@@ -160,6 +167,8 @@ NO_CONCURRENCY=false
|
||||
# encrypt
|
||||
# pdfengines-flatten
|
||||
# flatten
|
||||
# pdfengines-optimize
|
||||
# optimize
|
||||
# pdfengines-merge
|
||||
# merge
|
||||
# pdfengines-metadata
|
||||
|
||||
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.5
|
||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||
|
||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||
ARG PDFCPU_VERSION=v0.13.0
|
||||
ARG PDFCPU_VERSION=v0.15.0
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
|
||||
@@ -82,6 +82,7 @@ services:
|
||||
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
||||
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
||||
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
||||
- "--pdfengines-optimize-images-engines=${PDFENGINES_OPTIMIZE_IMAGES_ENGINES}"
|
||||
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
||||
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
||||
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
||||
|
||||
100
go.mod
100
go.mod
@@ -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
202
go.sum
@@ -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
51
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type PdfEngineMock struct {
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
OptimizeImagesMock func(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
@@ -80,6 +81,10 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, f
|
||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||
return engine.OptimizeImagesMock(ctx, logger, imageQuality, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
@@ -338,8 +338,18 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||
case errors.Is(err, ErrPublicIP):
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
||||
default:
|
||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||
// A cancellation or timeout is not a policy decision; surface it
|
||||
// as-is so callers do not report it as a filtered request.
|
||||
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||
default:
|
||||
// The host could not be resolved, so its address class cannot be
|
||||
// verified. Fail closed and treat it as filtered, the same as a
|
||||
// host that resolves to a blocked address, so clients get a
|
||||
// generic 403 rather than a 500. This also denies alternate IP
|
||||
// encodings such as http://2130706433/ that the resolver rejects
|
||||
// as a hostname but Chromium would read as a private IP.
|
||||
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %v: %w", normalized, err, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -311,6 +311,39 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_UnresolvableHostFailsClosed(t *testing.T) {
|
||||
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||
return nil, errors.New("no such host")
|
||||
})
|
||||
|
||||
// An alternate IP encoding (decimal for 127.0.0.1) that the resolver
|
||||
// rejects as a hostname must fail closed as filtered, not surface as a
|
||||
// server error, so clients receive a generic 403.
|
||||
_, err := DecideOutbound(context.Background(), "http://2130706433/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("expected ErrFiltered, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_ResolverCancellationNotFiltered(t *testing.T) {
|
||||
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||
return nil, context.Canceled
|
||||
})
|
||||
|
||||
// A cancellation or timeout is not a policy decision and must not be
|
||||
// reported as a filtered request.
|
||||
_, err := DecideOutbound(context.Background(), "http://example.com/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("cancellation must not be filtered, got: %v", err)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
|
||||
@@ -281,6 +281,12 @@ type PdfEngine interface {
|
||||
// PdfFormats. If no format, it does nothing.
|
||||
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
|
||||
// OptimizeImages re-encodes the raster images of a PDF in place to shrink
|
||||
// the file, leaving text, vectors, fonts and structure untouched.
|
||||
// imageQuality is the JPEG quality (1 to 100) applied to each re-encoded
|
||||
// image.
|
||||
OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
67
pkg/modules/api/api_test.go
Normal file
67
pkg/modules/api/api_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
|
||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := form.RemoveAll()
|
||||
if err != nil {
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("remove multipart temporary files: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
var formValuesSize int64
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -73,6 +74,64 @@ func TestNewContext_Cancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContext_RemovesMultipartTemporaryFiles(t *testing.T) {
|
||||
body := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("files", "input.odt")
|
||||
if err != nil {
|
||||
t.Fatalf("create multipart file: %v", err)
|
||||
}
|
||||
_, err = part.Write(bytes.Repeat([]byte("x"), 1024))
|
||||
if err != nil {
|
||||
t.Fatalf("write multipart file: %v", err)
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
err = req.ParseMultipartForm(1)
|
||||
if err != nil {
|
||||
t.Fatalf("parse multipart form: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = req.MultipartForm.RemoveAll()
|
||||
}()
|
||||
|
||||
upload, err := req.MultipartForm.File["files"][0].Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open disk-backed multipart file: %v", err)
|
||||
}
|
||||
temporaryFile, ok := upload.(*os.File)
|
||||
if !ok {
|
||||
_ = upload.Close()
|
||||
t.Fatal("multipart upload is not disk-backed")
|
||||
}
|
||||
temporaryPath := temporaryFile.Name()
|
||||
err = temporaryFile.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close disk-backed multipart file: %v", err)
|
||||
}
|
||||
|
||||
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||
downloadFromCfg := downloadFromConfig{disable: true}
|
||||
|
||||
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newContext returned error: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
_, err = os.Stat(temporaryPath)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("multipart temporary file still exists: %s", temporaryPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent downloadFrom entries must not race on the shared maps
|
||||
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||
// to catch the data race; without -race a sufficient number of entries
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -86,13 +88,37 @@ func ParseError(err error) (int, string) {
|
||||
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// statusClientClosedRequest is the non-standard 499 status (nginx convention)
|
||||
// recorded when the client aborts the request before it completes. It keeps
|
||||
// such outcomes out of the 5xx range in the access log and Prometheus metrics.
|
||||
const statusClientClosedRequest = 499
|
||||
|
||||
// requestCanceled reports whether err is the result of the client aborting the
|
||||
// request rather than a server-side failure. It requires both that err wraps
|
||||
// [context.Canceled] and that the request context itself was canceled, so a
|
||||
// context.Canceled originating elsewhere still surfaces as an internal error.
|
||||
// A server-side timeout is [context.DeadlineExceeded], mapped to 503 by
|
||||
// [ParseError], and is deliberately not treated as a client abort.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||
func requestCanceled(c echo.Context, err error) bool {
|
||||
return errors.Is(err, context.Canceled) && errors.Is(c.Request().Context().Err(), context.Canceled)
|
||||
}
|
||||
|
||||
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
||||
// returns a response as "text/plain; charset=UTF-8".
|
||||
func httpErrorHandler() echo.HTTPErrorHandler {
|
||||
return func(err error, c echo.Context) {
|
||||
logger := c.Get("logger").(*slog.Logger)
|
||||
status, message := ParseError(err)
|
||||
|
||||
if requestCanceled(c, err) {
|
||||
// The client is gone, so writing a body would only fail and add
|
||||
// noise. Record the status so the access log and metrics classify
|
||||
// it as a client abort rather than an internal error.
|
||||
c.Response().WriteHeader(statusClientClosedRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status, message := ParseError(err)
|
||||
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
||||
|
||||
err = c.String(status, message)
|
||||
@@ -263,9 +289,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
finishTime := time.Now()
|
||||
|
||||
status := c.Response().Status
|
||||
canceled := false
|
||||
if err != nil {
|
||||
parsedStatus, _ := ParseError(err)
|
||||
status = parsedStatus
|
||||
canceled = requestCanceled(c, err)
|
||||
if canceled {
|
||||
status = statusClientClosedRequest
|
||||
} else {
|
||||
parsedStatus, _ := ParseError(err)
|
||||
status = parsedStatus
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("error", err.Error()))
|
||||
c.Error(err)
|
||||
@@ -293,10 +325,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
||||
With(slog.Int64("bytes_out", c.Response().Size))
|
||||
|
||||
if err != nil {
|
||||
accessLogger.ErrorContext(ctx, err.Error())
|
||||
} else {
|
||||
switch {
|
||||
case err == nil:
|
||||
accessLogger.InfoContext(ctx, "request handled")
|
||||
case canceled:
|
||||
// A client abort is expected, not a server failure; keep it
|
||||
// visible but out of the error stream.
|
||||
accessLogger.InfoContext(ctx, err.Error())
|
||||
default:
|
||||
accessLogger.ErrorContext(ctx, err.Error())
|
||||
}
|
||||
|
||||
additionalAttributes := []attribute.KeyValue{
|
||||
@@ -333,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
|
||||
|
||||
@@ -1,15 +1,87 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// TestRequestCanceled pins the client-abort discriminator: only a
|
||||
// context.Canceled that stems from the request context counts, so a server
|
||||
// timeout or an unrelated cancellation still surfaces as an internal failure.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||
func TestRequestCanceled(t *testing.T) {
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
timedOut, cancelTimeout := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||
defer cancelTimeout()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reqCtx context.Context
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"client abort", canceled, context.Canceled, true},
|
||||
{"wrapped client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), true},
|
||||
{"canceled error but live request", context.Background(), context.Canceled, false},
|
||||
{"canceled request but unrelated error", canceled, errors.New("boom"), false},
|
||||
{"server timeout is not a client abort", timedOut, context.DeadlineExceeded, false},
|
||||
{"no error", canceled, nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
if got := requestCanceled(c, tc.err); got != tc.want {
|
||||
t.Fatalf("requestCanceled = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHttpErrorHandler_ClientClosedRequest ensures a client abort is recorded
|
||||
// as 499 rather than 500, and that a genuine failure keeps its status.
|
||||
func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reqCtx context.Context
|
||||
err error
|
||||
wantStatus int
|
||||
}{
|
||||
{"client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), statusClientClosedRequest},
|
||||
{"internal failure", context.Background(), errors.New("boom"), http.StatusInternalServerError},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||
rec := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(req, rec)
|
||||
c.Set("logger", slog.New(slog.DiscardHandler))
|
||||
|
||||
httpErrorHandler()(tc.err, c)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutputFilenameMiddleware pins the sanitizing of the
|
||||
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
||||
// a Content-Disposition header, so a path separator must never survive it.
|
||||
@@ -84,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type browserArguments struct {
|
||||
denyPublicIPs bool
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
clearStorage bool
|
||||
disableJavaScript bool
|
||||
}
|
||||
|
||||
@@ -179,6 +180,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
return fmt.Errorf("start pinning proxy: %w", err)
|
||||
}
|
||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||
|
||||
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs {
|
||||
// Chromium implicitly bypasses the proxy for loopback and
|
||||
// link-local destinations. A WebSocket handshake is never surfaced
|
||||
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
|
||||
// cannot filter it; the pinning proxy is the only layer that sees
|
||||
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
|
||||
// localhost, or the link-local cloud metadata endpoint
|
||||
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
||||
// the implicit bypass so those handshakes also traverse the pinning
|
||||
// proxy and go through [gotenberg.DecideOutbound] like every other
|
||||
// request.
|
||||
//
|
||||
// Gated on the IP-class policy: it is the control this closes, and
|
||||
// under it loopback and link-local HTTP sub-resources are already
|
||||
// blocked by listenForEventRequestPaused before they would reach
|
||||
// the proxy, so this adds only the missing WebSocket coverage. When
|
||||
// the policy is off, loopback is not restricted, and routing it
|
||||
// through the proxy would merely change how an unreachable loopback
|
||||
// sub-resource reports its failure.
|
||||
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
||||
}
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||
@@ -200,7 +223,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
if stopErr != nil {
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
||||
}
|
||||
return fmt.Errorf("run exec allocator: %w", err)
|
||||
return fmt.Errorf("run exec allocator: %w; if Chromium is slow to start, raise --chromium-start-timeout (currently %s)", err, b.arguments.wsUrlReadTimeout)
|
||||
}
|
||||
|
||||
b.ctxMu.Lock()
|
||||
@@ -345,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
@@ -371,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
||||
runtime.Enable(),
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
@@ -434,6 +459,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||
})
|
||||
|
||||
// WebSocket handshakes never surface as fetch.EventRequestPaused, so
|
||||
// listenForEventRequestPaused above cannot filter them. Validate them
|
||||
// against the same allow / deny lists and IP-class policy.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
listenForEventWebSocketCreated(taskCtx, logger, eventWebSocketCreatedOptions{
|
||||
allowList: b.arguments.allowList,
|
||||
denyList: b.arguments.denyList,
|
||||
denyPrivateIPs: b.arguments.denyPrivateIPs,
|
||||
denyPublicIPs: b.arguments.denyPublicIPs,
|
||||
})
|
||||
|
||||
var (
|
||||
invalidHttpStatusCode error
|
||||
invalidHttpStatusCodeMu sync.RWMutex
|
||||
|
||||
@@ -43,6 +43,10 @@ var (
|
||||
// or undefined.
|
||||
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
||||
|
||||
// ErrScreenshotSelectorNotFound happens when the CSS selector of a
|
||||
// screenshot matches no element with a rendered box.
|
||||
ErrScreenshotSelectorNotFound = errors.New("screenshot selector not found")
|
||||
|
||||
// ErrRpccMessageTooLarge happens when the messages received by
|
||||
// ChromeDevTools are larger than 100 MB.
|
||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||
@@ -347,6 +351,11 @@ type ScreenshotOptions struct {
|
||||
// dimensions.
|
||||
Clip bool
|
||||
|
||||
// Selector clips the screenshot to the bounding box of the first element
|
||||
// matching this CSS selector. Empty captures the whole page. Takes
|
||||
// precedence over Clip.
|
||||
Selector string
|
||||
|
||||
// Format is the image compression format, either "png" or "jpeg" or
|
||||
// "webp".
|
||||
Format string
|
||||
@@ -370,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Selector: "",
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
@@ -467,6 +477,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||
|
||||
@@ -518,6 +529,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,54 @@ func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate)
|
||||
})
|
||||
}
|
||||
|
||||
type eventWebSocketCreatedOptions struct {
|
||||
allowList, denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
}
|
||||
|
||||
// listenForEventWebSocketCreated validates the target of every WebSocket
|
||||
// handshake against the same allow / deny lists and IP-class policy as
|
||||
// [listenForEventRequestPaused]. Chromium never surfaces a WebSocket
|
||||
// handshake as a fetch.EventRequestPaused, so without this listener a page
|
||||
// could open a WebSocket to an address the outbound filter would otherwise
|
||||
// block. See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||
//
|
||||
// This listener records an operator-visible warning with the full ws:// URL.
|
||||
// The connection itself is severed by the pinning proxy, which every
|
||||
// WebSocket handshake traverses once the implicit loopback / link-local proxy
|
||||
// bypass is removed (see the "<-loopback>" flag in browser.go). When the
|
||||
// operator configures a custom proxy or host-resolver mappings, the pinning
|
||||
// proxy is not started; the WebSocket then follows the operator's egress path
|
||||
// and this warning is the remaining safeguard, since a WebSocket handshake
|
||||
// cannot be aborted through the CDP Network domain.
|
||||
func listenForEventWebSocketCreated(ctx context.Context, logger *slog.Logger, options eventWebSocketCreatedOptions) {
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
e, ok := ev.(*network.EventWebSocketCreated)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.DebugContext(ctx, fmt.Sprintf("event EventWebSocketCreated fired for '%s'", e.URL))
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
logger.ErrorContext(ctx, "context has no deadline, cannot filter WebSocket URL")
|
||||
return
|
||||
}
|
||||
|
||||
err := gotenberg.FilterOutboundURL(ctx, e.URL, options.allowList, options.denyList, deadline,
|
||||
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
|
||||
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
|
||||
)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, err.Error())
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
type eventRequestPausedOptions struct {
|
||||
allowList, denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
|
||||
@@ -366,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
var (
|
||||
width, height int
|
||||
clip bool
|
||||
selector string
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
@@ -376,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
Int("width", &width, defaultScreenshotOptions.Width).
|
||||
Int("height", &height, defaultScreenshotOptions.Height).
|
||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||
String("selector", &selector, defaultScreenshotOptions.Selector).
|
||||
Custom("format", func(value string) error {
|
||||
if value == "" {
|
||||
format = defaultScreenshotOptions.Format
|
||||
@@ -420,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Selector: selector,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
@@ -471,11 +474,18 @@ 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)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
@@ -492,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)
|
||||
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)
|
||||
}
|
||||
@@ -560,11 +570,18 @@ 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)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
@@ -576,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)
|
||||
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)
|
||||
}
|
||||
@@ -643,11 +660,18 @@ 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)
|
||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||
|
||||
@@ -664,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)
|
||||
@@ -679,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)
|
||||
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)
|
||||
}
|
||||
@@ -804,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) 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)
|
||||
@@ -886,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)
|
||||
}
|
||||
@@ -901,6 +925,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
return fmt.Errorf("rotate PDFs: %w", err)
|
||||
}
|
||||
|
||||
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("optimize PDF images: %w", err)
|
||||
}
|
||||
|
||||
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||
|
||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
@@ -963,7 +992,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
||||
outputPath := ctx.GeneratePath(ext)
|
||||
|
||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||
err = handleChromiumError(err, options.Options)
|
||||
if errors.Is(err, ErrScreenshotSelectorNotFound) {
|
||||
err = api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("The selector '%s' (selector) matched no element with a visible box", options.Selector),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
err = handleChromiumError(err, options.Options)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("screenshot: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
"github.com/chromedp/cdproto/emulation"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/cdproto/storage"
|
||||
"github.com/chromedp/chromedp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -54,6 +58,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
||||
defer span.End()
|
||||
|
||||
err := func() error {
|
||||
paperWidth := options.PaperWidth
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
@@ -67,9 +72,20 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
// We add top and bottom margins so that the content area
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
if options.Landscape {
|
||||
// Landscape swaps the paper dimensions, so the page is
|
||||
// WithPaperHeight wide by WithPaperWidth tall. Size both to
|
||||
// the content so the width expands to fit a wide document
|
||||
// (e.g. a table) instead of the height-only expansion
|
||||
// landing on the width axis and truncating it.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||
paperWidth = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
paperHeight = (cssContentSize.Width / 96) + options.MarginLeft + options.MarginRight
|
||||
} else {
|
||||
// We add top and bottom margins so that the content area
|
||||
// is large enough to fit the entire content.
|
||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||
}
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
@@ -78,7 +94,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperWidth(paperWidth).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
@@ -188,7 +204,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||
|
||||
if options.Clip {
|
||||
switch {
|
||||
case options.Selector != "":
|
||||
clip, err := elementClip(ctx, options.Selector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("clip screenshot to selector '%s'", options.Selector))
|
||||
captureScreenshot = captureScreenshot.WithClip(clip)
|
||||
case options.Clip:
|
||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||
Width: float64(options.Width),
|
||||
Height: float64(options.Height),
|
||||
@@ -229,6 +254,49 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
||||
}
|
||||
}
|
||||
|
||||
// elementClip resolves the first element matching selector to a page-space clip
|
||||
// rectangle for Page.captureScreenshot.
|
||||
//
|
||||
// getBoundingClientRect reports viewport-relative CSS pixels; adding the scroll
|
||||
// offset puts the rectangle in the document coordinate space that
|
||||
// WithCaptureBeyondViewport expects. It fails with
|
||||
// [ErrScreenshotSelectorNotFound] when nothing matches or the match has no
|
||||
// rendered box (display:none or a zero area), so the caller can answer 400.
|
||||
func elementClip(ctx context.Context, selector string) (*page.Viewport, error) {
|
||||
var rect struct {
|
||||
Found bool `json:"found"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
expr := fmt.Sprintf(`(() => {
|
||||
const el = document.querySelector(%s);
|
||||
if (!el) {
|
||||
return { found: false };
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
return { found: true, x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
|
||||
})()`, strconv.Quote(selector))
|
||||
|
||||
err := chromedp.Evaluate(expr, &rect).Do(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate selector box: %v: %w", err, ErrScreenshotSelectorNotFound)
|
||||
}
|
||||
if !rect.Found || rect.Width <= 0 || rect.Height <= 0 {
|
||||
return nil, fmt.Errorf("selector %q matched no element with a visible box: %w", selector, ErrScreenshotSelectorNotFound)
|
||||
}
|
||||
|
||||
return &page.Viewport{
|
||||
X: rect.X,
|
||||
Y: rect.Y,
|
||||
Width: rect.Width,
|
||||
Height: rect.Height,
|
||||
Scale: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.DebugContext(ctx, "set device metrics override")
|
||||
@@ -280,6 +348,54 @@ func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc
|
||||
}
|
||||
}
|
||||
|
||||
// clearStorageActionFunc clears the converted origin's local storage before the
|
||||
// page loads, so state written by a previous conversion of the same origin does
|
||||
// not leak into this one. See https://github.com/gotenberg/gotenberg/issues/919.
|
||||
//
|
||||
// Session storage is not touched: each conversion runs in its own browsing
|
||||
// context (a fresh tab), so it is already isolated and cannot leak. Local
|
||||
// storage is per-origin and shared across tabs of the long-lived browser, so it
|
||||
// is the only web storage that carries over.
|
||||
func clearStorageActionFunc(logger *slog.Logger, clear bool, rawURL string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if !clear {
|
||||
logger.DebugContext(ctx, "local storage not cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
origin, ok := httpOrigin(rawURL)
|
||||
if !ok {
|
||||
// A file:// upload gets an opaque, per-request origin that is not
|
||||
// shared between conversions, so there is nothing to clear.
|
||||
logger.DebugContext(ctx, "local storage not cleared: non-http(s) origin is already isolated")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("clear local storage for %s", origin))
|
||||
|
||||
err := storage.ClearDataForOrigin(origin, string(storage.TypeLocalStorage)).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("clear local storage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// httpOrigin returns the http(s) security origin (scheme://host[:port]) of
|
||||
// rawURL, and false when rawURL is not http(s). A non-http(s) URL such as a
|
||||
// file:// upload has an opaque origin that no other conversion shares.
|
||||
func httpOrigin(rawURL string) (string, bool) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), true
|
||||
}
|
||||
|
||||
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||
@@ -593,7 +709,13 @@ func waitForExpressionBeforePrintActionFunc(logger *slog.Logger, disableJavaScri
|
||||
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
||||
case <-ticker.C:
|
||||
var ok bool
|
||||
evaluate := chromedp.Evaluate(expression, &ok)
|
||||
// Await the result so a thenable expression (an async function
|
||||
// returning a Promise) resolves before its value is read. A
|
||||
// non-promise result is unaffected.
|
||||
// See https://github.com/gotenberg/gotenberg/pull/1617.
|
||||
evaluate := chromedp.Evaluate(expression, &ok, func(p *runtime.EvaluateParams) *runtime.EvaluateParams {
|
||||
return p.WithAwaitPromise(true)
|
||||
})
|
||||
|
||||
err := evaluate.Do(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -115,18 +115,29 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
|
||||
tag := key
|
||||
if key == "Trapped" {
|
||||
// ExifTool writes the document info /Trapped entry as a malformed
|
||||
// name-in-a-string, e.g. "(/Unknown)". pdfcpu's stricter validation
|
||||
// (as of v0.15) rejects it, which breaks later pdfcpu operations on
|
||||
// the file such as embedding. Writing Trapped to XMP keeps the value
|
||||
// readable without the invalid document info entry.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1628.
|
||||
tag = "XMP-pdf:Trapped"
|
||||
}
|
||||
|
||||
switch val := value.(type) {
|
||||
case string:
|
||||
if err := validateMetadataValue(key, val); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, fmt.Sprintf("-%s=%s", key, val))
|
||||
args = append(args, fmt.Sprintf("-%s=%s", tag, val))
|
||||
case []string:
|
||||
for _, s := range val {
|
||||
if err := validateMetadataValue(key, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
||||
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||
}
|
||||
case []any:
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
||||
@@ -138,18 +149,18 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
|
||||
if err := validateMetadataValue(key, s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
||||
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||
}
|
||||
case bool:
|
||||
args = append(args, fmt.Sprintf("-%s=%t", key, val))
|
||||
args = append(args, fmt.Sprintf("-%s=%t", tag, val))
|
||||
case int:
|
||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
||||
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||
case int64:
|
||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
||||
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||
case float32:
|
||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
||||
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||
case float64:
|
||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
||||
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||
default:
|
||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
@@ -296,6 +307,20 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
|
||||
return err
|
||||
}
|
||||
|
||||
// OptimizeImages is not available in this implementation.
|
||||
func (engine *ExifTool) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "exiftool.OptimizeImages",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
err := fmt.Errorf("optimize PDF images with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
||||
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
|
||||
@@ -879,6 +879,8 @@ func (a *Api) Extensions() []string {
|
||||
".potx",
|
||||
".ppm",
|
||||
".pps",
|
||||
".ppsm",
|
||||
".ppsx",
|
||||
".ppt",
|
||||
".pptm",
|
||||
".pptx",
|
||||
|
||||
@@ -293,6 +293,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
||||
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
||||
}
|
||||
|
||||
// SinglePageSheets starts each sheet's single page at the workbook's saved
|
||||
// scroll position, truncating everything above and to the left of it.
|
||||
// Render a copy with that position reset to the top-left cell instead.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||
if options.SinglePageSheets {
|
||||
inputPath = resetCalcScrollPosition(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--no-launch",
|
||||
"--format",
|
||||
|
||||
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// topLeftCellAttr matches the topLeftCell attribute that an OOXML worksheet
|
||||
// uses (on <sheetView> and, for frozen panes, <pane>) to store the cell that
|
||||
// was at the top-left of the window when the workbook was saved.
|
||||
var topLeftCellAttr = regexp.MustCompile(` topLeftCell="[^"]*"`)
|
||||
|
||||
// maxDecompressedWorksheet bounds how much a single worksheet may decompress to
|
||||
// while rewriting it. It guards against a decompression bomb and keeps memory
|
||||
// predictable. A worksheet larger than this is left untouched, so a pathological
|
||||
// workbook falls back to the original file rather than being rewritten.
|
||||
const maxDecompressedWorksheet = 128 << 20 // 128 MiB
|
||||
|
||||
// resetCalcScrollPosition returns a path to a copy of inputPath whose worksheet
|
||||
// scroll positions have been reset to the top-left cell, or inputPath unchanged
|
||||
// when the reset does not apply or cannot be performed safely.
|
||||
//
|
||||
// LibreOffice's SinglePageSheets export starts each single page at the sheet's
|
||||
// saved topLeftCell, dropping every row and column above and to the left of it.
|
||||
// A workbook saved scrolled away from A1 therefore renders truncated. Removing
|
||||
// the attribute before the conversion makes the whole used range render.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||
//
|
||||
// The function never fails the conversion. On a non-xlsx input, a workbook that
|
||||
// carries no scroll position, or any read, rewrite or validation error, it
|
||||
// returns the original path so a malformed rewrite can never reach LibreOffice.
|
||||
func resetCalcScrollPosition(ctx context.Context, logger *slog.Logger, inputPath string) string {
|
||||
// Resolve the extension to a literal so the sanitized filename is never
|
||||
// derived from the (user-controlled) upload name.
|
||||
var ext string
|
||||
switch strings.ToLower(filepath.Ext(inputPath)) {
|
||||
case ".xlsx":
|
||||
ext = ".xlsx"
|
||||
case ".xlsm":
|
||||
ext = ".xlsm"
|
||||
default:
|
||||
return inputPath
|
||||
}
|
||||
|
||||
src, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: read input: %s; using the original file", err))
|
||||
return inputPath
|
||||
}
|
||||
|
||||
out, changed, err := stripWorksheetScrollPosition(src)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||
return inputPath
|
||||
}
|
||||
if !changed {
|
||||
// The common case: nothing was saved scrolled, so nothing to do.
|
||||
return inputPath
|
||||
}
|
||||
|
||||
// A rewrite that dropped, renamed or corrupted an entry must never reach
|
||||
// LibreOffice; fall back to the original workbook if it does not round-trip.
|
||||
if err = validateWorkbook(src, out); err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||
return inputPath
|
||||
}
|
||||
|
||||
// Write the sanitized copy alongside the input, inside the request working
|
||||
// directory that LibreOffice already reads from. The pattern is constant,
|
||||
// so the resulting name carries no user-controlled path component.
|
||||
dst, err := os.CreateTemp(filepath.Dir(inputPath), "singlepagesheets-*"+ext)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: create sanitized file: %s; using the original file", err))
|
||||
return inputPath
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = dst.Write(out)
|
||||
if err != nil {
|
||||
_ = os.Remove(dst.Name())
|
||||
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: write sanitized file: %s; using the original file", err))
|
||||
return inputPath
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "reset calc scroll position: cleared worksheet topLeftCell for SinglePageSheets export")
|
||||
return dst.Name()
|
||||
}
|
||||
|
||||
// stripWorksheetScrollPosition rewrites the worksheet XML entries of an xlsx
|
||||
// workbook, removing the topLeftCell attribute, and reports whether anything
|
||||
// changed. Every non-worksheet entry, and every worksheet that does not carry
|
||||
// the attribute, is copied byte-for-byte without recompression.
|
||||
func stripWorksheetScrollPosition(src []byte) ([]byte, bool, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("open workbook: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := zip.NewWriter(&buf)
|
||||
changed := false
|
||||
|
||||
for _, file := range reader.File {
|
||||
rewritten, ok, err := rewriteWorksheet(file)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if ok {
|
||||
// Recompress only the worksheets that actually changed.
|
||||
header := file.FileHeader
|
||||
header.Method = zip.Deflate
|
||||
w, err := writer.CreateHeader(&header)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||
}
|
||||
_, err = w.Write(rewritten)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||
}
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
err = copyZipEntry(writer, file)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("finalize workbook: %w", err)
|
||||
}
|
||||
if !changed {
|
||||
return nil, false, nil
|
||||
}
|
||||
return buf.Bytes(), true, nil
|
||||
}
|
||||
|
||||
// rewriteWorksheet returns file's contents with topLeftCell removed, and
|
||||
// whether file is a worksheet that carried the attribute. A worksheet without
|
||||
// the attribute, or any other entry, returns ok false so the caller copies it
|
||||
// verbatim.
|
||||
func rewriteWorksheet(file *zip.File) ([]byte, bool, error) {
|
||||
if !strings.HasPrefix(file.Name, "xl/worksheets/") || !strings.HasSuffix(strings.ToLower(file.Name), ".xml") {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("open worksheet %q: %w", file.Name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Read at most maxDecompressedWorksheet+1 bytes so a decompression bomb
|
||||
// cannot exhaust memory; a genuine overflow aborts the rewrite.
|
||||
data, err := io.ReadAll(io.LimitReader(rc, maxDecompressedWorksheet+1))
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("read worksheet %q: %w", file.Name, err)
|
||||
}
|
||||
if len(data) > maxDecompressedWorksheet {
|
||||
return nil, false, fmt.Errorf("worksheet %q exceeds %d bytes", file.Name, maxDecompressedWorksheet)
|
||||
}
|
||||
|
||||
if !bytes.Contains(data, []byte("topLeftCell")) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return topLeftCellAttr.ReplaceAll(data, nil), true, nil
|
||||
}
|
||||
|
||||
// copyZipEntry writes file into writer without decompressing and recompressing
|
||||
// it, preserving its exact bytes.
|
||||
func copyZipEntry(writer *zip.Writer, file *zip.File) error {
|
||||
w, err := writer.CreateRaw(&file.FileHeader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||
}
|
||||
rc, err := file.OpenRaw()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open entry %q: %w", file.Name, err)
|
||||
}
|
||||
_, err = io.Copy(w, rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateWorkbook checks that out reopens as a zip holding exactly the same
|
||||
// entry names as src, rejecting a rewrite that lost, renamed or added an entry
|
||||
// or produced a broken central directory. The entry payloads themselves are
|
||||
// not re-read: unchanged entries are copied byte-for-byte from a workbook that
|
||||
// already parsed, and rewritten worksheets are produced by the standard library
|
||||
// writer, so re-decompressing everything would only add a decompression-bomb
|
||||
// surface without catching a failure this transform can introduce.
|
||||
func validateWorkbook(src, out []byte) error {
|
||||
original, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reopen original workbook: %w", err)
|
||||
}
|
||||
rewritten, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reopen rewritten workbook: %w", err)
|
||||
}
|
||||
|
||||
if len(rewritten.File) != len(original.File) {
|
||||
return fmt.Errorf("entry count changed from %d to %d", len(original.File), len(rewritten.File))
|
||||
}
|
||||
|
||||
names := make(map[string]struct{}, len(original.File))
|
||||
for _, file := range original.File {
|
||||
names[file.Name] = struct{}{}
|
||||
}
|
||||
for _, file := range rewritten.File {
|
||||
_, ok := names[file.Name]
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected entry %q", file.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildWorkbook packs entries into an in-memory xlsx-like zip.
|
||||
func buildWorkbook(t *testing.T, entries map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
for name, content := range entries {
|
||||
f, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("create entry %q: %v", name, err)
|
||||
}
|
||||
_, err = f.Write([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("write entry %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
err := w.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close workbook: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func readEntry(t *testing.T, workbook []byte, name string) string {
|
||||
t.Helper()
|
||||
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||
if err != nil {
|
||||
t.Fatalf("open workbook: %v", err)
|
||||
}
|
||||
for _, f := range r.File {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open entry %q: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read entry %q: %v", name, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
t.Fatalf("entry %q not found", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
const scrolledSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||
`<sheetViews><sheetView tabSelected="1" topLeftCell="A37" workbookViewId="0">` +
|
||||
`<pane topLeftCell="A37"/><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>` +
|
||||
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||
|
||||
const topSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||
`<sheetViews><sheetView tabSelected="1" workbookViewId="0"/></sheetViews>` +
|
||||
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||
|
||||
func TestStripWorksheetScrollPosition(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
workbook []byte
|
||||
expectErr bool
|
||||
expectChange bool
|
||||
}{
|
||||
{
|
||||
scenario: "removes topLeftCell from sheetView and pane",
|
||||
workbook: buildWorkbook(t, map[string]string{
|
||||
"[Content_Types].xml": "<Types/>",
|
||||
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||
"xl/sharedStrings.xml": "<sst/>",
|
||||
}),
|
||||
expectChange: true,
|
||||
},
|
||||
{
|
||||
scenario: "leaves a workbook without a saved scroll position untouched",
|
||||
workbook: buildWorkbook(t, map[string]string{
|
||||
"[Content_Types].xml": "<Types/>",
|
||||
"xl/worksheets/sheet1.xml": topSheet,
|
||||
}),
|
||||
expectChange: false,
|
||||
},
|
||||
{
|
||||
scenario: "only rewrites worksheet entries",
|
||||
workbook: buildWorkbook(t, map[string]string{
|
||||
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||
// A stray topLeftCell elsewhere must not be touched.
|
||||
"xl/workbook.xml": `<workbook topLeftCell="A9"/>`,
|
||||
}),
|
||||
expectChange: true,
|
||||
},
|
||||
{
|
||||
scenario: "rejects a non-zip input",
|
||||
workbook: []byte("not a zip file"),
|
||||
expectErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
out, changed, err := stripWorksheetScrollPosition(tc.workbook)
|
||||
|
||||
if tc.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if changed != tc.expectChange {
|
||||
t.Fatalf("expected changed=%v, got %v", tc.expectChange, changed)
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
|
||||
// The rewrite must round-trip and hold the same entries.
|
||||
if err = validateWorkbook(tc.workbook, out); err != nil {
|
||||
t.Fatalf("rewritten workbook did not validate: %v", err)
|
||||
}
|
||||
if strings.Contains(readEntry(t, out, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||
t.Fatalf("worksheet still contains topLeftCell")
|
||||
}
|
||||
// Non-worksheet entries are copied verbatim.
|
||||
if _, ok := entryNames(t, out)["xl/workbook.xml"]; ok {
|
||||
if got := readEntry(t, out, "xl/workbook.xml"); got != `<workbook topLeftCell="A9"/>` {
|
||||
t.Fatalf("non-worksheet entry was modified: %q", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func entryNames(t *testing.T, workbook []byte) map[string]struct{} {
|
||||
t.Helper()
|
||||
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||
if err != nil {
|
||||
t.Fatalf("open workbook: %v", err)
|
||||
}
|
||||
names := make(map[string]struct{}, len(r.File))
|
||||
for _, f := range r.File {
|
||||
names[f.Name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestResetCalcScrollPosition(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
ctx := context.Background()
|
||||
|
||||
writeFile := func(t *testing.T, name string, content []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
err := os.WriteFile(path, content, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %q: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("non-xlsx input is returned unchanged", func(t *testing.T) {
|
||||
path := writeFile(t, "input.docx", []byte("whatever"))
|
||||
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||
t.Fatalf("expected %q, got %q", path, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workbook without a scroll position is returned unchanged", func(t *testing.T) {
|
||||
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||
"xl/worksheets/sheet1.xml": topSheet,
|
||||
}))
|
||||
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||
t.Fatalf("expected original path %q, got %q", path, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("corrupt xlsx falls back to the original path", func(t *testing.T) {
|
||||
path := writeFile(t, "input.xlsx", []byte("PK\x03\x04 not really a zip"))
|
||||
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||
t.Fatalf("expected fallback to %q, got %q", path, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scrolled workbook yields a sanitized copy", func(t *testing.T) {
|
||||
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||
"[Content_Types].xml": "<Types/>",
|
||||
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||
}))
|
||||
got := resetCalcScrollPosition(ctx, logger, path)
|
||||
if got == path {
|
||||
t.Fatalf("expected a sanitized copy, got the original path")
|
||||
}
|
||||
if filepath.Dir(got) != filepath.Dir(path) {
|
||||
t.Fatalf("sanitized copy escaped the working directory: %q", got)
|
||||
}
|
||||
sanitized, err := os.ReadFile(got)
|
||||
if err != nil {
|
||||
t.Fatalf("read sanitized file: %v", err)
|
||||
}
|
||||
if strings.Contains(readEntry(t, sanitized, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||
t.Fatalf("sanitized worksheet still contains topLeftCell")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -60,6 +60,11 @@ func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Lo
|
||||
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// OptimizeImages is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||
return fmt.Errorf("optimize PDF images with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
||||
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
||||
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||
|
||||
@@ -37,13 +37,20 @@ 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)
|
||||
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||
|
||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||
|
||||
@@ -310,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)
|
||||
@@ -338,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))
|
||||
@@ -493,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)
|
||||
}
|
||||
@@ -515,6 +522,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
}
|
||||
}
|
||||
|
||||
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("optimize PDF images: %w", err)
|
||||
}
|
||||
|
||||
needsConvertStub := !nativePdfFormats ||
|
||||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
|
||||
(nativePdfFormats && hasPostProcessing)
|
||||
|
||||
321
pkg/modules/pdfcpu/optimize.go
Normal file
321
pkg/modules/pdfcpu/optimize.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package pdfcpu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
_ "image/png" // Register the PNG decoder: pdfcpu extracts FlateDecode images as PNG.
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// minOptimizeImageSize is the smallest encoded image worth re-encoding. Smaller
|
||||
// images (thumbnails, icons, and line art that FlateDecode already keeps tiny)
|
||||
// are left untouched: a JPEG pass would add artifacts for little or no gain.
|
||||
const minOptimizeImageSize = 30 << 10 // 30 KiB
|
||||
|
||||
// pdfcpuListRowID matches the image Id (e.g. "X6") in a `pdfcpu images extract`
|
||||
// filename such as "input_1_X6.png".
|
||||
var pdfcpuListRowID = regexp.MustCompile(`_(X\d+)\.`)
|
||||
|
||||
// pdfcpuImage is one raster image XObject as reported by `pdfcpu images list`.
|
||||
type pdfcpuImage struct {
|
||||
obj int
|
||||
id string
|
||||
masked bool
|
||||
comp int
|
||||
bytes int64
|
||||
filter string
|
||||
}
|
||||
|
||||
// OptimizeImages re-encodes the raster images of inputPath to JPEG in place,
|
||||
// shrinking image-heavy PDFs (a common case for Chromium output, which embeds
|
||||
// non-JPEG images losslessly) while leaving text, vectors, fonts and structure
|
||||
// untouched. See https://github.com/gotenberg/gotenberg/issues/359.
|
||||
//
|
||||
// Only lossless (FlateDecode), non-CMYK, non-masked images at or above
|
||||
// [minOptimizeImageSize] are touched. Already-compressed, transparent, CMYK and
|
||||
// small images are skipped so the pass never enlarges a file or corrupts
|
||||
// transparency. It never fails the conversion for a single unreadable image; it
|
||||
// logs and moves on, and returns the input unchanged when nothing qualifies.
|
||||
func (engine *PdfCpu) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.OptimizeImages",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
fail := func(err error) error {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
images, err := engine.listImages(ctx, inputPath)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
|
||||
}
|
||||
|
||||
var targets []pdfcpuImage
|
||||
for _, img := range images {
|
||||
if optimizableImage(img) {
|
||||
targets = append(targets, img)
|
||||
}
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
logger.DebugContext(ctx, "no images to optimize")
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp(filepath.Dir(inputPath), "optimize-images-*")
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("optimize PDF images with pdfcpu: create work directory: %w", err))
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(workDir); err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("remove image optimization work directory: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
extracted, err := engine.extractImages(ctx, logger, inputPath, workDir)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("optimize PDF images with pdfcpu: %w", err))
|
||||
}
|
||||
|
||||
// Chain one update per image. Each update writes a fresh file; current holds
|
||||
// the latest successful output, so a single failed image is skipped without
|
||||
// discarding the ones already done. The input is only replaced on success.
|
||||
current := inputPath
|
||||
optimized := 0
|
||||
for _, img := range targets {
|
||||
src, ok := extracted[img.id]
|
||||
if !ok {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("optimize images: image %s was not extracted, leaving it as is", img.id))
|
||||
continue
|
||||
}
|
||||
|
||||
reencoded := filepath.Join(workDir, img.id+".jpg")
|
||||
err = reencodeToJpeg(src, reencoded, imageQuality)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("optimize images: re-encode %s: %v; leaving it as is", img.id, err))
|
||||
continue
|
||||
}
|
||||
|
||||
next := filepath.Join(workDir, fmt.Sprintf("optimized-%d.pdf", optimized))
|
||||
err = engine.updateImage(ctx, logger, current, reencoded, next, img.obj)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("optimize images: update %s: %v; leaving it as is", img.id, err))
|
||||
continue
|
||||
}
|
||||
|
||||
current = next
|
||||
optimized++
|
||||
}
|
||||
|
||||
if optimized == 0 {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = os.Rename(current, inputPath)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("optimize PDF images with pdfcpu: replace input: %w", err))
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, fmt.Sprintf("optimized %d image(s) at quality %d", optimized, imageQuality))
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// optimizableImage reports whether an image is a safe, worthwhile target: a
|
||||
// lossless (FlateDecode), non-CMYK, non-masked image at or above the size
|
||||
// threshold. Everything else is left untouched.
|
||||
func optimizableImage(img pdfcpuImage) bool {
|
||||
switch {
|
||||
case img.filter != "FlateDecode":
|
||||
return false // Already compressed (JPEG/JPX); re-encoding would only add loss.
|
||||
case img.comp == 4:
|
||||
return false // CMYK; a JPEG round-trip is unsafe.
|
||||
case img.masked:
|
||||
return false // Soft mask, image mask or alpha; JPEG has no transparency.
|
||||
case img.bytes < minOptimizeImageSize:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// listImages runs `pdfcpu images list` and parses its table. The command writes
|
||||
// to stdout, so it is run directly to capture the output.
|
||||
func (engine *PdfCpu) listImages(ctx context.Context, inputPath string) ([]pdfcpuImage, error) {
|
||||
cmd := exec.CommandContext(ctx, engine.binPath, "images", "list", inputPath) //nolint:gosec // binPath is validated at Provision; inputPath is a Gotenberg working file.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("run pdfcpu images list: %w", err)
|
||||
}
|
||||
|
||||
return parseImagesList(stdout.String()), nil
|
||||
}
|
||||
|
||||
// parseImagesList parses the fixed-column table of `pdfcpu images list`. Columns
|
||||
// are separated by U+2502; the header and separator rows are skipped because
|
||||
// their second column is not a numeric object number.
|
||||
func parseImagesList(output string) []pdfcpuImage {
|
||||
var images []pdfcpuImage
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
cols := strings.Split(line, "│")
|
||||
if len(cols) < 9 {
|
||||
continue
|
||||
}
|
||||
|
||||
obj, err := strconv.Atoi(strings.TrimSpace(cols[1]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
comp := 0
|
||||
if fields := strings.Fields(cols[6]); len(fields) >= 2 {
|
||||
comp, _ = strconv.Atoi(fields[1])
|
||||
}
|
||||
|
||||
images = append(images, pdfcpuImage{
|
||||
obj: obj,
|
||||
id: strings.TrimSpace(cols[2]),
|
||||
masked: strings.TrimSpace(cols[3]) != "image",
|
||||
comp: comp,
|
||||
bytes: parseHumanSize(cols[7]),
|
||||
filter: strings.TrimSpace(cols[8]),
|
||||
})
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
// parseHumanSize converts a pdfcpu size cell such as "4.4 MB" or "194 KB" into
|
||||
// a byte count.
|
||||
func parseHumanSize(cell string) int64 {
|
||||
fields := strings.Fields(cell)
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
multiplier := float64(1)
|
||||
if len(fields) > 1 {
|
||||
switch strings.ToUpper(fields[1]) {
|
||||
case "KB":
|
||||
multiplier = 1 << 10
|
||||
case "MB":
|
||||
multiplier = 1 << 20
|
||||
case "GB":
|
||||
multiplier = 1 << 30
|
||||
}
|
||||
}
|
||||
|
||||
return int64(value * multiplier)
|
||||
}
|
||||
|
||||
// extractImages extracts every image of inputPath into dir and returns a map of
|
||||
// image Id (e.g. "X6") to the extracted file path.
|
||||
func (engine *PdfCpu) extractImages(ctx context.Context, logger *slog.Logger, inputPath, dir string) (map[string]string, error) {
|
||||
args := []string{"images", "extract", inputPath, dir}
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create command: %w", err)
|
||||
}
|
||||
|
||||
_, err = cmd.Exec()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract images: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read extracted images: %w", err)
|
||||
}
|
||||
|
||||
extracted := make(map[string]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
if match := pdfcpuListRowID.FindStringSubmatch(entry.Name()); match != nil {
|
||||
extracted[match[1]] = filepath.Join(dir, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return extracted, nil
|
||||
}
|
||||
|
||||
// updateImage replaces the image object objNr of inFile with the image at
|
||||
// imagePath, writing the result to outFile. The replacement must share the
|
||||
// original image dimensions, which reencodeToJpeg preserves.
|
||||
func (engine *PdfCpu) updateImage(ctx context.Context, logger *slog.Logger, inFile, imagePath, outFile string, objNr int) error {
|
||||
args := []string{"images", "update", inFile, imagePath, outFile, strconv.Itoa(objNr)}
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create command: %w", err)
|
||||
}
|
||||
|
||||
_, err = cmd.Exec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update image: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reencodeToJpeg decodes the image at src and writes it to dst as JPEG at the
|
||||
// given quality, keeping the original pixel dimensions (pdfcpu requires the
|
||||
// replacement to match). quality is clamped to the valid 1 to 100 range.
|
||||
func reencodeToJpeg(src, dst string, quality int) error {
|
||||
if quality < 1 {
|
||||
quality = 1
|
||||
}
|
||||
if quality > 100 {
|
||||
quality = 100
|
||||
}
|
||||
|
||||
in, err := os.Open(src) //nolint:gosec // src is a file this package extracted into its own temp dir.
|
||||
if err != nil {
|
||||
return fmt.Errorf("open image: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
img, _, err := image.Decode(in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image: %w", err)
|
||||
}
|
||||
|
||||
out, err := os.Create(dst) //nolint:gosec // dst is a file in this package's own temp dir.
|
||||
if err != nil {
|
||||
return fmt.Errorf("create re-encoded image: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
err = jpeg.Encode(out, img, &jpeg.Options{Quality: quality})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode JPEG: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
83
pkg/modules/pdfcpu/optimize_test.go
Normal file
83
pkg/modules/pdfcpu/optimize_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package pdfcpu
|
||||
|
||||
import "testing"
|
||||
|
||||
const sampleImagesList = `pages: all
|
||||
|
||||
/tmp/multi.pdf:
|
||||
4 images available (8.9 MB)
|
||||
Page │ Obj# │ Id │ Type SoftMask ImgMask │ Width │ Height │ ColorSpace Comp bpc Interp │ Size │ Filters
|
||||
━━━━━┿━━━━━━┿━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━┿━━━━━━━━━━━━
|
||||
1 │ 6 │ X6 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 5.5 MB │ FlateDecode
|
||||
│ 8 │ X8 │ image * │ 1400 │ 1000 │ ICCBased 3 8 │ 63 KB │ FlateDecode
|
||||
│ 9 │ X9 │ image │ 2400 │ 1800 │ ICCBased 3 8 │ 194 KB │ DCTDecode
|
||||
│ 10 │ X10 │ image │ 120 │ 90 │ DeviceCMYK 4 8 │ 14 KB │ FlateDecode
|
||||
`
|
||||
|
||||
func TestParseImagesList(t *testing.T) {
|
||||
images := parseImagesList(sampleImagesList)
|
||||
|
||||
if len(images) != 4 {
|
||||
t.Fatalf("expected 4 images, got %d", len(images))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
index int
|
||||
obj int
|
||||
id string
|
||||
masked bool
|
||||
comp int
|
||||
filter string
|
||||
}{
|
||||
{0, 6, "X6", false, 3, "FlateDecode"},
|
||||
{1, 8, "X8", true, 3, "FlateDecode"},
|
||||
{2, 9, "X9", false, 3, "DCTDecode"},
|
||||
{3, 10, "X10", false, 4, "FlateDecode"},
|
||||
} {
|
||||
img := images[tc.index]
|
||||
if img.obj != tc.obj || img.id != tc.id || img.masked != tc.masked || img.comp != tc.comp || img.filter != tc.filter {
|
||||
t.Errorf("image %d = %+v, want obj=%d id=%s masked=%v comp=%d filter=%s",
|
||||
tc.index, img, tc.obj, tc.id, tc.masked, tc.comp, tc.filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHumanSize(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cell string
|
||||
want int64
|
||||
}{
|
||||
{"5.5 MB", int64(5.5 * (1 << 20))},
|
||||
{"194 KB", 194 << 10},
|
||||
{" 14 KB ", 14 << 10},
|
||||
{"512 B", 512},
|
||||
{"2 GB", 2 << 30},
|
||||
{"", 0},
|
||||
{"garbage", 0},
|
||||
} {
|
||||
if got := parseHumanSize(tc.cell); got != tc.want {
|
||||
t.Errorf("parseHumanSize(%q) = %d, want %d", tc.cell, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimizableImage(t *testing.T) {
|
||||
base := pdfcpuImage{obj: 1, id: "X1", masked: false, comp: 3, bytes: 1 << 20, filter: "FlateDecode"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
mutate func(pdfcpuImage) pdfcpuImage
|
||||
want bool
|
||||
}{
|
||||
{"lossless RGB above threshold", func(i pdfcpuImage) pdfcpuImage { return i }, true},
|
||||
{"already compressed", func(i pdfcpuImage) pdfcpuImage { i.filter = "DCTDecode"; return i }, false},
|
||||
{"CMYK", func(i pdfcpuImage) pdfcpuImage { i.comp = 4; return i }, false},
|
||||
{"masked", func(i pdfcpuImage) pdfcpuImage { i.masked = true; return i }, false},
|
||||
{"below threshold", func(i pdfcpuImage) pdfcpuImage { i.bytes = minOptimizeImageSize - 1; return i }, false},
|
||||
{"grayscale above threshold", func(i pdfcpuImage) pdfcpuImage { i.comp = 1; return i }, true},
|
||||
} {
|
||||
if got := optimizableImage(tc.mutate(base)); got != tc.want {
|
||||
t.Errorf("%s: optimizableImage = %v, want %v", tc.scenario, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type multiPdfEngines struct {
|
||||
splitEngines []gotenberg.PdfEngine
|
||||
flattenEngines []gotenberg.PdfEngine
|
||||
convertEngines []gotenberg.PdfEngine
|
||||
optimizeImagesEngines []gotenberg.PdfEngine
|
||||
readMetadataEngines []gotenberg.PdfEngine
|
||||
writeMetadataEngines []gotenberg.PdfEngine
|
||||
passwordEngines []gotenberg.PdfEngine
|
||||
@@ -36,6 +37,7 @@ func newMultiPdfEngines(
|
||||
splitEngines,
|
||||
flattenEngines,
|
||||
convertEngines,
|
||||
optimizeImagesEngines,
|
||||
readMetadataEngines,
|
||||
writeMetadataEngines,
|
||||
passwordEngines,
|
||||
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
|
||||
splitEngines: splitEngines,
|
||||
flattenEngines: flattenEngines,
|
||||
convertEngines: convertEngines,
|
||||
optimizeImagesEngines: optimizeImagesEngines,
|
||||
readMetadataEngines: readMetadataEngines,
|
||||
writeMetadataEngines: writeMetadataEngines,
|
||||
passwordEngines: passwordEngines,
|
||||
@@ -189,6 +192,17 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger,
|
||||
)
|
||||
}
|
||||
|
||||
// OptimizeImages re-encodes the images of a PDF using the first available
|
||||
// engine that supports image optimization.
|
||||
func (multi *multiPdfEngines) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||
return runWithFallbackVoid(ctx, "pdfengines.OptimizeImages", multi.optimizeImagesEngines,
|
||||
func(ctx context.Context, engine gotenberg.PdfEngine) error {
|
||||
return engine.OptimizeImages(ctx, logger, imageQuality, inputPath)
|
||||
},
|
||||
func(err error) error { return fmt.Errorf("optimize PDF images with multi PDF engines: %w", err) },
|
||||
)
|
||||
}
|
||||
|
||||
// Convert transforms the given PDF to a specific PDF format using the first
|
||||
// available engine that supports PDF conversion.
|
||||
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
|
||||
@@ -32,6 +32,7 @@ type PdfEngines struct {
|
||||
splitNames []string
|
||||
flattenNames []string
|
||||
convertNames []string
|
||||
optimizeImagesNames []string
|
||||
readMetadataNames []string
|
||||
writeMetadataNames []string
|
||||
encryptNames []string
|
||||
@@ -57,6 +58,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
|
||||
fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
|
||||
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
|
||||
fs.StringSlice("pdfengines-optimize-images-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the image optimization feature - empty means all")
|
||||
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
|
||||
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
|
||||
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
|
||||
@@ -91,6 +93,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
splitNames := flags.MustStringSlice("pdfengines-split-engines")
|
||||
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
|
||||
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
|
||||
optimizeImagesNames := flags.MustStringSlice("pdfengines-optimize-images-engines")
|
||||
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
|
||||
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
||||
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
|
||||
@@ -148,6 +151,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||
mod.convertNames = convertNames
|
||||
}
|
||||
|
||||
mod.optimizeImagesNames = defaultNames
|
||||
if len(optimizeImagesNames) > 0 {
|
||||
mod.optimizeImagesNames = optimizeImagesNames
|
||||
}
|
||||
|
||||
mod.readMetadataNames = defaultNames
|
||||
if len(readMetadataNames) > 0 {
|
||||
mod.readMetadataNames = readMetadataNames
|
||||
@@ -247,6 +255,7 @@ func (mod *PdfEngines) Validate() error {
|
||||
findNonExistingEngines(mod.mergeNames)
|
||||
findNonExistingEngines(mod.splitNames)
|
||||
findNonExistingEngines(mod.flattenNames)
|
||||
findNonExistingEngines(mod.optimizeImagesNames)
|
||||
findNonExistingEngines(mod.convertNames)
|
||||
findNonExistingEngines(mod.readMetadataNames)
|
||||
findNonExistingEngines(mod.writeMetadataNames)
|
||||
@@ -275,6 +284,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
||||
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
|
||||
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
|
||||
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")),
|
||||
fmt.Sprintf("optimize images engines - %s", strings.Join(mod.optimizeImagesNames, " ")),
|
||||
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames, " ")),
|
||||
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
|
||||
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
|
||||
@@ -310,6 +320,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
||||
engines(mod.splitNames),
|
||||
engines(mod.flattenNames),
|
||||
engines(mod.convertNames),
|
||||
engines(mod.optimizeImagesNames),
|
||||
engines(mod.readMetadataNames),
|
||||
engines(mod.writeMetadataNames),
|
||||
engines(mod.encryptNames),
|
||||
@@ -341,6 +352,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
||||
mergeRoute(engine),
|
||||
splitRoute(engine),
|
||||
flattenRoute(engine),
|
||||
optimizeRoute(engine),
|
||||
convertRoute(engine),
|
||||
readMetadataRoute(engine),
|
||||
writeMetadataRoute(engine),
|
||||
|
||||
@@ -344,6 +344,65 @@ func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultImageQuality is the JPEG quality applied by the image optimization
|
||||
// feature when the imageQuality form field is not set.
|
||||
const defaultImageQuality = 80
|
||||
|
||||
// FormDataPdfOptimize extracts the image-optimization options from the form
|
||||
// data: whether to optimize the images, and the JPEG quality (1 to 100) to
|
||||
// apply to each re-encoded image.
|
||||
func FormDataPdfOptimize(form *api.FormData) (bool, int) {
|
||||
var (
|
||||
optimizeImages bool
|
||||
imageQuality int
|
||||
)
|
||||
|
||||
form.
|
||||
Bool("optimizeImages", &optimizeImages, false).
|
||||
Custom("imageQuality", func(value string) error {
|
||||
if value == "" {
|
||||
imageQuality = defaultImageQuality
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if intValue < 1 {
|
||||
return errors.New("value is inferior to 1")
|
||||
}
|
||||
|
||||
if intValue > 100 {
|
||||
return errors.New("value is superior to 100")
|
||||
}
|
||||
|
||||
imageQuality = intValue
|
||||
return nil
|
||||
})
|
||||
|
||||
return optimizeImages, imageQuality
|
||||
}
|
||||
|
||||
// OptimizeStub re-encodes the images of each given PDF to shrink the file when
|
||||
// optimizeImages is set, leaving text, vectors and structure untouched. It does
|
||||
// nothing when optimizeImages is false.
|
||||
func OptimizeStub(ctx *api.Context, engine gotenberg.PdfEngine, optimizeImages bool, imageQuality int, inputPaths []string) error {
|
||||
if !optimizeImages {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, inputPath := range inputPaths {
|
||||
err := engine.OptimizeImages(ctx, ctx.Log(), imageQuality, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("optimize images of '%s': %w", inputPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertStub transforms a given PDF to the specified formats defined in
|
||||
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
|
||||
// paths.
|
||||
@@ -383,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
|
||||
@@ -749,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,33 +993,42 @@ 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)
|
||||
optimizeImages, imageQuality := FormDataPdfOptimize(form)
|
||||
|
||||
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)
|
||||
@@ -976,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)
|
||||
}
|
||||
@@ -998,6 +1076,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("optimize PDF images: %w", err)
|
||||
}
|
||||
|
||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||
|
||||
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
@@ -1013,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)
|
||||
@@ -1023,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)
|
||||
@@ -1031,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)
|
||||
@@ -1101,13 +1194,20 @@ 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)
|
||||
optimizeImages, imageQuality := FormDataPdfOptimize(form)
|
||||
|
||||
var inputPaths []string
|
||||
var flatten bool
|
||||
@@ -1119,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)
|
||||
@@ -1148,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)
|
||||
}
|
||||
@@ -1170,6 +1270,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("optimize PDF images: %w", err)
|
||||
}
|
||||
|
||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||
|
||||
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||
@@ -1262,6 +1367,42 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
|
||||
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
|
||||
// format.
|
||||
func optimizeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/optimize",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
// This route optimizes unconditionally, so the optimizeImages toggle
|
||||
// is ignored; only the image quality is read.
|
||||
_, imageQuality := FormDataPdfOptimize(form)
|
||||
|
||||
var inputPaths []string
|
||||
err := form.
|
||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = OptimizeStub(ctx, engine, true, imageQuality, inputPaths)
|
||||
if err != nil {
|
||||
return fmt.Errorf("optimize PDF images: %w", err)
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(inputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
@@ -1590,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)
|
||||
}
|
||||
@@ -1633,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)
|
||||
}
|
||||
|
||||
181
pkg/modules/pdfengines/stamp_test.go
Normal file
181
pkg/modules/pdfengines/stamp_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,20 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
|
||||
return err
|
||||
}
|
||||
|
||||
// OptimizeImages is not available in this implementation.
|
||||
func (engine *PdfTk) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.OptimizeImages",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
err := fmt.Errorf("optimize PDF images with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
||||
|
||||
@@ -255,6 +255,20 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go
|
||||
return err
|
||||
}
|
||||
|
||||
// OptimizeImages is not available in this implementation.
|
||||
func (engine *QPdf) OptimizeImages(ctx context.Context, logger *slog.Logger, imageQuality int, inputPath string) error {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.OptimizeImages",
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(engine.spanAttrs()...),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
err := fmt.Errorf("optimize PDF images with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
||||
@@ -454,7 +468,7 @@ func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger,
|
||||
return err
|
||||
}
|
||||
|
||||
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(ctx, logger, objects, metadata)
|
||||
if len(filespecRefs) == 0 {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
return nil
|
||||
@@ -506,7 +520,7 @@ func parsePdfObjects(output []byte) (map[string]json.RawMessage, error) {
|
||||
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
|
||||
// and returns the catalog reference, catalog value, filespec references,
|
||||
// and the update objects map.
|
||||
func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
|
||||
func patchFilespecMetadata(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
|
||||
updateObjects := make(map[string]any)
|
||||
var catalogRef string
|
||||
var catalogValue map[string]any
|
||||
@@ -556,7 +570,7 @@ func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessa
|
||||
if ef, ok := value["/EF"].(map[string]any); ok {
|
||||
efRef, _ := ef["/F"].(string)
|
||||
if efRef != "" {
|
||||
setStreamSubtype(logger, objects, updateObjects, efRef, mimeType)
|
||||
setStreamSubtype(ctx, logger, objects, updateObjects, efRef, mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,38 +667,38 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
|
||||
|
||||
// setStreamSubtype finds a stream object by reference and sets the /Subtype
|
||||
// key in its dict.
|
||||
func setStreamSubtype(logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
|
||||
func setStreamSubtype(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
|
||||
objKey := ref
|
||||
if !strings.HasPrefix(objKey, "obj:") {
|
||||
objKey = "obj:" + objKey
|
||||
}
|
||||
raw, ok := objects[objKey]
|
||||
if !ok {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: object not found", ref))
|
||||
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: object not found", ref))
|
||||
return
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
|
||||
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
|
||||
return
|
||||
}
|
||||
|
||||
streamRaw, ok := obj["stream"]
|
||||
if !ok {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: no stream key", ref))
|
||||
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: no stream key", ref))
|
||||
return
|
||||
}
|
||||
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
|
||||
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
|
||||
return
|
||||
}
|
||||
|
||||
dict, ok := stream["dict"].(map[string]any)
|
||||
if !ok {
|
||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
|
||||
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package qpdf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -99,7 +100,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
||||
"factur-x.xml": {"relationship": "Data"},
|
||||
}
|
||||
|
||||
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||
|
||||
if catalogRef != "obj:1 0 R" {
|
||||
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
|
||||
@@ -125,7 +126,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
||||
"factur-x.xml": {"relationship": "Data"},
|
||||
}
|
||||
|
||||
_, _, filespecRefs, _ := patchFilespecMetadata(logger, objects, metadata)
|
||||
_, _, filespecRefs, _ := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||
if len(filespecRefs) != 0 {
|
||||
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
||||
}
|
||||
@@ -139,7 +140,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
||||
"factur-x.xml": {"relationship": "Alternative"},
|
||||
}
|
||||
|
||||
_, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
_, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||
if len(filespecRefs) != 1 {
|
||||
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
||||
}
|
||||
@@ -158,7 +159,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
||||
"factur-x.xml": {"mimeType": "text/xml"},
|
||||
}
|
||||
|
||||
_, _, _, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
||||
_, _, _, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||
streamObj, ok := updateObjects["obj:3 0 R"]
|
||||
if !ok {
|
||||
t.Fatal("expected obj:3 0 R in updateObjects")
|
||||
@@ -223,7 +224,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
||||
}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||
|
||||
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
|
||||
dict := streamObj["dict"].(map[string]any)
|
||||
@@ -238,7 +239,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
||||
}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "5 0 R", "application/pdf")
|
||||
setStreamSubtype(context.Background(), logger, objects, updateObjects, "5 0 R", "application/pdf")
|
||||
|
||||
if _, ok := updateObjects["obj:5 0 R"]; !ok {
|
||||
t.Error("expected obj:5 0 R in updateObjects")
|
||||
@@ -249,7 +250,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
||||
objects := map[string]json.RawMessage{}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "obj:99 0 R", "text/xml")
|
||||
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:99 0 R", "text/xml")
|
||||
|
||||
if len(updateObjects) != 0 {
|
||||
t.Error("expected no updates for missing object")
|
||||
@@ -262,7 +263,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
||||
}
|
||||
updateObjects := make(map[string]any)
|
||||
|
||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||
|
||||
if len(updateObjects) != 0 {
|
||||
t.Error("expected no updates for non-stream object")
|
||||
|
||||
@@ -23,12 +23,12 @@ make test-integration PLATFORM=linux/arm64 # force a specific platform
|
||||
|
||||
Available tags:
|
||||
|
||||
| Group | Tags |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url` |
|
||||
| LibreOffice | `libreoffice`, `libreoffice-convert` |
|
||||
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
||||
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||
| Group | Tags |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` |
|
||||
| LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
|
||||
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-optimize`, `optimize`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
||||
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||
|
||||
## Writing a new test
|
||||
|
||||
|
||||
@@ -62,6 +62,24 @@ Feature: /forms/chromium/convert/html
|
||||
Page 12
|
||||
"""
|
||||
|
||||
# A wide table on one landscape page: the page must expand its width to the
|
||||
# content, so the rightmost column is not truncated and the page comes out
|
||||
# landscape instead of the narrow-tall strip the height-only expansion used
|
||||
# to produce. See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||
Scenario: POST /forms/chromium/convert/html (Single Page Landscape)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/wide-table-html/index.html | file |
|
||||
| singlePage | true | field |
|
||||
| landscape | true | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" PDF should have 1 page(s)
|
||||
Then the "foo.pdf" PDF should be set to landscape orientation
|
||||
Then the "foo.pdf" PDF should have content matching "Column 12" at page 1
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Landscape)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
@@ -195,6 +213,26 @@ Feature: /forms/chromium/convert/html
|
||||
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
||||
"""
|
||||
|
||||
# A thenable (async) expression is awaited and its resolved value gates the
|
||||
# print. Without awaiting, the Promise object never evaluates to true and the
|
||||
# request would time out. See https://github.com/gotenberg/gotenberg/pull/1617.
|
||||
Scenario: POST /forms/chromium/convert/html (Wait For Thenable Expression)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/feature-rich-html/index.html | file |
|
||||
| waitForExpression | (async () => window.globalVar === 'ready')() | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then there should be the following file(s) in the response:
|
||||
| foo.pdf |
|
||||
Then the "foo.pdf" PDF should have 1 page(s)
|
||||
Then the "foo.pdf" PDF should have the following content at page 1:
|
||||
"""
|
||||
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitForExpression)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
@@ -416,6 +454,87 @@ Feature: /forms/chromium/convert/html
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'file:///etc/passwd' matches the expression from the denied list |
|
||||
|
||||
# Control for the WebSocket scenario below. An ordinary fetch to a loopback
|
||||
# address is surfaced as a Fetch.requestPaused event, so it is blocked by
|
||||
# CHROMIUM_DENY_PRIVATE_IPS and the block is logged. The allow-list is
|
||||
# cleared because a matching allow-list entry bypasses the IP-based check.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/html (Fetch to a non-public address is filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/ssrf-fetch-html/index.html | file |
|
||||
| waitDelay | 1s | field |
|
||||
Then the response status code should be 200
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'http://127.0.0.1:9999/ssrf-fetch' targets a non-public address |
|
||||
|
||||
# A WebSocket handshake is never surfaced as a Fetch.requestPaused event, so
|
||||
# it escapes the filter in listenForEventRequestPaused. The page opens
|
||||
# WebSockets to two non-public addresses (loopback and the link-local cloud
|
||||
# metadata IP). listenForEventWebSocketCreated logs each disallowed handshake
|
||||
# with its full ws:// URL (detection), and the pinning proxy severs the
|
||||
# connection now that the implicit loopback bypass is removed (enforcement).
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/html (WebSocket to a non-public address is filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||
| waitDelay | 1s | field |
|
||||
Then the response status code should be 200
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address |
|
||||
| CONNECT blocked for '127.0.0.1:9999' |
|
||||
|
||||
# A Web Worker is a separate CDP target, so its WebSocket handshake is not
|
||||
# observed by listenForEventWebSocketCreated. Enforcement must not depend on
|
||||
# that listener: the pinning proxy sees the handshake and severs it whatever
|
||||
# the originating context. Only the proxy's block is asserted, since no
|
||||
# detection log is produced for the worker target.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/html (WebSocket from a Web Worker is filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/ssrf-websocket-worker-html/index.html | file |
|
||||
| waitDelay | 1s | field |
|
||||
Then the response status code should be 200
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| CONNECT blocked for '127.0.0.1:9999' |
|
||||
|
||||
# wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same path
|
||||
# as ws://, and must be filtered identically.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/html (Secure WebSocket to a non-public address is filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/ssrf-websocket-tls-html/index.html | file |
|
||||
| waitDelay | 1s | field |
|
||||
Then the response status code should be 200
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
|
||||
| CONNECT blocked for '127.0.0.1:9999' |
|
||||
|
||||
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
|
||||
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/html (EventSource to a non-public address is filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/ssrf-eventsource-html/index.html | file |
|
||||
| waitDelay | 1s | field |
|
||||
Then the response status code should be 200
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'http://127.0.0.1:9999/sse' targets a non-public address |
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
||||
@@ -1088,6 +1207,26 @@ Feature: /forms/chromium/convert/html
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the response PDF(s) should be flatten
|
||||
|
||||
# Post-processing image optimization re-encodes the embedded lossless image to
|
||||
# JPEG. The same page is ~700 KB without it and well under 300 KB with it.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/359.
|
||||
Scenario: POST /forms/chromium/convert/html (Optimize Images)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/optimize-image-html/index.html | file |
|
||||
| files | testdata/optimize-image-html/image.png | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" file size should be greater than 300 KB
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/optimize-image-html/index.html | file |
|
||||
| files | testdata/optimize-image-html/image.png | file |
|
||||
| optimizeImages | true | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then the "foo.pdf" file size should be less than 300 KB
|
||||
|
||||
@encrypt
|
||||
Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
|
||||
Given I have a default Gotenberg container
|
||||
|
||||
@@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url
|
||||
Page 1
|
||||
"""
|
||||
|
||||
# localStorage is per-origin and shared by the long-lived browser, so without
|
||||
# clearing it accumulates across same-origin conversions.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||
Scenario: POST /forms/chromium/convert/url (localStorage leaks without clearing)
|
||||
Given I have a default Gotenberg container
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1
|
||||
|
||||
# With --chromium-clear-storage every conversion clears the origin's
|
||||
# localStorage first, so the counter never carries over.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||
Scenario: POST /forms/chromium/convert/url (Clear Storage)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_CLEAR_STORAGE | true |
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||
|
||||
Scenario: POST /forms/chromium/convert/url (Single Page)
|
||||
Given I have a default Gotenberg container
|
||||
Given I have a static server
|
||||
@@ -499,6 +534,7 @@ Feature: /forms/chromium/convert/url
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
@@ -512,6 +548,55 @@ Feature: /forms/chromium/convert/url
|
||||
Forbidden
|
||||
"""
|
||||
|
||||
# IPv6 loopback literal is parsed as an IP and rejected by the IP-class check,
|
||||
# like the IPv4 loopback literal above.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/url (Main URL is an IPv6 loopback literal, deny-private-ips on)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://[::1]/ | field |
|
||||
Then the response status code should be 403
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
Forbidden
|
||||
"""
|
||||
|
||||
# An alternate IP encoding (decimal for 127.0.0.1) that Chromium would read
|
||||
# as loopback but the resolver rejects as a hostname. It must fail closed as
|
||||
# filtered (a generic 403), not surface as a 500.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/url (Main URL is a decimal-encoded loopback IP, deny-private-ips on)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://2130706433/ | field |
|
||||
Then the response status code should be 403
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
Forbidden
|
||||
"""
|
||||
|
||||
# A classic SSRF vector: an allow-listed URL that redirects to an internal
|
||||
# address. The redirected request must not inherit the initial URL's
|
||||
# allow-list pass. listenForEventRequestPaused re-validates it; it does not
|
||||
# match the allow-list, so it is blocked (Chromium reports ERR_ACCESS_DENIED
|
||||
# and the conversion renders the resulting error page).
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/url (Redirect to a non-allow-listed address is re-filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | ^https?://host.docker.internal.* |
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/redirect-to-private | field |
|
||||
Then the response status code should be 200
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'http://127.0.0.1:9999/redirected' does not match any expression from the allowed list |
|
||||
|
||||
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | .+ |
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
@chromium-screenshot-html
|
||||
Feature: /forms/chromium/screenshot/html
|
||||
|
||||
# Route parity: the WebSocket outbound filter lives in the shared browser
|
||||
# code path, so the screenshot route enforces it exactly like conversion.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/screenshot/html (WebSocket to a non-public address is filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||
| waitDelay | 1s | field |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "image/png"
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address |
|
||||
| CONNECT blocked for '127.0.0.1:9999' |
|
||||
|
||||
Scenario: POST /forms/chromium/screenshot/html (Default)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||
@@ -53,6 +69,32 @@ Feature: /forms/chromium/screenshot/html
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "image/png"
|
||||
|
||||
# The target element is 300x200 and sits below a spacer, so a correct clip
|
||||
# proves both the element size and its page offset. See issue #947.
|
||||
Scenario: POST /forms/chromium/screenshot/html (Selector)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/screenshot-selector-html/index.html | file |
|
||||
| selector | #target | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "image/png"
|
||||
Then the "foo.png" image should be 300x200 pixels
|
||||
# A centered red pixel proves the clip landed on the element, not on the
|
||||
# white spacer above it, i.e. the page offset was applied.
|
||||
Then the "foo.png" image pixel at 150,100 should be "#ff0000"
|
||||
|
||||
Scenario: POST /forms/chromium/screenshot/html (Selector Not Found)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/screenshot-selector-html/index.html | file |
|
||||
| selector | #does-not-exist | field |
|
||||
Then the response status code should be 400
|
||||
Then the response body should contain string:
|
||||
"""
|
||||
The selector '#does-not-exist' (selector) matched no element with a visible box
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||
|
||||
@@ -83,6 +83,7 @@ Feature: /debug
|
||||
"chromium-auto-start": "false",
|
||||
"chromium-clear-cache": "false",
|
||||
"chromium-clear-cookies": "false",
|
||||
"chromium-clear-storage": "false",
|
||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||
"chromium-deny-private-ips": "false",
|
||||
"chromium-deny-public-ips": "false",
|
||||
@@ -221,6 +222,7 @@ Feature: /debug
|
||||
"chromium-auto-start": "false",
|
||||
"chromium-clear-cache": "false",
|
||||
"chromium-clear-cookies": "false",
|
||||
"chromium-clear-storage": "false",
|
||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||
"chromium-deny-private-ips": "false",
|
||||
"chromium-deny-public-ips": "false",
|
||||
|
||||
@@ -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)
|
||||
@@ -942,7 +969,7 @@ Feature: /forms/libreoffice/convert
|
||||
|
||||
# An embedded image is stored inside the document, not linked, so blocking
|
||||
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
||||
@libreoffice-linked-content
|
||||
@libreoffice-ssrf
|
||||
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
@@ -955,7 +982,7 @@ Feature: /forms/libreoffice/convert
|
||||
# An uploaded document always loads from an untrusted location, so soffice
|
||||
# refuses to resolve any content it links (absolute file:// path or external
|
||||
# URL). Closes the SSRF and local-file-read vector.
|
||||
@libreoffice-linked-content
|
||||
@libreoffice-ssrf
|
||||
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
@@ -964,3 +991,17 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response status code should be 200
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" PDF should have 0 image(s)
|
||||
|
||||
# The workbook was saved scrolled down to row 37. Without the topLeftCell
|
||||
# reset, SinglePageSheets would start the page there and drop the header rows,
|
||||
# so the "Meteor" column header only appears when the whole sheet is rendered.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||
Scenario: POST /forms/libreoffice/convert (SinglePageSheets renders a scrolled workbook in full)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/singlepagesheets-scrolled.xlsx | file |
|
||||
| singlePageSheets | true | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" PDF should have content matching "Meteor" at page 1
|
||||
|
||||
@@ -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
|
||||
|
||||
61
test/integration/features/pdfengines_optimize.feature
Normal file
61
test/integration/features/pdfengines_optimize.feature
Normal file
@@ -0,0 +1,61 @@
|
||||
@pdfengines
|
||||
@pdfengines-optimize
|
||||
@optimize
|
||||
Feature: /forms/pdfengines/optimize
|
||||
|
||||
# image-heavy.pdf is a ~710 KB PDF whose single image Chromium embedded
|
||||
# losslessly (FlateDecode). Re-encoding it to JPEG shrinks the file well
|
||||
# below this threshold while leaving the structure intact.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/359.
|
||||
Scenario: POST /forms/pdfengines/optimize (Image-heavy PDF)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||
| files | testdata/image-heavy.pdf | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "foo.pdf" file size should be less than 300 KB
|
||||
|
||||
Scenario: POST /forms/pdfengines/optimize (Custom Image Quality)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||
| files | testdata/image-heavy.pdf | file |
|
||||
| imageQuality | 40 | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" file size should be less than 300 KB
|
||||
|
||||
Scenario: POST /forms/pdfengines/optimize (PDF Without Images)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
|
||||
Scenario: POST /forms/pdfengines/optimize (Invalid Image Quality)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||
| files | testdata/image-heavy.pdf | file |
|
||||
| imageQuality | 200 | field |
|
||||
Then the response status code should be 400
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
|
||||
Scenario: POST /forms/pdfengines/optimize (Bad Request)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 400
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
Invalid form data: no form file found for extensions: [.pdf]
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/pdfengines/optimize (Routes Disabled)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| PDFENGINES_DISABLE_ROUTES | true |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/optimize" endpoint with the following form data and header(s):
|
||||
| files | testdata/image-heavy.pdf | file |
|
||||
Then the response status code should be 404
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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/ |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png" // Register the PNG decoder for image.DecodeConfig.
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
@@ -206,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)
|
||||
|
||||
@@ -218,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 == "" {
|
||||
@@ -361,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)
|
||||
|
||||
@@ -372,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 {
|
||||
@@ -1010,6 +1012,74 @@ func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, imag
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) theImageShouldBePixels(_ context.Context, name string, width, height int) error {
|
||||
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||
|
||||
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||
if err != nil {
|
||||
return fmt.Errorf("open image %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
config, format, err := image.DecodeConfig(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image %q: %w", path, err)
|
||||
}
|
||||
|
||||
if config.Width != width || config.Height != height {
|
||||
return fmt.Errorf("expected %s image %dx%d, but actual is %dx%d", format, width, height, config.Width, config.Height)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) theImagePixelShouldBe(_ context.Context, name string, x, y int, want string) error {
|
||||
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||
|
||||
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||
if err != nil {
|
||||
return fmt.Errorf("open image %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
img, format, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image %q: %w", path, err)
|
||||
}
|
||||
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
// RGBA returns 16-bit channels; shift down to the 8-bit hex form.
|
||||
got := fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||
if !strings.EqualFold(got, want) {
|
||||
return fmt.Errorf("expected %s pixel at %d,%d to be %s, but actual is %s", format, x, y, want, got)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) theFileSizeShouldBe(_ context.Context, name, comparator string, sizeKB int) error {
|
||||
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %q: %w", path, err)
|
||||
}
|
||||
|
||||
limit := int64(sizeKB) * 1024
|
||||
switch comparator {
|
||||
case "less":
|
||||
if info.Size() >= limit {
|
||||
return fmt.Errorf("expected %s (%d bytes) to be smaller than %d KB", name, info.Size(), sizeKB)
|
||||
}
|
||||
case "greater":
|
||||
if info.Size() <= limit {
|
||||
return fmt.Errorf("expected %s (%d bytes) to be larger than %d KB", name, info.Size(), sizeKB)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
||||
var path string
|
||||
if !strings.HasPrefix(name, "*_") {
|
||||
@@ -1047,7 +1117,9 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
||||
}
|
||||
|
||||
output = strings.ReplaceAll(output, " ", "")
|
||||
re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`)
|
||||
// The dimensions can be fractional (e.g. a content-sized single page),
|
||||
// so match floats, not just integers.
|
||||
re := regexp.MustCompile(`Pagesize:([\d.]+)x([\d.]+)`)
|
||||
matches := re.FindStringSubmatch(output)
|
||||
|
||||
if len(matches) < 3 {
|
||||
@@ -1056,22 +1128,22 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
||||
|
||||
invert := kind == "should NOT"
|
||||
|
||||
width, err := strconv.Atoi(matches[1])
|
||||
width, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert width value %q to integer: %w", matches[1], err)
|
||||
return fmt.Errorf("convert width value %q to float: %w", matches[1], err)
|
||||
}
|
||||
|
||||
height, err := strconv.Atoi(matches[2])
|
||||
height, err := strconv.ParseFloat(matches[2], 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert height value %q to integer: %w", matches[2], err)
|
||||
return fmt.Errorf("convert height value %q to float: %w", matches[2], err)
|
||||
}
|
||||
|
||||
if invert && height < width {
|
||||
return fmt.Errorf("expected height %d to be greater than width %d", height, width)
|
||||
return fmt.Errorf("expected height %g to be greater than width %g", height, width)
|
||||
}
|
||||
|
||||
if !invert && width < height {
|
||||
return fmt.Errorf("expected width %d to be greater than height %d", width, height)
|
||||
return fmt.Errorf("expected width %g to be greater than height %g", width, height)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1599,6 +1671,9 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
||||
ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels)
|
||||
ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe)
|
||||
ctx.Then(`^the "([^"]*)" file size should be (less|greater) than (\d+) KB$`, s.theFileSizeShouldBe)
|
||||
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
||||
if s.gotenbergContainer != nil {
|
||||
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
||||
|
||||
@@ -181,6 +181,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
}
|
||||
return c.HTML(http.StatusOK, string(b))
|
||||
})
|
||||
srv.GET("/redirect-to-private", func(c echo.Context) error {
|
||||
s.req = c.Request()
|
||||
// Redirect the browser to a non-public address so the outbound filter
|
||||
// is exercised on the redirected request rather than on this URL.
|
||||
return c.Redirect(http.StatusFound, "http://127.0.0.1:9999/redirected")
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
BIN
test/integration/testdata/image-heavy.pdf
vendored
Normal file
BIN
test/integration/testdata/image-heavy.pdf
vendored
Normal file
Binary file not shown.
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Local storage counter</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Increments a per-origin localStorage counter and renders it, so a value
|
||||
above 1 means a previous conversion of the same origin leaked into this
|
||||
one. See https://github.com/gotenberg/gotenberg/issues/919. -->
|
||||
<pre id="out"></pre>
|
||||
<script>
|
||||
var n = (parseInt(localStorage.getItem("n"), 10) || 0) + 1;
|
||||
localStorage.setItem("n", n);
|
||||
document.getElementById("out").textContent = "localStorageCount=" + n;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
test/integration/testdata/optimize-image-html/image.png
vendored
Normal file
BIN
test/integration/testdata/optimize-image-html/image.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
22
test/integration/testdata/optimize-image-html/index.html
vendored
Normal file
22
test/integration/testdata/optimize-image-html/index.html
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Image-heavy page</title>
|
||||
<style>
|
||||
@page {
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="image.png" />
|
||||
</body>
|
||||
</html>
|
||||
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Element screenshot</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: #ffffff;
|
||||
}
|
||||
/* A spacer pushes the target away from the origin so the clip has to
|
||||
account for the element offset, not just its size. */
|
||||
.spacer {
|
||||
height: 120px;
|
||||
}
|
||||
#target {
|
||||
width: 300px;
|
||||
height: 200px;
|
||||
background: #ff0000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="spacer"></div>
|
||||
<div id="target"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/slideshow.ppsm
vendored
Normal file
BIN
test/integration/testdata/slideshow.ppsm
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/slideshow.ppsx
vendored
Normal file
BIN
test/integration/testdata/slideshow.ppsx
vendored
Normal file
Binary file not shown.
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>EventSource SSRF</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>EventSource SSRF</h1>
|
||||
<script type="application/javascript">
|
||||
// EventSource issues an ordinary HTTP GET, so it is surfaced as a
|
||||
// Fetch.requestPaused event and blocked by the outbound filter.
|
||||
new EventSource("http://127.0.0.1:9999/sse");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Fetch SSRF Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Fetch SSRF Control</h1>
|
||||
<script type="application/javascript">
|
||||
// Control: an ordinary fetch to a loopback address IS surfaced as a
|
||||
// Fetch.requestPaused event and blocked by CHROMIUM_DENY_PRIVATE_IPS.
|
||||
fetch("http://127.0.0.1:9999/ssrf-fetch").catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
20
test/integration/testdata/ssrf-websocket-html/index.html
vendored
Normal file
20
test/integration/testdata/ssrf-websocket-html/index.html
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>WebSocket SSRF</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebSocket SSRF</h1>
|
||||
<script type="application/javascript">
|
||||
// Both targets resolve to non-public addresses, so CHROMIUM_DENY_PRIVATE_IPS
|
||||
// must block them. Unlike fetch/XHR/sub-resources, a WebSocket handshake is
|
||||
// never surfaced as a Fetch.requestPaused event, so it currently escapes the
|
||||
// outbound filter entirely.
|
||||
// 127.0.0.1 -> loopback
|
||||
// 169.254.169.254 -> link-local (cloud metadata)
|
||||
new WebSocket("ws://127.0.0.1:9999/ssrf-websocket");
|
||||
new WebSocket("ws://169.254.169.254:80/ssrf-websocket");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
test/integration/testdata/ssrf-websocket-tls-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-websocket-tls-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>WSS SSRF</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WSS SSRF</h1>
|
||||
<script type="application/javascript">
|
||||
// wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same
|
||||
// path as ws://, and must be filtered identically.
|
||||
new WebSocket("wss://127.0.0.1:9999/wss-test");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
test/integration/testdata/ssrf-websocket-worker-html/index.html
vendored
Normal file
22
test/integration/testdata/ssrf-websocket-worker-html/index.html
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Worker WebSocket SSRF</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Worker WebSocket SSRF</h1>
|
||||
<script type="application/javascript">
|
||||
// A Web Worker is a separate CDP target, so its WebSocket handshake is
|
||||
// not observed by listenForEventWebSocketCreated (which listens on the
|
||||
// page target). Enforcement must therefore not rely on that listener:
|
||||
// the pinning proxy sees every handshake regardless of the originating
|
||||
// context and severs the connection.
|
||||
const source =
|
||||
'new WebSocket("ws://127.0.0.1:9999/ws-from-worker");' +
|
||||
'new WebSocket("ws://169.254.169.254:80/ws-from-worker-metadata");';
|
||||
const blob = new Blob([source], { type: "application/javascript" });
|
||||
new Worker(URL.createObjectURL(blob));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
test/integration/testdata/titled_alpha.pdf
vendored
Normal file
BIN
test/integration/testdata/titled_alpha.pdf
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/titled_bravo.pdf
vendored
Normal file
BIN
test/integration/testdata/titled_bravo.pdf
vendored
Normal file
Binary file not shown.
BIN
test/integration/testdata/untitled_gamma.pdf
vendored
Normal file
BIN
test/integration/testdata/untitled_gamma.pdf
vendored
Normal file
Binary file not shown.
43
test/integration/testdata/wide-table-html/index.html
vendored
Normal file
43
test/integration/testdata/wide-table-html/index.html
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Wide table</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
border: 1px solid #000;
|
||||
padding: 8px 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- A single wide row: much wider than tall, so a correct single page is
|
||||
landscape, and the rightmost column only survives if the width was
|
||||
expanded to the content instead of truncated. -->
|
||||
<table>
|
||||
<tr>
|
||||
<td>Column 1</td>
|
||||
<td>Column 2</td>
|
||||
<td>Column 3</td>
|
||||
<td>Column 4</td>
|
||||
<td>Column 5</td>
|
||||
<td>Column 6</td>
|
||||
<td>Column 7</td>
|
||||
<td>Column 8</td>
|
||||
<td>Column 9</td>
|
||||
<td>Column 10</td>
|
||||
<td>Column 11</td>
|
||||
<td>Column 12</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user