mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-17 17:53:17 +01:00
Compare commits
24 Commits
v8.35.0
...
b3c06fb8ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ body:multipart-form {
|
|||||||
~splitUnify: false
|
~splitUnify: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ body:multipart-form {
|
|||||||
~autoIndexBookmarks: false
|
~autoIndexBookmarks: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
|
||||||
~userPassword:
|
~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
|
~flatten: false
|
||||||
~pdfa: PDF/A-1b
|
~pdfa: PDF/A-1b
|
||||||
~pdfua: true
|
~pdfua: true
|
||||||
|
~optimizeImages: false
|
||||||
|
~imageQuality: 80
|
||||||
~metadata: {"Author":"Bruno","Title":"Test"}
|
~metadata: {"Author":"Bruno","Title":"Test"}
|
||||||
~userPassword:
|
~userPassword:
|
||||||
~ownerPassword:
|
~ownerPassword:
|
||||||
|
|||||||
2
.github/workflows/continuous-integration.yml
vendored
2
.github/workflows/continuous-integration.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.12.2
|
||||||
|
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
name: Lint non-Golang codebase
|
name: Lint non-Golang codebase
|
||||||
|
|||||||
7
Makefile
7
Makefile
@@ -82,6 +82,7 @@ PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
|||||||
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
|
||||||
PDFENGINES_FLATTEN_ENGINES=qpdf
|
PDFENGINES_FLATTEN_ENGINES=qpdf
|
||||||
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||||
|
PDFENGINES_OPTIMIZE_IMAGES_ENGINES=pdfcpu
|
||||||
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
|
||||||
@@ -90,7 +91,7 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
|
|||||||
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
|
||||||
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
|
PDFENGINES_EMBED_ENGINES=pdfcpu
|
||||||
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
|
||||||
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
PDFENGINES_FACTUR_X_ENGINES=qpdf
|
||||||
PROMETHEUS_NAMESPACE=gotenberg
|
PROMETHEUS_NAMESPACE=gotenberg
|
||||||
@@ -147,10 +148,12 @@ NO_CONCURRENCY=false
|
|||||||
# chromium-screenshot-html
|
# chromium-screenshot-html
|
||||||
# chromium-screenshot-markdown
|
# chromium-screenshot-markdown
|
||||||
# chromium-screenshot-url
|
# chromium-screenshot-url
|
||||||
|
# chromium-ssrf
|
||||||
# debug
|
# debug
|
||||||
# health
|
# health
|
||||||
# libreoffice
|
# libreoffice
|
||||||
# libreoffice-convert
|
# libreoffice-convert
|
||||||
|
# libreoffice-ssrf
|
||||||
# output-filename
|
# output-filename
|
||||||
# pdfengines
|
# pdfengines
|
||||||
# pdfengines-convert
|
# pdfengines-convert
|
||||||
@@ -160,6 +163,8 @@ NO_CONCURRENCY=false
|
|||||||
# encrypt
|
# encrypt
|
||||||
# pdfengines-flatten
|
# pdfengines-flatten
|
||||||
# flatten
|
# flatten
|
||||||
|
# pdfengines-optimize
|
||||||
|
# optimize
|
||||||
# pdfengines-merge
|
# pdfengines-merge
|
||||||
# merge
|
# merge
|
||||||
# pdfengines-metadata
|
# pdfengines-metadata
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.5
|
|||||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||||
|
|
||||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||||
ARG PDFCPU_VERSION=v0.13.0
|
ARG PDFCPU_VERSION=v0.15.0
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ services:
|
|||||||
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
|
||||||
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
|
||||||
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
|
||||||
|
- "--pdfengines-optimize-images-engines=${PDFENGINES_OPTIMIZE_IMAGES_ENGINES}"
|
||||||
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
|
||||||
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
|
||||||
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
- "--pdfengines-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}"
|
||||||
|
|||||||
94
go.mod
94
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/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/cucumber/godog v0.16.0
|
||||||
github.com/dlclark/regexp2 v1.12.0
|
github.com/dlclark/regexp2 v1.12.0
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
|
github.com/gomarkdown/markdown v0.0.0-20260725000948-8435af3f5984
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||||
github.com/labstack/echo/v4 v4.15.4
|
github.com/labstack/echo/v4 v4.15.4
|
||||||
@@ -21,20 +21,20 @@ require (
|
|||||||
github.com/shirou/gopsutil/v4 v4.26.7
|
github.com/shirou/gopsutil/v4 v4.26.7
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/testcontainers/testcontainers-go v0.43.0
|
github.com/testcontainers/testcontainers-go v0.44.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0
|
||||||
go.opentelemetry.io/otel v1.45.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/metric v1.45.0
|
||||||
go.opentelemetry.io/otel/sdk 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/sdk/metric v1.45.0
|
||||||
go.opentelemetry.io/otel/trace 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/sync v0.22.0
|
||||||
golang.org/x/term v0.45.0
|
golang.org/x/term v0.45.0
|
||||||
golang.org/x/text v0.40.0
|
golang.org/x/text v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -42,11 +42,11 @@ require (
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bodgit/plumbing v1.3.0 // 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/bodgit/windows v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
@@ -57,16 +57,16 @@ require (
|
|||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v0.2.1 // indirect
|
github.com/containerd/platforms v0.2.1 // indirect
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||||
github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
|
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
|
||||||
github.com/cucumber/messages/go/v34 v34.2.0 // indirect
|
github.com/cucumber/messages/go/v34 v34.2.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.7.0 // indirect
|
github.com/docker/go-connections v0.8.1 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||||
github.com/ebitengine/purego v0.10.2 // indirect
|
github.com/ebitengine/purego v0.10.2 // indirect
|
||||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||||
github.com/go-logr/logr v1.4.4 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
@@ -74,70 +74,70 @@ require (
|
|||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/gobwas/ws v1.4.0 // indirect
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.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-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/magiconair/properties v1.8.10 // indirect
|
github.com/magiconair/properties v1.18.11 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // 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/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/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/patternmatcher v0.6.1 // indirect
|
||||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.1 // indirect
|
||||||
github.com/moby/sys/userns v0.1.0 // indirect
|
github.com/moby/sys/userns v0.2.0 // indirect
|
||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.27 // indirect
|
github.com/pierrec/lz4/v4 v4.1.28 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.70.1 // indirect
|
github.com/prometheus/common v0.70.1 // indirect
|
||||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||||
github.com/prometheus/procfs v0.21.1 // 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/sorairolake/lzip-go v0.3.8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
github.com/stangelandcl/ppmd v0.1.1 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.16 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
|
go.opentelemetry.io/otel/exporters/prometheus v0.67.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/crypto v0.54.0 // indirect
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 // indirect
|
||||||
google.golang.org/grpc v1.82.1 // indirect
|
google.golang.org/grpc v1.83.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
196
go.sum
196
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/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 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
|
||||||
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
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.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
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.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng=
|
||||||
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
|
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 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
@@ -46,12 +46,12 @@ github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GK
|
|||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
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.1 h1:ao9TVJmBb8uNLEcjMDFbhsoL3yC7gMpRLHgP1ZGfFWA=
|
||||||
github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
|
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 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
|
||||||
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
|
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.1 h1:qBPEl+HhNJuRX8Kjaw1Pm60KOODZf2/4WQAi0SR/neE=
|
||||||
github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
github.com/cucumber/messages/go/v34 v34.2.1/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -59,8 +59,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/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 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||||
@@ -72,8 +72,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
|||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
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-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/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.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 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -90,8 +90,8 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
|||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
github.com/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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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-20260725000948-8435af3f5984 h1:6DE2PprLLZelQJu7AeebZLxjcENwC1plRZHDvd5uMCU=
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
@@ -99,8 +99,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
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 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
@@ -121,8 +121,8 @@ github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG
|
|||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
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/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
@@ -138,56 +138,60 @@ github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c
|
|||||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
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-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
github.com/magiconair/properties v1.18.11 h1:j5ozYZl0zCjG7ahMDH0GWIobOvvUzT0BdAguG0ViKy0=
|
||||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
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 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
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 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
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 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
||||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
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.2.0 h1:6IOBuiHg04QxvbFfgFLT/9sMaO/UhL7S+ApW1mK8q5A=
|
||||||
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
|
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 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
||||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
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 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
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 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
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 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||||
|
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
|
||||||
|
github.com/moby/sys/mount v0.3.5/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 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||||
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
|
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.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
|
||||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
|
||||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
github.com/moby/sys/userns v0.2.0 h1:nEtDtp7NCV/6dutSklNe8FrENPwFdc4mXnZqC/JWgXM=
|
||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
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 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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.3.0 h1:CtgyxWm8ClLcSh1u4M58fOz6lmeb/j4V7KpaEi/6UtM=
|
||||||
github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
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 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
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 h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
|
||||||
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||||
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/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
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_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
@@ -202,8 +206,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
|||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc=
|
||||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
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 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||||
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
@@ -222,15 +226,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
|
||||||
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
|
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
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 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
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.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.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
@@ -241,66 +245,66 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0 h1:oEl2Pw/i4OQwhAuda2pAHFAcOMivA+Xa+iTccBfab/g=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.0/go.mod h1:yMSQaiiq5dpfrSJCYLBcqFeJkFFI67seT4ngvx6jfVo=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0 h1:qU2CqTGdlstwoVhu1WfjJJ3z2ntcNjTJO0ksTsFKzPI=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.70.0/go.mod h1:Ekh3I2XXfhdWkqbRq4PrivJS4BS/se7Er9ZsbK6YEtQ=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0 h1:wpCLEJ/4RHUadR11UOdznbmyyih5/OPYFcsehAh6PYI=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.70.0/go.mod h1:x7MbNOwoKV5Hj6uYMXQksHlQdTNOP3hoFPvqWISiu6s=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
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 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
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.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
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.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.21.0 h1:fvNHGyo3CdRv/DQveXqhqBxnKTDyRaC5sMSQxilX/A0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
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.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
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.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
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.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 h1:pnxy6c/kvNBWdNNFzqpjuJLm9Hjhgk/Q0nY221rwuk0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0/go.mod h1:qw6YsFapotRwoDhXRZvljzaOvCQB7UfnafEJagpN2TA=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
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.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E=
|
||||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0 h1:2lpf4hnrasYIsUyEXwnTZq5lsxrMm4T2Bwb06IctAZQ=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.21.0/go.mod h1:YWOW6h7jwApz9Pl76ie/izUsSPj0s2MdIlpqbPqaf3U=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0 h1:dm9iyzn6tioYZtwqaiBSU0TSI8Yu/8dTIbfG0+B49DY=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.45.0/go.mod h1:xAvxYjYK28qvt+yu4BYZ/zMmAjwMXINXD6JiMyeB8iI=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
|
||||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA=
|
||||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
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 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
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 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||||
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
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 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
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.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
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 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
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 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
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.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -312,21 +316,21 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
|||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/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 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
|
google.golang.org/genproto/googleapis/api v0.0.0-20260810153831-ec0a7760b754 h1:dWeMvEJ3JhYgqSCAHUZZJgMUyfniiiCvDc72x5EqJP0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
|
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-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754 h1:k5CJw9e5ONCcA/u0webKt092npXuY+KeGh3Q8NAVf0g=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260810153831-ec0a7760b754/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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": {
|
"devDependencies": {
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^4.0.0",
|
||||||
"prettier-plugin-sh": "^0.19.0"
|
"prettier-plugin-sh": "^0.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cucumber/gherkin": {
|
"node_modules/@cucumber/gherkin": {
|
||||||
"version": "32.2.0",
|
"version": "39.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-39.1.0.tgz",
|
||||||
"integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==",
|
"integrity": "sha512-pqmSO2bUWxJm3TbNrKXlDaHjL6c77+ez9kWmfCd9oRPeTRPEVH3spZvpAqdXYWOZYSNYwWFCAAeZ4RGpkauNoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cucumber/messages": ">=19.1.4 <28"
|
"@cucumber/messages": ">=31.0.0 <33"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cucumber/messages": {
|
"node_modules/@cucumber/messages": {
|
||||||
"version": "27.2.0",
|
"version": "32.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-32.3.1.tgz",
|
||||||
"integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==",
|
"integrity": "sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/uuid": "10.0.0",
|
|
||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2"
|
||||||
"uuid": "11.0.5"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reteps/dockerfmt": {
|
"node_modules/@reteps/dockerfmt": {
|
||||||
@@ -108,13 +106,6 @@
|
|||||||
"linux"
|
"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": {
|
"node_modules/class-transformer": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
@@ -139,14 +130,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier-plugin-gherkin": {
|
"node_modules/prettier-plugin-gherkin": {
|
||||||
"version": "3.1.3",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prettier-plugin-gherkin/-/prettier-plugin-gherkin-4.0.0.tgz",
|
||||||
"integrity": "sha512-w9uB413NlSi8ZQwpexyu+ttriJJ88eZLV0x88ZTkzkLZyHYEX5wrNtaCx/yFYviIu/tuwsBqDPM47VODnIV/hw==",
|
"integrity": "sha512-EBDwV1Ou9rG+seoh7jcwZ6sXXyHwsbUks5TeSyrlrLaTdz+/qYvnHuwY8rk7mb6/+M1ZGuY4/SiVc68ybwvJOA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cucumber/gherkin": "^32.0.0",
|
"@cucumber/gherkin": "^39.1.0",
|
||||||
"@cucumber/messages": "^27.2.0",
|
"@cucumber/messages": "^32.3.1",
|
||||||
"prettier": "^3.5.3"
|
"prettier": "^3.5.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -189,20 +180,6 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/sh-syntax"
|
"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": {
|
"devDependencies": {
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"prettier-plugin-gherkin": "^3.1.3",
|
"prettier-plugin-gherkin": "^4.0.0",
|
||||||
"prettier-plugin-sh": "^0.19.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)
|
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
|
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
|
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)
|
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)
|
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
|
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)
|
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) {
|
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
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)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||||
case errors.Is(err, ErrPublicIP):
|
case errors.Is(err, ErrPublicIP):
|
||||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
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)
|
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) {
|
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||||
|
|||||||
@@ -281,6 +281,12 @@ type PdfEngine interface {
|
|||||||
// PdfFormats. If no format, it does nothing.
|
// PdfFormats. If no format, it does nothing.
|
||||||
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
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 extracts the metadata of a given PDF file.
|
||||||
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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.
|
// This will ensure we do not exceed the body limit.
|
||||||
var formValuesSize int64
|
var formValuesSize int64
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// Concurrent downloadFrom entries must not race on the shared maps
|
||||||
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||||
// to catch the data race; without -race a sufficient number of entries
|
// to catch the data race; without -race a sufficient number of entries
|
||||||
|
|||||||
@@ -86,13 +86,37 @@ func ParseError(err error) (int, string) {
|
|||||||
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
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,
|
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
||||||
// returns a response as "text/plain; charset=UTF-8".
|
// returns a response as "text/plain; charset=UTF-8".
|
||||||
func httpErrorHandler() echo.HTTPErrorHandler {
|
func httpErrorHandler() echo.HTTPErrorHandler {
|
||||||
return func(err error, c echo.Context) {
|
return func(err error, c echo.Context) {
|
||||||
logger := c.Get("logger").(*slog.Logger)
|
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)
|
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
||||||
|
|
||||||
err = c.String(status, message)
|
err = c.String(status, message)
|
||||||
@@ -263,9 +287,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
finishTime := time.Now()
|
finishTime := time.Now()
|
||||||
|
|
||||||
status := c.Response().Status
|
status := c.Response().Status
|
||||||
|
canceled := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
parsedStatus, _ := ParseError(err)
|
canceled = requestCanceled(c, err)
|
||||||
status = parsedStatus
|
if canceled {
|
||||||
|
status = statusClientClosedRequest
|
||||||
|
} else {
|
||||||
|
parsedStatus, _ := ParseError(err)
|
||||||
|
status = parsedStatus
|
||||||
|
}
|
||||||
|
|
||||||
span.SetAttributes(attribute.String("error", err.Error()))
|
span.SetAttributes(attribute.String("error", err.Error()))
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -293,10 +323,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
||||||
With(slog.Int64("bytes_out", c.Response().Size))
|
With(slog.Int64("bytes_out", c.Response().Size))
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
accessLogger.ErrorContext(ctx, err.Error())
|
case err == nil:
|
||||||
} else {
|
|
||||||
accessLogger.InfoContext(ctx, "request handled")
|
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{
|
additionalAttributes := []attribute.KeyValue{
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,6 +14,70 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"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
|
// TestOutputFilenameMiddleware pins the sanitizing of the
|
||||||
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
||||||
// a Content-Disposition header, so a path separator must never survive it.
|
// a Content-Disposition header, so a path separator must never survive it.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type browserArguments struct {
|
|||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
clearCache bool
|
clearCache bool
|
||||||
clearCookies bool
|
clearCookies bool
|
||||||
|
clearStorage bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +180,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
return fmt.Errorf("start pinning proxy: %w", err)
|
return fmt.Errorf("start pinning proxy: %w", err)
|
||||||
}
|
}
|
||||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
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.
|
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||||
@@ -200,7 +223,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
if stopErr != nil {
|
if stopErr != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
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()
|
b.ctxMu.Lock()
|
||||||
@@ -345,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -371,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -434,6 +459,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
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 (
|
var (
|
||||||
invalidHttpStatusCode error
|
invalidHttpStatusCode error
|
||||||
invalidHttpStatusCodeMu sync.RWMutex
|
invalidHttpStatusCodeMu sync.RWMutex
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ var (
|
|||||||
// or undefined.
|
// or undefined.
|
||||||
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
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
|
// ErrRpccMessageTooLarge happens when the messages received by
|
||||||
// ChromeDevTools are larger than 100 MB.
|
// ChromeDevTools are larger than 100 MB.
|
||||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||||
@@ -347,6 +351,11 @@ type ScreenshotOptions struct {
|
|||||||
// dimensions.
|
// dimensions.
|
||||||
Clip bool
|
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
|
// Format is the image compression format, either "png" or "jpeg" or
|
||||||
// "webp".
|
// "webp".
|
||||||
Format string
|
Format string
|
||||||
@@ -370,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Clip: false,
|
Clip: false,
|
||||||
|
Selector: "",
|
||||||
Format: "png",
|
Format: "png",
|
||||||
Quality: 100,
|
Quality: 100,
|
||||||
OptimizeForSpeed: false,
|
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-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-cache", false, "Clear Chromium cache between each conversion")
|
||||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies 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-javascript", false, "Disable JavaScript")
|
||||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
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"),
|
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||||
|
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
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 {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
var (
|
var (
|
||||||
width, height int
|
width, height int
|
||||||
clip bool
|
clip bool
|
||||||
|
selector string
|
||||||
format string
|
format string
|
||||||
quality int
|
quality int
|
||||||
optimizeForSpeed bool
|
optimizeForSpeed bool
|
||||||
@@ -376,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Int("width", &width, defaultScreenshotOptions.Width).
|
Int("width", &width, defaultScreenshotOptions.Width).
|
||||||
Int("height", &height, defaultScreenshotOptions.Height).
|
Int("height", &height, defaultScreenshotOptions.Height).
|
||||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||||
|
String("selector", &selector, defaultScreenshotOptions.Selector).
|
||||||
Custom("format", func(value string) error {
|
Custom("format", func(value string) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
format = defaultScreenshotOptions.Format
|
format = defaultScreenshotOptions.Format
|
||||||
@@ -420,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
Clip: clip,
|
Clip: clip,
|
||||||
|
Selector: selector,
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
OptimizeForSpeed: optimizeForSpeed,
|
OptimizeForSpeed: optimizeForSpeed,
|
||||||
@@ -476,6 +479,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
@@ -501,7 +505,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate stamp: %w", err)
|
return fmt.Errorf("validate stamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -565,6 +569,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
@@ -587,7 +592,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -648,6 +653,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
stamp := pdfengines.FormDataPdfStamp(form, false)
|
stamp := pdfengines.FormDataPdfStamp(form, false)
|
||||||
stampFile := pdfengines.FormDataPdfStampFile(form)
|
stampFile := pdfengines.FormDataPdfStampFile(form)
|
||||||
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
|
||||||
@@ -679,7 +685,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
options.AllowedFilePrefixes = []string{ctx.DirPath()}
|
||||||
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages)
|
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages, optimizeImages, imageQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -804,7 +810,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
|||||||
return fmt.Sprintf("file://%s", inputPath), nil
|
return fmt.Sprintf("file://%s", inputPath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
|
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string, optimizeImages bool, imageQuality int) error {
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
// See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
filename := ctx.OutputFilename(outputPath)
|
filename := ctx.OutputFilename(outputPath)
|
||||||
@@ -901,6 +907,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
return fmt.Errorf("rotate PDFs: %w", err)
|
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)
|
pdfFormats = pdfengines.FacturXPdfFormats(ctx, engine, facturX, pdfFormats, true, nil)
|
||||||
|
|
||||||
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
@@ -963,7 +974,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
|||||||
outputPath := ctx.GeneratePath(ext)
|
outputPath := ctx.GeneratePath(ext)
|
||||||
|
|
||||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("screenshot: %w", err)
|
return fmt.Errorf("screenshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/emulation"
|
"github.com/chromedp/cdproto/emulation"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/cdproto/runtime"
|
||||||
|
"github.com/chromedp/cdproto/storage"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
@@ -54,6 +58,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := func() error {
|
err := func() error {
|
||||||
|
paperWidth := options.PaperWidth
|
||||||
paperHeight := options.PaperHeight
|
paperHeight := options.PaperHeight
|
||||||
pageRanges := options.PageRanges
|
pageRanges := options.PageRanges
|
||||||
|
|
||||||
@@ -67,9 +72,20 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
|
|
||||||
// There are 96 CSS pixels per inch.
|
// There are 96 CSS pixels per inch.
|
||||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||||
// We add top and bottom margins so that the content area
|
if options.Landscape {
|
||||||
// is large enough to fit the entire content.
|
// Landscape swaps the paper dimensions, so the page is
|
||||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
// 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.
|
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +94,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
WithLandscape(options.Landscape).
|
WithLandscape(options.Landscape).
|
||||||
WithPrintBackground(options.PrintBackground).
|
WithPrintBackground(options.PrintBackground).
|
||||||
WithScale(options.Scale).
|
WithScale(options.Scale).
|
||||||
WithPaperWidth(options.PaperWidth).
|
WithPaperWidth(paperWidth).
|
||||||
WithPaperHeight(paperHeight).
|
WithPaperHeight(paperHeight).
|
||||||
WithMarginTop(options.MarginTop).
|
WithMarginTop(options.MarginTop).
|
||||||
WithMarginBottom(options.MarginBottom).
|
WithMarginBottom(options.MarginBottom).
|
||||||
@@ -188,7 +204,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
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{
|
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||||
Width: float64(options.Width),
|
Width: float64(options.Width),
|
||||||
Height: float64(options.Height),
|
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 {
|
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger.DebugContext(ctx, "set device metrics override")
|
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 {
|
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
// 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())
|
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
var ok bool
|
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)
|
err := evaluate.Do(ctx)
|
||||||
if err != nil {
|
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)
|
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) {
|
switch val := value.(type) {
|
||||||
case string:
|
case string:
|
||||||
if err := validateMetadataValue(key, val); err != nil {
|
if err := validateMetadataValue(key, val); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, val))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, val))
|
||||||
case []string:
|
case []string:
|
||||||
for _, s := range val {
|
for _, s := range val {
|
||||||
if err := validateMetadataValue(key, s); err != nil {
|
if err := validateMetadataValue(key, s); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||||
}
|
}
|
||||||
case []any:
|
case []any:
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1048.
|
// 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 {
|
if err := validateMetadataValue(key, s); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
args = append(args, fmt.Sprintf("-%s=%s", key, s))
|
args = append(args, fmt.Sprintf("-%s=%s", tag, s))
|
||||||
}
|
}
|
||||||
case bool:
|
case bool:
|
||||||
args = append(args, fmt.Sprintf("-%s=%t", key, val))
|
args = append(args, fmt.Sprintf("-%s=%t", tag, val))
|
||||||
case int:
|
case int:
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||||
case int64:
|
case int64:
|
||||||
args = append(args, fmt.Sprintf("-%s=%d", key, val))
|
args = append(args, fmt.Sprintf("-%s=%d", tag, val))
|
||||||
case float32:
|
case float32:
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||||
case float64:
|
case float64:
|
||||||
args = append(args, fmt.Sprintf("-%s=%g", key, val))
|
args = append(args, fmt.Sprintf("-%s=%g", tag, val))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
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
|
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
|
// ReadMetadata extracts the metadata of a given PDF file by invoking
|
||||||
// the exiftool binary with "-j" (JSON output) and parsing the result.
|
// 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) {
|
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",
|
".potx",
|
||||||
".ppm",
|
".ppm",
|
||||||
".pps",
|
".pps",
|
||||||
|
".ppsm",
|
||||||
|
".ppsx",
|
||||||
".ppt",
|
".ppt",
|
||||||
".pptm",
|
".pptm",
|
||||||
".pptx",
|
".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")
|
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{
|
args := []string{
|
||||||
"--no-launch",
|
"--no-launch",
|
||||||
"--format",
|
"--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)
|
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
|
// 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/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]
|
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := pdfengines.FormDataPdfFacturX(form)
|
||||||
|
optimizeImages, imageQuality := pdfengines.FormDataPdfOptimize(form)
|
||||||
|
|
||||||
zeroValuedSplitMode := gotenberg.SplitMode{}
|
zeroValuedSplitMode := gotenberg.SplitMode{}
|
||||||
|
|
||||||
@@ -515,6 +516,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = pdfengines.OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
needsConvertStub := !nativePdfFormats ||
|
needsConvertStub := !nativePdfFormats ||
|
||||||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
|
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
|
||||||
(nativePdfFormats && hasPostProcessing)
|
(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
|
splitEngines []gotenberg.PdfEngine
|
||||||
flattenEngines []gotenberg.PdfEngine
|
flattenEngines []gotenberg.PdfEngine
|
||||||
convertEngines []gotenberg.PdfEngine
|
convertEngines []gotenberg.PdfEngine
|
||||||
|
optimizeImagesEngines []gotenberg.PdfEngine
|
||||||
readMetadataEngines []gotenberg.PdfEngine
|
readMetadataEngines []gotenberg.PdfEngine
|
||||||
writeMetadataEngines []gotenberg.PdfEngine
|
writeMetadataEngines []gotenberg.PdfEngine
|
||||||
passwordEngines []gotenberg.PdfEngine
|
passwordEngines []gotenberg.PdfEngine
|
||||||
@@ -36,6 +37,7 @@ func newMultiPdfEngines(
|
|||||||
splitEngines,
|
splitEngines,
|
||||||
flattenEngines,
|
flattenEngines,
|
||||||
convertEngines,
|
convertEngines,
|
||||||
|
optimizeImagesEngines,
|
||||||
readMetadataEngines,
|
readMetadataEngines,
|
||||||
writeMetadataEngines,
|
writeMetadataEngines,
|
||||||
passwordEngines,
|
passwordEngines,
|
||||||
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
|
|||||||
splitEngines: splitEngines,
|
splitEngines: splitEngines,
|
||||||
flattenEngines: flattenEngines,
|
flattenEngines: flattenEngines,
|
||||||
convertEngines: convertEngines,
|
convertEngines: convertEngines,
|
||||||
|
optimizeImagesEngines: optimizeImagesEngines,
|
||||||
readMetadataEngines: readMetadataEngines,
|
readMetadataEngines: readMetadataEngines,
|
||||||
writeMetadataEngines: writeMetadataEngines,
|
writeMetadataEngines: writeMetadataEngines,
|
||||||
passwordEngines: passwordEngines,
|
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
|
// Convert transforms the given PDF to a specific PDF format using the first
|
||||||
// available engine that supports PDF conversion.
|
// available engine that supports PDF conversion.
|
||||||
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
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
|
splitNames []string
|
||||||
flattenNames []string
|
flattenNames []string
|
||||||
convertNames []string
|
convertNames []string
|
||||||
|
optimizeImagesNames []string
|
||||||
readMetadataNames []string
|
readMetadataNames []string
|
||||||
writeMetadataNames []string
|
writeMetadataNames []string
|
||||||
encryptNames []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-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-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-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-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-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
|
||||||
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
|
fs.StringSlice("pdfengines-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")
|
splitNames := flags.MustStringSlice("pdfengines-split-engines")
|
||||||
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
|
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
|
||||||
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
|
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
|
||||||
|
optimizeImagesNames := flags.MustStringSlice("pdfengines-optimize-images-engines")
|
||||||
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
|
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
|
||||||
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
||||||
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
|
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
|
||||||
@@ -148,6 +151,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
mod.convertNames = convertNames
|
mod.convertNames = convertNames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod.optimizeImagesNames = defaultNames
|
||||||
|
if len(optimizeImagesNames) > 0 {
|
||||||
|
mod.optimizeImagesNames = optimizeImagesNames
|
||||||
|
}
|
||||||
|
|
||||||
mod.readMetadataNames = defaultNames
|
mod.readMetadataNames = defaultNames
|
||||||
if len(readMetadataNames) > 0 {
|
if len(readMetadataNames) > 0 {
|
||||||
mod.readMetadataNames = readMetadataNames
|
mod.readMetadataNames = readMetadataNames
|
||||||
@@ -247,6 +255,7 @@ func (mod *PdfEngines) Validate() error {
|
|||||||
findNonExistingEngines(mod.mergeNames)
|
findNonExistingEngines(mod.mergeNames)
|
||||||
findNonExistingEngines(mod.splitNames)
|
findNonExistingEngines(mod.splitNames)
|
||||||
findNonExistingEngines(mod.flattenNames)
|
findNonExistingEngines(mod.flattenNames)
|
||||||
|
findNonExistingEngines(mod.optimizeImagesNames)
|
||||||
findNonExistingEngines(mod.convertNames)
|
findNonExistingEngines(mod.convertNames)
|
||||||
findNonExistingEngines(mod.readMetadataNames)
|
findNonExistingEngines(mod.readMetadataNames)
|
||||||
findNonExistingEngines(mod.writeMetadataNames)
|
findNonExistingEngines(mod.writeMetadataNames)
|
||||||
@@ -275,6 +284,7 @@ func (mod *PdfEngines) SystemMessages() []string {
|
|||||||
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
|
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
|
||||||
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
|
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
|
||||||
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")),
|
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("read metadata engines - %s", strings.Join(mod.readMetadataNames, " ")),
|
||||||
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
|
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
|
||||||
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
|
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.splitNames),
|
||||||
engines(mod.flattenNames),
|
engines(mod.flattenNames),
|
||||||
engines(mod.convertNames),
|
engines(mod.convertNames),
|
||||||
|
engines(mod.optimizeImagesNames),
|
||||||
engines(mod.readMetadataNames),
|
engines(mod.readMetadataNames),
|
||||||
engines(mod.writeMetadataNames),
|
engines(mod.writeMetadataNames),
|
||||||
engines(mod.encryptNames),
|
engines(mod.encryptNames),
|
||||||
@@ -341,6 +352,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
|||||||
mergeRoute(engine),
|
mergeRoute(engine),
|
||||||
splitRoute(engine),
|
splitRoute(engine),
|
||||||
flattenRoute(engine),
|
flattenRoute(engine),
|
||||||
|
optimizeRoute(engine),
|
||||||
convertRoute(engine),
|
convertRoute(engine),
|
||||||
readMetadataRoute(engine),
|
readMetadataRoute(engine),
|
||||||
writeMetadataRoute(engine),
|
writeMetadataRoute(engine),
|
||||||
|
|||||||
@@ -344,6 +344,65 @@ func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []stri
|
|||||||
return nil
|
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
|
// ConvertStub transforms a given PDF to the specified formats defined in
|
||||||
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
|
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
|
||||||
// paths.
|
// paths.
|
||||||
@@ -931,6 +990,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
optimizeImages, imageQuality := FormDataPdfOptimize(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -998,6 +1058,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||||
|
|
||||||
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
@@ -1108,6 +1173,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
angle, rotatePages := FormDataPdfRotate(form, false)
|
angle, rotatePages := FormDataPdfRotate(form, false)
|
||||||
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
embedsMetadata := FormDataPdfEmbedsMetadata(form)
|
||||||
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
facturX, facturxXmlPath := FormDataPdfFacturX(form)
|
||||||
|
optimizeImages, imageQuality := FormDataPdfOptimize(form)
|
||||||
|
|
||||||
var inputPaths []string
|
var inputPaths []string
|
||||||
var flatten bool
|
var flatten bool
|
||||||
@@ -1170,6 +1236,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = OptimizeStub(ctx, engine, optimizeImages, imageQuality, outputPaths)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("optimize PDF images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
pdfFormats = FacturXPdfFormats(ctx, engine, facturX, pdfFormats, false, outputPaths)
|
||||||
|
|
||||||
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
|
||||||
@@ -1262,6 +1333,42 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
|
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
|
||||||
// format.
|
// 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 {
|
func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||||
return api.Route{
|
return api.Route{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
|
|||||||
@@ -219,6 +219,20 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g
|
|||||||
return err
|
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.
|
// ReadMetadata is not available in this implementation.
|
||||||
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
|
_, 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
|
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.
|
// ReadMetadata is not available in this implementation.
|
||||||
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||||
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
|
||||||
@@ -454,7 +468,7 @@ func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(ctx, logger, objects, metadata)
|
||||||
if len(filespecRefs) == 0 {
|
if len(filespecRefs) == 0 {
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
return nil
|
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
|
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
|
||||||
// and returns the catalog reference, catalog value, filespec references,
|
// and returns the catalog reference, catalog value, filespec references,
|
||||||
// and the update objects map.
|
// 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)
|
updateObjects := make(map[string]any)
|
||||||
var catalogRef string
|
var catalogRef string
|
||||||
var catalogValue map[string]any
|
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 {
|
if ef, ok := value["/EF"].(map[string]any); ok {
|
||||||
efRef, _ := ef["/F"].(string)
|
efRef, _ := ef["/F"].(string)
|
||||||
if efRef != "" {
|
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
|
// setStreamSubtype finds a stream object by reference and sets the /Subtype
|
||||||
// key in its dict.
|
// 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
|
objKey := ref
|
||||||
if !strings.HasPrefix(objKey, "obj:") {
|
if !strings.HasPrefix(objKey, "obj:") {
|
||||||
objKey = "obj:" + objKey
|
objKey = "obj:" + objKey
|
||||||
}
|
}
|
||||||
raw, ok := objects[objKey]
|
raw, ok := objects[objKey]
|
||||||
if !ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var obj map[string]json.RawMessage
|
var obj map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
streamRaw, ok := obj["stream"]
|
streamRaw, ok := obj["stream"]
|
||||||
if !ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dict, ok := stream["dict"].(map[string]any)
|
dict, ok := stream["dict"].(map[string]any)
|
||||||
if !ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package qpdf
|
package qpdf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -99,7 +100,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Data"},
|
"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" {
|
if catalogRef != "obj:1 0 R" {
|
||||||
t.Errorf("catalogRef = %q, want %q", 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"},
|
"factur-x.xml": {"relationship": "Data"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, filespecRefs, _ := patchFilespecMetadata(logger, objects, metadata)
|
_, _, filespecRefs, _ := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
if len(filespecRefs) != 0 {
|
if len(filespecRefs) != 0 {
|
||||||
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Alternative"},
|
"factur-x.xml": {"relationship": "Alternative"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
_, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
if len(filespecRefs) != 1 {
|
if len(filespecRefs) != 1 {
|
||||||
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"mimeType": "text/xml"},
|
"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"]
|
streamObj, ok := updateObjects["obj:3 0 R"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected obj:3 0 R in updateObjects")
|
t.Fatal("expected obj:3 0 R in updateObjects")
|
||||||
@@ -223,7 +224,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
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)
|
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
|
||||||
dict := streamObj["dict"].(map[string]any)
|
dict := streamObj["dict"].(map[string]any)
|
||||||
@@ -238,7 +239,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
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 {
|
if _, ok := updateObjects["obj:5 0 R"]; !ok {
|
||||||
t.Error("expected obj:5 0 R in updateObjects")
|
t.Error("expected obj:5 0 R in updateObjects")
|
||||||
@@ -249,7 +250,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
objects := map[string]json.RawMessage{}
|
objects := map[string]json.RawMessage{}
|
||||||
updateObjects := make(map[string]any)
|
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 {
|
if len(updateObjects) != 0 {
|
||||||
t.Error("expected no updates for missing object")
|
t.Error("expected no updates for missing object")
|
||||||
@@ -262,7 +263,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
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 {
|
if len(updateObjects) != 0 {
|
||||||
t.Error("expected no updates for non-stream object")
|
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:
|
Available tags:
|
||||||
|
|
||||||
| Group | Tags |
|
| Group | Tags |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url` |
|
| Chromium | `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 | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
|
||||||
| 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` |
|
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-optimize`, `optimize`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
||||||
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||||
|
|
||||||
## Writing a new test
|
## Writing a new test
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ Feature: /forms/chromium/convert/html
|
|||||||
Page 12
|
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)
|
Scenario: POST /forms/chromium/convert/html (Landscape)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -195,6 +213,26 @@ Feature: /forms/chromium/convert/html
|
|||||||
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
|
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)
|
Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitForExpression)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -416,6 +454,87 @@ Feature: /forms/chromium/convert/html
|
|||||||
Then the Gotenberg container should log the following entries:
|
Then the Gotenberg container should log the following entries:
|
||||||
| 'file:///etc/passwd' matches the expression from the denied list |
|
| '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)
|
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):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
| 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 there should be 1 PDF(s) in the response
|
||||||
Then the response PDF(s) should be flatten
|
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
|
@encrypt
|
||||||
Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
|
Scenario: POST /forms/chromium/convert/html (Encrypt - user password only)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
@@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url
|
|||||||
Page 1
|
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)
|
Scenario: POST /forms/chromium/convert/url (Single Page)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
Given I have a static server
|
Given I have a static server
|
||||||
@@ -499,6 +534,7 @@ Feature: /forms/chromium/convert/url
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
|
@chromium-ssrf
|
||||||
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
|
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):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | |
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
@@ -512,6 +548,55 @@ Feature: /forms/chromium/convert/url
|
|||||||
Forbidden
|
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)
|
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):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | .+ |
|
| CHROMIUM_ALLOW_LIST | .+ |
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
@chromium-screenshot-html
|
@chromium-screenshot-html
|
||||||
Feature: /forms/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)
|
Scenario: POST /forms/chromium/screenshot/html (Default)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
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 status code should be 200
|
||||||
Then the response header "Content-Type" should be "image/png"
|
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)
|
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
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-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
@@ -221,6 +222,7 @@ Feature: /debug
|
|||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ Feature: /forms/libreoffice/convert
|
|||||||
Page 1
|
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
|
# 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
|
# 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.
|
# stored under a UUID-based filename, so the UUID must not leak into the PDF.
|
||||||
@@ -942,7 +969,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
|
|
||||||
# An embedded image is stored inside the document, not linked, so blocking
|
# An embedded image is stored inside the document, not linked, so blocking
|
||||||
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
||||||
@libreoffice-linked-content
|
@libreoffice-ssrf
|
||||||
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
@@ -955,7 +982,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
# An uploaded document always loads from an untrusted location, so soffice
|
# An uploaded document always loads from an untrusted location, so soffice
|
||||||
# refuses to resolve any content it links (absolute file:// path or external
|
# refuses to resolve any content it links (absolute file:// path or external
|
||||||
# URL). Closes the SSRF and local-file-read vector.
|
# URL). Closes the SSRF and local-file-read vector.
|
||||||
@libreoffice-linked-content
|
@libreoffice-ssrf
|
||||||
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
@@ -964,3 +991,17 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
Then the "foo.pdf" PDF should have 0 image(s)
|
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
|
||||||
|
|||||||
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
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/png" // Register the PNG decoder for image.DecodeConfig.
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -1010,6 +1012,74 @@ func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, imag
|
|||||||
return nil
|
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 {
|
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
||||||
var path string
|
var path string
|
||||||
if !strings.HasPrefix(name, "*_") {
|
if !strings.HasPrefix(name, "*_") {
|
||||||
@@ -1047,7 +1117,9 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
output = strings.ReplaceAll(output, " ", "")
|
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)
|
matches := re.FindStringSubmatch(output)
|
||||||
|
|
||||||
if len(matches) < 3 {
|
if len(matches) < 3 {
|
||||||
@@ -1056,22 +1128,22 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
|
|
||||||
invert := kind == "should NOT"
|
invert := kind == "should NOT"
|
||||||
|
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.ParseFloat(matches[1], 64)
|
||||||
if err != nil {
|
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 {
|
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 {
|
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 {
|
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
|
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 the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
||||||
|
ctx.Then(`^the "([^"]*)" 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) {
|
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
||||||
if s.gotenbergContainer != nil {
|
if s.gotenbergContainer != nil {
|
||||||
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
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))
|
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
|
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>
|
||||||
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