mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-15 16:53:19 +01:00
Compare commits
24 Commits
fe4fb9416d
...
v8.38.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ee0f29d6 | ||
|
|
db150b7733 | ||
|
|
e1d714e5e0 | ||
|
|
7b604da4f1 | ||
|
|
887727b40e | ||
|
|
dd76e79443 | ||
|
|
0a337e97b8 | ||
|
|
5af64d2124 | ||
|
|
2f895d9313 | ||
|
|
c83dbd7baf | ||
|
|
cc9cd5d82d | ||
|
|
768f87bb1c | ||
|
|
a5bc10b9d8 | ||
|
|
ffc3cb98af | ||
|
|
8b46090eac | ||
|
|
39e43205b1 | ||
|
|
c4c9ea12e5 | ||
|
|
ff817f6cf8 | ||
|
|
ab18384a51 | ||
|
|
70783a01c6 | ||
|
|
2f9020455a | ||
|
|
8e850b1c93 | ||
|
|
430ed38cc5 | ||
|
|
623e93bf3e |
@@ -31,6 +31,7 @@ body:multipart-form {
|
||||
~skipEmptyPages: false
|
||||
~addOriginalDocumentAsStream: false
|
||||
~singlePageSheets: false
|
||||
~generateTaggedPdf: false
|
||||
~initialView: 0
|
||||
~initialPage: 1
|
||||
~magnification: 0
|
||||
|
||||
17
Makefile
17
Makefile
@@ -66,6 +66,7 @@ CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||
CHROMIUM_DENY_PRIVATE_IPS=false
|
||||
CHROMIUM_DENY_PUBLIC_IPS=false
|
||||
CHROMIUM_SHARED_BROWSER_CONTEXT=false
|
||||
CHROMIUM_CLEAR_CACHE=false
|
||||
CHROMIUM_CLEAR_COOKIES=false
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
@@ -154,6 +155,7 @@ NO_CONCURRENCY=false
|
||||
# chromium
|
||||
# chromium-concurrent
|
||||
# chromium-convert-html
|
||||
# chromium-image-deduplication
|
||||
# chromium-convert-markdown
|
||||
# chromium-convert-url
|
||||
# chromium-screenshot-html
|
||||
@@ -219,20 +221,17 @@ lint-prettier: ## Lint non-Golang codebase
|
||||
lint-todo: ## Find TODOs in Golang codebase
|
||||
golangci-lint run --no-config --default=none --enable godox
|
||||
|
||||
# TODO: restore a plain "go fix ./..." once both modernizers stop rewriting this
|
||||
# codebase into code that does not compile. Re-check by dropping a flag and
|
||||
# running "make fmt && make lint". Removing an analyzer upstream makes go fix
|
||||
# fail with "flag provided but not defined", so this cannot rot silently.
|
||||
# embedlit folds a post-literal field assignment into a literal that already
|
||||
# sets that key, producing a duplicate field name (cmd/gotenberg.go). Fixed in
|
||||
# Go 1.27.1, so drop that flag once the toolchain moves past 1.27.0.
|
||||
# See https://github.com/golang/go/issues/81101.
|
||||
# TODO: restore a plain "go fix ./..." once the errorsastype modernizer stops
|
||||
# rewriting this codebase into code that does not compile. Re-check by dropping
|
||||
# the flag and running "make fmt && make lint". Removing the analyzer upstream
|
||||
# makes go fix fail with "flag provided but not defined", so this cannot rot
|
||||
# silently.
|
||||
# errorsastype rewrites errors.As to errors.AsType[T] without checking that T
|
||||
# satisfies error, which breaks on api.HttpError since it does not embed
|
||||
# error. Still broken as of Go 1.27.1.
|
||||
.PHONY: fmt
|
||||
fmt: ## Format Golang codebase and "optimize" the dependencies
|
||||
go fix -embedlit=false -errorsastype=false ./...
|
||||
go fix -errorsastype=false ./...
|
||||
golangci-lint fmt
|
||||
go mod tidy
|
||||
|
||||
|
||||
@@ -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.27.0
|
||||
ARG GOLANG_VERSION=1.27.1
|
||||
|
||||
# ----------------------------------------------
|
||||
# pdfcpu binary build stage
|
||||
@@ -498,7 +498,7 @@ FROM gotenberg AS gotenberg-aws-lambda
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
@@ -516,7 +516,7 @@ FROM gotenberg-chromium AS gotenberg-aws-lambda-chromium
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
@@ -534,7 +534,7 @@ FROM gotenberg-libreoffice AS gotenberg-aws-lambda-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
|
||||
|
||||
ENV AWS_LWA_PORT=3000
|
||||
ENV AWS_LWA_READINESS_CHECK_PATH=/health
|
||||
|
||||
@@ -60,6 +60,7 @@ services:
|
||||
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
|
||||
- "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}"
|
||||
- "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}"
|
||||
- "--chromium-shared-browser-context=${CHROMIUM_SHARED_BROWSER_CONTEXT}"
|
||||
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
|
||||
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
|
||||
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
|
||||
|
||||
88
go.mod
88
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/gotenberg/gotenberg/v8
|
||||
|
||||
go 1.27.0
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.1
|
||||
@@ -8,40 +8,39 @@ require (
|
||||
github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/cucumber/godog v0.16.0
|
||||
github.com/dlclark/regexp2 v1.12.0
|
||||
github.com/dlclark/regexp2/v2 v2.8.0
|
||||
github.com/gomarkdown/markdown v0.0.0-20260824154242-13c5cf49db8d
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/labstack/echo/v4 v4.15.4
|
||||
github.com/labstack/echo/v5 v5.4.0
|
||||
github.com/labstack/gommon v0.5.0
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/moby/moby/api v1.56.0
|
||||
github.com/moby/moby/client v0.6.0
|
||||
github.com/moby/moby/api v1.56.1
|
||||
github.com/moby/moby/client v0.6.2
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.8
|
||||
github.com/shirou/gopsutil/v4 v4.26.9
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
|
||||
go.opentelemetry.io/otel v1.46.0
|
||||
go.opentelemetry.io/otel/log v0.22.0
|
||||
go.opentelemetry.io/otel/metric v1.46.0
|
||||
go.opentelemetry.io/otel/sdk v1.46.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.22.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0
|
||||
go.opentelemetry.io/otel/trace v1.46.0
|
||||
golang.org/x/net v0.58.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.21.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.72.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0
|
||||
go.opentelemetry.io/otel v1.47.0
|
||||
go.opentelemetry.io/otel/metric v1.47.0
|
||||
go.opentelemetry.io/otel/sdk v1.47.0
|
||||
go.opentelemetry.io/otel/sdk/log v1.47.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.47.0
|
||||
go.opentelemetry.io/otel/trace v1.47.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.3 // indirect
|
||||
@@ -62,10 +61,10 @@ require (
|
||||
github.com/cucumber/gherkin/go/v42 v42.0.1 // indirect
|
||||
github.com/cucumber/messages/go/v34 v34.2.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.8.1 // indirect
|
||||
github.com/docker/go-connections v0.8.2 // 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.11.0 // indirect
|
||||
github.com/ebitengine/purego v0.11.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
|
||||
@@ -76,7 +75,7 @@ require (
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||
@@ -104,7 +103,7 @@ require (
|
||||
github.com/pierrec/lz4/v4 v4.1.29 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
||||
github.com/prometheus/client_model v0.6.3 // indirect
|
||||
github.com/prometheus/common v0.71.0 // indirect
|
||||
github.com/prometheus/common v0.72.0 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||
@@ -114,31 +113,30 @@ require (
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.16 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect
|
||||
go.opentelemetry.io/otel/log v1.47.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.37.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect
|
||||
google.golang.org/grpc v1.84.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
181
go.sum
181
go.sum
@@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4 h1:m2xcjRABtG288vnlV3U/PKh/HnPURxZp50RKNUwBxM8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20261006220739-8c912ac31dd4/go.mod h1:MTUE7ZuZO89FAtAwozGg6AWD83pcdefnEq5gF82MB8s=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
@@ -58,17 +58,17 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
|
||||
github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
|
||||
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
||||
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
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.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
||||
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
||||
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
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=
|
||||
@@ -102,8 +102,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
@@ -131,8 +131,8 @@ github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||
github.com/labstack/echo/v5 v5.4.0 h1:iY674460IvSmUcj7MziL3YrgyTClghz5BpdZoKiGxR4=
|
||||
github.com/labstack/echo/v5 v5.4.0/go.mod h1:4iEGNQiPPZnkfYpNR/L6fINd3NLiGWUD5+eBotFALas=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
@@ -157,10 +157,10 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
|
||||
github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
|
||||
github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ=
|
||||
github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||
github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs=
|
||||
github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ=
|
||||
github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c=
|
||||
github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
|
||||
github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4=
|
||||
github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
|
||||
@@ -194,14 +194,14 @@ github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59u
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
|
||||
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
|
||||
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
|
||||
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
|
||||
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
|
||||
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.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
|
||||
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
|
||||
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
|
||||
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig=
|
||||
github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
|
||||
@@ -231,66 +231,62 @@ github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyU
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
|
||||
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 h1:5sHc4ToTFjfSZCtGAAM6jPunICAmJX73htv372T4ipc=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1/go.mod h1:oa6kgvyz/3GYW04dohd0++xJIH4xdQY8PAbpeCMaM8M=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0 h1:9qgxsFLskbDMXl8WMqThoF6w8yGJgCumn9qRc67OmnI=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.71.0/go.mod h1:2rCjF4F2siiTeLCzJsaGZ3CK0XIoimCSKXEBPdv+Je0=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0 h1:VCsJbp0YLyPtx2tu5Vgv2a2/qLoaMCj8hT2uZ34+Mx0=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.71.0/go.mod h1:qxZqn7e10f6ajmMCkg/47rMS7qQYfaOl2nj/4aytHUQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
|
||||
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0 h1:lYk7RmxdLK865qLwibroNGldHa1U7SWKYYvNjlK7PIo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.22.0/go.mod h1:6GvlND0H0xdUJanOtIAn0xfwLkauh1tmsYEEVSMDdqY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0 h1:kvMAiLEudKmk+CSG+iYbU8vTUGNNDaf/V09OO5lrTwI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.22.0/go.mod h1:L9Dlksri+MdT1cb2gIiA1cJJYW3Y92ipvDjNxYEyaDI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0 h1:PR9eAf7o0dQs3hshZNZpE9aW2dXWX/KdDf6pJilVD3U=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.46.0/go.mod h1:2Z4KyNdH1uuzivdinyfGsxzNNT/Rl45pwtVwfYVI0xk=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM=
|
||||
go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0=
|
||||
go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc=
|
||||
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
|
||||
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||
go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk=
|
||||
go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.21.0 h1:/CdgE52i1OEPHhsGUqrggYNRaZy4v4YgVenGd52dRBI=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.21.0/go.mod h1:9pBdbVbuehIDzxN4accnzzxEUPT8Nq7grbZMo+J1VN4=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 h1:OUIUu7ovHCEvG4IE5LQd2PNBH2eVYh50w/1ROwHEM7M=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.72.0/go.mod h1:pCrnkC07AZ5+h+0A0Z/1GYyqB+xu3A8LaEEhxvv9s6o=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.72.0 h1:TPXrl7vEkmh69tp7dHTpz+Y6AUenFg1Du3Nx3a6dlxY=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.72.0/go.mod h1:0jE6K8nsOgeiF53rcEhy71cgakek+XEWi4OPROUqLoI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw=
|
||||
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
|
||||
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 h1:5Ek4qQ5XX0PSQfY7lMW3CvcR36yy8YFDfGnYnaku8zE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0/go.mod h1:NUKIMOKSexQdTmS1pSxOQBDiVes3DHRier2ePsGW/gQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 h1:5WVp40k2fJHuc5BtNTgKk+EugKFT8Eg3lywFfCPI/nE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0/go.mod h1:59nH0RYCGHRlwt15WyioeSYINBbuObeBpTowPhEKWHY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 h1:LaLVGJtIzHcvHjVs2OftmKcc+M9mdOqS8vZ1fBD1Yes=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0/go.mod h1:vF6H5sNDuQBJWgSX00iZgbIqk2MZZoVMH0ar5t5bOpw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0/go.mod h1:CLzakYy8jOdkBfcveCWYkeKJxujcF4xPhh2Z7IfE98U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.69.0 h1:LQVBTGnHvzrfGJpDoCSVTY7eaLZArvaTSq5l84Skxew=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.69.0/go.mod h1:moif0nlgj/M16NRT8lDZVt8MC52+EOAJfR8EKRJ8LyU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 h1:PW/E9Tw6ZykC88pey0MZY8hKOUeEw+hX6PU+oXA4A9o=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0/go.mod h1:hUw4VNfiOnQ/yzGgy6ZdWVnudXw7iRfImhd6FjXOnvI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 h1:ww5DYBpMKcqLdxjWrhGUIh6O+ZfvZUaMk4niVyNQPEQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0/go.mod h1:Av5t+Ezx5S/Ks2dsEfWhny+GK+wrZ1K7NZ6aHSWnX3U=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA=
|
||||
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
|
||||
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
|
||||
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
|
||||
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
|
||||
go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM=
|
||||
go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k=
|
||||
go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
|
||||
go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
|
||||
go.opentelemetry.io/otel/sdk/log v1.47.0 h1:W4N/ZfgpkRRnmARnqYoQ6BrWA0mrj8IzVDNLlpmUJZY=
|
||||
go.opentelemetry.io/otel/sdk/log v1.47.0/go.mod h1:Wu74Z2EmOKXi2FleSNAb0Hyau2G+r5kubSEMJw5aAMs=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.23.0 h1:j3pm42QCrKYYLLkI6OrV1QHFkUfDA6/4EP+Y1PzggLg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.23.0/go.mod h1:zt0gEnuTZgDvqvczHwYswz93eqMaIr6upTTsdV/HUlU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
|
||||
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
|
||||
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
|
||||
go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw=
|
||||
go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
@@ -299,36 +295,35 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
|
||||
google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
|
||||
google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -5,9 +5,9 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.6",
|
||||
"prettier": "3.9.9",
|
||||
"prettier-plugin-gherkin": "^4.0.0",
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
"prettier-plugin-sh": "^0.20.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@cucumber/gherkin": {
|
||||
@@ -114,9 +114,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"version": "3.9.9",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
|
||||
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -142,14 +142,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-plugin-sh": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
|
||||
"integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.20.2.tgz",
|
||||
"integrity": "sha512-4QVyY7Vav0f5i6AVa80SFwUJXaWsAv9t0yUZIivwI0ucxTei6Jwhso/khWqaKDj4QGdwjP2py1nxQrDUeZ0jyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reteps/dockerfmt": "^0.5.4",
|
||||
"sh-syntax": "^0.6.0"
|
||||
"sh-syntax": "^0.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -169,9 +169,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/sh-syntax": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
|
||||
"integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.7.0.tgz",
|
||||
"integrity": "sha512-sgMyAxpHiGgAaNg+gD3ad41vha24CzIjjghD4mdiON1lUafZPLJNYknyjcXmU4geilPstd69zCKkpccY0YvajA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"prettier": "3.9.6",
|
||||
"prettier": "3.9.9",
|
||||
"prettier-plugin-gherkin": "^4.0.0",
|
||||
"prettier-plugin-sh": "^0.19.0"
|
||||
"prettier-plugin-sh": "^0.20.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package gotenberg
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
func TestAuditAllowList(t *testing.T) {
|
||||
@@ -93,7 +93,7 @@ func TestAuditAllowList_FlaggedPatternsAreActuallyExploitable(t *testing.T) {
|
||||
t.Fatalf("pattern %q was not flagged", tc.pattern)
|
||||
}
|
||||
|
||||
ok, err := regexp2.MustCompile(tc.pattern, 0).MatchString(tc.attack)
|
||||
ok, err := regexp2.MustCompile(tc.pattern, regexp2.None).MatchString(tc.attack)
|
||||
if err != nil {
|
||||
t.Fatalf("match %q: %v", tc.attack, err)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestAuditAllowList_SafePatternsRejectTheAttacks(t *testing.T) {
|
||||
t.Fatalf("safe pattern %q was flagged as %q", pattern, findings[0].Risk)
|
||||
}
|
||||
|
||||
re := regexp2.MustCompile(pattern, 0)
|
||||
re := regexp2.MustCompile(pattern, regexp2.None)
|
||||
for _, attack := range attacks {
|
||||
ok, err := re.MatchString(attack)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"github.com/labstack/gommon/bytes"
|
||||
flag "github.com/spf13/pflag"
|
||||
|
||||
@@ -222,6 +222,10 @@ func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newNam
|
||||
// built. Patterns compiled any other way keep regexp2's default of
|
||||
// math.MaxInt64, which it treats as no timeout at all, so a hand-built slice
|
||||
// must set this itself before reaching [DecideOutbound].
|
||||
//
|
||||
// The ceiling is wall-clock. Match through [MatchPattern] rather than calling
|
||||
// regexp2 directly: a match that never approaches the ceiling still aborts if
|
||||
// the process loses the CPU at the wrong moment.
|
||||
const PatternMatchTimeout = 250 * time.Millisecond
|
||||
|
||||
// MustRegexp returns the regular expression of a flag given by name.
|
||||
@@ -235,7 +239,7 @@ func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
re := regexp2.MustCompile(val, 0)
|
||||
re := regexp2.MustCompile(val, regexp2.None)
|
||||
re.MatchTimeout = PatternMatchTimeout
|
||||
|
||||
return re
|
||||
@@ -272,7 +276,7 @@ func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
|
||||
continue
|
||||
}
|
||||
|
||||
re := regexp2.MustCompile(val, 0)
|
||||
re := regexp2.MustCompile(val, regexp2.None)
|
||||
re.MatchTimeout = PatternMatchTimeout
|
||||
|
||||
regexps = append(regexps, re)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/exporters/autoexport"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
@@ -190,7 +189,7 @@ func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string)
|
||||
|
||||
loggerProvider := log.NewLoggerProvider(logOpts...)
|
||||
otelHandler := otelslog.NewHandler(serviceName, otelslog.WithLoggerProvider(loggerProvider))
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
otel.SetLoggerProvider(loggerProvider)
|
||||
|
||||
return loggerProvider.Shutdown, otelHandler, nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
@@ -109,11 +109,18 @@ var nonPublicIPv4Prefixes = []netip.Prefix{
|
||||
// the prefixes themselves are deprecated or translation-only. See
|
||||
// [nonPublicIPv6Prefixes] and [nonPublicIPv4Prefixes] for the full lists
|
||||
// and rationale.
|
||||
//
|
||||
// An IPv6 zone identifier is ignored, so [::%1] classifies the same as [::].
|
||||
func IsPublicIP(addr netip.Addr) bool {
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
// A zone does not change where a non-link-local address routes, but
|
||||
// [netip.Prefix.Contains] never matches a zoned address and
|
||||
// [netip.Addr.IsUnspecified] compares the zone too. Keeping it would let
|
||||
// http://[::%251]/ or any zoned address in the prefixes below pass as
|
||||
// public.
|
||||
addr = addr.WithZone("").Unmap()
|
||||
switch {
|
||||
case addr.IsLoopback(),
|
||||
addr.IsPrivate(),
|
||||
@@ -329,12 +336,16 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
||||
allowMatched := false
|
||||
if len(allowList) > 0 {
|
||||
for _, pattern := range allowList {
|
||||
ok, err := pattern.MatchString(normalized)
|
||||
ok, err := MatchPattern(pattern, normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return OutboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", pattern.String(), normalized, err)
|
||||
// The pattern could not be evaluated, so the URL cannot be
|
||||
// cleared for the IP-check bypass an allow-list match grants.
|
||||
// Fail closed like an unresolvable host does below, so the
|
||||
// client gets a generic 403 rather than a 500.
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %v: %w", pattern.String(), normalized, err, ErrFiltered)
|
||||
}
|
||||
|
||||
if ok {
|
||||
@@ -349,12 +360,15 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
||||
}
|
||||
|
||||
for _, pattern := range denyList {
|
||||
ok, err := pattern.MatchString(normalized)
|
||||
ok, err := MatchPattern(pattern, normalized)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return OutboundDecision{}, context.DeadlineExceeded
|
||||
}
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", pattern.String(), normalized, err)
|
||||
// The pattern could not be evaluated, so the URL cannot be proven
|
||||
// to fall outside the deny-list. Fail closed rather than letting a
|
||||
// deny-list that never ran pass the request through.
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %v: %w", pattern.String(), normalized, err, ErrFiltered)
|
||||
}
|
||||
|
||||
if ok {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
@@ -103,6 +103,23 @@ func TestIsPublicIP(t *testing.T) {
|
||||
|
||||
// Discard prefix (RFC 6666).
|
||||
{"100::1", false},
|
||||
|
||||
// A zone identifier must not change the classification.
|
||||
{"::%1", false},
|
||||
{"::%lo", false},
|
||||
{"::1%1", false},
|
||||
{"fe80::1%eth0", false},
|
||||
{"fc00::1%1", false},
|
||||
{"::ffff:127.0.0.1%1", false},
|
||||
{"fec0::1%eth0", false},
|
||||
{"2002:a9fe:a9fe::%1", false},
|
||||
{"2001:0:abcd:ef12:3456:7890:a9fe:a9fe%1", false},
|
||||
{"64:ff9b::a9fe:a9fe%1", false},
|
||||
{"64:ff9b:1::a9fe:a9fe%1", false},
|
||||
{"::a9fe:a9fe%1", false},
|
||||
{"2001:db8::1%1", false},
|
||||
{"100::1%1", false},
|
||||
{"2606:4700:4700::1111%1", true},
|
||||
} {
|
||||
t.Run(tc.addr, func(t *testing.T) {
|
||||
addr, err := netip.ParseAddr(tc.addr)
|
||||
@@ -147,10 +164,10 @@ func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
|
||||
|
||||
func TestFilterOutboundURL(t *testing.T) {
|
||||
defaultDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0),
|
||||
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, regexp2.None),
|
||||
}
|
||||
chromiumDeny := []*regexp2.Regexp{
|
||||
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, regexp2.None),
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -229,7 +246,7 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
{
|
||||
scenario: "allow-list match bypasses IP check",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, regexp2.None)},
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: false,
|
||||
@@ -237,15 +254,15 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
{
|
||||
scenario: "deny-list still wins over allow-list match",
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)},
|
||||
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)},
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, regexp2.None)},
|
||||
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, regexp2.None)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "allow-list non-empty and no match rejects",
|
||||
rawURL: "https://other.example/",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)},
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, regexp2.None)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
@@ -304,6 +321,22 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "zoned unspecified address blocked with deny-private-ips",
|
||||
rawURL: "http://[::%251]:9999/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "zoned site-local address blocked with deny-private-ips",
|
||||
rawURL: "http://[fec0:1234::3%25eth0]:8080/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.stub != nil {
|
||||
@@ -506,7 +539,7 @@ func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
|
||||
// The regex deny-list fires before any resolution; verifies that
|
||||
// operator-supplied deny patterns remain effective regardless of
|
||||
// IP-class options.
|
||||
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
|
||||
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, regexp2.None)}
|
||||
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
@@ -572,7 +605,7 @@ func TestDecideOutbound_UserinfoDoesNotEvadeDenyList(t *testing.T) {
|
||||
context.Background(),
|
||||
rawURL,
|
||||
nil,
|
||||
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, 0)},
|
||||
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, regexp2.None)},
|
||||
time.Now().Add(5*time.Second),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
@@ -584,7 +617,7 @@ func TestDecideOutbound_UserinfoDoesNotEvadeDenyList(t *testing.T) {
|
||||
|
||||
func TestDecideOutbound_UserinfoDoesNotSatisfyAllowList(t *testing.T) {
|
||||
// A host-terminated allow-list, the shape the documentation recommends.
|
||||
allowList := []*regexp2.Regexp{regexp2.MustCompile(`^https://trusted\.example\.com(:[0-9]+)?(/|$)`, 0)}
|
||||
allowList := []*regexp2.Regexp{regexp2.MustCompile(`^https://trusted\.example\.com(:[0-9]+)?(/|$)`, regexp2.None)}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://trusted.example.com@169.254.169.254/latest/meta-data/",
|
||||
@@ -623,7 +656,7 @@ func TestDecideOutbound_UserinfoKeptOutOfErrorMessages(t *testing.T) {
|
||||
context.Background(),
|
||||
"http://alice:hunter2@127.0.0.1:9999/",
|
||||
nil,
|
||||
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, 0)},
|
||||
[]*regexp2.Regexp{regexp2.MustCompile(privateIPsDenyList, regexp2.None)},
|
||||
time.Now().Add(5*time.Second),
|
||||
)
|
||||
if err == nil {
|
||||
@@ -647,7 +680,7 @@ func TestDecideOutbound_LegitimateCredentialsStillReachTheHost(t *testing.T) {
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"https://alice:hunter2@example.com/report.pdf",
|
||||
[]*regexp2.Regexp{regexp2.MustCompile(`^https://example\.com(:[0-9]+)?(/|$)`, 0)},
|
||||
[]*regexp2.Regexp{regexp2.MustCompile(`^https://example\.com(:[0-9]+)?(/|$)`, regexp2.None)},
|
||||
nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
@@ -741,7 +774,7 @@ func TestDecideOutboundBoundsCatastrophicPatterns(t *testing.T) {
|
||||
// caller's whole budget, so a 30s API_TIMEOUT bought a 30s CPU burn.
|
||||
// The trailing "!" makes the match fail only after the nested quantifier
|
||||
// has explored every way to split the run of "a"s.
|
||||
pattern := regexp2.MustCompile(`^https://example\.com/(a+)+$`, 0)
|
||||
pattern := regexp2.MustCompile(`^https://example\.com/(a+)+$`, regexp2.None)
|
||||
pattern.MatchTimeout = PatternMatchTimeout
|
||||
|
||||
rawURL := "https://example.com/" + strings.Repeat("a", 40) + "!"
|
||||
@@ -760,6 +793,13 @@ func TestDecideOutboundBoundsCatastrophicPatterns(t *testing.T) {
|
||||
t.Fatal("expected an error from a catastrophic deny-list pattern")
|
||||
}
|
||||
|
||||
// A deny-list that could not be evaluated cannot clear the URL, so the
|
||||
// decision fails closed and the client gets a generic 403 rather than a
|
||||
// 500 naming the pattern.
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("expected ErrFiltered from an unevaluable deny-list pattern but got: %v", err)
|
||||
}
|
||||
|
||||
// Generous headroom over the 250ms ceiling, still far below the 30s
|
||||
// deadline the match would otherwise have been allowed to consume.
|
||||
if elapsed > 5*time.Second {
|
||||
|
||||
55
pkg/gotenberg/pattern.go
Normal file
55
pkg/gotenberg/pattern.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
// patternMatchAttempts caps how many times [MatchPattern] runs one pattern
|
||||
// against one string. It is what keeps a pattern that is genuinely out of
|
||||
// budget from retrying forever: three attempts bound its cost at three
|
||||
// [PatternMatchTimeout], which is still two orders of magnitude below the
|
||||
// --api-timeout (env API_TIMEOUT) the ceiling exists to protect.
|
||||
const patternMatchAttempts = 3
|
||||
|
||||
// MatchPattern reports whether s matches pattern. It bounds the match by the
|
||||
// pattern's MatchTimeout without the false timeouts that the bound alone
|
||||
// produces.
|
||||
//
|
||||
// regexp2 does not time a match against [time.Now]. It derives the deadline
|
||||
// from a process-global clock that a background goroutine advances every
|
||||
// 100ms, and it tests that deadline on the very first step of the match.
|
||||
// Anything that stops the whole process, a cgroup CPU-quota throttle or a long
|
||||
// stop-the-world pause, also stops that goroutine, which then advances the
|
||||
// clock by the full pause in a single write. A match holding a deadline from
|
||||
// before that jump aborts whatever work it had done: a 366ns match against a
|
||||
// short URL reports "match timeout after 250ms". The abort lands on whichever
|
||||
// match straddles the jump rather than on a match that was slow, which is why
|
||||
// it fires on an idle instance and against Gotenberg's own file:///tmp/ URLs.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1659.
|
||||
//
|
||||
// Retrying separates the two cases. Catastrophic backtracking is
|
||||
// deterministic: the same pattern against the same string exhausts the same
|
||||
// budget on every attempt, so a genuine runaway still aborts, and costs at
|
||||
// most patternMatchAttempts ceilings to prove it. A clock-induced abort needs
|
||||
// the process to lose the CPU inside one specific match, which the next
|
||||
// attempt does not reproduce.
|
||||
//
|
||||
// Elapsed time cannot make that call instead. A match frozen mid-flight
|
||||
// reports the freeze as its own cost, 806ms against a 250ms ceiling in one
|
||||
// measured run, so it is indistinguishable by wall clock from a match that
|
||||
// really did spend its budget. Go exposes no per-goroutine CPU time, and
|
||||
// process CPU time counts every other request in flight.
|
||||
func MatchPattern(pattern *regexp2.Regexp, s string) (bool, error) {
|
||||
var err error
|
||||
|
||||
for range patternMatchAttempts {
|
||||
var ok bool
|
||||
|
||||
ok, err = pattern.MatchString(s)
|
||||
if err == nil {
|
||||
return ok, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
99
pkg/gotenberg/pattern_test.go
Normal file
99
pkg/gotenberg/pattern_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
// mustPattern compiles a pattern the way the production lists are built.
|
||||
func mustPattern(t *testing.T, expr string) *regexp2.Regexp {
|
||||
t.Helper()
|
||||
|
||||
re := regexp2.MustCompile(expr, regexp2.None)
|
||||
re.MatchTimeout = PatternMatchTimeout
|
||||
|
||||
return re
|
||||
}
|
||||
|
||||
func TestMatchPattern(t *testing.T) {
|
||||
// The abort [MatchPattern] absorbs cannot be staged here: it needs the
|
||||
// whole process to lose the CPU around one specific match, which no test
|
||||
// can schedule. What is testable is the other half of the contract, that
|
||||
// retrying never turns a genuine runaway into a pass.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1659.
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
pattern *regexp2.Regexp
|
||||
s string
|
||||
expectMatch bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "deny-list match",
|
||||
pattern: mustPattern(t, `^file:(?!//\/tmp/).*`),
|
||||
s: "file:///etc/passwd",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
scenario: "no match against Gotenberg's own working directory",
|
||||
pattern: mustPattern(t, `^file:(?!//\/tmp/).*`),
|
||||
s: "file:///tmp/1a2b3c4d/5e6f7a8b/9c0d1e2f.html",
|
||||
expectMatch: false,
|
||||
},
|
||||
{
|
||||
scenario: "no match",
|
||||
pattern: mustPattern(t, `^https://example\.com/`),
|
||||
s: "https://example.org/",
|
||||
expectMatch: false,
|
||||
},
|
||||
{
|
||||
scenario: "catastrophic backtracking still aborts",
|
||||
pattern: mustPattern(t, `^https://example\.com/(a+)+$`),
|
||||
s: "https://example.com/" + strings.Repeat("a", 40) + "!",
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ok, err := MatchPattern(tc.pattern, tc.s)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected an error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if ok != tc.expectMatch {
|
||||
t.Fatalf("expected match %t but got %t", tc.expectMatch, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPatternBoundsCatastrophicPattern(t *testing.T) {
|
||||
// Proving a runaway is genuine costs one ceiling per attempt, so the
|
||||
// worst case is patternMatchAttempts of them plus regexp2's clock period
|
||||
// on each, roughly a second. The bound that matters is the one this
|
||||
// replaced: before the ceiling existed, the same match was allowed to
|
||||
// burn a core for the caller's whole 30s budget.
|
||||
pattern := mustPattern(t, `^https://example\.com/(a+)+$`)
|
||||
s := "https://example.com/" + strings.Repeat("a", 40) + "!"
|
||||
|
||||
start := time.Now()
|
||||
_, err := MatchPattern(pattern, s)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a catastrophic pattern")
|
||||
}
|
||||
|
||||
// Generous headroom over the expected second keeps this stable on a
|
||||
// loaded CI box while still failing if the bound is gone.
|
||||
if elapsed > 5*time.Second {
|
||||
t.Fatalf("match took %s, want at most %d ceilings of %s", elapsed, patternMatchAttempts, PatternMatchTimeout)
|
||||
}
|
||||
}
|
||||
@@ -201,17 +201,13 @@ func TestNewServerRecordMetrics(t *testing.T) {
|
||||
server.RecordMetrics(t.Context(), semconv.ServerMetricData{
|
||||
ServerName: "stuff",
|
||||
ResponseSize: 200,
|
||||
MetricAttributes: semconv.MetricAttributes{
|
||||
Req: req,
|
||||
StatusCode: 301,
|
||||
AdditionalAttributes: []attribute.KeyValue{
|
||||
attribute.String("key", "value"),
|
||||
},
|
||||
},
|
||||
MetricData: semconv.MetricData{
|
||||
RequestSize: 100,
|
||||
ElapsedTime: 300,
|
||||
Req: req,
|
||||
StatusCode: 301,
|
||||
AdditionalAttributes: []attribute.KeyValue{
|
||||
attribute.String("key", "value"),
|
||||
},
|
||||
RequestSize: 100,
|
||||
ElapsedTime: 300,
|
||||
})
|
||||
|
||||
rm := metricdata.ResourceMetrics{}
|
||||
|
||||
@@ -12,10 +12,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"github.com/labstack/echo/v5"
|
||||
flag "github.com/spf13/pflag"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -58,6 +57,12 @@ type Api struct {
|
||||
fs *gotenberg.FileSystem
|
||||
logger *slog.Logger
|
||||
srv *echo.Echo
|
||||
|
||||
// shutdownCancel triggers the graceful shutdown of the server started by
|
||||
// [echo.StartConfig]. Echo v5 drives shutdown from a context instead of an
|
||||
// Echo.Shutdown method. serveDone closes once that shutdown completed.
|
||||
shutdownCancel context.CancelFunc
|
||||
serveDone chan struct{}
|
||||
}
|
||||
|
||||
type downloadFromConfig struct {
|
||||
@@ -134,7 +139,7 @@ const (
|
||||
// middleware := Middleware{
|
||||
// Handler: func() echo.MiddlewareFunc {
|
||||
// return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
// return func(c echo.Context) error {
|
||||
// return func(c *echo.Context) error {
|
||||
// rootPath := c.Get("rootPath").(string)
|
||||
// healthURI := fmt.Sprintf("%shealth", rootPath)
|
||||
//
|
||||
@@ -536,16 +541,29 @@ func (a *Api) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newEchoServer builds the [echo.Echo] instance with the settings Gotenberg
|
||||
// relies on, independently of the routes and middlewares added by [Api.Start].
|
||||
func newEchoServer() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HTTPErrorHandler = httpErrorHandler()
|
||||
// Echo v5 serves files through Echo.Filesystem, an [fs.FS] rooted at the
|
||||
// working directory, and [fs.FS] rejects absolute names. Every output file
|
||||
// lives under the request's temporary directory, so Context.Attachment gets
|
||||
// an absolute path and the default filesystem answers 404 for every
|
||||
// conversion. Rooting at "/" restores the v4 behavior, where the path was
|
||||
// opened as-is.
|
||||
srv.Filesystem = echo.NewDefaultFS("/")
|
||||
// Echo v5 dropped the X-Forwarded-For and X-Real-IP fallbacks from
|
||||
// Context.RealIP. Keep the previous behavior so that the access log still
|
||||
// reports the client IP when Gotenberg sits behind a reverse proxy.
|
||||
srv.IPExtractor = echo.LegacyIPExtractor()
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// Start starts the HTTP server.
|
||||
func (a *Api) Start() error {
|
||||
a.srv = echo.New()
|
||||
a.srv.HideBanner = true
|
||||
a.srv.HidePort = true
|
||||
a.srv.Server.ReadTimeout = a.timeout
|
||||
a.srv.Server.IdleTimeout = a.timeout
|
||||
// See https://github.com/gotenberg/gotenberg/issues/396.
|
||||
a.srv.Server.WriteTimeout = a.timeout + a.timeout
|
||||
a.srv.HTTPErrorHandler = httpErrorHandler()
|
||||
a.srv = newEchoServer()
|
||||
|
||||
// Let's prepare the modules' routes.
|
||||
var disableTelemetryForPaths []string
|
||||
@@ -609,7 +627,7 @@ func (a *Api) Start() error {
|
||||
securityMiddleware = oidcAuthMiddleware(verifier)
|
||||
default:
|
||||
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
@@ -641,7 +659,7 @@ func (a *Api) Start() error {
|
||||
// Root route.
|
||||
a.srv.GET(
|
||||
a.rootPath,
|
||||
func(c echo.Context) error {
|
||||
func(c *echo.Context) error {
|
||||
return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the <a href="https://gotenberg.dev">documentation</a> to learn how to interact with it 🚀`)
|
||||
},
|
||||
securityMiddleware,
|
||||
@@ -650,7 +668,7 @@ func (a *Api) Start() error {
|
||||
// Favicon route.
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"),
|
||||
func(c echo.Context) error {
|
||||
func(c *echo.Context) error {
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
},
|
||||
securityMiddleware,
|
||||
@@ -681,7 +699,7 @@ func (a *Api) Start() error {
|
||||
// ...the version route.
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "version"),
|
||||
func(c echo.Context) error {
|
||||
func(c *echo.Context) error {
|
||||
return c.String(http.StatusOK, gotenberg.Version)
|
||||
},
|
||||
securityMiddleware,
|
||||
@@ -691,7 +709,7 @@ func (a *Api) Start() error {
|
||||
if a.enableDebugRoute {
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "debug"),
|
||||
func(c echo.Context) error {
|
||||
func(c *echo.Context) error {
|
||||
return c.JSONPretty(http.StatusOK, gotenberg.Debug(), " ")
|
||||
},
|
||||
securityMiddleware,
|
||||
@@ -712,18 +730,50 @@ func (a *Api) Start() error {
|
||||
return fmt.Errorf("waiting for modules readiness: %w", err)
|
||||
}
|
||||
|
||||
startConfig := echo.StartConfig{
|
||||
Address: fmt.Sprintf("%s:%d", a.bindIp, a.port),
|
||||
HideBanner: true,
|
||||
HidePort: true,
|
||||
BeforeServeFunc: func(s *http.Server) error {
|
||||
s.ReadTimeout = a.timeout
|
||||
s.IdleTimeout = a.timeout
|
||||
// See https://github.com/gotenberg/gotenberg/issues/396.
|
||||
s.WriteTimeout = a.timeout + a.timeout
|
||||
|
||||
if a.tlsCertFile == "" || a.tlsKeyFile == "" {
|
||||
// Serve HTTP/2 Cleartext (h2c). Echo v5 dropped
|
||||
// StartH2CServer and golang.org/x/net/http2/h2c is deprecated,
|
||||
// so the standard library serves h2c through Server.Protocols
|
||||
// instead.
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(true)
|
||||
protocols.SetUnencryptedHTTP2(true)
|
||||
s.Protocols = protocols
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Not named cancel: that would reassign the readiness timeout's cancel from
|
||||
// above, whose deferred call is already bound to the old value.
|
||||
serveCtx, serveCancel := context.WithCancel(context.Background())
|
||||
a.shutdownCancel = serveCancel
|
||||
a.serveDone = make(chan struct{})
|
||||
|
||||
// As the following code is blocking, run it in a goroutine.
|
||||
go func() {
|
||||
defer close(a.serveDone)
|
||||
|
||||
var err error
|
||||
if a.tlsCertFile != "" && a.tlsKeyFile != "" {
|
||||
// Start an HTTPS server (supports HTTP/2).
|
||||
err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile)
|
||||
err = startConfig.StartTLS(serveCtx, a.srv, a.tlsCertFile, a.tlsKeyFile)
|
||||
} else {
|
||||
// Start an HTTP/2 Cleartext (non-HTTPS) server.
|
||||
server := &http2.Server{}
|
||||
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
|
||||
err = startConfig.Start(serveCtx, a.srv)
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
a.logger.ErrorContext(context.Background(), err.Error())
|
||||
}
|
||||
}()
|
||||
@@ -740,6 +790,26 @@ func (a *Api) StartupMessage() string {
|
||||
return fmt.Sprintf("server started on %s:%d", ip, a.port)
|
||||
}
|
||||
|
||||
// shutdown triggers the server's graceful shutdown and waits for it to
|
||||
// complete, or for ctx to be done. Cancelling the serve context is what Echo v5
|
||||
// exposes in place of Echo.Shutdown: [echo.StartConfig] shuts the server down
|
||||
// on its own deadline, so the shutdown proceeds even when ctx is already done.
|
||||
func (a *Api) shutdown(ctx context.Context) error {
|
||||
if a.shutdownCancel == nil {
|
||||
// Start never ran, so there is nothing to shut down.
|
||||
return nil
|
||||
}
|
||||
|
||||
a.shutdownCancel()
|
||||
|
||||
select {
|
||||
case <-a.serveDone:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the HTTP server.
|
||||
func (a *Api) Stop(ctx context.Context) error {
|
||||
for {
|
||||
@@ -749,7 +819,7 @@ func (a *Api) Stop(ctx context.Context) error {
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return a.srv.Shutdown(ctx)
|
||||
return a.shutdown(ctx)
|
||||
default:
|
||||
a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count))
|
||||
if count > 0 {
|
||||
@@ -757,7 +827,7 @@ func (a *Api) Stop(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
a.logger.DebugContext(ctx, "no more asynchronous requests, continue with shutdown")
|
||||
err := a.srv.Shutdown(ctx)
|
||||
err := a.shutdown(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shutdown: %w", err)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/mholt/archives"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -70,7 +70,7 @@ type Context struct {
|
||||
outputFilename string
|
||||
|
||||
logger *slog.Logger
|
||||
echoCtx echo.Context
|
||||
echoCtx *echo.Context
|
||||
mkdirAll gotenberg.MkdirAll
|
||||
pathRename gotenberg.PathRename
|
||||
context.Context
|
||||
@@ -158,7 +158,7 @@ type downloadFrom struct {
|
||||
}
|
||||
|
||||
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
||||
func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) {
|
||||
func newContext(echoCtx *echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) {
|
||||
processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
|
||||
|
||||
// We want to make sure the multipart/form-data does not exceed a given
|
||||
@@ -166,13 +166,17 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
// downloaded remotely ("download from" feature).
|
||||
var totalBytesRead atomic.Int64
|
||||
|
||||
bodyLimitErr := func() error {
|
||||
return WrapError(
|
||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
|
||||
)
|
||||
}
|
||||
|
||||
addReadBytes := func(n int64) error {
|
||||
newTotal := totalBytesRead.Add(n)
|
||||
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||
return WrapError(
|
||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, "The request body exceeds the configured size limit. Increase it with --api-body-limit, or send a smaller request."),
|
||||
)
|
||||
return bodyLimitErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -217,8 +221,25 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
||||
}
|
||||
}()
|
||||
|
||||
// The multipart parser reads the whole request and spools file parts to
|
||||
// disk before the counter above sees a single byte, so the limit must
|
||||
// also bound the raw body itself. Content-Length allows an early
|
||||
// rejection, but it may be absent or lie, hence the reader.
|
||||
if bodyLimit > 0 {
|
||||
req := echoCtx.Request()
|
||||
if req.ContentLength > bodyLimit {
|
||||
return nil, cancel, fmt.Errorf("check content length: %w", bodyLimitErr())
|
||||
}
|
||||
req.Body = http.MaxBytesReader(echoCtx.Response(), req.Body, bodyLimit)
|
||||
}
|
||||
|
||||
form, err := echoCtx.MultipartForm()
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
return nil, cancel, fmt.Errorf("get multipart form: %w", bodyLimitErr())
|
||||
}
|
||||
|
||||
if errors.Is(err, http.ErrNotMultipart) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -19,8 +20,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
@@ -875,7 +876,7 @@ func TestNewContext_DownloadFromRedirectVerdictStaysGeneric(t *testing.T) {
|
||||
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||
|
||||
// The first hop is allowed, the redirect target is denied by the deny-list.
|
||||
denyList := []*regexp2.Regexp{regexp2.MustCompile("^"+regexp.QuoteMeta(private.URL), 0)}
|
||||
denyList := []*regexp2.Regexp{regexp2.MustCompile("^"+regexp.QuoteMeta(private.URL), regexp2.None)}
|
||||
|
||||
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{
|
||||
denyList: denyList,
|
||||
@@ -902,3 +903,100 @@ func TestNewContext_DownloadFromRedirectVerdictStaysGeneric(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type countingReadCloser struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countingReadCloser) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
c.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (*countingReadCloser) Close() error { return nil }
|
||||
|
||||
// The body limit must stop the multipart parser itself. It used to apply
|
||||
// only after the parser had read the whole request and spooled file parts
|
||||
// to disk.
|
||||
func TestNewContext_BodyLimitBoundsIngress(t *testing.T) {
|
||||
body := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(body)
|
||||
err := writer.WriteField("foo", "bar")
|
||||
if err != nil {
|
||||
t.Fatalf("write multipart field: %v", err)
|
||||
}
|
||||
part, err := writer.CreateFormFile("files", "large.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("create multipart file: %v", err)
|
||||
}
|
||||
_, err = part.Write(bytes.Repeat([]byte("A"), 2<<20))
|
||||
if err != nil {
|
||||
t.Fatalf("write multipart file: %v", err)
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
size := int64(body.Len())
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
bodyLimit int64
|
||||
knownLength bool
|
||||
expectErr bool
|
||||
maxBytesConsumed int64
|
||||
}{
|
||||
{scenario: "known length over the limit is rejected before reading", bodyLimit: 1024, knownLength: true, expectErr: true, maxBytesConsumed: 0},
|
||||
{scenario: "unknown length over the limit stops at the limit", bodyLimit: 1024, knownLength: false, expectErr: true, maxBytesConsumed: 1024 + 1},
|
||||
{scenario: "unknown length one byte over the limit", bodyLimit: size - 1, knownLength: false, expectErr: true, maxBytesConsumed: size},
|
||||
{scenario: "body exactly at the limit", bodyLimit: size, knownLength: true, expectErr: false},
|
||||
{scenario: "body exactly at the limit with unknown length", bodyLimit: size, knownLength: false, expectErr: false},
|
||||
{scenario: "no limit", bodyLimit: 0, knownLength: false, expectErr: false},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
counted := &countingReadCloser{r: bytes.NewReader(body.Bytes())}
|
||||
req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/merge", nil)
|
||||
req.Body = counted
|
||||
req.ContentLength = -1
|
||||
if tc.knownLength {
|
||||
req.ContentLength = size
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||
|
||||
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, tc.bodyLimit, downloadFromConfig{disable: true})
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if !tc.expectErr {
|
||||
if err != nil {
|
||||
t.Fatalf("newContext returned error: %v", err)
|
||||
}
|
||||
if counted.n != size {
|
||||
t.Fatalf("consumed %d bytes, want %d", counted.n, size)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected the body limit to reject the request")
|
||||
}
|
||||
var httpErr HttpError
|
||||
if !errors.As(err, &httpErr) {
|
||||
t.Fatalf("expected an HttpError, got: %v", err)
|
||||
}
|
||||
if status, _ := httpErr.HttpError(); status != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want %d", status, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
if counted.n > tc.maxBytesConsumed {
|
||||
t.Fatalf("consumed %d/%d bytes before rejection, want at most %d", counted.n, size, tc.maxBytesConsumed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/labstack/echo/v5/middleware"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -37,10 +37,12 @@ var (
|
||||
// ParseError parses an error and returns the corresponding HTTP status and
|
||||
// HTTP message.
|
||||
func ParseError(err error) (int, string) {
|
||||
var echoErr *echo.HTTPError
|
||||
ok := errors.As(err, &echoErr)
|
||||
if ok {
|
||||
return echoErr.Code, http.StatusText(echoErr.Code)
|
||||
// [echo.StatusCode] also matches the router's ErrNotFound and
|
||||
// ErrMethodNotAllowed sentinels, which Echo v5 no longer models as
|
||||
// [echo.HTTPError]. Matching that type alone would let every unrouted
|
||||
// request fall through to a 500.
|
||||
if code := echo.StatusCode(err); code != 0 {
|
||||
return code, http.StatusText(code)
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
@@ -100,14 +102,14 @@ const statusClientClosedRequest = 499
|
||||
// A server-side timeout is [context.DeadlineExceeded], mapped to 503 by
|
||||
// [ParseError], and is deliberately not treated as a client abort.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||
func requestCanceled(c echo.Context, err error) bool {
|
||||
func requestCanceled(c *echo.Context, err error) bool {
|
||||
return errors.Is(err, context.Canceled) && errors.Is(c.Request().Context().Err(), context.Canceled)
|
||||
}
|
||||
|
||||
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
||||
// returns a response as "text/plain; charset=UTF-8".
|
||||
func httpErrorHandler() echo.HTTPErrorHandler {
|
||||
return func(err error, c echo.Context) {
|
||||
return func(c *echo.Context, err error) {
|
||||
logger := c.Get("logger").(*slog.Logger)
|
||||
|
||||
if requestCanceled(c, err) {
|
||||
@@ -134,7 +136,7 @@ func httpErrorHandler() echo.HTTPErrorHandler {
|
||||
// startTime := c.Get("startTime").(time.Time)
|
||||
func latencyMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
// First piece for calculating the latency.
|
||||
startTime := time.Now()
|
||||
c.Set("startTime", startTime)
|
||||
@@ -159,7 +161,7 @@ func latencyMiddleware() echo.MiddlewareFunc {
|
||||
// }
|
||||
func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
c.Set("rootPath", rootPath)
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
@@ -173,7 +175,7 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
||||
// outputFilename := c.Get("outputFilename").(string)
|
||||
func outputFilenameMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
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.
|
||||
@@ -203,10 +205,18 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
semconvSrv := semconvutil.NewHTTPServer(meter)
|
||||
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
startTime := c.Get("startTime").(time.Time)
|
||||
rootPath := c.Get("rootPath").(string)
|
||||
|
||||
// Echo v5 returns the bare [http.ResponseWriter] from
|
||||
// Context.Response, so unwrap it to read the recorded status and
|
||||
// size below.
|
||||
response, errUnwrap := echo.UnwrapResponse(c.Response())
|
||||
if errUnwrap != nil {
|
||||
return fmt.Errorf("unwrap response: %w", errUnwrap)
|
||||
}
|
||||
|
||||
request := c.Request()
|
||||
savedCtx := request.Context()
|
||||
defer func() {
|
||||
@@ -239,7 +249,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
|
||||
err := next(c)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
c.Echo().HTTPErrorHandler(c, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -288,7 +298,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
err := next(c)
|
||||
finishTime := time.Now()
|
||||
|
||||
status := c.Response().Status
|
||||
status := response.Status
|
||||
canceled := false
|
||||
if err != nil {
|
||||
canceled = requestCanceled(c, err)
|
||||
@@ -300,13 +310,13 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("error", err.Error()))
|
||||
c.Error(err)
|
||||
c.Echo().HTTPErrorHandler(c, err)
|
||||
}
|
||||
|
||||
span.SetStatus(semconvSrv.Status(status))
|
||||
span.SetAttributes(semconvSrv.ResponseTraceAttrs(semconvutil.ResponseTelemetry{
|
||||
StatusCode: status,
|
||||
WriteBytes: c.Response().Size,
|
||||
WriteBytes: response.Size,
|
||||
})...)
|
||||
|
||||
// Pick the level and message before building the record: err.Error
|
||||
@@ -343,11 +353,11 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
slog.String("path", routePath),
|
||||
slog.String("referer", c.Request().Referer()),
|
||||
slog.String("user_agent", c.Request().UserAgent()),
|
||||
slog.Int("status", c.Response().Status),
|
||||
slog.Int("status", response.Status),
|
||||
slog.Int64("latency", int64(latency)),
|
||||
slog.String("latency_human", latency.String()),
|
||||
slog.Int64("bytes_in", c.Request().ContentLength),
|
||||
slog.Int64("bytes_out", c.Response().Size),
|
||||
slog.Int64("bytes_out", response.Size),
|
||||
)
|
||||
|
||||
additionalAttributes := []attribute.KeyValue{
|
||||
@@ -355,17 +365,13 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
}
|
||||
|
||||
semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{
|
||||
ServerName: serverName,
|
||||
ResponseSize: c.Response().Size,
|
||||
MetricAttributes: semconvutil.MetricAttributes{
|
||||
Req: request,
|
||||
StatusCode: status,
|
||||
AdditionalAttributes: additionalAttributes,
|
||||
},
|
||||
MetricData: semconvutil.MetricData{
|
||||
RequestSize: request.ContentLength,
|
||||
ElapsedTime: float64(time.Since(startTime)) / float64(time.Millisecond),
|
||||
},
|
||||
ServerName: serverName,
|
||||
ResponseSize: response.Size,
|
||||
Req: request,
|
||||
StatusCode: status,
|
||||
AdditionalAttributes: additionalAttributes,
|
||||
RequestSize: request.ContentLength,
|
||||
ElapsedTime: float64(time.Since(startTime)) / float64(time.Millisecond),
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -375,7 +381,7 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
||||
|
||||
// basicAuthMiddleware manages basic authentication.
|
||||
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
|
||||
return middleware.BasicAuth(func(c *echo.Context, u string, p string) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
|
||||
return true, nil
|
||||
@@ -421,7 +427,7 @@ func (a *Api) buildOidcVerifier() (*oidc.IDTokenVerifier, error) {
|
||||
// it to the client.
|
||||
func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
rawToken, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||
if !ok || rawToken == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "a Bearer token is required in the Authorization header")
|
||||
@@ -450,7 +456,7 @@ func oidcAuthMiddleware(verifier *oidc.IDTokenVerifier) echo.MiddlewareFunc {
|
||||
// cancel := c.Get("cancel").(context.CancelFunc)
|
||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
logger, _ := c.Get("logger").(*slog.Logger)
|
||||
if logger == nil {
|
||||
return errors.New("no logger in context (possible pool reuse)")
|
||||
@@ -511,7 +517,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
|
||||
// handler fails to timeout as expected.
|
||||
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
// Guard the type assertion so a pooled [echo.Context] whose
|
||||
// store has been recycled under us does not crash the process.
|
||||
// See the webhook async handler for the race this protects
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
@@ -9,13 +10,15 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// TestRequestCanceled pins the client-abort discriminator: only a
|
||||
@@ -73,7 +76,7 @@ func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
|
||||
c := echo.New().NewContext(req, rec)
|
||||
c.Set("logger", slog.New(slog.DiscardHandler))
|
||||
|
||||
httpErrorHandler()(tc.err, c)
|
||||
httpErrorHandler()(c, tc.err)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||
@@ -105,7 +108,7 @@ func TestOutputFilenameMiddleware(t *testing.T) {
|
||||
{"control characters", "fo\x01o\x7f", "foo"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
|
||||
handler := outputFilenameMiddleware()(func(c *echo.Context) error { return nil })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
if tc.header != "" {
|
||||
@@ -131,7 +134,7 @@ func TestOutputFilenameMiddleware(t *testing.T) {
|
||||
|
||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||
handler := mw(func(c echo.Context) error { return nil })
|
||||
handler := mw(func(c *echo.Context) error { return nil })
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
@@ -220,7 +223,7 @@ func TestOidcAuthMiddleware(t *testing.T) {
|
||||
}
|
||||
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||
|
||||
handler := oidcAuthMiddleware(verifier)(func(c echo.Context) error {
|
||||
handler := oidcAuthMiddleware(verifier)(func(c *echo.Context) error {
|
||||
return c.NoContent(http.StatusOK)
|
||||
})
|
||||
|
||||
@@ -243,3 +246,114 @@ func TestOidcAuthMiddleware(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseError_StatusMapping pins the statuses [ParseError] derives from the
|
||||
// errors Echo and Gotenberg produce.
|
||||
//
|
||||
// Echo v5 models the router's ErrNotFound and ErrMethodNotAllowed as an
|
||||
// unexported type rather than [echo.HTTPError], so matching that type alone
|
||||
// would turn every unrouted request into a 500. It also guards the ordering:
|
||||
// Gotenberg's own [SentinelHttpError] carries a client-facing message and must
|
||||
// not be shadowed by the generic status lookup.
|
||||
func TestParseError_StatusMapping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
err error
|
||||
wantStatus int
|
||||
wantMessage string
|
||||
}{
|
||||
{"router not found", echo.ErrNotFound, http.StatusNotFound, http.StatusText(http.StatusNotFound)},
|
||||
{"router method not allowed", echo.ErrMethodNotAllowed, http.StatusMethodNotAllowed, http.StatusText(http.StatusMethodNotAllowed)},
|
||||
{"explicit HTTP error", echo.NewHTTPError(http.StatusUnauthorized, "nope"), http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)},
|
||||
{"wrapped HTTP error", fmt.Errorf("authenticate request: %w", echo.NewHTTPError(http.StatusUnauthorized, "nope")), http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)},
|
||||
{"sentinel keeps its message", NewSentinelHttpError(http.StatusBadRequest, "Invalid 'foo' form field value"), http.StatusBadRequest, "Invalid 'foo' form field value"},
|
||||
{"unknown error", errors.New("boom"), http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
status, message := ParseError(tc.err)
|
||||
if status != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", status, tc.wantStatus)
|
||||
}
|
||||
if message != tc.wantMessage {
|
||||
t.Fatalf("message = %q, want %q", message, tc.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewEchoServer_RealIP pins the client IP extraction that the access log's
|
||||
// "remote_ip" field depends on.
|
||||
//
|
||||
// Echo v5.1.0 dropped the X-Forwarded-For and X-Real-IP fallbacks from
|
||||
// Context.RealIP, so without an explicit extractor a Gotenberg behind a reverse
|
||||
// proxy would log the proxy's address for every request. [newEchoServer]
|
||||
// restores the previous behavior.
|
||||
func TestNewEchoServer_RealIP(t *testing.T) {
|
||||
srv := newEchoServer()
|
||||
if srv.IPExtractor == nil {
|
||||
t.Fatal("no IPExtractor configured: remote_ip would report the proxy address")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
headers map[string]string
|
||||
want string
|
||||
}{
|
||||
{"x-forwarded-for keeps the client, not the proxy", map[string]string{"X-Forwarded-For": "203.0.113.7, 70.41.3.18"}, "203.0.113.7"},
|
||||
{"single x-forwarded-for", map[string]string{"X-Forwarded-For": "203.0.113.7"}, "203.0.113.7"},
|
||||
{"bracketed IPv6 is unwrapped", map[string]string{"X-Forwarded-For": "[2001:db8::1], 70.41.3.18"}, "2001:db8::1"},
|
||||
{"x-real-ip when no x-forwarded-for", map[string]string{"X-Real-IP": "203.0.113.9"}, "203.0.113.9"},
|
||||
{"no headers falls back to the remote address", nil, "192.0.2.1"},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
for key, value := range tc.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
c := srv.NewContext(req, httptest.NewRecorder())
|
||||
|
||||
if got := c.RealIP(); got != tc.want {
|
||||
t.Fatalf("RealIP = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewEchoServer_AttachmentServesAbsolutePath pins the filesystem that every
|
||||
// conversion response is sent through.
|
||||
//
|
||||
// Echo v5 serves files through Echo.Filesystem, an [fs.FS] rooted at the working
|
||||
// directory, and [fs.FS] rejects absolute names. Gotenberg builds every output
|
||||
// file under the request's temporary directory and hands Context.Attachment an
|
||||
// absolute path, so with the default filesystem every conversion route answers
|
||||
// 404 while still reading the whole upload.
|
||||
func TestNewEchoServer_AttachmentServesAbsolutePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "output.pdf")
|
||||
want := []byte("%PDF-1.7 not really a PDF")
|
||||
|
||||
err := os.WriteFile(path, want, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
|
||||
srv := newEchoServer()
|
||||
rec := httptest.NewRecorder()
|
||||
c := srv.NewContext(httptest.NewRequest(http.MethodPost, "/", nil), rec)
|
||||
|
||||
err = c.Attachment(path, "output.pdf")
|
||||
if err != nil {
|
||||
t.Fatalf("Attachment(%q) = %v, want nil", path, err)
|
||||
}
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, want) {
|
||||
t.Fatalf("body = %q, want %q", got, want)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, `filename="output.pdf"`) {
|
||||
t.Fatalf("Content-Disposition = %q, want it to carry filename=\"output.pdf\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
@@ -83,7 +83,7 @@ func (ctx *ContextMock) SetLogger(logger *slog.Logger) {
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setEchoContext(c)
|
||||
func (ctx *ContextMock) SetEchoContext(c echo.Context) {
|
||||
func (ctx *ContextMock) SetEchoContext(c *echo.Context) {
|
||||
ctx.echoCtx = c
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ import (
|
||||
"time"
|
||||
|
||||
cdprotobrowser "github.com/chromedp/cdproto/browser"
|
||||
"github.com/chromedp/cdproto/cdp"
|
||||
"github.com/chromedp/cdproto/fetch"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/cdproto/target"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"github.com/shirou/gopsutil/v4/process"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -62,14 +64,29 @@ type browserArguments struct {
|
||||
hyphenDataDirPath string
|
||||
|
||||
// Tasks specific.
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
clearStorage bool
|
||||
disableJavaScript bool
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
sharedBrowserContext bool
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
clearStorage bool
|
||||
disableJavaScript bool
|
||||
}
|
||||
|
||||
// restrictsNetwork reports whether the outbound policy can reject a network
|
||||
// URL. The default deny-list only rejects file:// URLs, so it does not count.
|
||||
func (a browserArguments) restrictsNetwork() bool {
|
||||
if a.denyPrivateIPs || a.denyPublicIPs || len(a.allowList) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, pattern := range a.denyList {
|
||||
if pattern.String() != defaultDenyListPattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type chromiumBrowser struct {
|
||||
@@ -202,7 +219,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
}
|
||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||
|
||||
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs {
|
||||
if b.arguments.restrictsNetwork() {
|
||||
// Chromium implicitly bypasses the proxy for loopback and
|
||||
// link-local destinations. A WebSocket handshake is never surfaced
|
||||
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
|
||||
@@ -210,17 +227,14 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
|
||||
// localhost, or the link-local cloud metadata endpoint
|
||||
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
||||
// the implicit bypass so those handshakes also traverse the pinning
|
||||
// proxy and go through [gotenberg.DecideOutbound] like every other
|
||||
// request.
|
||||
// the implicit bypass so those handshakes also traverse the
|
||||
// pinning proxy and go through [gotenberg.DecideOutbound] like
|
||||
// every other request.
|
||||
//
|
||||
// Gated on the IP-class policy: it is the control this closes, and
|
||||
// under it loopback and link-local HTTP sub-resources are already
|
||||
// blocked by listenForEventRequestPaused before they would reach
|
||||
// the proxy, so this adds only the missing WebSocket coverage. When
|
||||
// the policy is off, loopback is not restricted, and routing it
|
||||
// through the proxy would merely change how an unreachable loopback
|
||||
// sub-resource reports its failure.
|
||||
// Gated on a policy that can reject a network URL: without one,
|
||||
// loopback is not restricted, and routing it through the proxy
|
||||
// would only turn a refused loopback sub-resource into a 502
|
||||
// response instead of a loading failure.
|
||||
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
||||
}
|
||||
}
|
||||
@@ -435,6 +449,55 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
||||
})
|
||||
}
|
||||
|
||||
// newIsolatedTarget creates a browser context and opens a blank tab in it, so
|
||||
// cookies, cache, and storage never leak to another conversion, even a
|
||||
// concurrent one. The caller must dispose the browser context.
|
||||
//
|
||||
// chromedp.WithNewBrowserContext does not work here: it opens the tab without
|
||||
// a new window, which headless Chromium rejects with "no browser is open", and
|
||||
// it leaks the browser context on that failure.
|
||||
func (b *chromiumBrowser) newIsolatedTarget(ctx context.Context, logger *slog.Logger) (cdp.BrowserContextID, target.ID, error) {
|
||||
c := chromedp.FromContext(b.ctx)
|
||||
if c == nil || c.Browser == nil {
|
||||
return "", "", errors.New("browser not allocated")
|
||||
}
|
||||
executor := cdp.WithExecutor(ctx, c.Browser)
|
||||
|
||||
browserContextID, err := target.CreateBrowserContext().WithDisposeOnDetach(true).Do(executor)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("create browser context: %w", err)
|
||||
}
|
||||
|
||||
targetID, err := target.CreateTarget("about:blank").
|
||||
WithBrowserContextID(browserContextID).
|
||||
WithNewWindow(true).
|
||||
Do(executor)
|
||||
if err != nil {
|
||||
b.disposeBrowserContext(logger, browserContextID)
|
||||
return "", "", fmt.Errorf("create target: %w", err)
|
||||
}
|
||||
|
||||
return browserContextID, targetID, nil
|
||||
}
|
||||
|
||||
// disposeBrowserContext disposes a browser context created by
|
||||
// [chromiumBrowser.newIsolatedTarget]. It uses a fresh context, as the
|
||||
// conversion's one may already be done.
|
||||
func (b *chromiumBrowser) disposeBrowserContext(logger *slog.Logger, browserContextID cdp.BrowserContextID) {
|
||||
c := chromedp.FromContext(b.ctx)
|
||||
if c == nil || c.Browser == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := target.DisposeBrowserContext(browserContextID).Do(cdp.WithExecutor(ctx, c.Browser))
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("dispose browser context: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, aggregate *networkAggregate, tasks chromedp.Tasks) error {
|
||||
if !b.isStarted.Load() {
|
||||
return errors.New("browser not started, cannot handle tasks")
|
||||
@@ -461,7 +524,18 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
||||
timeoutCtx, timeoutCancel := context.WithTimeout(b.ctx, time.Until(deadline))
|
||||
defer timeoutCancel()
|
||||
|
||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx)
|
||||
var taskOpts []chromedp.ContextOption
|
||||
if !b.arguments.sharedBrowserContext {
|
||||
browserContextID, targetID, err := b.newIsolatedTarget(timeoutCtx, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create isolated browser context: %w", err)
|
||||
}
|
||||
// Deferred before taskCancel, so it runs after the tab is closed.
|
||||
defer b.disposeBrowserContext(logger, browserContextID)
|
||||
taskOpts = append(taskOpts, chromedp.WithTargetID(targetID))
|
||||
}
|
||||
|
||||
taskCtx, taskCancel := chromedp.NewContext(timeoutCtx, taskOpts...)
|
||||
defer taskCancel()
|
||||
|
||||
// Accumulate per-conversion network activity for telemetry.
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
|
||||
@@ -59,3 +61,36 @@ func TestChromiumDisableFeatures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A WebSocket the outbound policy rejects must reach the pinning proxy, so
|
||||
// the implicit loopback proxy bypass is removed whenever a policy can reject
|
||||
// a network URL. See GHSA-73vq-mh8c-27wg.
|
||||
func TestBrowserArguments_restrictsNetwork(t *testing.T) {
|
||||
re := func(patterns ...string) []*regexp2.Regexp {
|
||||
var res []*regexp2.Regexp
|
||||
for _, p := range patterns {
|
||||
res = append(res, regexp2.MustCompile(p, regexp2.None))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
args browserArguments
|
||||
expect bool
|
||||
}{
|
||||
{scenario: "default deny-list only", args: browserArguments{denyList: re(defaultDenyListPattern)}, expect: false},
|
||||
{scenario: "no lists", args: browserArguments{}, expect: false},
|
||||
{scenario: "allow-list", args: browserArguments{allowList: re(`^https://example\.com`), denyList: re(defaultDenyListPattern)}, expect: true},
|
||||
{scenario: "custom deny-list", args: browserArguments{denyList: re(`^http://internal`)}, expect: true},
|
||||
{scenario: "default and custom deny-list", args: browserArguments{denyList: re(defaultDenyListPattern, `^http://internal`)}, expect: true},
|
||||
{scenario: "deny private IPs", args: browserArguments{denyList: re(defaultDenyListPattern), denyPrivateIPs: true}, expect: true},
|
||||
{scenario: "deny public IPs", args: browserArguments{denyPublicIPs: true}, expect: true},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if got := tc.args.restrictsNetwork(); got != tc.expect {
|
||||
t.Fatalf("restrictsNetwork() = %t, want %t", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -97,6 +97,10 @@ var (
|
||||
ErrPageRangesExceedsPageCount = errors.New("page ranges exceeds page count")
|
||||
)
|
||||
|
||||
// defaultDenyListPattern is the default --chromium-deny-list entry. It only
|
||||
// rejects file:// URLs outside /tmp.
|
||||
const defaultDenyListPattern = `^file:(?!//\/tmp/).*`
|
||||
|
||||
// Chromium is a module that provides both an [Api] and routes for converting
|
||||
// an HTML document to PDF.
|
||||
type Chromium struct {
|
||||
@@ -476,12 +480,13 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||
fs.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset")
|
||||
fs.StringSlice("chromium-allow-list", []string{}, `Set the allowed URLs for Chromium using regular expressions - supports multiple values. A match bypasses --chromium-deny-private-ips (CHROMIUM_DENY_PRIVATE_IPS) and --chromium-deny-public-ips (CHROMIUM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`)
|
||||
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.StringSlice("chromium-deny-list", []string{defaultDenyListPattern}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
|
||||
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)")
|
||||
fs.Bool("chromium-shared-browser-context", false, "Run every conversion in one shared browser context, so cookies, cache, and storage carry over between conversions. Only enable it when all callers trust each other: a conversion can then reuse cookies another one set")
|
||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion - only applies with --chromium-shared-browser-context")
|
||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion - only applies with --chromium-shared-browser-context")
|
||||
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion - only applies with --chromium-shared-browser-context")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||
|
||||
@@ -515,6 +520,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
return errors.New("CHROMIUM_HYPHEN_DATA_DIR_PATH environment variable is not set; set it to the absolute path of the Chromium hyphenation data directory (it ships in the Gotenberg image)")
|
||||
}
|
||||
|
||||
sharedBrowserContext := flags.MustBool("chromium-shared-browser-context")
|
||||
|
||||
mod.args = browserArguments{
|
||||
binPath: binPath,
|
||||
allowInsecureLocalhost: flags.MustBool("chromium-allow-insecure-localhost"),
|
||||
@@ -527,13 +534,16 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
|
||||
hyphenDataDirPath: hyphenDataDirPath,
|
||||
|
||||
allowList: flags.MustRegexpSlice("chromium-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("chromium-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||
allowList: flags.MustRegexpSlice("chromium-allow-list"),
|
||||
denyList: flags.MustRegexpSlice("chromium-deny-list"),
|
||||
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
|
||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||
sharedBrowserContext: sharedBrowserContext,
|
||||
// An isolated conversion starts from an empty browser context that
|
||||
// is disposed afterward, so there is nothing to clear.
|
||||
clearCache: sharedBrowserContext && flags.MustBool("chromium-clear-cache"),
|
||||
clearCookies: sharedBrowserContext && flags.MustBool("chromium-clear-cookies"),
|
||||
clearStorage: sharedBrowserContext && flags.MustBool("chromium-clear-storage"),
|
||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -206,7 +206,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
||||
}
|
||||
|
||||
matchStart := time.Now()
|
||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||
ok, err := gotenberg.MatchPattern(header.Scope, e.Request.URL)
|
||||
budget.consume(time.Since(matchStart))
|
||||
|
||||
switch {
|
||||
@@ -468,6 +468,8 @@ func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, optio
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
"net::ERR_CONNECTION_ABORTED",
|
||||
"net::ERR_CONNECTION_FAILED",
|
||||
// What a pinning proxy CONNECT that cannot reach its target reports.
|
||||
"net::ERR_TUNNEL_CONNECTION_FAILED",
|
||||
"net::ERR_NAME_NOT_RESOLVED",
|
||||
"net::ERR_INTERNET_DISCONNECTED",
|
||||
"net::ERR_ADDRESS_UNREACHABLE",
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -522,7 +522,7 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
||||
} else {
|
||||
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
|
||||
}
|
||||
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||
resetClient(w)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -585,3 +585,23 @@ func isClientCancellation(ctx context.Context, err error) bool {
|
||||
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
|
||||
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
|
||||
}
|
||||
|
||||
// resetClient aborts the client connection with a TCP reset instead of
|
||||
// answering. Chromium treats a proxy response as the origin's own, so a 502
|
||||
// would reach the page as an ordinary HTTP error; a reset surfaces as a
|
||||
// network failure, as it would without the proxy.
|
||||
func resetClient(w http.ResponseWriter) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
conn, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcpConn.SetLinger(0)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
@@ -764,6 +765,47 @@ func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A forward request whose target cannot be reached resets the client
|
||||
// connection rather than answering 502. Chromium treats a proxy response as
|
||||
// the origin's own, so a 502 would hide the failure from
|
||||
// failOnResourceLoadingFailed.
|
||||
func TestPinningProxy_Forward_DialFailure_ResetsClient(t *testing.T) {
|
||||
p := newPinningProxy(nil, nil, false, false, false)
|
||||
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
|
||||
}
|
||||
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
err := p.Start(logger)
|
||||
if err != nil {
|
||||
t.Fatalf("start pinning proxy: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop(logger) })
|
||||
|
||||
conn, err := net.Dial("tcp", strings.TrimPrefix(p.URL(), "http://"))
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "GET http://real.example/ HTTP/1.1\r\nHost: real.example\r\n\r\n")
|
||||
if err != nil {
|
||||
t.Fatalf("write request: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
t.Fatalf("got status %d, want the connection reset", resp.StatusCode)
|
||||
}
|
||||
if !errors.Is(err, syscall.ECONNRESET) && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("read response: %v, want a connection reset", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies
|
||||
// the handleForward dial-cancellation path: when the inner Transport's
|
||||
// dial returns a canceled error (client hung up mid-dial), the proxy
|
||||
@@ -790,12 +832,9 @@ func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T)
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
resp, err := client.Get("http://www.google.com/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET via proxy: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", resp.StatusCode)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
t.Fatalf("GET via proxy: got status %d, want the connection reset", resp.StatusCode)
|
||||
}
|
||||
|
||||
records := rec.snapshot()
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"github.com/gomarkdown/markdown"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -466,7 +466,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/convert/url",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
@@ -528,7 +528,7 @@ func screenshotUrlRoute(chromium Api) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/screenshot/url",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumScreenshotOptions(ctx)
|
||||
|
||||
@@ -562,7 +562,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/convert/html",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
@@ -621,7 +621,7 @@ func screenshotHtmlRoute(chromium Api) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/screenshot/html",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumScreenshotOptions(ctx)
|
||||
|
||||
@@ -652,7 +652,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/convert/markdown",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
mode := pdfengines.FormDataPdfSplitMode(form, false)
|
||||
@@ -720,7 +720,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/chromium/screenshot/markdown",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumScreenshotOptions(ctx)
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestScopeMatchBudget(t *testing.T) {
|
||||
@@ -90,7 +92,7 @@ func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
|
||||
break
|
||||
}
|
||||
matchStart := time.Now()
|
||||
_, _ = pattern.MatchString(url)
|
||||
_, _ = gotenberg.MatchPattern(pattern, url)
|
||||
b.consume(time.Since(matchStart))
|
||||
matched++
|
||||
}
|
||||
@@ -100,10 +102,13 @@ func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
|
||||
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
|
||||
// A match that is genuinely out of budget costs a few
|
||||
// extraHttpHeaderScopeMatchTimeout rather than one:
|
||||
// [gotenberg.MatchPattern] retries an abort to tell a real runaway from
|
||||
// one caused by the process losing the CPU. 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 + 4*extraHttpHeaderScopeMatchTimeout + time.Second
|
||||
if elapsed > ceiling {
|
||||
t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
|
||||
}
|
||||
|
||||
@@ -266,6 +266,10 @@ type Options struct {
|
||||
// NativeTiledWatermarkText specifies the tiled watermark text.
|
||||
NativeTiledWatermarkText string
|
||||
|
||||
// GenerateTaggedPdf specifies whether to generate a tagged (accessible)
|
||||
// PDF without declaring PDF/UA conformance. PdfFormats.PdfUa implies it.
|
||||
GenerateTaggedPdf bool
|
||||
|
||||
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
|
||||
// PDF/A-3b and PDF/UA.
|
||||
PdfFormats gotenberg.PdfFormats
|
||||
@@ -318,6 +322,7 @@ func DefaultOptions() Options {
|
||||
NativeWatermarkRotateAngle: 0,
|
||||
NativeWatermarkFontName: "Helvetica",
|
||||
NativeTiledWatermarkText: "",
|
||||
GenerateTaggedPdf: false,
|
||||
PdfFormats: gotenberg.PdfFormats{
|
||||
PdfA: "",
|
||||
PdfUa: false,
|
||||
|
||||
@@ -437,7 +437,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
||||
args = append(
|
||||
args,
|
||||
"--export", "PDFUACompliance=false",
|
||||
"--export", "UseTaggedPDF=false",
|
||||
"--export", fmt.Sprintf("UseTaggedPDF=%t", options.GenerateTaggedPdf),
|
||||
"--export", "EnableTextAccessForAccessibilityTools=false",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
|
||||
@@ -17,14 +17,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
func compileRegexes(t *testing.T, patterns ...string) []*regexp2.Regexp {
|
||||
t.Helper()
|
||||
out := make([]*regexp2.Regexp, 0, len(patterns))
|
||||
for _, p := range patterns {
|
||||
r, err := regexp2.Compile(p, 0)
|
||||
r, err := regexp2.Compile(p, regexp2.None)
|
||||
if err != nil {
|
||||
t.Fatalf("compile %q: %v", p, err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
@@ -27,7 +27,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/libreoffice/convert",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
defaultOptions := libreofficeapi.DefaultOptions()
|
||||
|
||||
@@ -75,6 +75,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
skipEmptyPages bool
|
||||
addOriginalDocumentAsStream bool
|
||||
singlePageSheets bool
|
||||
generateTaggedPdf bool
|
||||
losslessImageCompression bool
|
||||
quality int
|
||||
reduceImageResolution bool
|
||||
@@ -126,6 +127,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
|
||||
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
|
||||
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
|
||||
Bool("generateTaggedPdf", &generateTaggedPdf, defaultOptions.GenerateTaggedPdf).
|
||||
Custom("initialView", func(value string) error {
|
||||
if value == "" {
|
||||
initialView = defaultOptions.InitialView
|
||||
@@ -396,6 +398,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
NativeWatermarkRotateAngle: nativeWatermarkRotateAngle,
|
||||
NativeWatermarkFontName: nativeWatermarkFontName,
|
||||
NativeTiledWatermarkText: nativeTiledWatermarkText,
|
||||
GenerateTaggedPdf: generateTaggedPdf,
|
||||
}
|
||||
|
||||
if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
@@ -1011,7 +1011,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/merge",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1212,7 +1212,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/split",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1364,7 +1364,7 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/flatten",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1399,7 +1399,7 @@ func optimizeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/optimize",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1435,7 +1435,7 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/convert",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1492,7 +1492,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/metadata/read",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
var inputPaths []string
|
||||
@@ -1546,7 +1546,7 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/metadata/write",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1581,7 +1581,7 @@ func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/bookmarks/read",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
var inputPaths []string
|
||||
@@ -1634,7 +1634,7 @@ func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/bookmarks/write",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1669,7 +1669,7 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/encrypt",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1714,7 +1714,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/embed",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1776,7 +1776,7 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/watermark",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1833,7 +1833,7 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/stamp",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1892,7 +1892,7 @@ func rotateRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/rotate",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
@@ -1929,7 +1929,7 @@ func facturXRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/factur-x",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
Handler: func(c *echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
form := ctx.FormData()
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
flag "github.com/spf13/pflag"
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
@@ -95,7 +95,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
})
|
||||
}
|
||||
|
||||
return func(c echo.Context) error {
|
||||
return func(c *echo.Context) error {
|
||||
webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url")
|
||||
if webhookUrl == "" {
|
||||
// No webhook URL, call the next middleware in the chain.
|
||||
@@ -208,7 +208,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve values from echo.Context before it gets recycled.
|
||||
// Retrieve values from [echo.Context] before it gets recycled.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/1000.
|
||||
startTime := c.Get("startTime").(time.Time)
|
||||
correlationIdHeader := c.Get("correlationIdHeader").(string)
|
||||
@@ -323,16 +323,16 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
// As a webhook URL has been given, we handle the request in a
|
||||
// goroutine and return immediately.
|
||||
//
|
||||
// Echo returns the echo.Context back to its sync.Pool as
|
||||
// Echo returns the [echo.Context] back to its sync.Pool as
|
||||
// soon as this synchronous handler returns ErrAsyncProcess.
|
||||
// A concurrent request can then claim the recycled context
|
||||
// and c.Reset() wipes the shared store, which would cause
|
||||
// any c.Get("...").(T) assertion downstream of the webhook
|
||||
// goroutine to panic on a nil value and crash the process.
|
||||
// Snapshot the keys downstream reads onto a detached
|
||||
// wrapper before spawning the goroutine so pool reuse
|
||||
// context before spawning the goroutine so pool reuse
|
||||
// cannot reach into our async work.
|
||||
detached := newPoolSafeContext(c, "logger", "context", "correlationId", "correlationIdHeader", "startTime")
|
||||
detached := newDetachedContext(c, "logger", "context", "correlationId", "correlationIdHeader", "startTime")
|
||||
|
||||
w.asyncCount.Add(1)
|
||||
go func() {
|
||||
|
||||
@@ -1,58 +1,34 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// poolSafeContext wraps an [echo.Context] and keeps a private snapshot of
|
||||
// the values that downstream middleware and route handlers read from the
|
||||
// store. Echo returns an [echo.Context] to its sync.Pool as soon as the
|
||||
// synchronous handler returns, including when the webhook middleware
|
||||
// returns [api.ErrAsyncProcess]. A concurrent request can then claim the
|
||||
// recycled context and c.Reset() wipes the shared store out from under
|
||||
// the webhook goroutine, which causes any
|
||||
// `c.Get("logger").(*slog.Logger)`-style assertion further down the
|
||||
// chain to panic on a nil value.
|
||||
// newDetachedContext returns an [echo.Context] carrying a snapshot of the given
|
||||
// keys, detached from Echo's context pool.
|
||||
//
|
||||
// Wrapping c before handing it to the goroutine insulates the async work
|
||||
// from pool reuse: Get/Set read and write the private store while every
|
||||
// other [echo.Context] method delegates to the embedded context for
|
||||
// anything the downstream might still need.
|
||||
type poolSafeContext struct {
|
||||
echo.Context
|
||||
mu sync.RWMutex
|
||||
store map[string]any
|
||||
}
|
||||
|
||||
// newPoolSafeContext snapshots the given keys from c into a detached
|
||||
// store and returns a wrapper whose Get/Set operate on that store
|
||||
// exclusively. Keys absent from c are omitted; the wrapper still
|
||||
// Echo returns an [echo.Context] to its sync.Pool as soon as the synchronous
|
||||
// handler returns, including when the webhook middleware returns
|
||||
// [api.ErrAsyncProcess]. A concurrent request can then claim the recycled
|
||||
// context and c.Reset() wipes the shared store out from under the webhook
|
||||
// goroutine, which causes any `c.Get("logger").(*slog.Logger)`-style assertion
|
||||
// further down the chain to panic on a nil value.
|
||||
//
|
||||
// [echo.NewContext] allocates outside the pool, so recycling cannot reach the
|
||||
// returned context. Keys absent from c are omitted; the returned context still
|
||||
// returns nil for them, matching [echo.Context.Get] behavior.
|
||||
func newPoolSafeContext(c echo.Context, keys ...string) *poolSafeContext {
|
||||
store := make(map[string]any, len(keys))
|
||||
//
|
||||
// Only the asynchronous path uses this. Nothing downstream of the webhook
|
||||
// middleware writes to the response: contextMiddleware sits upstream and has
|
||||
// already answered 204 by the time the goroutine runs.
|
||||
func newDetachedContext(c *echo.Context, keys ...string) *echo.Context {
|
||||
detached := echo.NewContext(c.Request(), c.Response(), c.Echo())
|
||||
|
||||
for _, key := range keys {
|
||||
if v := c.Get(key); v != nil {
|
||||
store[key] = v
|
||||
detached.Set(key, v)
|
||||
}
|
||||
}
|
||||
return &poolSafeContext{Context: c, store: store}
|
||||
}
|
||||
|
||||
// Get returns the value stored in the detached store, not the embedded
|
||||
// context's pooled store.
|
||||
func (p *poolSafeContext) Get(key string) any {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.store[key]
|
||||
}
|
||||
|
||||
// Set writes to the detached store, not the embedded context's pooled
|
||||
// store. This prevents downstream middleware writes from leaking into a
|
||||
// later request that claims the same pooled context.
|
||||
func (p *poolSafeContext) Set(key string, val any) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.store[key] = val
|
||||
return detached
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
func TestPoolSafeContext_SurvivesUnderlyingReset(t *testing.T) {
|
||||
func TestNewDetachedContext_SurvivesUnderlyingReset(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -19,10 +19,10 @@ func TestPoolSafeContext_SurvivesUnderlyingReset(t *testing.T) {
|
||||
c.Set("logger", logger)
|
||||
c.Set("correlationId", "abc-123")
|
||||
|
||||
detached := newPoolSafeContext(c, "logger", "correlationId", "missing")
|
||||
detached := newDetachedContext(c, "logger", "correlationId", "missing")
|
||||
|
||||
// Simulate Echo recycling c for a concurrent request. Reset wipes the
|
||||
// shared store, which is exactly the crash scenario the wrapper
|
||||
// Simulate Echo recycling c for a concurrent request. Reset clears the
|
||||
// pooled store, which is exactly the crash scenario the detached context
|
||||
// guards against.
|
||||
c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder())
|
||||
|
||||
@@ -38,17 +38,17 @@ func TestPoolSafeContext_SurvivesUnderlyingReset(t *testing.T) {
|
||||
|
||||
// Underlying c must remain clean.
|
||||
if c.Get("logger") != nil {
|
||||
t.Fatalf("underlying c.Get(\"logger\") leaked wrapper state after reset")
|
||||
t.Fatalf("underlying c.Get(\"logger\") leaked detached state after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSafeContext_SetDoesNotTouchUnderlying(t *testing.T) {
|
||||
func TestNewDetachedContext_SetDoesNotTouchUnderlying(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
detached := newPoolSafeContext(c)
|
||||
detached := newDetachedContext(c)
|
||||
detached.Set("foo", "bar")
|
||||
|
||||
if got, _ := detached.Get("foo").(string); got != "bar" {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
|
||||
@@ -25,7 +25,7 @@ Available tags:
|
||||
|
||||
| Group | Tags |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` |
|
||||
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-image-deduplication`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` |
|
||||
| LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
|
||||
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-optimize`, `optimize`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
||||
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||
@@ -67,6 +67,7 @@ Available tags:
|
||||
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
|
||||
- `the "<name>" PDF should have <N> page(s)`
|
||||
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
|
||||
- `the "<name>" PDF (should|should NOT) be tagged`
|
||||
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
|
||||
- `the "<name>" PDF (should|should NOT) have content matching "<regexp>" at page <N>`
|
||||
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
|
||||
@@ -75,6 +76,7 @@ Available tags:
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) allow "<action>"` (actions: `printing`, `copying`, `modifying`, `annotating`)
|
||||
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
|
||||
- `the "<name>" PDF should have <N> image(s)`
|
||||
- `the "<name>" PDF should reuse <N> image XObject(s) across <P> page(s)` (checks image appearances on every page and distinct PDF object IDs)
|
||||
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
|
||||
- `all concurrent response status codes should be <code>`
|
||||
- `all concurrent responses should have <N> PDF(s)`
|
||||
|
||||
@@ -521,6 +521,22 @@ Feature: /forms/chromium/convert/html
|
||||
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
|
||||
| CONNECT blocked for '127.0.0.1:9999' |
|
||||
|
||||
# An allow-list is enforced on its own, without the IP-class flags. Chromium
|
||||
# skips the proxy for loopback and link-local destinations unless told
|
||||
# otherwise, so a WebSocket the allow-list rejects must still reach the
|
||||
# pinning proxy to be severed.
|
||||
@chromium-ssrf
|
||||
Scenario: POST /forms/chromium/convert/html (WebSocket not in the allow-list is filtered)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | ^file:///tmp/.* |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||
| waitDelay | 1s | field |
|
||||
Then the response status code should be 200
|
||||
Then the Gotenberg container should log the following entries:
|
||||
| 'ws://127.0.0.1:9999/ssrf-websocket' does not match any expression from the allowed list |
|
||||
| CONNECT blocked for '127.0.0.1:9999' |
|
||||
|
||||
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
|
||||
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
|
||||
@chromium-ssrf
|
||||
@@ -609,6 +625,7 @@ Feature: /forms/chromium/convert/html
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/feature-rich-html/index.html | file |
|
||||
| failOnResourceHttpStatusCodes | [499,599] | field |
|
||||
| skipNetworkIdleEvent | false | field |
|
||||
Then the response status code should be 409
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
@@ -627,12 +644,29 @@ Feature: /forms/chromium/convert/html
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
|
||||
# Through the pinning proxy, an unreachable resource must still fail the
|
||||
# conversion. The proxy resets the connection instead of answering 502, which
|
||||
# Chromium would treat as an ordinary HTTP error.
|
||||
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed through the pinning proxy)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/feature-rich-html/index.html | file |
|
||||
| failOnResourceLoadingFailed | true | field |
|
||||
Then the response status code should be 409
|
||||
Then the response body should contain string:
|
||||
"""
|
||||
resource Stylesheet: net::ERR_CONNECTION_RESET
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
|
||||
# A configured allow-list routes loopback through the pinning proxy, which
|
||||
# reports a refused connection as a reset. See the scenario below.
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||
| files | testdata/feature-rich-html/index.html | file |
|
||||
| failOnResourceLoadingFailed | true | field |
|
||||
Then the response status code should be 409
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should contain string:
|
||||
"""
|
||||
|
||||
@@ -400,6 +400,7 @@ Feature: /forms/chromium/convert/markdown
|
||||
| files | testdata/feature-rich-markdown/index.html | file |
|
||||
| files | testdata/feature-rich-markdown/table.md | file |
|
||||
| failOnResourceHttpStatusCodes | [499,599] | field |
|
||||
| skipNetworkIdleEvent | false | field |
|
||||
Then the response status code should be 409
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should match string:
|
||||
@@ -409,7 +410,11 @@ Feature: /forms/chromium/convert/markdown
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed)
|
||||
Given I have a default Gotenberg container
|
||||
# A configured allow-list routes loopback through the pinning proxy, which
|
||||
# reports a refused connection as a reset. See the convert/html
|
||||
# scenario.
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
||||
| files | testdata/feature-rich-markdown/index.html | file |
|
||||
| files | testdata/feature-rich-markdown/table.md | file |
|
||||
|
||||
@@ -22,10 +22,9 @@ Feature: /forms/chromium/convert/url
|
||||
Page 1
|
||||
"""
|
||||
|
||||
# localStorage is per-origin and shared by the long-lived browser, so without
|
||||
# clearing it accumulates across same-origin conversions.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||
Scenario: POST /forms/chromium/convert/url (localStorage leaks without clearing)
|
||||
# Each conversion runs in its own browser context, so localStorage never
|
||||
# carries over.
|
||||
Scenario: POST /forms/chromium/convert/url (localStorage isolated per conversion)
|
||||
Given I have a default Gotenberg container
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
@@ -37,14 +36,34 @@ Feature: /forms/chromium/convert/url
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||
|
||||
# With --chromium-shared-browser-context, localStorage is per-origin and
|
||||
# shared by the long-lived browser, so without clearing it accumulates
|
||||
# across same-origin conversions.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||
Scenario: POST /forms/chromium/convert/url (localStorage leaks in a shared browser context)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_SHARED_BROWSER_CONTEXT | true |
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then the response status code should be 200
|
||||
Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1
|
||||
|
||||
# With --chromium-clear-storage every conversion clears the origin's
|
||||
# localStorage first, so the counter never carries over.
|
||||
# With --chromium-clear-storage every conversion in a shared browser context
|
||||
# clears the origin's localStorage first, so the counter never carries over.
|
||||
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||
Scenario: POST /forms/chromium/convert/url (Clear Storage)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_CLEAR_STORAGE | true |
|
||||
| CHROMIUM_SHARED_BROWSER_CONTEXT | true |
|
||||
| CHROMIUM_CLEAR_STORAGE | true |
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||
@@ -210,6 +229,36 @@ Feature: /forms/chromium/convert/url
|
||||
Then the server request cookie "cookie_1" should be "foo"
|
||||
Then the server request cookie "cookie_2" should be ""
|
||||
|
||||
# A cookie set by one conversion must not reach the next one.
|
||||
Scenario: POST /forms/chromium/convert/url (Cookies isolated per conversion)
|
||||
Given I have a default Gotenberg container
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||
| cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"}] | field |
|
||||
Then the response status code should be 200
|
||||
Then the server request cookie "cookie_1" should be "foo"
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||
Then the response status code should be 200
|
||||
Then the server request cookie "cookie_1" should be ""
|
||||
|
||||
# With --chromium-shared-browser-context, cookies carry over between
|
||||
# conversions.
|
||||
Scenario: POST /forms/chromium/convert/url (Cookies in a shared browser context)
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_SHARED_BROWSER_CONTEXT | true |
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||
| cookies | [{"name":"cookie_1","value":"foo","domain":"host.docker.internal:%d"}] | field |
|
||||
Then the response status code should be 200
|
||||
Then the server request cookie "cookie_1" should be "foo"
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||
Then the response status code should be 200
|
||||
Then the server request cookie "cookie_1" should be "foo"
|
||||
|
||||
# See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||
Scenario: POST /forms/chromium/convert/url (case-insensitive sameSite)
|
||||
Given I have a default Gotenberg container
|
||||
@@ -649,6 +698,7 @@ Feature: /forms/chromium/convert/url
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
|
||||
| failOnResourceHttpStatusCodes | [499,599] | field |
|
||||
| skipNetworkIdleEvent | false | field |
|
||||
Then the response status code should be 409
|
||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||
Then the response body should contain string:
|
||||
@@ -661,7 +711,11 @@ Feature: /forms/chromium/convert/url
|
||||
"""
|
||||
|
||||
Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed)
|
||||
Given I have a default Gotenberg container
|
||||
# A configured allow-list routes loopback through the pinning proxy, which
|
||||
# reports a refused connection as a reset. See the convert/html
|
||||
# scenario.
|
||||
Given I have a Gotenberg container with the following environment variable(s):
|
||||
| CHROMIUM_ALLOW_LIST | |
|
||||
Given I have a static server
|
||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@chromium
|
||||
@chromium-convert-html
|
||||
@chromium-image-deduplication
|
||||
Feature: Chromium header and footer image deduplication
|
||||
|
||||
# See: https://github.com/gotenberg/gotenberg/issues/1077.
|
||||
# See: https://github.com/gotenberg/gotenberg/issues/1669.
|
||||
# The PNG must appear on every page, but its image XObject must be shared.
|
||||
Scenario: POST /forms/chromium/convert/html (Header image reused across pages)
|
||||
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/page-1-html/index.html | file |
|
||||
| files | testdata/header-footer-image/header.html | file |
|
||||
| marginTop | 1 | field |
|
||||
| marginBottom | 1 | field |
|
||||
| Gotenberg-Output-Filename | header | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "header.pdf" PDF should have 1 page(s)
|
||||
Then the "header.pdf" PDF should reuse 1 image XObject(s) across 1 page(s)
|
||||
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 |
|
||||
| files | testdata/header-footer-image/header.html | file |
|
||||
| marginTop | 1 | field |
|
||||
| marginBottom | 1 | field |
|
||||
| Gotenberg-Output-Filename | header | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "header.pdf" PDF should have 3 page(s)
|
||||
Then the "header.pdf" PDF should reuse 1 image XObject(s) across 3 page(s)
|
||||
|
||||
Scenario: POST /forms/chromium/convert/html (Footer image reused across pages)
|
||||
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/page-1-html/index.html | file |
|
||||
| files | testdata/header-footer-image/footer.html | file |
|
||||
| marginTop | 1 | field |
|
||||
| marginBottom | 1 | field |
|
||||
| Gotenberg-Output-Filename | footer | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "footer.pdf" PDF should have 1 page(s)
|
||||
Then the "footer.pdf" PDF should reuse 1 image XObject(s) across 1 page(s)
|
||||
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 |
|
||||
| files | testdata/header-footer-image/footer.html | file |
|
||||
| marginTop | 1 | field |
|
||||
| marginBottom | 1 | field |
|
||||
| Gotenberg-Output-Filename | footer | header |
|
||||
Then the response status code should be 200
|
||||
Then the response header "Content-Type" should be "application/pdf"
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then the "footer.pdf" PDF should have 3 page(s)
|
||||
Then the "footer.pdf" PDF should reuse 1 image XObject(s) across 3 page(s)
|
||||
@@ -98,6 +98,7 @@ Feature: /debug
|
||||
"chromium-max-queue-size": "0",
|
||||
"chromium-proxy-server": "",
|
||||
"chromium-restart-after": "100",
|
||||
"chromium-shared-browser-context": "false",
|
||||
"chromium-start-timeout": "20s",
|
||||
"gotenberg-build-debug-data": "true",
|
||||
"gotenberg-graceful-shutdown-duration": "30s",
|
||||
@@ -237,6 +238,7 @@ Feature: /debug
|
||||
"chromium-max-concurrency": "6",
|
||||
"chromium-proxy-server": "",
|
||||
"chromium-restart-after": "100",
|
||||
"chromium-shared-browser-context": "false",
|
||||
"chromium-start-timeout": "20s",
|
||||
"gotenberg-build-debug-data": "true",
|
||||
"gotenberg-graceful-shutdown-duration": "30s",
|
||||
|
||||
@@ -142,6 +142,24 @@ Feature: /forms/libreoffice/convert
|
||||
| foo.pdf |
|
||||
Then the "foo.pdf" PDF should be set to landscape orientation
|
||||
|
||||
Scenario: POST /forms/libreoffice/convert (Tagged PDF)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
| files | testdata/page_1.docx | file |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then there should be the following file(s) in the response:
|
||||
| foo.pdf |
|
||||
Then the "foo.pdf" PDF should NOT be tagged
|
||||
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 |
|
||||
| generateTaggedPdf | true | field |
|
||||
| Gotenberg-Output-Filename | foo | header |
|
||||
Then there should be 1 PDF(s) in the response
|
||||
Then there should be the following file(s) in the response:
|
||||
| foo.pdf |
|
||||
Then the "foo.pdf" PDF should be tagged
|
||||
|
||||
Scenario: POST /forms/libreoffice/convert (Native Page Ranges - Single Document)
|
||||
Given I have a default Gotenberg container
|
||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||
@@ -215,6 +233,7 @@ Feature: /forms/libreoffice/convert
|
||||
| skipEmptyPages | foo | field |
|
||||
| addOriginalDocumentAsStream | foo | field |
|
||||
| singlePageSheets | foo | field |
|
||||
| generateTaggedPdf | foo | field |
|
||||
| initialView | 5 | field |
|
||||
| initialPage | -1 | field |
|
||||
| magnification | 9 | field |
|
||||
@@ -255,6 +274,7 @@ Feature: /forms/libreoffice/convert
|
||||
form field 'skipEmptyPages' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||
form field 'addOriginalDocumentAsStream' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||
form field 'singlePageSheets' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||
form field 'generateTaggedPdf' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
|
||||
form field 'initialView' is invalid (got '5', resulting to value is not 0, 1 or 2)
|
||||
form field 'initialPage' is invalid (got '-1', resulting to value is inferior to 1)
|
||||
form field 'magnification' is invalid (got '9', resulting to value is not 0, 1, 2, 3 or 4)
|
||||
|
||||
72
test/integration/scenario/images.go
Normal file
72
test/integration/scenario/images.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *scenario) thePdfShouldReuseImageXObjects(ctx context.Context, name string, images, pages int) error {
|
||||
path := filepath.Join(s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("stat PDF %q: %w", path, err)
|
||||
}
|
||||
|
||||
cmd := []string{"pdfimages", "-list", filepath.Base(path)}
|
||||
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
return checkImageXObjectReuse(output, images, pages)
|
||||
}
|
||||
|
||||
func checkImageXObjectReuse(output string, images, pages int) error {
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) < 2 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "page") {
|
||||
return fmt.Errorf("invalid pdfimages -list output: %q", output)
|
||||
}
|
||||
|
||||
objects := make(map[[2]int]struct{})
|
||||
imagesPerPage := make(map[int]int)
|
||||
for _, line := range lines[2:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 12 {
|
||||
return fmt.Errorf("invalid pdfimages -list row: %q", line)
|
||||
}
|
||||
|
||||
page, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse image page %q: %w", fields[0], err)
|
||||
}
|
||||
if page < 1 || page > pages {
|
||||
return fmt.Errorf("image on page %d outside expected range 1-%d", page, pages)
|
||||
}
|
||||
|
||||
// pdfimages lists appearances, including repeated references to the
|
||||
// same image. Columns 11 and 12 identify the underlying PDF object.
|
||||
var ref [2]int
|
||||
for i := range ref {
|
||||
ref[i], err = strconv.Atoi(fields[10+i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse image object ID in %q: %w", line, err)
|
||||
}
|
||||
}
|
||||
objects[ref] = struct{}{}
|
||||
imagesPerPage[page]++
|
||||
}
|
||||
|
||||
for page := 1; page <= pages; page++ {
|
||||
if imagesPerPage[page] != images {
|
||||
return fmt.Errorf("expected %d image appearance(s) on page %d, but actual is %d", images, page, imagesPerPage[page])
|
||||
}
|
||||
}
|
||||
if len(objects) != images {
|
||||
return fmt.Errorf("expected %d unique image XObject(s) shared across %d page(s), but actual is %d", images, pages, len(objects))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
106
test/integration/scenario/images_test.go
Normal file
106
test/integration/scenario/images_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckImageXObjectReuse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const header = `page num type width height color comp bpc enc interp object ID x-ppi y-ppi size ratio
|
||||
--------------------------------------------------------------------------------------------
|
||||
`
|
||||
imageRow := func(page, object, generation int) string {
|
||||
return fmt.Sprintf("%d %d image 32 32 rgb 3 8 image no %d %d 96 96 3104B 101%%\n", page, page-1, object, generation)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
pages int
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "one page baseline",
|
||||
output: header + imageRow(1, 7, 0),
|
||||
pages: 1,
|
||||
},
|
||||
{
|
||||
name: "three pages share one image object",
|
||||
output: header + imageRow(1, 7, 0) + imageRow(2, 7, 0) + imageRow(3, 7, 0),
|
||||
pages: 3,
|
||||
},
|
||||
{
|
||||
name: "regression embeds a different object per page",
|
||||
output: header + imageRow(1, 7, 0) + imageRow(2, 8, 0) + imageRow(3, 9, 0),
|
||||
pages: 3,
|
||||
wantErr: "expected 1 unique image XObject(s) shared across 3 page(s), but actual is 3",
|
||||
},
|
||||
{
|
||||
name: "object generation is part of its identity",
|
||||
output: header + imageRow(1, 7, 0) + imageRow(2, 7, 1) + imageRow(3, 7, 0),
|
||||
pages: 3,
|
||||
wantErr: "expected 1 unique image XObject(s) shared across 3 page(s), but actual is 2",
|
||||
},
|
||||
{
|
||||
name: "image missing from a page",
|
||||
output: header + imageRow(1, 7, 0) + imageRow(3, 7, 0),
|
||||
pages: 3,
|
||||
wantErr: "expected 1 image appearance(s) on page 2, but actual is 0",
|
||||
},
|
||||
{
|
||||
name: "no image cannot pass",
|
||||
output: header,
|
||||
pages: 1,
|
||||
wantErr: "expected 1 image appearance(s) on page 1, but actual is 0",
|
||||
},
|
||||
{
|
||||
name: "repeated image only on first page cannot pass",
|
||||
output: header + imageRow(1, 7, 0) + imageRow(1, 7, 0) + imageRow(1, 7, 0),
|
||||
pages: 3,
|
||||
wantErr: "expected 1 image appearance(s) on page 1, but actual is 3",
|
||||
},
|
||||
{
|
||||
name: "image on unexpected page",
|
||||
output: header + imageRow(1, 7, 0) + imageRow(2, 7, 0),
|
||||
pages: 1,
|
||||
wantErr: "image on page 2 outside expected range 1-1",
|
||||
},
|
||||
{
|
||||
name: "tool error cannot pass",
|
||||
output: "Syntax Error: Couldn't find trailer dictionary",
|
||||
pages: 1,
|
||||
wantErr: "invalid pdfimages -list output:",
|
||||
},
|
||||
{
|
||||
name: "malformed image row",
|
||||
output: header + "1 0 image\n",
|
||||
pages: 1,
|
||||
wantErr: "invalid pdfimages -list row:",
|
||||
},
|
||||
{
|
||||
name: "inline image has no reusable object ID",
|
||||
output: header + "1 0 image 32 32 rgb 3 8 image no [inline] - 96 96 3104B 101%\n",
|
||||
pages: 1,
|
||||
wantErr: "parse image object ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := checkImageXObjectReuse(tt.output, 1, tt.pages)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1795,10 +1795,12 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
||||
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
|
||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be tagged$`, s.thePdfShouldBeTagged)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have a document outline$`, s.thePdfShouldHaveADocumentOutline)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
||||
ctx.Then(`^the "([^"]*)" PDF should reuse (\d+) image XObject\(s\) across (\d+) page\(s\)$`, s.thePdfShouldReuseImageXObjects)
|
||||
ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels)
|
||||
ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe)
|
||||
ctx.Then(`^the "([^"]*)" file size should be (less|greater) than (\d+) KB$`, s.theFileSizeShouldBe)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
"github.com/cucumber/godog"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/mholt/archives"
|
||||
)
|
||||
|
||||
@@ -26,12 +26,17 @@ type server struct {
|
||||
errChan chan error
|
||||
eventBody []byte
|
||||
eventMu sync.Mutex
|
||||
|
||||
shutdown context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
// The static file handlers below serve absolute paths, which Echo v5's
|
||||
// default working-directory filesystem rejects. See newEchoServer in
|
||||
// pkg/modules/api.
|
||||
srv.Filesystem = echo.NewDefaultFS("/")
|
||||
s := &server{
|
||||
srv: srv,
|
||||
errChan: make(chan error, 1),
|
||||
@@ -47,7 +52,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
return err
|
||||
}
|
||||
|
||||
webhookHandler := func(c echo.Context) error {
|
||||
webhookHandler := func(c *echo.Context) error {
|
||||
s.req = c.Request()
|
||||
|
||||
body, err := io.ReadAll(s.req.Body)
|
||||
@@ -131,7 +136,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
|
||||
return webhookErr(c.String(http.StatusOK, http.StatusText(http.StatusOK)))
|
||||
}
|
||||
webhookErrorHandler := func(c echo.Context) error {
|
||||
webhookErrorHandler := func(c *echo.Context) error {
|
||||
s.req = c.Request()
|
||||
body, err := io.ReadAll(s.req.Body)
|
||||
if err != nil {
|
||||
@@ -148,7 +153,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
srv.PATCH("/webhook/error", webhookErrorHandler)
|
||||
srv.PUT("/webhook/error", webhookErrorHandler)
|
||||
|
||||
webhookEventsHandler := func(c echo.Context) error {
|
||||
webhookEventsHandler := func(c *echo.Context) error {
|
||||
body, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return c.String(http.StatusInternalServerError, err.Error())
|
||||
@@ -159,7 +164,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
}
|
||||
srv.POST("/webhook/events", webhookEventsHandler)
|
||||
srv.GET("/static/:path", func(c echo.Context) error {
|
||||
srv.GET("/static/:path", func(c *echo.Context) error {
|
||||
s.req = c.Request()
|
||||
path := c.Param("path")
|
||||
if strings.Contains(path, "teststore") {
|
||||
@@ -167,7 +172,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
}
|
||||
return c.Attachment(fmt.Sprintf("%s/%s", wd, path), filepath.Base(path))
|
||||
})
|
||||
srv.GET("/html/:path", func(c echo.Context) error {
|
||||
srv.GET("/html/:path", func(c *echo.Context) error {
|
||||
s.req = c.Request()
|
||||
path := fmt.Sprintf("%s/%s", wd, c.Param("path"))
|
||||
f, err := os.Open(path)
|
||||
@@ -181,7 +186,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
||||
}
|
||||
return c.HTML(http.StatusOK, string(b))
|
||||
})
|
||||
srv.GET("/redirect-to-private", func(c echo.Context) error {
|
||||
srv.GET("/redirect-to-private", func(c *echo.Context) error {
|
||||
s.req = c.Request()
|
||||
// Redirect the browser to a non-public address so the outbound filter
|
||||
// is exercised on the redirected request rather than on this URL.
|
||||
@@ -206,9 +211,20 @@ func (s *server) start(ctx context.Context) (int, error) {
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
startConfig := echo.StartConfig{
|
||||
Listener: ln,
|
||||
HideBanner: true,
|
||||
HidePort: true,
|
||||
}
|
||||
|
||||
serveCtx, cancel := context.WithCancel(context.Background())
|
||||
s.shutdown = cancel
|
||||
s.done = make(chan struct{})
|
||||
|
||||
go func() {
|
||||
s.srv.Listener = ln
|
||||
err = s.srv.Start("")
|
||||
defer close(s.done)
|
||||
|
||||
err := startConfig.Start(serveCtx, s.srv)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
godog.Log(ctx, err.Error())
|
||||
}
|
||||
@@ -219,5 +235,17 @@ func (s *server) start(ctx context.Context) (int, error) {
|
||||
|
||||
func (s *server) stop(ctx context.Context) error {
|
||||
close(s.errChan)
|
||||
return s.srv.Shutdown(ctx)
|
||||
|
||||
if s.shutdown == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.shutdown()
|
||||
|
||||
select {
|
||||
case <-s.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
44
test/integration/scenario/tagged.go
Normal file
44
test/integration/scenario/tagged.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *scenario) thePdfShouldBeTagged(ctx context.Context, name, kind string) error {
|
||||
path := filepath.Join(s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("stat PDF %q: %w", path, err)
|
||||
}
|
||||
|
||||
cmd := []string{"pdfinfo", filepath.Base(path)}
|
||||
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("exec %q: %w", cmd, err)
|
||||
}
|
||||
|
||||
return checkTagged(output, kind != "should NOT")
|
||||
}
|
||||
|
||||
// checkTagged reads the "Tagged" line of pdfinfo, which reflects the Marked
|
||||
// entry of the document catalog's MarkInfo dictionary.
|
||||
func checkTagged(output string, want bool) error {
|
||||
for line := range strings.SplitSeq(output, "\n") {
|
||||
value, found := strings.CutPrefix(strings.TrimSpace(line), "Tagged:")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
tagged := strings.TrimSpace(value) == "yes"
|
||||
if tagged != want {
|
||||
return fmt.Errorf("expected tagged PDF to be %t, but actual is %t", want, tagged)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid pdfinfo output, no Tagged line: %q", output)
|
||||
}
|
||||
67
test/integration/scenario/tagged_test.go
Normal file
67
test/integration/scenario/tagged_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package scenario
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckTagged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pdfinfo := func(tagged string) string {
|
||||
return "Producer: LibreOffice 26.8\nTagged: " + tagged + "\nPages: 1\n"
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
want bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "tagged PDF expected and found",
|
||||
output: pdfinfo("yes"),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "untagged PDF expected and found",
|
||||
output: pdfinfo("no"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "tagged PDF expected but untagged",
|
||||
output: pdfinfo("no"),
|
||||
want: true,
|
||||
wantErr: "expected tagged PDF to be true, but actual is false",
|
||||
},
|
||||
{
|
||||
name: "untagged PDF expected but tagged",
|
||||
output: pdfinfo("yes"),
|
||||
want: false,
|
||||
wantErr: "expected tagged PDF to be false, but actual is true",
|
||||
},
|
||||
{
|
||||
name: "no Tagged line",
|
||||
output: "Pages: 1\n",
|
||||
want: true,
|
||||
wantErr: "invalid pdfinfo output, no Tagged line:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := checkTagged(tc.output, tc.want)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
test/integration/testdata/header-footer-image/footer.html
vendored
Normal file
15
test/integration/testdata/header-footer-image/footer.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body style="margin: 0; width: 100%">
|
||||
<!-- An opaque RGB PNG avoids a separate alpha-mask image XObject. -->
|
||||
<img
|
||||
alt="Regression test logo"
|
||||
width="32"
|
||||
height="32"
|
||||
src="data:image/png;base64,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"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
15
test/integration/testdata/header-footer-image/header.html
vendored
Normal file
15
test/integration/testdata/header-footer-image/header.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body style="margin: 0; width: 100%">
|
||||
<!-- An opaque RGB PNG avoids a separate alpha-mask image XObject. -->
|
||||
<img
|
||||
alt="Regression test logo"
|
||||
width="32"
|
||||
height="32"
|
||||
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAMK0lEQVR4nAEgDN/zAHFeV9yZjwo6SF080rU79JVR0qeuIuYqIIkxwf4rwT7KASBVtxNyEiAxJ4grbwObwdwYZ6P0T7ZyaHzTFJ005RAb+dDFcre2+xqwdWMu6K2qYgD5LRL11Gbts9TyrnGOXwDlHG5y4w4MwrW50xeDinDuoJTl8/S8A8ZLkQMA1u8ezIEjb2uuymxnltx/FBavvETNXZziNgILdKHlL8eh4arzO57gogg1pmz87SbQ68LuEWliRgfzVbw5zKP0DV+iMwMA/Uo3EaY4a9y1Gb5+wwZvfwZoLxNRqlG/ihDl7ZuePDIBwSIq9uDtvTmlFmt51h+uwN/P0/JA8TD79MeLDcLyfWrOiHYWgmZfIIRQ55No5JDAhmwwdhdSUULvyfYHfRYUAA87FNMkQJMhwONa9zyeAZpvUbc3YYjl7PXdmWt9TjIOR9KLlr/oYX4v0p2gwqBEpFuU8rztON0/kzogZ6AyV/Ii59Y1U1XmBQOZc1ZuB0GZdNFLd7YEl5xZ0RoQBng9MACeb3oQG5av5e+OeVd0EBSwTVjj2LIeVDPjC6K3pzm71SWZlQnzGva6q8yRXNgnwCWqhw7il9UPqutAY92KZbAAWXRPSghQMcfJXKhMhTk9IKc00BUIwWMrAM/Iiag0BUsAJ28WinyHUJ+Z7i6kY3mRkpTElQVtxb9PkzzJEkiDNBM2zvI213tqQbms1p3uDAJl7ZhfxXdzN0GUN35tW41YJHqKAx+goQH2joWt3KjyZ1EU0MjktAvsQPLI4aMGNLLLAHjgm2bT2agbLgwuHy/s8zu4MsitoBUFrkFaapQimZrqQe7rk2mE1CE+m7nR/T0TIqzLZ8FfY/kw08XIks4pw63W2xPi039Ixpm4GWd9Dw/XvkgqeKvWtIm+SrK+/hVfEwAwAx4vZY4b9ludgZxTSo3GCP+eJ56dnvDxstPd/iRjbWOLlzMO9RIyHQsQiR6GyAHaYMd10nVDTURBLBRh/vOIVgqW/y5sFLWAT/JwsYFrKSqq+aFD5Qi1vzVrNPbFSRYAeUpOpmKQMUpXRGFn2121iPDhkQtxMuhgIV3GLdj7N2D8jvFitz1ArnJAmbaLXAhUsqvaFVW8uQIl9/RnuS5/nTNZVyzGPpn6/DdR+ilEVioeoJbnMc2dsaHMV3dMX6o6AMtgzHv6tqdLzj1e1JNd6NO7kLp3hJgMZdA6QCjZZTta7F6kUkn+50IrgZKvYSV/OAvJlp5R0hYnIZxYT/GjnG1NX46skoyZYLVEaAbta6Ilt0hT2tJm/k+OW20J7nHXbgCgdIDlXjHrwu0j7Lhb9YlCw3+FY4FZax6fq8ito7hwB1P1HRTXppGkpRcKrNf/RG/98IblVELbH5AvahR5o+fsAVkAMVF2ikK3Jb9QcWdyZuz9+adExsJl2ThBU3fMf5AAQnkPbPrOi7S/aiHfuFP/elFTtZCb9d0XGvEe2KW608dbRIitQrF7CG75lg7kTtuo5MKyBjkdG4ZhtXvAVCbP46MkxLTp/IJacHf9n876CzrC5n09UzQ/WiLD0KmtWyHYAJZArRFStklX9WzAcLi416r/QDof5aShd4zM0K0WwmQFr35OgAmN78LDxglaRbQLAbG8YWb45XwrVfXF/8jCS/wCfaID5TutbSLRVUFPchzq+NeQP0RXVHipz7Bl4b59xwAXI/gQ3FN+tlw/+vQpGgfeMX0QBbI3Q6mu8GaiDZyBoSlDa9jNKmdjM+XcpJAnNLIHXWZ2ZvHZkbOHWw4rer7JWffwPYhKlbbLiCpEBTRWquJRfFL2OHYtKYYpEdd0mJgAkwRzNP3YQg99kB1QNJnQfaejmXSrokCxVMbnbmdW4T2gCN8JjGxUO+ZT1MaSNTpzviDfWBvr44z5bBL5PFPsjNx8AEEDYq91v5qew+43n8sat3dEytOM6aZmQnxjBmYtAEqN9xFdkB1/z0qCCA3fuCN44ERMy7Jow6p0wotUsMJg96xE6bult59nhteUqGUEgtj0X1z/xhEx9WII6tuUJIW6BeTT3FfEegZbXzKBjkZi9BqeCmVCOIzTfTrCJxDLHQBEMri8mSZWdzrQoZTLSjEtRu4WJpIFcMnd/acdues9mbUB7qcfmZURJiPP6SD9byHKkfM+Kp6jCbicn0+Pmyzd9Rpzui25NaoJ9o7hetV4wOB803SYynbu1R8TjRovHNMAqfNrqBjFCS2++XVHB367TUdL1rnyL1Gl3LIZ7ypYhsESIPv/eHkDoi47rtoCuohtsy2QHcmmGqB2GaJYhyyPdVUWYDJvOAH59qRjNMPMkUCGsTBurwug/aTzaF5AmdVmAH3e7o+C5ANUCCxTpHMory8yI8BtJoeKbZbwsYF4RuhzHnlFrRiixiCoGp0HpWwaTkasHjJT2JfVFLtEOqauCQlKYWZgxN+o5lyG7GPt6B1sRfK9BcBesrMPbMuBFjGgbADdq1xYYWJre/Iku1F3q7S3X9CIbwQ5HndUQcTZqotqll9orYPbstFytlAHbl+hCAyVvmtbFHi5HCkKSOtdy3I2we8ziuW+w2keW0cxb3hTvW3LDl6+KrxYfvfp1a6rr3oAZhd+aTUupdRJnkMTiLM0YARjYKkxsvIeJ+rXscGEa9+uKzUjHfCM8MCRqJuDMLJOYQZJMOy8/gESh/r2df6zjwu0rOPK2g3mQN24HQpf4NHA3Js0eJgW8x6H21nn3xbWACB/7AHIBaIXx29qFx9Vc+2Mn1EjMpupjzL5gdKvFACuszoKYQFGLonvisV1+rSGhvCBsWZjL+zNfEbhSmD5RgBdVMsAzZUGCm+Hc2jHk0sasbmh9jb4YX9Od4iy7BjjQwCXObO/HyL9I3eokGoAXQL7Cv5oflCMnzOw0kg0JhPvZdAfAqUNGba4sTAWQ79aNhmXYON7wb6yr+47G9gR6pLgdeWwLZO0fuFUy8G33hlagsilZKpgZZSs2ZKcozp0UC8AeMqdXC2TLaMHmdqzCZ/677p9TlCINu86p7LB8cmH8jzFEnirZmg/t8GClPin9DkttqoLW1mb8xsBy5/Mkv5+E06p+YUsrpoFU971XdOr6hlft10OqQmZcHpGBUcLZqjdAJR4uCC1SWcHO5/yT00LTyKtib1NDjIw3TgVlTt48gkn/Ssl7ScPCch3Wd4JYVIfy8FBCVPAklz6FZqdn7WbsVsgRIm0h2V6952GTBWtSOFwBsCoikWEO7uqRc2czrF0KQAKtLj6OhcofSTdbVMjSc7+H1OoZYl0Qaf4qFc1ElGZTjFS3JQ9dHuTc927mzDXYbCFmE5e8iMQ30pDlO2xUKAvXw3XceMY6e6708a/Amumxlppr1OMH2nQrLTPV1RuQ3YAtV7Nz6wAZf53b3umPpauivw+BRxmrGdw4xxx8IFZ2Xsg0bjjOq4uF2ce3qRhaEEibWNJlq8LiykF7/jMfNbC75T20pEBauCau7zJ0yLZABmlLDoLEznqnNtdUa4c8/VEANZCgXnsKm56WwPCP2QnMyKSSzgNg8wFISFXn5I3aUEs8Qgzo8lwzeHGwL3NDaX4yhbqHjHk4TRZWReQqpOFZ8xEtkNVYyuqfWwXmyoBO0rSKNU/36G+/LL7vkdRETQjfgBD64rnfYLXwSR0MEEJL9WQQImW58JbcDFpoiZ2TTCpzj3iX9p2X9fH2a5gHqjIyuVuHu+BXRU3bDZGdBzEBlMHk9n7zG+95PNXWO23ri3d0rwmL1IWrGfJBkNOIs7dkwsAjgR1a7R+EpPEPm0lTkcKEzLYCpHRN3jKkNJrqahi0nM7nOXLy+IKIp6lywIbwmP4vgOYS/kkEDlT6Rf3AN77C8B7RubVMiHcS54ZlAgVxLN9GCsFuUjX1ixxO3SqhEE5AKTJGK+E6+eWiOunUeL+xbQH+/ai6TEuNd0/CF80seyg32ozQvPiVytrPFPwWW5Zm0BejcYhdrD5LP/VfZoxnpTSsYd2JE84ox8M8XjXhhn1ijeGXTCIX9aG7IMfWfZQgQAbd1xNL6atgyHE6lwQwB2rZ42OEk7tkkRjr9DiWKMQmDOKb2WYoW9vi6AEenhJXcgT6Lr/h6PFXyMtF/aCej/FJq4i9/U+F8W5CVfmBAUdOk91ozn4HQFhLrSAGZrcaa5BZe5R1Of8LAAAAABJRU5ErkJggg=="
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user