Compare commits

...

13 Commits

Author SHA1 Message Date
Julien Neuhart
8ff9d3bcf1 feat(chromium): add scope to extraHttpHeaders 2024-10-11 13:45:07 +02:00
Julien Neuhart
99c328c302 fix(ci): LINUX_AMD64_RELEASE is no more ignored thanks to curly braces 2024-10-10 13:27:36 +02:00
Julien Neuhart
8a9f0a245e chore(api): improve --api-body-limit flag description to include examples 2024-10-08 19:58:08 +02:00
Julien Neuhart
9daecc127a chore(deps): update Go dependencies 2024-10-08 19:58:08 +02:00
Julien Neuhart
119bba4f04 feat(api): add flag --api-bind-ip 2024-10-08 19:58:08 +02:00
Julien Neuhart
bf205c579d fix(pdfcpu): switch to CLI 2024-10-06 18:32:09 +02:00
Julien Neuhart
aa57b17254 chore(deps): update Go dependencies 2024-10-06 09:52:48 +02:00
Julien Neuhart
7df786c5c6 fix(webhook): retrieve values from echo.Context before it get recycled 2024-10-05 14:24:22 +02:00
Julien Neuhart
b315464e98 chore(deps): update Noto Color Emoji to Unicode 16.0 2024-10-05 11:46:00 +02:00
Julien Neuhart
1252ea076b feat(chromium): skipNetworkIdleEvent is now true by default 2024-09-29 18:04:37 +02:00
Julien Neuhart
d970e446a8 chore(deps): update Go dependencies 2024-09-29 17:47:51 +02:00
Julien Neuhart
fe40f3727b feat(api): add --api-body-limit flag that set the body limit for multipart/form-data requests 2024-09-29 17:46:20 +02:00
Julien Neuhart
10a290b065 chore(libreoffice): improve HTTP error messages 2024-09-29 13:19:44 +02:00
28 changed files with 943 additions and 252 deletions

View File

@@ -11,8 +11,9 @@ DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001 GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001 GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.042 # See https://github.com/googlefonts/noto-emoji/releases. NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package. PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.60.3 # See https://github.com/golangci/golangci-lint/releases. GOLANGCI_LINT_VERSION=v1.60.3 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build .PHONY: build
@@ -24,14 +25,17 @@ build: ## Build the Gotenberg's Docker image
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \ --build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \ --build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \ --build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f build/Dockerfile . -f build/Dockerfile .
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_BIND_IP=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT=
API_ROOT_PATH=/ API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false API_ENABLE_BASIC_AUTH=false
@@ -94,15 +98,17 @@ run: ## Start a Gotenberg container
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \ --api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \ --api-port-from-env=$(API_PORT_FROM_ENV) \
--api-bind-ip=$(API_BIND_IP) \
--api-start-timeout=$(API_START_TIMEOUT) \ --api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \ --api-timeout=$(API_TIMEOUT) \
--api-body-limit="$(API_BODY_LIMIT)" \
--api-root-path=$(API_ROOT_PATH) \ --api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \ --api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \ --api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \ --api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \ --api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \ --api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \ --api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \ --chromium-auto-start=$(CHROMIUM_AUTO_START) \
@@ -195,6 +201,7 @@ release: ## Build the Gotenberg's Docker image and push it to a Docker repositor
$(GOTENBERG_USER_UID) \ $(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \ $(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \ $(PDFTK_VERSION) \
$(PDFCPU_VERSION) \
$(DOCKER_REGISTRY) \ $(DOCKER_REGISTRY) \
$(DOCKER_REPOSITORY) \ $(DOCKER_REPOSITORY) \
$(LINUX_AMD64_RELEASE) $(LINUX_AMD64_RELEASE)

View File

@@ -3,10 +3,35 @@
# stage that uses them. # stage that uses them.
ARG GOLANG_VERSION ARG GOLANG_VERSION
# ----------------------------------------------
# pdfcpu binary build stage
# ----------------------------------------------
# Note: this stage is required as pdfcpu does not release an armhf variant by
# default.
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
ARG PDFCPU_VERSION
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
tar --strip-components=1 -xvzf pdfcpu.tar.gz
# Install module dependencies.
RUN go mod download &&\
go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
# Verify installation.
./pdfcpu version
# ---------------------------------------------- # ----------------------------------------------
# Gotenberg binary build stage # Gotenberg binary build stage
# ---------------------------------------------- # ----------------------------------------------
FROM golang:$GOLANG_VERSION AS binary-stage FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage
ARG GOTENBERG_VERSION ARG GOTENBERG_VERSION
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
@@ -187,8 +212,11 @@ RUN \
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d. # https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy the Gotenberg binary from the binary stage. # Copy the pdfcpu binary from the pdfcpu-binary-stage.
COPY --from=binary-stage /home/gotenberg /usr/bin/ COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
# Copy the Gotenberg binary from the gotenberg-binary-stage.
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else. # Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
@@ -197,6 +225,7 @@ ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
ENV PDFTK_BIN_PATH=/usr/bin/pdftk ENV PDFTK_BIN_PATH=/usr/bin/pdftk
ENV QPDF_BIN_PATH=/usr/bin/qpdf ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
USER gotenberg USER gotenberg
WORKDIR /home/gotenberg WORKDIR /home/gotenberg

28
go.mod
View File

@@ -6,13 +6,13 @@ require (
github.com/alexliesenfeld/health v0.8.0 github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/barasher/go-exiftool v1.10.0 github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df
github.com/chromedp/chromedp v0.10.0 github.com/chromedp/chromedp v0.10.0
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/compress v1.17.10 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.12.0 github.com/labstack/echo/v4 v4.12.0
github.com/labstack/gommon v0.4.2 github.com/labstack/gommon v0.4.2
@@ -20,7 +20,6 @@ require (
github.com/mholt/archiver/v3 v3.5.1 github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3 // indirect github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.8.1
github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/prometheus/client_golang v1.20.4 github.com/prometheus/client_golang v1.20.4
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
@@ -28,13 +27,12 @@ require (
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.27.0 // indirect golang.org/x/crypto v0.28.0 // indirect
golang.org/x/image v0.20.0 // indirect golang.org/x/net v0.30.0
golang.org/x/net v0.29.0
golang.org/x/sync v0.8.0 golang.org/x/sync v0.8.0
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.24.0 golang.org/x/term v0.25.0
golang.org/x/text v0.18.0 golang.org/x/text v0.19.0
) )
require github.com/dlclark/regexp2 v1.11.4 require github.com/dlclark/regexp2 v1.11.4
@@ -50,22 +48,16 @@ require (
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.59.1 // indirect github.com/prometheus/common v0.60.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.7.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )

66
go.sum
View File

@@ -12,8 +12,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f h1:dEjjp+iN34En5Pl9XIi978DmR2/CMwuOxoPWtiHixKQ= github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df h1:cbtSn19AtqQha1cxmP2Qvgd3fFMz51AeAEKLJMyEUhc=
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E= github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E=
github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE= github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
@@ -52,24 +52,16 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
@@ -85,8 +77,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -98,28 +88,19 @@ github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9l
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.8.1 h1:AiWUb8uXlrXqJ73OmiYXBjDF0Qxt4OuM281eAfkAOMA=
github.com/pdfcpu/pdfcpu v0.8.1/go.mod h1:M5SFotxdaw0fedxthpjbA/PADytAo6wJnGH0SSBWJ7s=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.59.1 h1:LXb1quJHWm1P6wq/U824uxYi4Sg0oGvNeUm1z5dJoX0= github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA=
github.com/prometheus/common v0.59.1/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0= github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -146,32 +127,25 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -168,33 +168,37 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
return f.MustDuration(newName) return f.MustDuration(newName)
} }
// MustHumanReadableBytesString returns the human-readable bytes string of a // MustHumanReadableBytes returns the human-readable bytes string of a flag
// flag given by name. // given by name.
// It panics if an error occurs. // It panics if an error occurs.
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string { func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
val, err := f.GetString(name) val, err := f.GetString(name)
if err != nil { if err != nil {
panic(err) panic(err)
} }
_, err = bytes.Parse(val) if val == "" {
return 0
}
b, err := bytes.Parse(val)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return val return b
} }
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes // MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
// string of a deprecated flag if it was explicitly set or the human-readable // deprecated flag if it was explicitly set or the human-readable bytes string
// bytes string of the new flag. // of the new flag.
// It panics if an error occurs. // It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string { func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
if f.Changed(deprecated) { if f.Changed(deprecated) {
return f.MustHumanReadableBytesString(deprecated) return f.MustHumanReadableBytes(deprecated)
} }
return f.MustHumanReadableBytesString(newName) return f.MustHumanReadableBytes(newName)
} }
// MustRegexp returns the regular expression of a flag given by name. // MustRegexp returns the regular expression of a flag given by name.

View File

@@ -644,10 +644,11 @@ func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
} }
} }
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) { func TestParsedFlags_MustHumanReadableBytes(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError) fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.String("foo", "1MB", "") fs.String("foo", "1MB", "")
fs.String("bar", "1MB", "") fs.String("bar", "1MB", "")
fs.String("qux", "", "")
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"}) err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
if err != nil { if err != nil {
@@ -671,6 +672,11 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
name: "bar", name: "bar",
expectPanic: true, expectPanic: true,
}, },
{
scenario: "success: empty value",
name: "qux",
expectPanic: false,
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic { if tc.expectPanic {
@@ -689,31 +695,31 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
}() }()
} }
parsedFlags.MustHumanReadableBytesString(tc.name) parsedFlags.MustHumanReadableBytes(tc.name)
}) })
} }
} }
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) { func TestParsedFlags_MustDeprecatedHumanReadableBytes(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
rawFlags []string rawFlags []string
expectValue string expectValue int64
}{ }{
{ {
scenario: "deprecated flag value", scenario: "deprecated flag value",
rawFlags: []string{"--foo=1MB"}, rawFlags: []string{"--foo=1MB"},
expectValue: "1MB", expectValue: 1000000,
}, },
{ {
scenario: "non-deprecated flag value", scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=2MB"}, rawFlags: []string{"--bar=2MB"},
expectValue: "2MB", expectValue: 2000000,
}, },
{ {
scenario: "deprecated flag value > non-deprecated flag value", scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=1MB", "--bar=2MB"}, rawFlags: []string{"--foo=1MB", "--bar=2MB"},
expectValue: "1MB", expectValue: 1000000,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
@@ -728,9 +734,9 @@ func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar") actual := parsedFlags.MustDeprecatedHumanReadableBytes("foo", "bar")
if actual != tc.expectValue { if actual != tc.expectValue {
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual) t.Errorf("expected %d but got %d", tc.expectValue, actual)
} }
}) })
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
@@ -29,9 +30,11 @@ func init() {
// middlewares or health checks. // middlewares or health checks.
type Api struct { type Api struct {
port int port int
bindIp string
tlsCertFile string tlsCertFile string
tlsKeyFile string tlsKeyFile string
startTimeout time.Duration startTimeout time.Duration
bodyLimit int64
timeout time.Duration timeout time.Duration
rootPath string rootPath string
traceHeader string traceHeader string
@@ -170,10 +173,12 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("api", flag.ExitOnError) fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int("api-port", 3000, "Set the port on which the API should listen") fs.Int("api-port", 3000, "Set the port on which the API should listen")
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port") fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
fs.String("api-bind-ip", "", "Set the IP address the API should bind to for incoming connections")
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support") fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support") fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start") fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests") fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
@@ -192,10 +197,12 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
func (a *Api) Provision(ctx *gotenberg.Context) error { func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port") a.port = flags.MustInt("api-port")
a.bindIp = flags.MustString("api-bind-ip")
a.tlsCertFile = flags.MustString("api-tls-cert-file") a.tlsCertFile = flags.MustString("api-tls-cert-file")
a.tlsKeyFile = flags.MustString("api-tls-key-file") a.tlsKeyFile = flags.MustString("api-tls-key-file")
a.startTimeout = flags.MustDuration("api-start-timeout") a.startTimeout = flags.MustDuration("api-start-timeout")
a.timeout = flags.MustDuration("api-timeout") a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path") a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header") a.traceHeader = flags.MustString("api-trace-header")
a.downloadFromCfg = downloadFromConfig{ a.downloadFromCfg = downloadFromConfig{
@@ -326,6 +333,10 @@ func (a *Api) Validate() error {
) )
} }
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") { if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = multierr.Append(err, err = multierr.Append(err,
errors.New("both TLS certificate and key files must be set"), errors.New("both TLS certificate and key files must be set"),
@@ -455,7 +466,7 @@ func (a *Api) Start() error {
} }
if route.IsMultipart { if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.downloadFromCfg)) middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
for _, externalMultipartMiddleware := range externalMultipartMiddlewares { for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler) middlewares = append(middlewares, externalMultipartMiddleware.Handler)
@@ -519,11 +530,11 @@ func (a *Api) Start() error {
var err error var err error
if a.tlsCertFile != "" && a.tlsKeyFile != "" { if a.tlsCertFile != "" && a.tlsKeyFile != "" {
// Start an HTTPS server (supports HTTP/2). // Start an HTTPS server (supports HTTP/2).
err = a.srv.StartTLS(fmt.Sprintf(":%d", a.port), a.tlsCertFile, a.tlsKeyFile) err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile)
} else { } else {
// Start an HTTP/2 Cleartext (non-HTTPS) server. // Start an HTTP/2 Cleartext (non-HTTPS) server.
server := &http2.Server{} server := &http2.Server{}
err = a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server) err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
} }
if !errors.Is(err, http.ErrServerClosed) { if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error()) a.logger.Fatal(err.Error())
@@ -535,7 +546,11 @@ func (a *Api) Start() error {
// StartupMessage returns a custom startup message. // StartupMessage returns a custom startup message.
func (a *Api) StartupMessage() string { func (a *Api) StartupMessage() string {
return fmt.Sprintf("server listening on port %d", a.port) ip := a.bindIp
if a.bindIp == "" {
ip = "[::]"
}
return fmt.Sprintf("server started on %s:%d", ip, a.port)
} }
// Stop stops the HTTP server. // Stop stops the HTTP server.

View File

@@ -57,6 +57,30 @@ func TestApi_Provision(t *testing.T) {
}(), }(),
expectError: true, expectError: true,
}, },
{
scenario: "port from env: invalid environment variable value",
ctx: func() *gotenberg.Context {
fs := new(Api).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func() {
err := os.Setenv("PORT", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectError: true,
},
{ {
scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable", scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
@@ -99,30 +123,6 @@ func TestApi_Provision(t *testing.T) {
}, },
expectError: true, expectError: true,
}, },
{
scenario: "port from env: invalid environment variable value",
ctx: func() *gotenberg.Context {
fs := new(Api).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func() {
err := os.Setenv("PORT", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectError: true,
},
{ {
scenario: "no valid routers", scenario: "no valid routers",
ctx: func() *gotenberg.Context { ctx: func() *gotenberg.Context {
@@ -462,6 +462,7 @@ func TestApi_Validate(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
port int port int
bindIp string
tlsCertFile string tlsCertFile string
tlsKeyFile string tlsKeyFile string
rootPath string rootPath string
@@ -473,6 +474,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid port (< 1)", scenario: "invalid port (< 1)",
port: 0, port: 0,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -482,6 +484,17 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid port (> 65535)", scenario: "invalid port (> 65535)",
port: 65536, port: 65536,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
middlewares: nil,
expectError: true,
},
{
scenario: "invalid IP",
port: 10,
bindIp: "foo",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -491,6 +504,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid TLS files: only cert file provided", scenario: "invalid TLS files: only cert file provided",
port: 10, port: 10,
bindIp: "127.0.0.1",
tlsCertFile: "cert.pem", tlsCertFile: "cert.pem",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
@@ -501,6 +515,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid TLS files: only key file provided", scenario: "invalid TLS files: only key file provided",
port: 10, port: 10,
bindIp: "127.0.0.1",
tlsKeyFile: "key.pem", tlsKeyFile: "key.pem",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
@@ -511,6 +526,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid root path: missing / prefix", scenario: "invalid root path: missing / prefix",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "foo/", rootPath: "foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -520,6 +536,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid root path: missing / suffix", scenario: "invalid root path: missing / suffix",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo", rootPath: "/foo",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -529,6 +546,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid trace header", scenario: "invalid trace header",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "", traceHeader: "",
routes: nil, routes: nil,
@@ -538,6 +556,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: empty path", scenario: "invalid route: empty path",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -551,6 +570,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: missing / prefix in path", scenario: "invalid route: missing / prefix in path",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -564,6 +584,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid multipart route: no /forms prefix in path", scenario: "invalid multipart route: no /forms prefix in path",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -578,6 +599,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: no method", scenario: "invalid route: no method",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -592,6 +614,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: nil handler", scenario: "invalid route: nil handler",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -607,6 +630,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid route: path already existing", scenario: "invalid route: path already existing",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -627,6 +651,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "invalid middleware: nil handler", scenario: "invalid middleware: nil handler",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: nil, routes: nil,
@@ -641,6 +666,7 @@ func TestApi_Validate(t *testing.T) {
{ {
scenario: "success", scenario: "success",
port: 10, port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/", rootPath: "/foo/",
traceHeader: "foo", traceHeader: "foo",
routes: []Route{ routes: []Route{
@@ -694,6 +720,7 @@ func TestApi_Validate(t *testing.T) {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
mod := Api{ mod := Api{
port: tc.port, port: tc.port,
bindIp: tc.bindIp,
tlsCertFile: tc.tlsCertFile, tlsCertFile: tc.tlsCertFile,
tlsKeyFile: tc.tlsKeyFile, tlsKeyFile: tc.tlsKeyFile,
rootPath: tc.rootPath, rootPath: tc.rootPath,
@@ -918,15 +945,36 @@ func TestApi_Start(t *testing.T) {
} }
func TestApi_StartupMessage(t *testing.T) { func TestApi_StartupMessage(t *testing.T) {
mod := Api{ for _, tc := range []struct {
port: 3000, scenario string
} port int
bindIp string
expectMessage string
}{
{
scenario: "no custom IP",
port: 3000,
bindIp: "",
expectMessage: "server started on [::]:3000",
},
{
scenario: "custom IP",
port: 3000,
bindIp: "127.0.0.1",
expectMessage: "server started on 127.0.0.1:3000",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := Api{
port: tc.port,
bindIp: tc.bindIp,
}
actual := mod.StartupMessage() actual := mod.StartupMessage()
expect := "server listening on port 3000" if actual != tc.expectMessage {
t.Errorf("expected '%s' but got '%s'", tc.expectMessage, actual)
if actual != expect { }
t.Errorf("expected '%s' but got '%s'", expect, actual) })
} }
} }

View File

@@ -13,6 +13,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -50,6 +51,28 @@ type Context struct {
context.Context context.Context
} }
type trackingReader struct {
R io.Reader
AddReadBytes func(n int64) error
}
func (t *trackingReader) Read(p []byte) (int, error) {
n, err := t.R.Read(p)
if n > 0 {
errAddRead := t.AddReadBytes(int64(n))
if errAddRead != nil {
return n, fmt.Errorf("add read bytes: %w", errAddRead)
}
}
if err != nil {
// It's a common practice in Go to return io.EOF unwrapped to signal
// the end of a data stream. Wrapping it can lead to unexpected
// behavior in standard library functions.
return n, err
}
return n, nil
}
type downloadFrom struct { type downloadFrom struct {
// Url is the URL to download a file from. // Url is the URL to download a file from.
Url string `json:"url"` Url string `json:"url"`
@@ -65,9 +88,25 @@ func (o *osPathRename) Rename(oldpath, newpath string) error {
} }
// newContext returns a [Context] by parsing a "multipart/form-data" request. // newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) { func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout) processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
// We want to make sure the multipart/form-data does not exceed a given
// limit. We consider: form fields (keys, values, files) and files
// downloaded remotely ("download from" feature).
var totalBytesRead atomic.Int64
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, http.StatusText(http.StatusRequestEntityTooLarge)),
)
}
return nil
}
ctx := &Context{ ctx := &Context{
outputPaths: make([]string, 0), outputPaths: make([]string, 0),
cancelled: false, cancelled: false,
@@ -129,6 +168,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return nil, cancel, fmt.Errorf("get multipart form: %w", err) return nil, cancel, fmt.Errorf("get multipart form: %w", err)
} }
// This will ensure we do not exceed the body limit.
var formValuesSize int64
for key, valArray := range form.Value {
formValuesSize += int64(len(key))
for _, val := range valArray {
formValuesSize += int64(len(val))
}
}
err = addReadBytes(formValuesSize)
if err != nil {
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
}
dirPath, err := fs.MkdirAll() dirPath, err := fs.MkdirAll()
if err != nil { if err != nil {
return nil, cancel, fmt.Errorf("create working directory: %w", err) return nil, cancel, fmt.Errorf("create working directory: %w", err)
@@ -262,9 +314,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
}() }()
_, err = io.Copy(out, resp.Body) // This will ensure we do not exceed the body limit.
reader := &trackingReader{R: resp.Body, AddReadBytes: addReadBytes}
_, err = io.Copy(out, reader)
if err != nil { if err != nil {
return fmt.Errorf("copy downloaded file from '%s' to local file: %v", dl.Url, err) return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
} }
ctx.files[filename] = path ctx.files[filename] = path
@@ -292,6 +347,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
}() }()
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
// Avoid directory traversal and make sure filename characters are // Avoid directory traversal and make sure filename characters are
// normalized. // normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662. // See: https://github.com/gotenberg/gotenberg/issues/662.
@@ -309,7 +367,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
}() }()
_, err = io.Copy(out, in) _, err = io.Copy(out, reader)
if err != nil { if err != nil {
return fmt.Errorf("copy multipart file to local file: %w", err) return fmt.Errorf("copy multipart file to local file: %w", err)
} }
@@ -331,6 +389,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values)) ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files)) ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err return ctx, cancel, err
} }

View File

@@ -95,6 +95,7 @@ func TestNewContext(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
request *http.Request request *http.Request
bodyLimit int64
downloadFromCfg downloadFromConfig downloadFromCfg downloadFromConfig
downloadFromSrv *echo.Echo downloadFromSrv *echo.Echo
expectContext *Context expectContext *Context
@@ -143,6 +144,95 @@ func TestNewContext(t *testing.T) {
expectHttpError: true, expectHttpError: true,
expectHttpStatus: http.StatusBadRequest, expectHttpStatus: http.StatusBadRequest,
}, },
{
scenario: "request entity too large: form values",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("key", "value")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: downloadFrom",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 45, // form values = 44 bytes.
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: form files",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = part.Write([]byte("foo"))
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{ {
scenario: "invalid downloadFrom form field: cannot unmarshal", scenario: "invalid downloadFrom form field: cannot unmarshal",
request: func() *http.Request { request: func() *http.Request {
@@ -458,7 +548,7 @@ func TestNewContext(t *testing.T) {
} }
handler := func(c echo.Context) error { handler := func(c echo.Context) error {
ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.downloadFromCfg, "Gotenberg-Trace", "123") ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
defer cancel() defer cancel()
// Context already cancelled. // Context already cancelled.
defer cancel() defer cancel()

View File

@@ -236,7 +236,7 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
// //
// ctx := c.Get("context").(*api.Context) // ctx := c.Get("context").(*api.Context)
// cancel := c.Get("cancel").(context.CancelFunc) // cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc { func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger) logger := c.Get("logger").(*zap.Logger)
@@ -245,7 +245,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, download
// We create a context with a timeout so that underlying processes are // We create a context with a timeout so that underlying processes are
// able to stop early and handle correctly a timeout scenario. // able to stop early and handle correctly a timeout scenario.
ctx, cancel, err := newContext(c, logger, fs, timeout, downloadFromCfg, traceHeader, trace) ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
if err != nil { if err != nil {
cancel() cancel()

View File

@@ -462,7 +462,7 @@ func TestContextMiddleware(t *testing.T) {
c.Set("trace", "foo") c.Set("trace", "foo")
c.Set("startTime", time.Now()) c.Set("startTime", time.Now())
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, downloadFromConfig{})(tc.next)(c) err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
if tc.expectErr && err == nil { if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Errorf("test %d: expected error but got: %v", i, err)

View File

@@ -228,7 +228,6 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies), setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent), userAgentOverride(logger, options.UserAgent),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(), forceExactColorsActionFunc(),
@@ -252,7 +251,6 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies), setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent), userAgentOverride(logger, options.UserAgent),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(), forceExactColorsActionFunc(),
@@ -291,8 +289,14 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
defer taskCancel() defer taskCancel()
// We validate all others requests against our allow / deny lists. // We validate all others requests against our allow / deny lists.
// If a request does not pass the validation, we make it fail. // If a request does not pass the validation, we make it fail. It also set
listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList) // the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
extraHttpHeaders: options.ExtraHttpHeaders,
})
var ( var (
invalidHttpStatusCode error invalidHttpStatusCode error

View File

@@ -379,7 +379,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
}, },
{ {
scenario: "skip networkIdle event", scenario: "do not skip networkIdle event",
browser: newChromiumBrowser( browser: newChromiumBrowser(
browserArguments{ browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"), binPath: os.Getenv("CHROMIUM_BIN_PATH"),
@@ -404,13 +404,13 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs return fs
}(), }(),
options: PdfOptions{ options: PdfOptions{
Options: Options{SkipNetworkIdleEvent: true}, Options: Options{SkipNetworkIdleEvent: false},
}, },
noDeadline: false, noDeadline: false,
start: true, start: true,
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"skipping network idle event", "event networkIdle fired",
}, },
}, },
{ {
@@ -702,8 +702,21 @@ func TestChromiumBrowser_pdf(t *testing.T) {
return fs return fs
}(), }(),
options: PdfOptions{ options: PdfOptions{
Options: Options{ExtraHttpHeaders: map[string]string{ Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{
"X-Foo": "Bar", {
Name: "X-Foo",
Value: "foo",
},
{
Name: "X-Bar",
Value: "bar",
Scope: regexp2.MustCompile(`.*index\.html.*`, 0),
},
{
Name: "X-Baz",
Value: "baz",
Scope: regexp2.MustCompile(`.*another\.html.*`, 0),
},
}}, }},
}, },
noDeadline: false, noDeadline: false,
@@ -711,6 +724,10 @@ func TestChromiumBrowser_pdf(t *testing.T) {
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"extra HTTP headers:", "extra HTTP headers:",
"extra HTTP header 'X-Foo' will be set for request URL",
"extra HTTP header 'X-Bar' (scoped) will be set for request URL",
"extra HTTP header 'X-Baz' (scoped) will not be set for request URL",
"setting extra HTTP headers for request URL",
}, },
}, },
{ {
@@ -1225,6 +1242,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
"no cookies to set", "no cookies to set",
"no extra HTTP headers", "no extra HTTP headers",
"navigate to", "navigate to",
"skipping network idle event",
"default white background not hidden", "default white background not hidden",
"no emulated media type", "no emulated media type",
"no wait delay", "no wait delay",
@@ -1452,7 +1470,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
}, },
{ {
scenario: "skip networkIdle event", scenario: "do not skip networkIdle event",
browser: newChromiumBrowser( browser: newChromiumBrowser(
browserArguments{ browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"), binPath: os.Getenv("CHROMIUM_BIN_PATH"),
@@ -1477,13 +1495,13 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
return fs return fs
}(), }(),
options: ScreenshotOptions{ options: ScreenshotOptions{
Options: Options{SkipNetworkIdleEvent: true}, Options: Options{SkipNetworkIdleEvent: false},
}, },
noDeadline: false, noDeadline: false,
start: true, start: true,
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"skipping network idle event", "event networkIdle fired",
}, },
}, },
{ {
@@ -1715,6 +1733,41 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"set cookie", "set cookie",
}, },
}, },
{
scenario: "user agent override",
browser: newChromiumBrowser(
browserArguments{
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
wsUrlReadTimeout: 5 * time.Second,
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: ScreenshotOptions{
Options: Options{UserAgent: "foo"},
},
noDeadline: false,
start: true,
expectError: false,
expectedLogEntries: []string{
fmt.Sprintf("user agent override: foo"),
},
},
{ {
scenario: "extra HTTP headers", scenario: "extra HTTP headers",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -1741,8 +1794,21 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
return fs return fs
}(), }(),
options: ScreenshotOptions{ options: ScreenshotOptions{
Options: Options{ExtraHttpHeaders: map[string]string{ Options: Options{ExtraHttpHeaders: []ExtraHttpHeader{
"X-Foo": "Bar", {
Name: "X-Foo",
Value: "foo",
},
{
Name: "X-Bar",
Value: "bar",
Scope: regexp2.MustCompile(`.*index\.html.*`, 0),
},
{
Name: "X-Baz",
Value: "baz",
Scope: regexp2.MustCompile(`.*another\.html.*`, 0),
},
}}, }},
}, },
noDeadline: false, noDeadline: false,
@@ -1750,6 +1816,10 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
expectError: false, expectError: false,
expectedLogEntries: []string{ expectedLogEntries: []string{
"extra HTTP headers:", "extra HTTP headers:",
"extra HTTP header 'X-Foo' will be set for request URL",
"extra HTTP header 'X-Bar' (scoped) will be set for request URL",
"extra HTTP header 'X-Baz' (scoped) will not be set for request URL",
"setting extra HTTP headers for request URL",
}, },
}, },
{ {
@@ -2184,6 +2254,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"no user agent override", "no user agent override",
"no extra HTTP headers", "no extra HTTP headers",
"navigate to", "navigate to",
"skipping network idle event",
"default white background not hidden", "default white background not hidden",
"no emulated media type", "no emulated media type",
"no wait delay", "no wait delay",

View File

@@ -9,6 +9,7 @@ import (
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/zap" "go.uber.org/zap"
@@ -109,7 +110,7 @@ type Options struct {
// ExtraHttpHeaders are extra HTTP headers to send by Chromium while // ExtraHttpHeaders are extra HTTP headers to send by Chromium while
// loading he HTML document. // loading he HTML document.
ExtraHttpHeaders map[string]string ExtraHttpHeaders []ExtraHttpHeader
// EmulatedMediaType is the media type to emulate, either "screen" or // EmulatedMediaType is the media type to emulate, either "screen" or
// "print". // "print".
@@ -123,7 +124,7 @@ type Options struct {
// DefaultOptions returns the default values for Options. // DefaultOptions returns the default values for Options.
func DefaultOptions() Options { func DefaultOptions() Options {
return Options{ return Options{
SkipNetworkIdleEvent: false, SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599}, FailOnHttpStatusCodes: []int64{499, 599},
FailOnConsoleExceptions: false, FailOnConsoleExceptions: false,
WaitDelay: 0, WaitDelay: 0,
@@ -289,6 +290,22 @@ type Cookie struct {
SameSite network.CookieSameSite `json:"sameSite,omitempty"` SameSite network.CookieSameSite `json:"sameSite,omitempty"`
} }
// ExtraHttpHeader are extra HTTP headers to send by Chromium.
type ExtraHttpHeader struct {
// Name is the header name.
// Required.
Name string
// Value is the header value.
// Required.
Value string
// Scope is the header scope. If nil, the header will be applied to ALL
// requests from the page.
// Optional.
Scope *regexp2.Regexp
}
// Api helps to interact with Chromium for converting HTML documents to PDF. // Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface { type Api interface {
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error

View File

@@ -20,10 +20,22 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type eventRequestPausedOptions struct {
allowList, denyList *regexp2.Regexp
extraHttpHeaders []ExtraHttpHeader
}
// listenForEventRequestPaused listens for requests to check if they are // listenForEventRequestPaused listens for requests to check if they are
// allowed or not.network.SetBlockedURLS() // allowed or not. It also set the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now). // TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) { func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
if len(options.extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers")
} else {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
}
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) { switch e := ev.(type) {
case *fetch.EventRequestPaused: case *fetch.EventRequestPaused:
@@ -37,7 +49,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
return return
} }
err := gotenberg.FilterDeadline(allowList, denyList, e.Request.URL, deadline) err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil { if err != nil {
logger.Warn(err.Error()) logger.Warn(err.Error())
allow = false allow = false
@@ -46,19 +58,78 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
cctx := chromedp.FromContext(ctx) cctx := chromedp.FromContext(ctx)
executorCtx := cdp.WithExecutor(ctx, cctx.Target) executorCtx := cdp.WithExecutor(ctx, cctx.Target)
if allow { if !allow {
req := fetch.ContinueRequest(e.RequestID) req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("continue request: %s", err)) logger.Error(fmt.Sprintf("fail request: %s", err))
} }
return return
} }
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) req := fetch.ContinueRequest(e.RequestID)
var extraHttpHeadersToSet []ExtraHttpHeader
if len(options.extraHttpHeaders) > 0 {
// The user want to set extra HTTP headers.
// First, we have to check if at least one header has to be
// set for current request.
for _, header := range options.extraHttpHeaders {
if header.Scope == nil {
// Non-scoped header.
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
continue
}
ok, err := header.Scope.MatchString(e.Request.URL)
if err != nil {
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
} else if ok {
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
} else {
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
}
}
}
if len(extraHttpHeadersToSet) > 0 {
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
originalHeaders := e.Request.Headers
headers := make(map[string]string)
for key, value := range originalHeaders {
strValue, ok := value.(string)
if ok {
headers[key] = strValue
} else {
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
}
}
var headersEntries []*fetch.HeaderEntry
for key, value := range headers {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: key,
Value: value,
})
}
for _, header := range extraHttpHeadersToSet {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: header.Name,
Value: header.Value,
})
}
req.Headers = headersEntries
}
err = req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err)) logger.Error(fmt.Sprintf("continue request: %s", err))
} }
}() }()
} }

View File

@@ -13,6 +13,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2" "github.com/russross/blackfriday/v2"
@@ -36,7 +37,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
waitForExpression string waitForExpression string
cookies []Cookie cookies []Cookie
userAgent string userAgent string
extraHttpHeaders map[string]string extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string emulatedMediaType string
omitBackground bool omitBackground bool
) )
@@ -86,12 +87,59 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil return nil
} }
err := json.Unmarshal([]byte(value), &extraHttpHeaders) var headers map[string]string
err := json.Unmarshal([]byte(value), &headers)
if err != nil { if err != nil {
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err) return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
} }
return nil for k, v := range headers {
var scope string
var valueTokens []string
var invalidScopeToken bool
tokens := strings.Split(v, ";")
for _, token := range tokens {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
tokenNoSpaces := strings.Join(strings.Fields(token), "")
parts := strings.SplitN(tokenNoSpaces, "=", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
scope = parts[1]
} else {
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
invalidScopeToken = true
break
}
} else {
if token != "" {
valueTokens = append(valueTokens, token)
}
}
}
if invalidScopeToken {
continue
}
var scopeRegexp *regexp2.Regexp
if len(scope) > 0 {
p, errCompile := regexp2.Compile(scope, 0)
if errCompile != nil {
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
continue
}
scopeRegexp = p
}
extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{
Name: k,
Value: strings.Join(valueTokens, "; "),
Scope: scopeRegexp,
})
}
return err
}). }).
Custom("emulatedMediaType", func(value string) error { Custom("emulatedMediaType", func(value string) error {
if value == "" { if value == "" {

View File

@@ -7,8 +7,10 @@ import (
"net/http" "net/http"
"os" "os"
"reflect" "reflect"
"sort"
"testing" "testing"
"github.com/dlclark/regexp2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
@@ -19,14 +21,18 @@ import (
func TestFormDataChromiumOptions(t *testing.T) { func TestFormDataChromiumOptions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedOptions Options expectedOptions Options
compareWithoutDeepEqual bool
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: false,
}, },
{ {
scenario: "invalid failOnHttpStatusCodes form field", scenario: "invalid failOnHttpStatusCodes form field",
@@ -44,6 +50,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.FailOnHttpStatusCodes = nil options.FailOnHttpStatusCodes = nil
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "valid failOnHttpStatusCodes form field", scenario: "valid failOnHttpStatusCodes form field",
@@ -61,6 +69,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.FailOnHttpStatusCodes = []int64{399, 499, 599} options.FailOnHttpStatusCodes = []int64{399, 499, 599}
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: false,
}, },
{ {
scenario: "invalid cookies form field", scenario: "invalid cookies form field",
@@ -73,7 +83,9 @@ func TestFormDataChromiumOptions(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "invalid cookies form field (missing required values)", scenario: "invalid cookies form field (missing required values)",
@@ -93,6 +105,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.Cookies = []Cookie{{}} options.Cookies = []Cookie{{}}
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "valid cookies form field", scenario: "valid cookies form field",
@@ -114,9 +128,11 @@ func TestFormDataChromiumOptions(t *testing.T) {
}} }}
return options return options
}(), }(),
compareWithoutDeepEqual: false,
expectValidationError: false,
}, },
{ {
scenario: "invalid extraHttpHeaders form field", scenario: "invalid extraHttpHeaders form field: cannot unmarshall",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
@@ -126,7 +142,39 @@ func TestFormDataChromiumOptions(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
},
{
scenario: "invalid extraHttpHeaders form field: invalid scope",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
`{"foo":"bar;scope;;"}`,
},
})
return ctx
}(),
expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
},
{
scenario: "invalid extraHttpHeaders form field: invalid scope regex pattern",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"extraHttpHeaders": {
`{"foo":"bar;scope=*."}`,
},
})
return ctx
}(),
expectedOptions: DefaultOptions(),
compareWithoutDeepEqual: false,
expectValidationError: true,
}, },
{ {
scenario: "valid extraHttpHeaders form field", scenario: "valid extraHttpHeaders form field",
@@ -134,18 +182,28 @@ func TestFormDataChromiumOptions(t *testing.T) {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"extraHttpHeaders": { "extraHttpHeaders": {
`{"foo":"bar"}`, `{"foo":"bar","baz":"qux;scope=https?:\\/\\/([a-zA-Z0-9-]+\\.)*qux\\.com\\/.*"}`,
}, },
}) })
return ctx return ctx
}(), }(),
expectedOptions: func() Options { expectedOptions: func() Options {
options := DefaultOptions() options := DefaultOptions()
options.ExtraHttpHeaders = map[string]string{ options.ExtraHttpHeaders = []ExtraHttpHeader{
"foo": "bar", {
Name: "foo",
Value: "bar",
},
{
Name: "baz",
Value: "qux",
Scope: regexp2.MustCompile(`https?:\/\/([a-zA-Z0-9-]+\.)*qux\.com\/.*`, 0),
},
} }
return options return options
}(), }(),
compareWithoutDeepEqual: true,
expectValidationError: false,
}, },
{ {
scenario: "invalid emulatedMediaType form field", scenario: "invalid emulatedMediaType form field",
@@ -158,7 +216,8 @@ func TestFormDataChromiumOptions(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedOptions: DefaultOptions(), expectedOptions: DefaultOptions(),
expectValidationError: true,
}, },
{ {
scenario: "valid emulatedMediaType form field", scenario: "valid emulatedMediaType form field",
@@ -176,14 +235,61 @@ func TestFormDataChromiumOptions(t *testing.T) {
options.EmulatedMediaType = "screen" options.EmulatedMediaType = "screen"
return options return options
}(), }(),
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
_, actual := FormDataChromiumOptions(tc.ctx.Context) form, actual := FormDataChromiumOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.expectedOptions) { if tc.compareWithoutDeepEqual {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) if len(tc.expectedOptions.ExtraHttpHeaders) != len(actual.ExtraHttpHeaders) {
t.Fatalf("expected %d extra HTTP headers, but got %d", len(tc.expectedOptions.ExtraHttpHeaders), len(actual.ExtraHttpHeaders))
}
sort.Slice(tc.expectedOptions.ExtraHttpHeaders, func(i, j int) bool {
return tc.expectedOptions.ExtraHttpHeaders[i].Name < tc.expectedOptions.ExtraHttpHeaders[j].Name
})
sort.Slice(actual.ExtraHttpHeaders, func(i, j int) bool {
return actual.ExtraHttpHeaders[i].Name < actual.ExtraHttpHeaders[j].Name
})
for i := range tc.expectedOptions.ExtraHttpHeaders {
if tc.expectedOptions.ExtraHttpHeaders[i].Name != actual.ExtraHttpHeaders[i].Name {
t.Fatalf("expected '%s' extra HTTP header, but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Name, tc.expectedOptions.ExtraHttpHeaders[i].Name)
}
if tc.expectedOptions.ExtraHttpHeaders[i].Value != actual.ExtraHttpHeaders[i].Value {
t.Fatalf("expected '%s' as value for extra HTTP header '%s', but got '%s'", tc.expectedOptions.ExtraHttpHeaders[i].Value, tc.expectedOptions.ExtraHttpHeaders[i].Name, actual.ExtraHttpHeaders[i].Value)
}
var expectedScope string
if tc.expectedOptions.ExtraHttpHeaders[i].Scope != nil {
expectedScope = tc.expectedOptions.ExtraHttpHeaders[i].Scope.String()
}
var actualScope string
if actual.ExtraHttpHeaders[i].Scope != nil {
actualScope = actual.ExtraHttpHeaders[i].Scope.String()
}
if expectedScope != actualScope {
t.Fatalf("expected '%s' as scope for extra HTTP header '%s', but got '%s'", expectedScope, tc.expectedOptions.ExtraHttpHeaders[i].Name, actualScope)
}
}
} else {
if !reflect.DeepEqual(actual, tc.expectedOptions) {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
}
}
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
} }
}) })
} }
@@ -191,14 +297,16 @@ func TestFormDataChromiumOptions(t *testing.T) {
func TestFormDataChromiumPdfOptions(t *testing.T) { func TestFormDataChromiumPdfOptions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedOptions PdfOptions expectedOptions PdfOptions
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedOptions: DefaultPdfOptions(), expectedOptions: DefaultPdfOptions(),
expectValidationError: false,
}, },
{ {
scenario: "custom form fields (Options & PdfOptions)", scenario: "custom form fields (Options & PdfOptions)",
@@ -220,29 +328,42 @@ func TestFormDataChromiumPdfOptions(t *testing.T) {
options.EmulatedMediaType = "screen" options.EmulatedMediaType = "screen"
return options return options
}(), }(),
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
_, actual := FormDataChromiumPdfOptions(tc.ctx.Context) form, actual := FormDataChromiumPdfOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.expectedOptions) { if !reflect.DeepEqual(actual, tc.expectedOptions) {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }
func TestFormDataChromiumScreenshotOptions(t *testing.T) { func TestFormDataChromiumScreenshotOptions(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedOptions ScreenshotOptions expectedOptions ScreenshotOptions
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedOptions: DefaultScreenshotOptions(), expectedOptions: DefaultScreenshotOptions(),
expectValidationError: false,
}, },
{ {
scenario: "invalid format form field", scenario: "invalid format form field",
@@ -260,6 +381,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "" options.Format = ""
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "valid png format form field", scenario: "valid png format form field",
@@ -277,6 +399,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "png" options.Format = "png"
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "valid jpeg format form field", scenario: "valid jpeg format form field",
@@ -294,6 +417,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "jpeg" options.Format = "jpeg"
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "valid webp format form field", scenario: "valid webp format form field",
@@ -311,6 +435,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Format = "webp" options.Format = "webp"
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "invalid quality form field (not an integer)", scenario: "invalid quality form field (not an integer)",
@@ -328,6 +453,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 0 options.Quality = 0
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "invalid quality form field (< 0)", scenario: "invalid quality form field (< 0)",
@@ -345,6 +471,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 0 options.Quality = 0
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "invalid quality form field (> 100)", scenario: "invalid quality form field (> 100)",
@@ -362,6 +489,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 0 options.Quality = 0
return options return options
}(), }(),
expectValidationError: true,
}, },
{ {
scenario: "valid quality form field", scenario: "valid quality form field",
@@ -379,6 +507,7 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.Quality = 50 options.Quality = 50
return options return options
}(), }(),
expectValidationError: false,
}, },
{ {
scenario: "custom form fields (Options & ScreenshotOptions)", scenario: "custom form fields (Options & ScreenshotOptions)",
@@ -412,29 +541,42 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
options.EmulatedMediaType = "screen" options.EmulatedMediaType = "screen"
return options return options
}(), }(),
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
_, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context) form, actual := FormDataChromiumScreenshotOptions(tc.ctx.Context)
if !reflect.DeepEqual(actual, tc.expectedOptions) { if !reflect.DeepEqual(actual, tc.expectedOptions) {
t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedOptions, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }
func TestFormDataChromiumPdfFormats(t *testing.T) { func TestFormDataChromiumPdfFormats(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedPdfFormats gotenberg.PdfFormats expectedPdfFormats gotenberg.PdfFormats
expectValidationError bool
}{ }{
{ {
scenario: "no custom form fields", scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedPdfFormats: gotenberg.PdfFormats{}, expectedPdfFormats: gotenberg.PdfFormats{},
expectValidationError: false,
}, },
{ {
scenario: "pdfa and pdfua form fields", scenario: "pdfa and pdfua form fields",
@@ -450,30 +592,44 @@ func TestFormDataChromiumPdfFormats(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true}, expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true},
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
actual := FormDataChromiumPdfFormats(tc.ctx.Context.FormData()) form := tc.ctx.Context.FormData()
actual := FormDataChromiumPdfFormats(form)
if !reflect.DeepEqual(actual, tc.expectedPdfFormats) { if !reflect.DeepEqual(actual, tc.expectedPdfFormats) {
t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }
func TestFormDataPdfMetadata(t *testing.T) { func TestFormDataPdfMetadata(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *api.ContextMock ctx *api.ContextMock
expectedMetadata map[string]interface{} expectedMetadata map[string]interface{}
expectValidationError bool
}{ }{
{ {
scenario: "no metadata form field", scenario: "no metadata form field",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
expectedMetadata: nil, expectedMetadata: nil,
expectValidationError: false,
}, },
{ {
scenario: "invalid metadata form field", scenario: "invalid metadata form field",
@@ -486,7 +642,8 @@ func TestFormDataPdfMetadata(t *testing.T) {
}) })
return ctx return ctx
}(), }(),
expectedMetadata: nil, expectedMetadata: nil,
expectValidationError: true,
}, },
{ {
scenario: "valid metadata form field", scenario: "valid metadata form field",
@@ -502,15 +659,27 @@ func TestFormDataPdfMetadata(t *testing.T) {
expectedMetadata: map[string]interface{}{ expectedMetadata: map[string]interface{}{
"foo": "bar", "foo": "bar",
}, },
expectValidationError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
actual := FormDataPdfMetadata(tc.ctx.Context.FormData()) form := tc.ctx.Context.FormData()
actual := FormDataPdfMetadata(form)
if !reflect.DeepEqual(actual, tc.expectedMetadata) { if !reflect.DeepEqual(actual, tc.expectedMetadata) {
t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual) t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
} }
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
}) })
} }
} }

View File

@@ -298,28 +298,33 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc
} }
} }
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { // This code has been replaced with the listenForEventRequestPaused function.
return func(ctx context.Context) error { // Indeed, the user may want to scope the headers per domain, but using
if len(extraHttpHeaders) == 0 { // network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
logger.Debug("no extra HTTP headers") // See https://github.com/gotenberg/gotenberg/issues/1011.
return nil //
} //func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders)) // if len(extraHttpHeaders) == 0 {
// logger.Debug("no extra HTTP headers")
headers := make(network.Headers, len(extraHttpHeaders)) // return nil
for key, value := range extraHttpHeaders { // }
headers[key] = value //
} // logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
//
err := network.SetExtraHTTPHeaders(headers).Do(ctx) // headers := make(network.Headers, len(extraHttpHeaders))
if err == nil { // for key, value := range extraHttpHeaders {
return nil // headers[key] = value
} // }
//
return fmt.Errorf("set extra HTTP headers: %w", err) // err := network.SetExtraHTTPHeaders(headers).Do(ctx)
} // if err == nil {
} // return nil
// }
//
// return fmt.Errorf("set extra HTTP headers: %w", err)
// }
//}
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc { func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
return func(ctx context.Context) error { return func(ctx context.Context) error {

View File

@@ -191,14 +191,14 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
if errors.Is(err, libreofficeapi.ErrUnoException) { if errors.Is(err, libreofficeapi.ErrUnoException) {
return api.WrapError( return api.WrapError(
fmt.Errorf("convert to PDF: %w", err), fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process the document: possible causes include malformed page ranges '%s' (nativePageRanges) or the document might not be password-protected, but the exact cause is uncertain", options.PageRanges)), api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
) )
} }
if errors.Is(err, libreofficeapi.ErrRuntimeException) { if errors.Is(err, libreofficeapi.ErrRuntimeException) {
return api.WrapError( return api.WrapError(
fmt.Errorf("convert to PDF: %w", err), fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be invalid or required, but the exact cause is uncertain"), api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
) )
} }

View File

@@ -1,5 +1,5 @@
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine // Package pdfcpu provides an implementation of the gotenberg.PdfEngine
// interface using the pdfcpu library. This package allows for: // interface using the pdfcpu command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// //

View File

@@ -2,11 +2,10 @@ package pdfcpu
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os"
pdfcpuAPI "github.com/pdfcpu/pdfcpu/pkg/api"
pdfcpuLog "github.com/pdfcpu/pdfcpu/pkg/log"
pdfcpuConfig "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -16,10 +15,10 @@ func init() {
gotenberg.MustRegisterModule(new(PdfCpu)) gotenberg.MustRegisterModule(new(PdfCpu))
} }
// PdfCpu abstracts the pdfcpu library and implements the [gotenberg.PdfEngine] // PdfCpu abstracts the CLI tool pdfcpu and implements the
// interface. // [gotenberg.PdfEngine] interface.
type PdfCpu struct { type PdfCpu struct {
conf *pdfcpuConfig.Configuration binPath string
} }
// Descriptor returns a [PdfCpu]'s module descriptor. // Descriptor returns a [PdfCpu]'s module descriptor.
@@ -32,16 +31,38 @@ func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor {
// Provision sets the engine properties. // Provision sets the engine properties.
func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error { func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error {
pdfcpuConfig.ConfigPath = "disable" binPath, ok := os.LookupEnv("PDFCPU_BIN_PATH")
pdfcpuLog.DisableLoggers() if !ok {
engine.conf = pdfcpuConfig.NewDefaultConfiguration() return errors.New("PDFCPU_BIN_PATH environment variable is not set")
}
engine.binPath = binPath
return nil
}
// Validate validates the module properties.
func (engine *PdfCpu) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("pdfcpu binary path does not exist: %w", err)
}
return nil return nil
} }
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, false, engine.conf) var args []string
args = append(args, "merge", outputPath)
args = append(args, inputPaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err == nil { if err == nil {
return nil return nil
} }
@@ -68,5 +89,6 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, met
var ( var (
_ gotenberg.Module = (*PdfCpu)(nil) _ gotenberg.Module = (*PdfCpu)(nil)
_ gotenberg.Provisioner = (*PdfCpu)(nil) _ gotenberg.Provisioner = (*PdfCpu)(nil)
_ gotenberg.Validator = (*PdfCpu)(nil)
_ gotenberg.PdfEngine = (*PdfCpu)(nil) _ gotenberg.PdfEngine = (*PdfCpu)(nil)
) )

View File

@@ -33,14 +33,59 @@ func TestPdfCpu_Provision(t *testing.T) {
} }
} }
func TestPdfCpu_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("PDFTK_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
engine.binPath = tc.binPath
err := engine.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfCpu_Merge(t *testing.T) { func TestPdfCpu_Merge(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx context.Context
inputPaths []string inputPaths []string
expectError bool expectError bool
}{ }{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{ {
scenario: "invalid input path", scenario: "invalid input path",
ctx: context.TODO(),
inputPaths: []string{ inputPaths: []string{
"foo", "foo",
}, },
@@ -48,6 +93,7 @@ func TestPdfCpu_Merge(t *testing.T) {
}, },
{ {
scenario: "single file success", scenario: "single file success",
ctx: context.TODO(),
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
}, },
@@ -55,10 +101,12 @@ func TestPdfCpu_Merge(t *testing.T) {
}, },
{ {
scenario: "many files success", scenario: "many files success",
ctx: context.TODO(),
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf", "/tests/test/testdata/pdfengines/sample2.pdf",
}, },
expectError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
@@ -81,7 +129,7 @@ func TestPdfCpu_Merge(t *testing.T) {
} }
}() }()
err = engine.Merge(nil, nil, tc.inputPaths, outputDir+"/foo.pdf") err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
if !tc.expectError && err != nil { if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)

View File

@@ -45,7 +45,7 @@ func (engine *PdfTk) Provision(ctx *gotenberg.Context) error {
func (engine *PdfTk) Validate() error { func (engine *PdfTk) Validate() error {
_, err := os.Stat(engine.binPath) _, err := os.Stat(engine.binPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("PdfTk binary path does not exist: %w", err) return fmt.Errorf("PDFtk binary path does not exist: %w", err)
} }
return nil return nil

View File

@@ -97,6 +97,7 @@ func TestPdfTk_Merge(t *testing.T) {
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
}, },
expectError: false,
}, },
{ {
scenario: "many files success", scenario: "many files success",
@@ -105,6 +106,7 @@ func TestPdfTk_Merge(t *testing.T) {
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf", "/tests/test/testdata/pdfengines/sample2.pdf",
}, },
expectError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {

View File

@@ -97,6 +97,7 @@ func TestQPdf_Merge(t *testing.T) {
inputPaths: []string{ inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
}, },
expectError: false,
}, },
{ {
scenario: "many files success", scenario: "many files success",
@@ -105,6 +106,7 @@ func TestQPdf_Merge(t *testing.T) {
"/tests/test/testdata/pdfengines/sample1.pdf", "/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf", "/tests/test/testdata/pdfengines/sample2.pdf",
}, },
expectError: false,
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {

View File

@@ -113,13 +113,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
} }
// Retrieve values from echo.Context before it get recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
client := &client{ client := &client{
url: webhookUrl, url: webhookUrl,
method: webhookMethod, method: webhookMethod,
errorUrl: webhookErrorUrl, errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod, errorMethod: webhookErrorMethod,
extraHttpHeaders: extraHttpHeaders, extraHttpHeaders: extraHttpHeaders,
startTime: c.Get("startTime").(time.Time), startTime: startTime,
client: &retryablehttp.Client{ client: &retryablehttp.Client{
HTTPClient: &http.Client{ HTTPClient: &http.Client{
@@ -157,8 +163,8 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
headers := map[string]string{ headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSON, echo.HeaderContentType: echo.MIMEApplicationJSON,
c.Get("traceHeader").(string): c.Get("trace").(string), traceHeader: trace,
} }
err = client.send(bytes.NewReader(b), headers, true) err = client.send(bytes.NewReader(b), headers, true)
@@ -236,7 +242,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)), echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
echo.HeaderContentType: http.DetectContentType(fileHeader), echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
c.Get("traceHeader").(string): c.Get("trace").(string), traceHeader: trace,
} }
// Send the output file to the webhook. // Send the output file to the webhook.

View File

@@ -9,9 +9,10 @@ GOTENBERG_USER_GID="$3"
GOTENBERG_USER_UID="$4" GOTENBERG_USER_UID="$4"
NOTO_COLOR_EMOJI_VERSION="$5" NOTO_COLOR_EMOJI_VERSION="$5"
PDFTK_VERSION="$6" PDFTK_VERSION="$6"
DOCKER_REGISTRY="$7" PDFCPU_VERSION="$7"
DOCKER_REPOSITORY="$8" DOCKER_REGISTRY="$8"
LINUX_AMD64_RELEASE="$9" DOCKER_REPOSITORY="$9"
LINUX_AMD64_RELEASE="${10}"
# Find out if given version is "semver". # Find out if given version is "semver".
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}" GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
@@ -65,6 +66,7 @@ docker buildx build \
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \ --build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \ --build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \ --build-arg PDFTK_VERSION="$PDFTK_VERSION" \
--build-arg PDFCPU_VERSION="$PDFCPU_VERSION" \
$PLATFORM_FLAG \ $PLATFORM_FLAG \
"${TAGS[@]}" \ "${TAGS[@]}" \
--push \ --push \