mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
17 Commits
337a5cca64
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec7be4687 | ||
|
|
de7f335791 | ||
|
|
815f586315 | ||
|
|
b71df026f6 | ||
|
|
8d29638b74 | ||
|
|
63c9a36599 | ||
|
|
31fa392db2 | ||
|
|
bb0b874d16 | ||
|
|
60f5a7b996 | ||
|
|
8b559eb699 | ||
|
|
50e8e44bc1 | ||
|
|
b243283c9b | ||
|
|
a0d5e93ced | ||
|
|
7568fc379b | ||
|
|
5b21dbf1a9 | ||
|
|
0a62359691 | ||
|
|
7418b5cab3 |
@@ -121,20 +121,28 @@ Enforced by `gci`: standard library, then third-party, then `github.com/gotenber
|
||||
Every exported type and function has a Godoc comment starting with its identifier name:
|
||||
|
||||
```go
|
||||
// Violation records a single rule violation with context.
|
||||
type Violation struct { ... }
|
||||
// OutboundDecision is the result of validating an outbound URL via
|
||||
// [DecideOutbound]. ...
|
||||
type OutboundDecision struct { ... }
|
||||
|
||||
// ValidatePDFA audits the document against a PDF/A profile.
|
||||
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
|
||||
// DialPinned dials each addr in turn until one connects, returning the
|
||||
// first successful connection or the last error. ...
|
||||
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
|
||||
```
|
||||
|
||||
Each package should have a `doc.go` with a `// Package foo ...` comment.
|
||||
Each package should have a `doc.go` with a `// Package foo ...` comment:
|
||||
|
||||
```go
|
||||
// Package api manages a LibreOffice instance via the UNO API.
|
||||
package api
|
||||
```
|
||||
|
||||
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
||||
|
||||
```go
|
||||
// ValidatePDFA returns violations as []error where each element
|
||||
// is a [Violation] value. See [Rule] for the structured fields.
|
||||
// Callers pass the Pinned slice from [OutboundDecision] so that the dial
|
||||
// targets exactly the IPs that [DecideOutbound] resolved, preventing DNS
|
||||
// rebinding between validation and connect.
|
||||
```
|
||||
|
||||
### Code comments
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ARG instructions do not create additional layers. Instead, next layers will
|
||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||
# stage that uses them.
|
||||
ARG GOLANG_VERSION=1.26.2
|
||||
ARG GOLANG_VERSION=1.26.5
|
||||
|
||||
# ----------------------------------------------
|
||||
# pdfcpu binary build stage
|
||||
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.2
|
||||
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||
|
||||
# See https://github.com/pdfcpu/pdfcpu/releases.
|
||||
ARG PDFCPU_VERSION=v0.12.0
|
||||
ARG PDFCPU_VERSION=v0.13.0
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
|
||||
|
||||
WORKDIR /downloads
|
||||
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.0/unoconv -o unoconverter \
|
||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
|
||||
&& chmod +x unoconverter
|
||||
|
||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
||||
|
||||
33
go.mod
33
go.mod
@@ -1,12 +1,12 @@
|
||||
module github.com/gotenberg/gotenberg/v8
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.1
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
|
||||
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.15.1
|
||||
github.com/cucumber/godog v0.16.0
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -16,21 +16,21 @@ require (
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/moby/moby/api v1.55.0
|
||||
github.com/moby/moby/client v0.5.0
|
||||
github.com/prometheus/client_golang v1.24.0
|
||||
github.com/shirou/gopsutil/v4 v4.26.6
|
||||
github.com/moby/moby/client v0.5.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.43.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel v1.45.0
|
||||
go.opentelemetry.io/otel/log v0.20.0
|
||||
go.opentelemetry.io/otel/metric v1.44.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/metric v1.45.0
|
||||
go.opentelemetry.io/otel/sdk v1.45.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0
|
||||
go.opentelemetry.io/otel/trace v1.45.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
@@ -57,23 +57,22 @@ require (
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||
github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
|
||||
github.com/cucumber/messages/go/v34 v34.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
@@ -81,7 +80,7 @@ require (
|
||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
@@ -104,7 +103,7 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.0 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
|
||||
81
go.sum
81
go.sum
@@ -44,16 +44,14 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
|
||||
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
|
||||
github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8=
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
|
||||
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
|
||||
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
|
||||
github.com/cucumber/gherkin/go/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
|
||||
github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
|
||||
github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
|
||||
github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
|
||||
github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
|
||||
github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -68,8 +66,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
@@ -77,8 +75,8 @@ github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
|
||||
github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
@@ -90,10 +88,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
|
||||
@@ -111,10 +105,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n
|
||||
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/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
|
||||
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
|
||||
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
@@ -128,18 +120,14 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
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/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -172,8 +160,8 @@ github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
|
||||
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
|
||||
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
|
||||
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
|
||||
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
|
||||
@@ -200,30 +188,26 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
||||
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
|
||||
@@ -236,7 +220,6 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
|
||||
@@ -266,8 +249,8 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0ax
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||
@@ -292,20 +275,20 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -5,7 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.5",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
}
|
||||
@@ -123,9 +123,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
|
||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.5",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-gherkin": "^3.1.3",
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
}
|
||||
|
||||
@@ -17,13 +17,18 @@ import (
|
||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||
)
|
||||
|
||||
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
||||
// meter, and logger providers. Detection is best-effort: a detector or merge
|
||||
// failure is logged and the build proceeds with whatever was gathered, so a
|
||||
// flaky environment never prevents telemetry from starting.
|
||||
//
|
||||
// The semconv version imported here must match the one the SDK resource
|
||||
// detectors use (go.opentelemetry.io/otel/sdk/resource). Drift makes
|
||||
// [resource.Merge] fail with [resource.ErrSchemaURLConflict] and strips the
|
||||
// schema URL off every exported signal.
|
||||
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
||||
base := resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
@@ -55,9 +60,14 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic
|
||||
return base
|
||||
}
|
||||
|
||||
// A schema URL conflict still yields a resource holding every attribute, only
|
||||
// without a schema URL. Keep it: falling back to base would drop the host,
|
||||
// OS, container, process, and OTEL_RESOURCE_ATTRIBUTES data.
|
||||
merged, err := resource.Merge(detected, base)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||
}
|
||||
if merged == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||
)
|
||||
|
||||
func TestBuildResource(t *testing.T) {
|
||||
@@ -24,6 +24,13 @@ func TestBuildResource(t *testing.T) {
|
||||
values[string(kv.Key)] = kv.Value.AsString()
|
||||
}
|
||||
|
||||
// Guards the semconv version pinned in buildResource against the one the SDK
|
||||
// resource detectors use. Drift makes resource.Merge conflict and drops the
|
||||
// schema URL from every exported signal.
|
||||
if res.SchemaURL() != semconv.SchemaURL {
|
||||
t.Errorf("resource schema URL = %q, want %q", res.SchemaURL(), semconv.SchemaURL)
|
||||
}
|
||||
|
||||
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
||||
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -187,6 +188,12 @@ type OutboundDecision struct {
|
||||
// is stored.
|
||||
type outboundDecisionKey struct{}
|
||||
|
||||
// outboundProxiedKey is the context key under which [outboundRoundTripper]
|
||||
// records that the environment proxy will carry this request, so that the
|
||||
// dialer knows the address it receives is the proxy's rather than the
|
||||
// destination's.
|
||||
type outboundProxiedKey struct{}
|
||||
|
||||
// decideConfig carries optional settings for [DecideOutbound] and
|
||||
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||
type decideConfig struct {
|
||||
@@ -364,6 +371,10 @@ type outboundRoundTripper struct {
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
opts []DecideOption
|
||||
|
||||
// proxyFunc mirrors the transport's own proxy resolution. It is nil unless
|
||||
// the environment proxy is enabled.
|
||||
proxyFunc func(*url.URL) (*url.URL, error)
|
||||
}
|
||||
|
||||
// RoundTrip validates req.URL and delegates to the base transport.
|
||||
@@ -379,6 +390,18 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
||||
|
||||
// A request the proxy will not carry is dialed directly, so it still gets
|
||||
// pinned. Without this, enabling the environment proxy would silently drop
|
||||
// DNS-rebinding protection for every NO_PROXY host, and for all traffic
|
||||
// when no proxy variable is set at all.
|
||||
if rt.proxyFunc != nil {
|
||||
proxyURL, proxyErr := rt.proxyFunc(req.URL)
|
||||
if proxyErr == nil && proxyURL != nil {
|
||||
ctx = context.WithValue(ctx, outboundProxiedKey{}, true)
|
||||
}
|
||||
}
|
||||
|
||||
return rt.base.RoundTrip(req.WithContext(ctx))
|
||||
}
|
||||
|
||||
@@ -396,24 +419,34 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
||||
//
|
||||
// When enableEnvironmentProxy is true, the client routes through the proxy
|
||||
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
|
||||
// including any credentials embedded in those URLs. In that mode the proxy
|
||||
// owns DNS and egress, so destination dial pinning does not apply; the URL
|
||||
// allow/deny and IP-class validation still runs. Callers gate this behind
|
||||
// their module's opt-in flag. See
|
||||
// including any credentials embedded in those URLs. Dial pinning does not apply
|
||||
// to a hop the proxy carries, since the proxy owns DNS and egress there; a hop
|
||||
// the proxy declines, such as a NO_PROXY host, is dialed directly and stays
|
||||
// pinned. The URL allow/deny and IP-class validation runs either way. Callers
|
||||
// gate this behind their module's opt-in flag. See
|
||||
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
var proxyFunc func(*url.URL) (*url.URL, error)
|
||||
|
||||
if enableEnvironmentProxy {
|
||||
// Route through the operator's proxy (standard env vars, credentials
|
||||
// included). NO_PROXY hosts get a direct, unpinned dial.
|
||||
// httpproxy.FromEnvironment reads the environment now rather than
|
||||
// caching it process-wide like http.ProxyFromEnvironment.
|
||||
proxyFunc := httpproxy.FromEnvironment().ProxyFunc()
|
||||
// included). httpproxy.FromEnvironment reads the environment now rather
|
||||
// than caching it process-wide like http.ProxyFromEnvironment.
|
||||
proxyFunc = httpproxy.FromEnvironment().ProxyFunc()
|
||||
base.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
return proxyFunc(req.URL)
|
||||
}
|
||||
base.DialContext = outboundDialer.DialContext
|
||||
// Only a hop the proxy actually carries skips pinning: there the dial
|
||||
// targets the proxy, not the destination, and the proxy owns DNS. A hop
|
||||
// the proxy declines is dialed directly and stays pinned.
|
||||
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if proxied, _ := ctx.Value(outboundProxiedKey{}).(bool); proxied {
|
||||
return outboundDialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
return secureDialContext(ctx, network, addr)
|
||||
}
|
||||
} else {
|
||||
// Default: ignore any proxy environment variables and pin the dial to
|
||||
// the IPs resolved during validation, closing the DNS-rebinding
|
||||
@@ -431,10 +464,60 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
||||
allowList: allowList,
|
||||
denyList: denyList,
|
||||
opts: opts,
|
||||
proxyFunc: proxyFunc,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
|
||||
// reads, in the casing precedence it applies.
|
||||
var environmentProxyVariables = []string{
|
||||
"HTTP_PROXY", "http_proxy",
|
||||
"HTTPS_PROXY", "https_proxy",
|
||||
"ALL_PROXY", "all_proxy",
|
||||
}
|
||||
|
||||
// ValidateEnvironmentProxyVariables checks that every proxy variable currently
|
||||
// set can be parsed as a proxy URL.
|
||||
//
|
||||
// httpproxy discards a parse error and falls back to a direct connection, so an
|
||||
// operator who mistypes a proxy URL would silently lose the egress path they
|
||||
// meant to enforce. Modules exposing an environment proxy flag call this from
|
||||
// their Validate so that startup fails loudly instead.
|
||||
//
|
||||
// Values are never included in the error: a proxy URL may carry credentials.
|
||||
func ValidateEnvironmentProxyVariables() error {
|
||||
var err error
|
||||
|
||||
for _, name := range environmentProxyVariables {
|
||||
if os.Getenv(name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isUsableProxyURL(os.Getenv(name)) {
|
||||
err = errors.Join(err, fmt.Errorf("environment variable %s is not a usable proxy URL; unset it, or set it to a value like 'http://user:password@host:3128'", name))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// isUsableProxyURL mirrors httpproxy's own parsing: a URL with a proxy scheme,
|
||||
// or anything that becomes one once a scheme is prefixed.
|
||||
func isUsableProxyURL(value string) bool {
|
||||
proxyURL, err := url.Parse(value)
|
||||
if err == nil {
|
||||
switch proxyURL.Scheme {
|
||||
case "http", "https", "socks5", "socks5h":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// httpproxy retries bare values such as "host:3128" with a scheme.
|
||||
_, err = url.Parse("http://" + value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
||||
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
||||
// match), it dials directly. When the decision contains pinned IPs, it
|
||||
@@ -580,3 +663,14 @@ type bufferedConn struct {
|
||||
func (c *bufferedConn) Read(b []byte) (int, error) {
|
||||
return c.r.Read(b)
|
||||
}
|
||||
|
||||
// CloseWrite half-closes the underlying connection. Embedding [net.Conn] hides
|
||||
// the method, so a CONNECT splice over this connection could never signal EOF
|
||||
// to the upstream and both sides waited for the other until a timeout.
|
||||
func (c *bufferedConn) CloseWrite() error {
|
||||
cw, ok := c.Conn.(interface{ CloseWrite() error })
|
||||
if !ok {
|
||||
return fmt.Errorf("underlying %T does not support half-close", c.Conn)
|
||||
}
|
||||
return cw.CloseWrite()
|
||||
}
|
||||
|
||||
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateEnvironmentProxyVariables(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
wantErr bool
|
||||
// wantIn is a substring the error must name, so that an operator can
|
||||
// find the offending variable.
|
||||
wantIn string
|
||||
}{
|
||||
{
|
||||
name: "nothing set",
|
||||
env: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "well formed URL",
|
||||
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128"},
|
||||
},
|
||||
{
|
||||
name: "credentials are accepted",
|
||||
env: map[string]string{"HTTPS_PROXY": "http://user:password@proxy.example.com:3128"},
|
||||
},
|
||||
{
|
||||
name: "bare host and port is accepted, as httpproxy prefixes a scheme",
|
||||
env: map[string]string{"HTTP_PROXY": "proxy.example.com:3128"},
|
||||
},
|
||||
{
|
||||
name: "socks5 is accepted",
|
||||
env: map[string]string{"ALL_PROXY": "socks5://proxy.example.com:1080"},
|
||||
},
|
||||
{
|
||||
name: "lowercase variables are checked too",
|
||||
env: map[string]string{"http_proxy": "http://proxy.example.com:3128"},
|
||||
},
|
||||
{
|
||||
name: "unparseable URL",
|
||||
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128/%zz"},
|
||||
wantErr: true,
|
||||
wantIn: "HTTP_PROXY",
|
||||
},
|
||||
{
|
||||
name: "the failing variable is named",
|
||||
env: map[string]string{"HTTPS_PROXY": "://%zz"},
|
||||
wantErr: true,
|
||||
wantIn: "HTTPS_PROXY",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, name := range environmentProxyVariables {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
for name, value := range tc.env {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
|
||||
err := ValidateEnvironmentProxyVariables()
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected an error, got none")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) {
|
||||
t.Errorf("error %q does not name %q", err, tc.wantIn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials pins that a
|
||||
// proxy URL, which may embed a password, never reaches the error text.
|
||||
func TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials(t *testing.T) {
|
||||
for _, name := range environmentProxyVariables {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
t.Setenv("HTTP_PROXY", "http://admin:hunter2@proxy.example.com:3128/%zz")
|
||||
|
||||
err := ValidateEnvironmentProxyVariables()
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got none")
|
||||
}
|
||||
if strings.Contains(err.Error(), "hunter2") {
|
||||
t.Errorf("error leaks the proxy password: %q", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "admin") {
|
||||
t.Errorf("error leaks the proxy username: %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops is the regression
|
||||
// test for the dial-pinning gap: with the environment proxy enabled but no
|
||||
// proxy applicable to the request, the dial must still go through the pinning
|
||||
// dialer rather than a plain one.
|
||||
//
|
||||
// The request targets a hostname that only the stub resolver knows, so a plain
|
||||
// dial would hand that unresolvable name to the OS and fail. Only a pinned dial,
|
||||
// which substitutes the address resolved during validation, can connect.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||
func TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("split server address: %v", err)
|
||||
}
|
||||
|
||||
const host = "pinned-only.invalid"
|
||||
|
||||
// NO_PROXY covers the destination, so httpproxy declines it and the
|
||||
// transport dials directly. That direct dial is the hop that used to lose
|
||||
// pinning.
|
||||
for _, name := range environmentProxyVariables {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
t.Setenv("HTTP_PROXY", "http://proxy.invalid:3128")
|
||||
t.Setenv("NO_PROXY", host)
|
||||
|
||||
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||
})
|
||||
|
||||
client := NewOutboundHttpClient(0, nil, nil, true)
|
||||
rt, ok := client.Transport.(*outboundRoundTripper)
|
||||
if !ok {
|
||||
t.Fatalf("transport is %T, want *outboundRoundTripper", client.Transport)
|
||||
}
|
||||
if rt.proxyFunc == nil {
|
||||
t.Fatal("proxyFunc is nil, want the environment proxy to be resolved per request")
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://" + net.JoinHostPort(host, port))
|
||||
if err != nil {
|
||||
t.Fatalf("GET failed, so the direct hop was not pinned: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -380,6 +380,13 @@ func (a *Api) Validate() error {
|
||||
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
||||
}
|
||||
|
||||
if a.downloadFromCfg.enableEnvironmentProxy {
|
||||
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||
if proxyErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("--api-download-from-enable-environment-proxy is set: %w", proxyErr))
|
||||
}
|
||||
}
|
||||
|
||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||
err = errors.Join(err,
|
||||
errors.New("both TLS certificate and key files must be set"),
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -150,9 +149,16 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
|
||||
// Keep only the last path segment, so that a caller cannot name an
|
||||
// output file after a path.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1227.
|
||||
//
|
||||
// [filepath.Base] alone is not enough: on Linux it does not treat a
|
||||
// backslash as a separator, and this value reaches archive entry
|
||||
// names. Use the same sanitizer as the other caller-supplied
|
||||
// filenames.
|
||||
if filename != "" {
|
||||
filename = filepath.Base(filename)
|
||||
filename = sanitizeFilename(filename)
|
||||
}
|
||||
c.Set("outputFilename", filename)
|
||||
// Call the next middleware in the chain.
|
||||
|
||||
@@ -10,6 +10,53 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// TestOutputFilenameMiddleware pins the sanitizing of the
|
||||
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
||||
// a Content-Disposition header, so a path separator must never survive it.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1227 and
|
||||
// GHSA-hwc4-gmrw-5222.
|
||||
func TestOutputFilenameMiddleware(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"no header", "", ""},
|
||||
{"plain filename", "foo", "foo"},
|
||||
{"POSIX path", "/tmp/foo", "foo"},
|
||||
{"POSIX traversal", "../../../etc/passwd", "passwd"},
|
||||
{"Windows traversal", `..\..\..\..\Windows\System32\evil`, "evil"},
|
||||
{"rooted Windows path", `C:\Windows\Temp\evil`, "evil"},
|
||||
{"mixed separators", `a/b\c`, "c"},
|
||||
{"trailing separator", "/tmp/", ""},
|
||||
{"bare dot dot", "..", ".."},
|
||||
{"control characters", "fo\x01o\x7f", "foo"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
if tc.header != "" {
|
||||
req.Header.Set("Gotenberg-Output-Filename", tc.header)
|
||||
}
|
||||
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
|
||||
err := handler(c)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got, ok := c.Get("outputFilename").(string)
|
||||
if !ok {
|
||||
t.Fatal("outputFilename is not set as a string")
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("outputFilename = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||
handler := mw(func(c echo.Context) error { return nil })
|
||||
|
||||
@@ -669,6 +669,13 @@ func (mod *Chromium) Validate() error {
|
||||
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
||||
}
|
||||
|
||||
if mod.args.enableEnvironmentProxy {
|
||||
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||
if proxyErr != nil {
|
||||
return fmt.Errorf("--chromium-enable-environment-proxy is set: %w", proxyErr)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := os.Stat(mod.args.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
@@ -62,6 +63,11 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||
}
|
||||
|
||||
// Shared by every scope match of this conversion, across all paused
|
||||
// requests. Its lifetime is the conversion, as this function is called once
|
||||
// per conversion with that conversion's context.
|
||||
budget := newScopeMatchBudget(scopeMatchBudgetPerConversion)
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev any) {
|
||||
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
||||
go func() {
|
||||
@@ -127,6 +133,14 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
// First, we have to check if at least one header has to be
|
||||
// set for the current request.
|
||||
for _, header := range options.extraHttpHeaders {
|
||||
// This goroutine outlives the response: nothing cancels an
|
||||
// in-flight match, so stop as soon as the conversion is over.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if header.Scope == nil {
|
||||
// Non-scoped header.
|
||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||
@@ -134,7 +148,18 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
continue
|
||||
}
|
||||
|
||||
if !budget.tryAcquire() {
|
||||
// Treat the remaining scoped headers as non-matching rather
|
||||
// than spending more CPU on a request the client may already
|
||||
// have given up on.
|
||||
logger.WarnContext(ctx, fmt.Sprintf("scope matching budget of %s exhausted, extra HTTP header '%s' and any subsequent scoped header will not be set; simplify the 'scope' patterns or reduce the number of scoped headers", scopeMatchBudgetPerConversion, header.Name))
|
||||
break
|
||||
}
|
||||
|
||||
matchStart := time.Now()
|
||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||
budget.consume(time.Since(matchStart))
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||
|
||||
@@ -24,6 +24,20 @@ import (
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||
)
|
||||
|
||||
// Bounds on the scoped extra HTTP headers feature. Chromium matches every
|
||||
// scoped header against every paused sub-resource request, so the total
|
||||
// matching work is the product of the header count and the sub-resource count.
|
||||
// These caps bound the factors the client controls; [scopeMatchBudget] bounds
|
||||
// the product. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||
const (
|
||||
maxExtraHttpHeaders = 64
|
||||
maxExtraHttpHeaderScopeLength = 1024
|
||||
|
||||
// A scope pattern matches against a URL, which takes microseconds for any
|
||||
// reasonable pattern.
|
||||
extraHttpHeaderScopeMatchTimeout = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
var sameSiteRegexp = regexp2.MustCompile(
|
||||
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
|
||||
regexp2.None,
|
||||
@@ -169,6 +183,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
||||
}
|
||||
|
||||
if len(headers) > maxExtraHttpHeaders {
|
||||
return fmt.Errorf("too many headers, got %d, expected at most %d", len(headers), maxExtraHttpHeaders)
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
var scope string
|
||||
var valueTokens []string
|
||||
@@ -198,12 +216,17 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
var scopeRegexp *regexp2.Regexp
|
||||
if len(scope) > 0 {
|
||||
if len(scope) > maxExtraHttpHeaderScopeLength {
|
||||
err = errors.Join(err, fmt.Errorf("scope regex pattern for header '%s' is too long, got %d characters, expected at most %d", k, len(scope), maxExtraHttpHeaderScopeLength))
|
||||
continue
|
||||
}
|
||||
|
||||
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
||||
if errCompile != nil {
|
||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||
continue
|
||||
}
|
||||
p.MatchTimeout = 5 * time.Second
|
||||
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||
scopeRegexp = p
|
||||
}
|
||||
|
||||
|
||||
52
pkg/modules/chromium/scopebudget.go
Normal file
52
pkg/modules/chromium/scopebudget.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scopeMatchBudgetPerConversion caps the total time a single conversion may
|
||||
// spend matching scoped extra HTTP header patterns.
|
||||
//
|
||||
// The per-pattern MatchTimeout bounds one match, not their number: Chromium
|
||||
// pauses every sub-resource request, and each paused request is matched against
|
||||
// every scoped header. Without a shared budget the total is the product of the
|
||||
// two, both of which the client controls.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||
const scopeMatchBudgetPerConversion = 5 * time.Second
|
||||
|
||||
// scopeMatchBudget is a time allowance shared by every scope match of a
|
||||
// conversion. It is safe for concurrent use: paused requests are handled on
|
||||
// their own goroutines.
|
||||
type scopeMatchBudget struct {
|
||||
remaining atomic.Int64
|
||||
}
|
||||
|
||||
// newScopeMatchBudget returns a [scopeMatchBudget] allowing d of matching.
|
||||
func newScopeMatchBudget(d time.Duration) *scopeMatchBudget {
|
||||
b := new(scopeMatchBudget)
|
||||
b.remaining.Store(int64(d))
|
||||
return b
|
||||
}
|
||||
|
||||
// tryAcquire reports whether the budget still allows a match.
|
||||
func (b *scopeMatchBudget) tryAcquire() bool {
|
||||
return b.remaining.Load() > 0
|
||||
}
|
||||
|
||||
// consume subtracts the time a match took. It saturates at zero so that a long
|
||||
// match cannot wrap the counter back into credit.
|
||||
func (b *scopeMatchBudget) consume(d time.Duration) {
|
||||
for {
|
||||
current := b.remaining.Load()
|
||||
if current <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
next := max(current-int64(d), 0)
|
||||
|
||||
if b.remaining.CompareAndSwap(current, next) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
122
pkg/modules/chromium/scopebudget_test.go
Normal file
122
pkg/modules/chromium/scopebudget_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package chromium
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
func TestScopeMatchBudget(t *testing.T) {
|
||||
t.Run("allows matching while credit remains", func(t *testing.T) {
|
||||
b := newScopeMatchBudget(time.Second)
|
||||
if !b.tryAcquire() {
|
||||
t.Fatal("tryAcquire() = false on a fresh budget, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("denies matching once exhausted", func(t *testing.T) {
|
||||
b := newScopeMatchBudget(time.Second)
|
||||
b.consume(time.Second)
|
||||
if b.tryAcquire() {
|
||||
t.Error("tryAcquire() = true after the budget was spent, want false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("saturates at zero instead of wrapping into credit", func(t *testing.T) {
|
||||
b := newScopeMatchBudget(time.Second)
|
||||
b.consume(time.Hour)
|
||||
if got := b.remaining.Load(); got != 0 {
|
||||
t.Errorf("remaining = %d, want 0", got)
|
||||
}
|
||||
if b.tryAcquire() {
|
||||
t.Error("tryAcquire() = true after an overlong match, want false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a spent budget stays spent", func(t *testing.T) {
|
||||
b := newScopeMatchBudget(time.Second)
|
||||
b.consume(time.Second)
|
||||
b.consume(time.Millisecond)
|
||||
if got := b.remaining.Load(); got != 0 {
|
||||
t.Errorf("remaining = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is safe for concurrent use", func(t *testing.T) {
|
||||
const goroutines = 64
|
||||
// Each goroutine spends 1ms against a budget of half that many
|
||||
// milliseconds, so the total spend overshoots it.
|
||||
b := newScopeMatchBudget(time.Duration(goroutines/2) * time.Millisecond)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range goroutines {
|
||||
wg.Go(func() {
|
||||
b.tryAcquire()
|
||||
b.consume(time.Millisecond)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := b.remaining.Load(); got != 0 {
|
||||
t.Errorf("remaining = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestScopeMatchBudget_BoundsCatastrophicBacktracking is the regression test for
|
||||
// the amplification: many scoped headers matched against a hostile URL must cost
|
||||
// the budget, not a multiple of it.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||
func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
|
||||
const (
|
||||
headers = 16
|
||||
budget = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// Nested quantifier with no possible match: classic catastrophic
|
||||
// backtracking.
|
||||
pattern := compileScopePattern(t, `(a+)+b`)
|
||||
url := "http://example.com/" + strings.Repeat("a", 40)
|
||||
|
||||
b := newScopeMatchBudget(budget)
|
||||
|
||||
start := time.Now()
|
||||
var matched int
|
||||
for range headers {
|
||||
if !b.tryAcquire() {
|
||||
break
|
||||
}
|
||||
matchStart := time.Now()
|
||||
_, _ = pattern.MatchString(url)
|
||||
b.consume(time.Since(matchStart))
|
||||
matched++
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if matched == headers {
|
||||
t.Errorf("all %d headers were matched, want the budget to stop matching early", headers)
|
||||
}
|
||||
|
||||
// Each match is separately capped at extraHttpHeaderScopeMatchTimeout, so
|
||||
// the worst case is the budget plus one final match that started with the
|
||||
// last of the credit. Generous slack keeps this stable on a loaded CI box.
|
||||
ceiling := budget + extraHttpHeaderScopeMatchTimeout + time.Second
|
||||
if elapsed > ceiling {
|
||||
t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
|
||||
}
|
||||
}
|
||||
|
||||
func compileScopePattern(t *testing.T, pattern string) *regexp2.Regexp {
|
||||
t.Helper()
|
||||
|
||||
p, err := regexp2.Compile(pattern, regexp2.None)
|
||||
if err != nil {
|
||||
t.Fatalf("compile %q: %v", pattern, err)
|
||||
}
|
||||
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -33,12 +33,33 @@ var (
|
||||
// formats option.
|
||||
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||
|
||||
// ErrUnoException happens when unoconverter returns exit code 5.
|
||||
// ErrUnoException happens when unoconverter returns exit code 5. That code
|
||||
// is the residual bucket of unoconverter's catch-all UNO exception handler:
|
||||
// it covers a malformed page range, a password supplied to a document that
|
||||
// does not need one, a failure to open the document and a failure to write
|
||||
// the output alike. It names the exception class that was caught, not a
|
||||
// cause. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||
ErrUnoException = errors.New("uno exception")
|
||||
|
||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
||||
// unoconverter's own message for it reads "Office probably died", yet a
|
||||
// wrong or missing password also surfaces there. Like [ErrUnoException], it
|
||||
// does not establish who is at fault.
|
||||
ErrRuntimeException = errors.New("runtime exception")
|
||||
|
||||
// ErrIoException happens when unoconverter returns exit code 3. LibreOffice
|
||||
// could not read the source document.
|
||||
ErrIoException = errors.New("io exception")
|
||||
|
||||
// ErrCannotConvertException happens when unoconverter returns exit code 4.
|
||||
// LibreOffice read the document but could not convert it to PDF.
|
||||
ErrCannotConvertException = errors.New("cannot convert exception")
|
||||
|
||||
// ErrIllegalArgumentException happens when unoconverter returns exit code
|
||||
// 8. LibreOffice rejected the source document, usually because its contents
|
||||
// do not match its extension.
|
||||
ErrIllegalArgumentException = errors.New("illegal argument exception")
|
||||
|
||||
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||
// expected, and some other time the same conversion will fail.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
@@ -501,6 +522,13 @@ func (a *Api) Validate() error {
|
||||
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
|
||||
}
|
||||
|
||||
if a.args.proxyOptions.enableEnvironmentProxy {
|
||||
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||
if proxyErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("--libreoffice-enable-environment-proxy is set: %w", proxyErr))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -767,7 +795,10 @@ func conversionRequestAttributes(inputPath string, options Options) []attribute.
|
||||
// [gotenberg.ClassifyError].
|
||||
func libreofficeErrorType(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidPdfFormats):
|
||||
case errors.Is(err, ErrInvalidPdfFormats),
|
||||
errors.Is(err, ErrIoException),
|
||||
errors.Is(err, ErrCannotConvertException),
|
||||
errors.Is(err, ErrIllegalArgumentException):
|
||||
return gotenberg.ErrorTypeInvalidInput
|
||||
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
||||
return "libreoffice_exception"
|
||||
|
||||
@@ -17,6 +17,9 @@ func TestLibreofficeErrorType(t *testing.T) {
|
||||
{"deadline", context.DeadlineExceeded, "timeout"},
|
||||
{"canceled", context.Canceled, "context_cancelled"},
|
||||
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
|
||||
{"io exception", ErrIoException, "invalid_input"},
|
||||
{"cannot convert exception", ErrCannotConvertException, "invalid_input"},
|
||||
{"illegal argument exception", ErrIllegalArgumentException, "invalid_input"},
|
||||
{"uno exception", ErrUnoException, "libreoffice_exception"},
|
||||
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
|
||||
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
|
||||
|
||||
@@ -435,9 +435,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibreOffice's errors are not explicit.
|
||||
// For instance, exit code 5 may be explained by a malformed page range
|
||||
// but also by a not required password.
|
||||
// LibreOffice's errors are not explicit: unoconverter derives its exit code
|
||||
// from the UNO exception class it caught, not from a diagnosis. Exit codes
|
||||
// 5 and 6 are ambiguous in particular, so the route decides the HTTP status
|
||||
// from the request and the document rather than from the code alone.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||
|
||||
// We may want to retry in case of a core-dumped event.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
@@ -445,13 +447,17 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
||||
return ErrCoreDumped
|
||||
}
|
||||
|
||||
if exitCode == 5 {
|
||||
// Potentially malformed page ranges or password not required.
|
||||
switch exitCode {
|
||||
case 3:
|
||||
return ErrIoException
|
||||
case 4:
|
||||
return ErrCannotConvertException
|
||||
case 5:
|
||||
return ErrUnoException
|
||||
}
|
||||
if exitCode == 6 {
|
||||
// Password potentially required or invalid.
|
||||
case 6:
|
||||
return ErrRuntimeException
|
||||
case 8:
|
||||
return ErrIllegalArgumentException
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
|
||||
129
pkg/modules/libreoffice/api/protection.go
Normal file
129
pkg/modules/libreoffice/api/protection.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PasswordProtection describes whether a document requires a password to open.
|
||||
type PasswordProtection int
|
||||
|
||||
const (
|
||||
// PasswordProtectionUnknown means the document's encryption state could not
|
||||
// be determined.
|
||||
PasswordProtectionUnknown PasswordProtection = iota
|
||||
|
||||
// PasswordProtectionNone means the document opens without a password.
|
||||
PasswordProtectionNone
|
||||
|
||||
// PasswordProtectionRequired means the document is encrypted.
|
||||
PasswordProtectionRequired
|
||||
)
|
||||
|
||||
var (
|
||||
// Compound File Binary magic. An encrypted OOXML document is an
|
||||
// MS-OFFCRYPTO container, which is a compound file. Per MS-CFB 2.2, the
|
||||
// header signature is fixed.
|
||||
ole2Magic = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
|
||||
|
||||
// Local file header signature. Per APPNOTE.TXT 4.3.7, every ZIP entry
|
||||
// starts with it, so an intact package starts with it too.
|
||||
zipMagic = []byte{0x50, 0x4b, 0x03, 0x04}
|
||||
|
||||
// An unencrypted OOXML document is always a ZIP package, so any of these
|
||||
// extensions over a compound file means the payload is encrypted. Legacy
|
||||
// binary formats (.doc, .xls, .ppt) are compound files either way and are
|
||||
// deliberately absent.
|
||||
ooxmlExtensions = map[string]struct{}{
|
||||
".docx": {}, ".docm": {}, ".dotx": {}, ".dotm": {},
|
||||
".xlsx": {}, ".xlsm": {}, ".xltx": {}, ".xltm": {},
|
||||
".pptx": {}, ".pptm": {}, ".potx": {}, ".potm": {},
|
||||
".ppsx": {}, ".ppsm": {},
|
||||
}
|
||||
)
|
||||
|
||||
// odfManifestSizeLimit caps how much of an ODF manifest is read. The manifest
|
||||
// is a few kilobytes in practice; the cap stops a crafted archive from
|
||||
// exhausting memory through its decompressed size.
|
||||
const odfManifestSizeLimit = 1 << 20
|
||||
|
||||
// DetectPasswordProtection reports whether the document at path is encrypted.
|
||||
//
|
||||
// Detection is advisory and never fails: an unreadable file, an unknown format
|
||||
// or a malformed archive all yield [PasswordProtectionUnknown]. It exists to
|
||||
// refine the diagnosis of a conversion that already failed, since LibreOffice's
|
||||
// exit codes do not distinguish a missing password from a crash.
|
||||
func DetectPasswordProtection(path string) PasswordProtection {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return PasswordProtectionUnknown
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
magic := make([]byte, 8)
|
||||
n, err := io.ReadFull(f, magic)
|
||||
if err != nil && n < len(zipMagic) {
|
||||
return PasswordProtectionUnknown
|
||||
}
|
||||
magic = magic[:n]
|
||||
|
||||
switch {
|
||||
case bytes.HasPrefix(magic, ole2Magic):
|
||||
if _, ok := ooxmlExtensions[strings.ToLower(filepath.Ext(path))]; ok {
|
||||
return PasswordProtectionRequired
|
||||
}
|
||||
// A legacy binary document is a compound file whether or not it is
|
||||
// encrypted; its encryption lives in a stream this cannot cheaply read.
|
||||
return PasswordProtectionUnknown
|
||||
case bytes.HasPrefix(magic, zipMagic):
|
||||
return detectZipPasswordProtection(f)
|
||||
default:
|
||||
// Flat XML (.fodt), RTF, CSV and everything else carry no encryption.
|
||||
return PasswordProtectionUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// detectZipPasswordProtection inspects a ZIP package. ODF keeps META-INF/manifest.xml
|
||||
// in cleartext even when encrypted, declaring each encrypted entry. An OOXML
|
||||
// package has no manifest, and reaching this point already proves it is not an
|
||||
// MS-OFFCRYPTO container, so it opens without a password.
|
||||
func detectZipPasswordProtection(f *os.File) PasswordProtection {
|
||||
size, err := f.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return PasswordProtectionUnknown
|
||||
}
|
||||
|
||||
r, err := zip.NewReader(f, size)
|
||||
if err != nil {
|
||||
return PasswordProtectionUnknown
|
||||
}
|
||||
|
||||
manifest, err := r.Open("META-INF/manifest.xml")
|
||||
if err != nil {
|
||||
// No manifest: an OOXML package, or a ZIP that is not an office
|
||||
// document at all. Neither is encrypted.
|
||||
return PasswordProtectionNone
|
||||
}
|
||||
defer func() {
|
||||
_ = manifest.Close()
|
||||
}()
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(manifest, odfManifestSizeLimit))
|
||||
if err != nil {
|
||||
return PasswordProtectionUnknown
|
||||
}
|
||||
|
||||
// Per OpenDocument 1.3 part 3, section 4.16, an encrypted entry carries a
|
||||
// <manifest:encryption-data> child.
|
||||
if bytes.Contains(content, []byte("encryption-data")) {
|
||||
return PasswordProtectionRequired
|
||||
}
|
||||
|
||||
return PasswordProtectionNone
|
||||
}
|
||||
193
pkg/modules/libreoffice/api/protection_test.go
Normal file
193
pkg/modules/libreoffice/api/protection_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeFile writes content to a new file named name inside dir and returns its
|
||||
// path.
|
||||
func writeFile(t *testing.T, dir, name string, content []byte) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
err := os.WriteFile(path, content, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// writeZip builds a ZIP archive from entries and returns its path.
|
||||
func writeZip(t *testing.T, dir, name string, entries map[string]string) string {
|
||||
t.Helper()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
w := zip.NewWriter(buf)
|
||||
|
||||
for entryName, content := range entries {
|
||||
f, err := w.Create(entryName)
|
||||
if err != nil {
|
||||
t.Fatalf("create zip entry %s: %v", entryName, err)
|
||||
}
|
||||
_, err = f.Write([]byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("write zip entry %s: %v", entryName, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := w.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close zip writer: %v", err)
|
||||
}
|
||||
|
||||
return writeFile(t, dir, name, buf.Bytes())
|
||||
}
|
||||
|
||||
func TestDetectPasswordProtection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
ole2 := func(name string) string {
|
||||
return writeFile(t, dir, name, append(ole2Magic, bytes.Repeat([]byte{0x00}, 64)...))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
want PasswordProtection
|
||||
}{
|
||||
{
|
||||
name: "encrypted OOXML is a compound file",
|
||||
path: ole2("encrypted.docx"),
|
||||
want: PasswordProtectionRequired,
|
||||
},
|
||||
{
|
||||
name: "extension casing is ignored",
|
||||
path: ole2("encrypted.DOCX"),
|
||||
want: PasswordProtectionRequired,
|
||||
},
|
||||
{
|
||||
name: "encrypted spreadsheet",
|
||||
path: ole2("encrypted.xlsx"),
|
||||
want: PasswordProtectionRequired,
|
||||
},
|
||||
{
|
||||
name: "legacy binary document is inconclusive",
|
||||
path: ole2("legacy.doc"),
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
{
|
||||
name: "plain OOXML package",
|
||||
path: writeZip(t, dir, "plain.docx", map[string]string{
|
||||
"[Content_Types].xml": "<Types/>",
|
||||
"word/document.xml": "<w:document/>",
|
||||
}),
|
||||
want: PasswordProtectionNone,
|
||||
},
|
||||
{
|
||||
name: "encrypted ODF declares encryption-data in its manifest",
|
||||
path: writeZip(t, dir, "encrypted.odt", map[string]string{
|
||||
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry><manifest:encryption-data manifest:checksum="x"/></manifest:file-entry></manifest:manifest>`,
|
||||
"content.xml": "<office:document-content/>",
|
||||
}),
|
||||
want: PasswordProtectionRequired,
|
||||
},
|
||||
{
|
||||
name: "plain ODF has a manifest without encryption-data",
|
||||
path: writeZip(t, dir, "plain.odt", map[string]string{
|
||||
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry manifest:full-path="/"/></manifest:manifest>`,
|
||||
"content.xml": "<office:document-content/>",
|
||||
}),
|
||||
want: PasswordProtectionNone,
|
||||
},
|
||||
{
|
||||
name: "flat XML carries no encryption",
|
||||
path: writeFile(t, dir, "flat.fodt", []byte("<?xml version=\"1.0\"?><office:document/>")),
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
{
|
||||
name: "plain text",
|
||||
path: writeFile(t, dir, "notes.txt", []byte("hello")),
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
{
|
||||
name: "file shorter than any magic",
|
||||
path: writeFile(t, dir, "tiny.docx", []byte{0x50}),
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
path: writeFile(t, dir, "empty.docx", nil),
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
{
|
||||
name: "truncated archive",
|
||||
path: writeFile(t, dir, "truncated.docx", append(zipMagic, bytes.Repeat([]byte{0x00}, 32)...)),
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
{
|
||||
name: "non-existent path",
|
||||
path: filepath.Join(dir, "does-not-exist.docx"),
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
{
|
||||
name: "directory",
|
||||
path: dir,
|
||||
want: PasswordProtectionUnknown,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectPasswordProtection_Fixtures anchors detection to the same documents
|
||||
// the integration scenarios upload, so a fixture swap cannot silently flip a
|
||||
// status code.
|
||||
func TestDetectPasswordProtection_Fixtures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want PasswordProtection
|
||||
}{
|
||||
{"../../../../test/integration/testdata/protected_page_1.docx", PasswordProtectionRequired},
|
||||
{"../../../../test/integration/testdata/page_1.docx", PasswordProtectionNone},
|
||||
} {
|
||||
t.Run(filepath.Base(tc.path), func(t *testing.T) {
|
||||
if _, err := os.Stat(tc.path); err != nil {
|
||||
t.Skipf("fixture unavailable: %v", err)
|
||||
}
|
||||
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectPasswordProtection_OversizedManifest verifies that a manifest far
|
||||
// larger than the cap still yields a verdict through a bounded read.
|
||||
func TestDetectPasswordProtection_OversizedManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Well past odfManifestSizeLimit, and highly compressible, so the archive
|
||||
// on disk stays small.
|
||||
filler := strings.Repeat("<manifest:file-entry manifest:full-path=\"pad\"/>", 200_000)
|
||||
|
||||
path := writeZip(t, dir, "oversized.odt", map[string]string{
|
||||
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||
"META-INF/manifest.xml": "<manifest:manifest>" + filler + "</manifest:manifest>",
|
||||
})
|
||||
|
||||
if got := DetectPasswordProtection(path); got != PasswordProtectionNone {
|
||||
t.Errorf("DetectPasswordProtection(oversized) = %d, want %d", got, PasswordProtectionNone)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||
)
|
||||
|
||||
// unattributableFailureMessage is returned when LibreOffice fails and no
|
||||
// client-supplied input is implicated. Its only format verb is the original
|
||||
// filename.
|
||||
const unattributableFailureMessage = "LibreOffice failed to convert the document '%s'. This is usually a resource issue: increase the container's memory and CPU, or reduce the document's size. The request is valid and may be retried."
|
||||
|
||||
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
||||
// to PDF.
|
||||
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
||||
@@ -405,20 +410,52 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrUnoException) {
|
||||
filename := ctx.OriginalFilename(inputPath)
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrIoException) || errors.Is(err, libreofficeapi.ErrIllegalArgumentException) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not read the document '%s'. Ensure the file is not corrupted and that its extension matches its actual format.", filename)),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
||||
if errors.Is(err, libreofficeapi.ErrCannotConvertException) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
|
||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice read the document '%s' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.", filename)),
|
||||
)
|
||||
}
|
||||
|
||||
// Exit codes 5 and 6 name the UNO exception class that was
|
||||
// caught, not a cause: both cover a client mistake and a
|
||||
// LibreOffice crash. Blame the client only when one of its
|
||||
// inputs is actually implicated, since the server is the
|
||||
// only remaining explanation otherwise. Password evidence
|
||||
// outranks page ranges: a password failure aborts on import,
|
||||
// before the export filter applies any page range.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||
if errors.Is(err, libreofficeapi.ErrUnoException) || errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
||||
protection := libreofficeapi.DetectPasswordProtection(inputPath)
|
||||
|
||||
var sentinel api.SentinelHttpError
|
||||
switch {
|
||||
case protection == libreofficeapi.PasswordProtectionRequired && options.Password == "":
|
||||
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is password-protected. Provide its password in the 'password' form field.", filename))
|
||||
case protection == libreofficeapi.PasswordProtectionRequired:
|
||||
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The password for the document '%s' is incorrect. Check the 'password' form field.", filename))
|
||||
case protection == libreofficeapi.PasswordProtectionNone && options.Password != "":
|
||||
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is not password-protected. Remove the 'password' form field.", filename))
|
||||
case options.Password != "":
|
||||
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not open the document '%s' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.", filename))
|
||||
case errors.Is(err, libreofficeapi.ErrUnoException) && options.PageRanges != "":
|
||||
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not apply the page ranges '%s' to the document '%s'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.", options.PageRanges, filename))
|
||||
default:
|
||||
sentinel = api.NewSentinelHttpError(http.StatusInternalServerError, fmt.Sprintf(unattributableFailureMessage, filename))
|
||||
}
|
||||
|
||||
return api.WrapError(fmt.Errorf("convert to PDF: %w", err), sentinel)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert to PDF: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
241
pkg/modules/libreoffice/routes_test.go
Normal file
241
pkg/modules/libreoffice/routes_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package libreoffice
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
)
|
||||
|
||||
// compoundFile writes a document whose header marks it as a compound file. Over
|
||||
// an OOXML extension, that means an encrypted payload.
|
||||
func compoundFile(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
|
||||
content := append(
|
||||
[]byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1},
|
||||
bytes.Repeat([]byte{0x00}, 64)...,
|
||||
)
|
||||
|
||||
return writeTestFile(t, dir, name, content)
|
||||
}
|
||||
|
||||
// zipPackage writes a minimal, unencrypted OOXML package.
|
||||
func zipPackage(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
w := zip.NewWriter(buf)
|
||||
|
||||
f, err := w.Create("[Content_Types].xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create zip entry: %v", err)
|
||||
}
|
||||
_, err = f.Write([]byte("<Types/>"))
|
||||
if err != nil {
|
||||
t.Fatalf("write zip entry: %v", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close zip writer: %v", err)
|
||||
}
|
||||
|
||||
return writeTestFile(t, dir, name, buf.Bytes())
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, dir, name string, content []byte) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
err := os.WriteFile(path, content, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// TestConvertRoute_FailureStatus pins the branch table that decides whether a
|
||||
// LibreOffice failure is the client's fault. See
|
||||
// https://github.com/gotenberg/gotenberg/issues/1588.
|
||||
func TestConvertRoute_FailureStatus(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
var (
|
||||
protected = compoundFile(t, dir, "protected_page_1.docx")
|
||||
plain = zipPackage(t, dir, "page_1.docx")
|
||||
legacy = compoundFile(t, dir, "legacy.doc")
|
||||
corrupted = writeTestFile(t, dir, "corrupted.docx", []byte("not a document"))
|
||||
unreachable = filepath.Join(dir, "vanished.docx")
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
inputPath string
|
||||
values map[string][]string
|
||||
err error
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "encrypted document, no password",
|
||||
inputPath: protected,
|
||||
err: libreofficeapi.ErrRuntimeException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
|
||||
},
|
||||
{
|
||||
name: "encrypted document, wrong password",
|
||||
inputPath: protected,
|
||||
values: map[string][]string{"password": {"bar"}},
|
||||
err: libreofficeapi.ErrRuntimeException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.",
|
||||
},
|
||||
{
|
||||
name: "unencrypted document, password supplied",
|
||||
inputPath: plain,
|
||||
values: map[string][]string{"password": {"foo"}},
|
||||
err: libreofficeapi.ErrUnoException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "The document 'page_1.docx' is not password-protected. Remove the 'password' form field.",
|
||||
},
|
||||
{
|
||||
name: "inconclusive document, password supplied",
|
||||
inputPath: legacy,
|
||||
values: map[string][]string{"password": {"foo"}},
|
||||
err: libreofficeapi.ErrUnoException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "LibreOffice could not open the document 'legacy.doc' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.",
|
||||
},
|
||||
{
|
||||
name: "malformed page ranges",
|
||||
inputPath: plain,
|
||||
values: map[string][]string{"nativePageRanges": {"foo"}},
|
||||
err: libreofficeapi.ErrUnoException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.",
|
||||
},
|
||||
{
|
||||
name: "password evidence outranks page ranges",
|
||||
inputPath: protected,
|
||||
values: map[string][]string{"nativePageRanges": {"1-2"}},
|
||||
err: libreofficeapi.ErrUnoException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
|
||||
},
|
||||
{
|
||||
name: "page ranges do not excuse a runtime exception",
|
||||
inputPath: plain,
|
||||
values: map[string][]string{"nativePageRanges": {"1-2"}},
|
||||
err: libreofficeapi.ErrRuntimeException,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||
},
|
||||
{
|
||||
name: "nothing implicated, uno exception",
|
||||
inputPath: plain,
|
||||
err: libreofficeapi.ErrUnoException,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||
},
|
||||
{
|
||||
name: "nothing implicated, runtime exception",
|
||||
inputPath: plain,
|
||||
err: libreofficeapi.ErrRuntimeException,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||
},
|
||||
{
|
||||
name: "detection cannot read the document",
|
||||
inputPath: unreachable,
|
||||
err: libreofficeapi.ErrUnoException,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: fmt.Sprintf(unattributableFailureMessage, "vanished.docx"),
|
||||
},
|
||||
{
|
||||
name: "unreadable source",
|
||||
inputPath: corrupted,
|
||||
err: libreofficeapi.ErrIoException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
|
||||
},
|
||||
{
|
||||
name: "rejected source",
|
||||
inputPath: corrupted,
|
||||
err: libreofficeapi.ErrIllegalArgumentException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
|
||||
},
|
||||
{
|
||||
name: "unconvertible document",
|
||||
inputPath: corrupted,
|
||||
err: libreofficeapi.ErrCannotConvertException,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "LibreOffice read the document 'corrupted.docx' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.",
|
||||
},
|
||||
{
|
||||
name: "core dumped past the retry cap",
|
||||
inputPath: plain,
|
||||
err: libreofficeapi.ErrCoreDumped,
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
{
|
||||
name: "unmapped exit code",
|
||||
inputPath: plain,
|
||||
err: fmt.Errorf("convert to PDF: exit status 7"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetDirPath(dir)
|
||||
ctx.SetFiles(map[string]string{filepath.Base(tc.inputPath): tc.inputPath})
|
||||
ctx.SetValues(tc.values)
|
||||
ctx.SetLogger(slog.New(slog.DiscardHandler))
|
||||
|
||||
uno := &libreofficeapi.ApiMock{
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx", ".doc"}
|
||||
},
|
||||
PdfMock: func(_ context.Context, _ *slog.Logger, _, _ string, _ libreofficeapi.Options) error {
|
||||
// Mirror the wrapping done by [libreofficeapi.Api.Pdf].
|
||||
return fmt.Errorf("supervisor run task: %w", tc.err)
|
||||
},
|
||||
}
|
||||
|
||||
c := echo.New().NewContext(
|
||||
httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", nil),
|
||||
httptest.NewRecorder(),
|
||||
)
|
||||
c.Set("context", ctx.Context)
|
||||
|
||||
err := convertRoute(uno, new(gotenberg.PdfEngineMock)).Handler(c)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got none")
|
||||
}
|
||||
|
||||
status, message := api.ParseError(err)
|
||||
if status != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d (message: %s)", status, tc.wantStatus, message)
|
||||
}
|
||||
if message != tc.wantBody {
|
||||
t.Errorf("message =\n%s\nwant\n%s", message, tc.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,10 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
|
||||
defer span.End()
|
||||
|
||||
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
|
||||
args := []string{"bookmarks", "export", inputPath, tmpPath}
|
||||
// --force: without it, a leftover file from an interrupted run makes pdfcpu
|
||||
// refuse, and the stale contents would then be read as this document's
|
||||
// bookmarks.
|
||||
args := []string{"bookmarks", "export", "--force", inputPath, tmpPath}
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("create command: %w", err)
|
||||
@@ -456,7 +459,9 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
|
||||
}
|
||||
}()
|
||||
|
||||
args := []string{"bookmarks", "import", "--replace", inputPath, tmpPath, inputPath}
|
||||
// --force: the output path is the input path, and pdfcpu refuses to
|
||||
// overwrite an existing file without it.
|
||||
args := []string{"bookmarks", "import", "--replace", "--force", inputPath, tmpPath, inputPath}
|
||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("create command: %w", err)
|
||||
@@ -559,8 +564,11 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
|
||||
perm = "none"
|
||||
}
|
||||
|
||||
args := make([]string, 0, 11)
|
||||
args := make([]string, 0, 12)
|
||||
args = append(args, "encrypt")
|
||||
// --force: the output path is the input path, and pdfcpu refuses to
|
||||
// overwrite an existing file without it.
|
||||
args = append(args, "--force")
|
||||
args = append(args, "--mode", "aes")
|
||||
args = append(args, "--upw", opts.UserPassword)
|
||||
args = append(args, "--opw", ownerPassword)
|
||||
@@ -633,7 +641,9 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
args := []string{"rotate"}
|
||||
// --force: the output path is the input path, and pdfcpu refuses to
|
||||
// overwrite an existing file without it.
|
||||
args := []string{"rotate", "--force"}
|
||||
if pages != "" {
|
||||
args = append(args, "--pages", pages)
|
||||
}
|
||||
@@ -679,7 +689,9 @@ func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Lo
|
||||
}
|
||||
description := strings.Join(descParts, ", ")
|
||||
|
||||
args := []string{command, "add", "--mode", mode}
|
||||
// --force: the output path is the input path, and pdfcpu refuses to
|
||||
// overwrite an existing file without it.
|
||||
args := []string{command, "add", "--mode", mode, "--force"}
|
||||
|
||||
if stamp.Pages != "" {
|
||||
args = append(args, "--pages", stamp.Pages)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -107,10 +108,25 @@ func (w *Webhook) AsyncCount() int64 {
|
||||
return w.asyncCount.Load()
|
||||
}
|
||||
|
||||
// Validate checks the module's configuration.
|
||||
func (w *Webhook) Validate() error {
|
||||
if !w.enableEnvironmentProxy {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := gotenberg.ValidateEnvironmentProxyVariables()
|
||||
if err != nil {
|
||||
return fmt.Errorf("--webhook-enable-environment-proxy is set: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*Webhook)(nil)
|
||||
_ gotenberg.Provisioner = (*Webhook)(nil)
|
||||
_ gotenberg.Validator = (*Webhook)(nil)
|
||||
_ api.MiddlewareProvider = (*Webhook)(nil)
|
||||
_ api.AsynchronousCounter = (*Webhook)(nil)
|
||||
)
|
||||
|
||||
@@ -813,8 +813,40 @@ Feature: /forms/chromium/convert/html
|
||||
"""
|
||||
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
# A backslash is not a path separator on Linux, so filepath.Base leaves it in
|
||||
# place and it reaches the archive entry names. See GHSA-hwc4-gmrw-5222.
|
||||
@split
|
||||
@output-filename
|
||||
Scenario: POST /forms/chromium/convert/html (Split Windows Path As Output Filename)
|
||||
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/pages-3-html/index.html | file |
|
||||
| splitMode | intervals | field |
|
||||
| splitSpan | 2 | field |
|
||||
| Gotenberg-Output-Filename | ..\\..\\..\\Windows\\System32\\foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/zip"
|
||||
Then there should be 2 PDF(s) in the response
|
||||
Then there should be the following file(s) in the response:
|
||||
| foo.zip |
|
||||
| foo_0.pdf |
|
||||
| foo_1.pdf |
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Split Rooted Windows Path As Output Filename)
|
||||
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/pages-3-html/index.html | file |
|
||||
| splitMode | intervals | field |
|
||||
| splitSpan | 2 | field |
|
||||
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/zip"
|
||||
Then there should be 2 PDF(s) in the response
|
||||
Then there should be the following file(s) in the response:
|
||||
| foo.zip |
|
||||
| foo_0.pdf |
|
||||
| foo_1.pdf |
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Split Output Filename)
|
||||
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):
|
||||
|
||||
@@ -88,7 +88,7 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
|
||||
The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/protected_page_1.docx | file |
|
||||
@@ -255,7 +255,7 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
LibreOffice failed to process a document: possible causes include malformed page ranges 'foo' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
|
||||
LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
@@ -264,7 +264,7 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
LibreOffice failed to process a document: possible causes include malformed page ranges '' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
|
||||
The document 'page_1.docx' is not password-protected. Remove the 'password' form field.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/protected_page_1.docx | file |
|
||||
@@ -273,7 +273,7 @@ Feature: /forms/libreoffice/convert
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
"""
|
||||
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
|
||||
The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.
|
||||
"""
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
|
||||
@@ -22,6 +22,17 @@ Feature: Output Filename
|
||||
Then there should be the following file(s) in the response:
|
||||
| foo.zip |
|
||||
|
||||
# See GHSA-hwc4-gmrw-5222.
|
||||
Scenario: Windows Path As Filename
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.pdf | file |
|
||||
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be the following file(s) in the response:
|
||||
| foo.pdf |
|
||||
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1227.
|
||||
Scenario: Path As Filename
|
||||
Given I have a default Gotenberg container
|
||||
|
||||
Reference in New Issue
Block a user