Compare commits

...

20 Commits

Author SHA1 Message Date
Julien Neuhart
66dbdc09b5 chore(deps): update Go dependencies 2024-11-18 09:39:32 +01:00
Tam Nguyen
79f396f6ff feat(chromium): add GenerateDocumentOutline option 2024-11-18 08:58:26 +01:00
Julien Neuhart
62ba4877ac chore(deps): update golangci-lint 2024-11-05 11:53:20 +01:00
Julien Neuhart
e65a5e6d22 chore(deps): update Go dependencies 2024-11-05 11:44:29 +01:00
Julien Neuhart
711b43070d feat(chromium): add failOnResourceLoadingFailed and failOnResourceHttpStatusCodes form fields 2024-11-05 11:44:01 +01:00
Julien Neuhart
b2f957d1d8 chore(pdfengines): update new flags with a description of the empty value behavior 2024-10-14 09:24:42 +02:00
Julien Neuhart
249745f256 feat(pdfengines): one flag per PDF engine method for a more granular selection of PDF engines 2024-10-13 11:59:26 +02:00
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
35 changed files with 1828 additions and 532 deletions

View File

@@ -24,7 +24,7 @@ jobs:
- name: Run linters - name: Run linters
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v6
with: with:
version: v1.60.3 version: v1.61.0
tests: tests:
needs: needs:

View File

@@ -11,9 +11,10 @@ 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.
GOLANGCI_LINT_VERSION=v1.60.3 # See https://github.com/golangci/golangci-lint/releases. PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.61.0 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build .PHONY: build
build: ## Build the Gotenberg's Docker image build: ## Build the Gotenberg's Docker image
@@ -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
@@ -68,6 +72,10 @@ LOG_LEVEL=info
LOG_FORMAT=auto LOG_FORMAT=auto
LOG_FIELDS_PREFIX= LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES= PDFENGINES_ENGINES=
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_DISABLE_ROUTES=false PDFENGINES_DISABLE_ROUTES=false
PROMETHEUS_NAMESPACE=gotenberg PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_COLLECT_INTERVAL=1s
@@ -94,15 +102,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) \
@@ -130,6 +140,10 @@ run: ## Start a Gotenberg container
--log-format=$(LOG_FORMAT) \ --log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \ --log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \ --pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \ --pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
@@ -195,6 +209,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

38
go.mod
View File

@@ -4,15 +4,15 @@ go 1.23.0
require ( 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.1 // 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-20241110205750-a72e6703cd9b
github.com/chromedp/chromedp v0.10.0 github.com/chromedp/chromedp v0.11.2
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.11 // 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,21 +20,19 @@ 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.5
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
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.29.0 // indirect
golang.org/x/image v0.20.0 // indirect golang.org/x/net v0.31.0
golang.org/x/net v0.29.0 golang.org/x/sync v0.9.0
golang.org/x/sync v0.8.0 golang.org/x/sys v0.27.0 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/term v0.26.0
golang.org/x/term v0.24.0 golang.org/x/text v0.20.0
golang.org/x/text v0.18.0
) )
require github.com/dlclark/regexp2 v1.11.4 require github.com/dlclark/regexp2 v1.11.4
@@ -43,29 +41,23 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/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.1 // 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.8.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )

90
go.sum
View File

@@ -1,8 +1,8 @@
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk= github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc= github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
@@ -11,13 +11,12 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/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-20241110205750-a72e6703cd9b h1:md1Gk5jkNE91SZxFDCMHmKqX0/GsEr1/VTejht0sCbY=
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f h1:dEjjp+iN34En5Pl9XIi978DmR2/CMwuOxoPWtiHixKQ= github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/cdproto v0.0.0-20240919203636-12af5e8a671f/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E= github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -52,24 +51,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.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.11/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 +76,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 +87,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.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.20.5/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.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
github.com/prometheus/common v0.59.1/go.mod h1:GpWM7dewqmVYcd7SmRaiWVe9SSqjf0UrwnYnpEZNuT0= github.com/prometheus/common v0.60.1/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=
@@ -140,38 +120,32 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 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.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.6.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.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.35.2/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,17 +289,35 @@ 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
invalidHttpStatusCodeMu sync.RWMutex invalidHttpStatusCodeMu sync.RWMutex
invalidResourceHttpStatusCode error
invalidResourceHttpStatusCodeMu sync.RWMutex
) )
// See https://github.com/gotenberg/gotenberg/issues/613. // See:
if len(options.FailOnHttpStatusCodes) != 0 { // https://github.com/gotenberg/gotenberg/issues/613.
listenForEventResponseReceived(taskCtx, logger, url, options.FailOnHttpStatusCodes, &invalidHttpStatusCode, &invalidHttpStatusCodeMu) // https://github.com/gotenberg/gotenberg/issues/1021.
if len(options.FailOnHttpStatusCodes) != 0 || len(options.FailOnResourceHttpStatusCodes) != 0 {
listenForEventResponseReceived(taskCtx, logger, eventResponseReceivedOptions{
mainPageUrl: url,
failOnHttpStatusCodes: options.FailOnHttpStatusCodes,
invalidHttpStatusCode: &invalidHttpStatusCode,
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
})
} }
var ( var (
@@ -315,14 +331,22 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
} }
var ( var (
loadingFailed error loadingFailed error
loadingFailedMu sync.RWMutex loadingFailedMu sync.RWMutex
resourceLoadingFailed error
resourceLoadingFailedMu sync.RWMutex
) )
// See: // See:
// https://github.com/gotenberg/gotenberg/issues/913 // https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959 // https://github.com/gotenberg/gotenberg/issues/959.
listenForEventLoadingFailed(taskCtx, logger, &loadingFailed, &loadingFailedMu) // https://github.com/gotenberg/gotenberg/issues/1021.
listenForEventLoadingFailed(taskCtx, logger, eventLoadingFailedOptions{
loadingFailed: &loadingFailed,
loadingFailedMu: &loadingFailedMu,
resourceLoadingFailed: &resourceLoadingFailed,
resourceLoadingFailedMu: &resourceLoadingFailedMu,
})
err = chromedp.Run(taskCtx, tasks...) err = chromedp.Run(taskCtx, tasks...)
if err != nil { if err != nil {
@@ -351,6 +375,14 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode) return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
} }
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/262. // See https://github.com/gotenberg/gotenberg/issues/262.
consoleExceptionsMu.RLock() consoleExceptionsMu.RLock()
defer consoleExceptionsMu.RUnlock() defer consoleExceptionsMu.RUnlock()
@@ -360,8 +392,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
} }
// See: // See:
// https://github.com/gotenberg/gotenberg/issues/913 // https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959 // https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock() loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock() defer loadingFailedMu.RUnlock()
@@ -369,6 +401,13 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed) return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
} }
// See https://github.com/gotenberg/gotenberg/issues/1021.
if options.FailOnResourceLoadingFailed {
if resourceLoadingFailed != nil {
return fmt.Errorf("%v: %w", resourceLoadingFailed, ErrResourceLoadingFailed)
}
}
return nil return nil
} }

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",
}, },
}, },
{ {
@@ -446,6 +446,44 @@ func TestChromiumBrowser_pdf(t *testing.T) {
expectError: true, expectError: true,
expectedError: ErrInvalidHttpStatusCode, expectedError: ErrInvalidHttpStatusCode,
}, },
{
scenario: "ErrInvalidResourceHttpStatusCode",
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/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"></head><body><h1>ErrInvalidResourceHttpStatusCode</h1></body></html>"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: PdfOptions{
Options: Options{FailOnResourceHttpStatusCodes: []int64{200}},
},
noDeadline: false,
start: true,
expectError: true,
expectedError: ErrInvalidResourceHttpStatusCode,
},
{ {
scenario: "ErrConsoleExceptions", scenario: "ErrConsoleExceptions",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -505,6 +543,39 @@ func TestChromiumBrowser_pdf(t *testing.T) {
expectError: true, expectError: true,
expectedError: ErrLoadingFailed, expectedError: ErrLoadingFailed,
}, },
{
scenario: "ErrResourceLoadingFailed",
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("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: PdfOptions{
Options: Options{FailOnResourceLoadingFailed: true},
},
noDeadline: false,
start: true,
expectError: true,
expectedError: ErrResourceLoadingFailed,
},
{ {
scenario: "clear cache", scenario: "clear cache",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -702,8 +773,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 +795,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 +1313,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 +1541,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 +1566,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",
}, },
}, },
{ {
@@ -1519,6 +1608,44 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
expectError: true, expectError: true,
expectedError: ErrInvalidHttpStatusCode, expectedError: ErrInvalidHttpStatusCode,
}, },
{
scenario: "ErrInvalidResourceHttpStatusCode",
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/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"></head><body><h1>ErrInvalidResourceHttpStatusCode</h1></body></html>"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: ScreenshotOptions{
Options: Options{FailOnResourceHttpStatusCodes: []int64{299}},
},
noDeadline: false,
start: true,
expectError: true,
expectedError: ErrInvalidResourceHttpStatusCode,
},
{ {
scenario: "ErrConsoleExceptions", scenario: "ErrConsoleExceptions",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -1562,7 +1689,6 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
denyList: regexp2.MustCompile("", 0), denyList: regexp2.MustCompile("", 0),
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem()
@@ -1579,6 +1705,39 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
expectError: true, expectError: true,
expectedError: ErrLoadingFailed, expectedError: ErrLoadingFailed,
}, },
{
scenario: "ErrResourceLoadingFailed",
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("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: ScreenshotOptions{
Options: Options{FailOnResourceLoadingFailed: true},
},
noDeadline: false,
start: true,
expectError: true,
expectedError: ErrResourceLoadingFailed,
},
{ {
scenario: "clear cache", scenario: "clear cache",
browser: newChromiumBrowser( browser: newChromiumBrowser(
@@ -1715,6 +1874,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 +1935,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 +1957,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 +2395,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"
@@ -37,14 +38,22 @@ var (
// matches with one of the entry in [Options.FailOnHttpStatusCodes]. // matches with one of the entry in [Options.FailOnHttpStatusCodes].
ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code") ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code")
// ErrInvalidResourceHttpStatusCode happens when the status code from one
// or more resources matches with one of the entry in
// [Options.FailOnResourceHttpStatusCodes].
ErrInvalidResourceHttpStatusCode = errors.New("invalid resource HTTP status code")
// ErrConsoleExceptions happens when there are exceptions in the Chromium // ErrConsoleExceptions happens when there are exceptions in the Chromium
// console. It also happens only if the [Options.FailOnConsoleExceptions] // console. It also happens only if the [Options.FailOnConsoleExceptions]
// is set to true. // is set to true.
ErrConsoleExceptions = errors.New("console exceptions") ErrConsoleExceptions = errors.New("console exceptions")
// ErrLoadingFailed happens when a URL failed to load. // ErrLoadingFailed happens when the main page failed to load.
ErrLoadingFailed = errors.New("loading failed") ErrLoadingFailed = errors.New("loading failed")
// ErrResourceLoadingFailed happens when one or more resources failed to load.
ErrResourceLoadingFailed = errors.New("resource loading failed")
// PDF specific. // PDF specific.
// ErrOmitBackgroundWithoutPrintBackground happens if // ErrOmitBackgroundWithoutPrintBackground happens if
@@ -85,6 +94,14 @@ type Options struct {
// code from the main page matches with one of its entries. // code from the main page matches with one of its entries.
FailOnHttpStatusCodes []int64 FailOnHttpStatusCodes []int64
// FailOnResourceHttpStatusCodes sets if the conversion should fail if the
// status code from at least one resource matches with one if its entries.
FailOnResourceHttpStatusCodes []int64
// FailOnResourceLoadingFailed sets if the conversion should fail like the
// main page if Chromium fails to load at least one resource.
FailOnResourceLoadingFailed bool
// FailOnConsoleExceptions sets if the conversion should fail if there are // FailOnConsoleExceptions sets if the conversion should fail if there are
// exceptions in the Chromium console. // exceptions in the Chromium console.
FailOnConsoleExceptions bool FailOnConsoleExceptions bool
@@ -109,7 +126,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,17 +140,19 @@ 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, FailOnResourceHttpStatusCodes: nil,
WaitDelay: 0, FailOnResourceLoadingFailed: false,
WaitWindowStatus: "", FailOnConsoleExceptions: false,
WaitForExpression: "", WaitDelay: 0,
Cookies: nil, WaitWindowStatus: "",
UserAgent: "", WaitForExpression: "",
ExtraHttpHeaders: nil, Cookies: nil,
EmulatedMediaType: "", UserAgent: "",
OmitBackground: false, ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
} }
} }
@@ -194,26 +213,31 @@ type PdfOptions struct {
// PreferCssPageSize defines whether to prefer page size as defined by CSS. // PreferCssPageSize defines whether to prefer page size as defined by CSS.
// If false, the content will be scaled to fit the paper size. // If false, the content will be scaled to fit the paper size.
PreferCssPageSize bool PreferCssPageSize bool
// GenerateDocumentOutline defines whether the document outline should be
// embedded into the PDF.
GenerateDocumentOutline bool
} }
// DefaultPdfOptions returns the default values for PdfOptions. // DefaultPdfOptions returns the default values for PdfOptions.
func DefaultPdfOptions() PdfOptions { func DefaultPdfOptions() PdfOptions {
return PdfOptions{ return PdfOptions{
Options: DefaultOptions(), Options: DefaultOptions(),
Landscape: false, Landscape: false,
PrintBackground: false, PrintBackground: false,
Scale: 1.0, Scale: 1.0,
SinglePage: false, SinglePage: false,
PaperWidth: 8.5, PaperWidth: 8.5,
PaperHeight: 11, PaperHeight: 11,
MarginTop: 0.39, MarginTop: 0.39,
MarginBottom: 0.39, MarginBottom: 0.39,
MarginLeft: 0.39, MarginLeft: 0.39,
MarginRight: 0.39, MarginRight: 0.39,
PageRanges: "", PageRanges: "",
HeaderTemplate: "<html><head></head><body></body></html>", HeaderTemplate: "<html><head></head><body></body></html>",
FooterTemplate: "<html><head></head><body></body></html>", FooterTemplate: "<html><head></head><body></body></html>",
PreferCssPageSize: false, PreferCssPageSize: false,
GenerateDocumentOutline: false,
} }
} }
@@ -289,6 +313,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

@@ -3,6 +3,7 @@ package chromium
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"slices" "slices"
"sync" "sync"
@@ -20,10 +21,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 +50,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,33 +59,114 @@ 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))
} }
}() }()
} }
}) })
} }
type eventResponseReceivedOptions struct {
mainPageUrl string
failOnHttpStatusCodes []int64
invalidHttpStatusCode *error
invalidHttpStatusCodeMu *sync.RWMutex
failOnResourceOnHttpStatusCode []int64
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
}
// listenForEventResponseReceived listens for an invalid HTTP status code that // listenForEventResponseReceived listens for an invalid HTTP status code that
// is returned by the main page. // is returned by the main page or by one or more resources.
// See https://github.com/gotenberg/gotenberg/issues/613. // See:
func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url string, failOnHttpStatusCodes []int64, invalidHttpStatusCode *error, invalidHttpStatusCodeMu *sync.RWMutex) { // https://github.com/gotenberg/gotenberg/issues/613.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventResponseReceived(
ctx context.Context,
logger *zap.Logger,
options eventResponseReceivedOptions,
) {
for _, code := range []int64{199, 299, 399, 499, 599} { for _, code := range []int64{199, 299, 399, 499, 599} {
if slices.Contains(failOnHttpStatusCodes, code) { if slices.Contains(options.failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ { for i := code - 99; i <= code; i++ {
failOnHttpStatusCodes = append(failOnHttpStatusCodes, i) options.failOnHttpStatusCodes = append(options.failOnHttpStatusCodes, i)
}
}
if slices.Contains(options.failOnResourceOnHttpStatusCode, code) {
for i := code - 99; i <= code; i++ {
options.failOnResourceOnHttpStatusCode = append(options.failOnResourceOnHttpStatusCode, i)
} }
} }
} }
@@ -80,41 +174,53 @@ func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) { switch ev := ev.(type) {
case *network.EventResponseReceived: case *network.EventResponseReceived:
if ev.Response.URL != url { if ev.Response.URL == options.mainPageUrl {
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
options.invalidHttpStatusCodeMu.Lock()
defer options.invalidHttpStatusCodeMu.Unlock()
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
}
return return
} }
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response)) logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(failOnHttpStatusCodes, ev.Response.Status) { if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
invalidHttpStatusCodeMu.Lock() options.invalidResourceHttpStatusCodeMu.Lock()
defer invalidHttpStatusCodeMu.Unlock() defer options.invalidResourceHttpStatusCodeMu.Unlock()
*invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText) *options.invalidResourceHttpStatusCode = multierr.Append(
*options.invalidResourceHttpStatusCode,
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
)
} }
} }
}) })
} }
type eventLoadingFailedOptions struct {
loadingFailed *error
loadingFailedMu *sync.RWMutex
resourceLoadingFailed *error
resourceLoadingFailedMu *sync.RWMutex
}
// listenForEventLoadingFailed listens for an event indicating that the main // listenForEventLoadingFailed listens for an event indicating that the main
// page failed to load. // page or one or more resources failed to load.
// See: // See:
// https://github.com/gotenberg/gotenberg/issues/913. // https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959. // https://github.com/gotenberg/gotenberg/issues/959.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadingFailed *error, loadingFailedMu *sync.RWMutex) { // https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) { switch ev := ev.(type) {
case *network.EventLoadingFailed: case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText)) logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
if ev.Type != network.ResourceTypeDocument {
logger.Debug("skip EventLoadingFailed: is not resource type Document")
return
}
// Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page.
// We are looking for common errors. // We are looking for common errors.
// TODO: sufficient? // TODO: sufficient?
errors := []string{ errors := []string{
@@ -128,16 +234,35 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadin
"net::ERR_ADDRESS_UNREACHABLE", "net::ERR_ADDRESS_UNREACHABLE",
"net::ERR_BLOCKED_BY_CLIENT", "net::ERR_BLOCKED_BY_CLIENT",
"net::ERR_BLOCKED_BY_RESPONSE", "net::ERR_BLOCKED_BY_RESPONSE",
"net::ERR_FILE_NOT_FOUND",
} }
if !slices.Contains(errors, ev.ErrorText) { if !slices.Contains(errors, ev.ErrorText) {
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors)) logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
return return
} }
loadingFailedMu.Lock() if ev.Type == network.ResourceTypeDocument {
defer loadingFailedMu.Unlock() // Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page.
logger.Debug("event EventLoadingFailed fired for main page")
*loadingFailed = fmt.Errorf("%s", ev.ErrorText) options.loadingFailedMu.Lock()
defer options.loadingFailedMu.Unlock()
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
return
}
logger.Debug("event EventLoadingFailed fired for a resource")
options.resourceLoadingFailedMu.Lock()
defer options.resourceLoadingFailedMu.Unlock()
*options.resourceLoadingFailed = multierr.Append(
*options.resourceLoadingFailed,
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
)
} }
}) })
} }

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"
@@ -28,17 +29,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions() defaultOptions := DefaultOptions()
var ( var (
skipNetworkIdleEvent bool skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64 failOnHttpStatusCodes []int64
failOnConsoleExceptions bool failOnResourceHttpStatusCodes []int64
waitDelay time.Duration failOnResourceLoadingFailed bool
waitWindowStatus string failOnConsoleExceptions bool
waitForExpression string waitDelay time.Duration
cookies []Cookie waitWindowStatus string
userAgent string waitForExpression string
extraHttpHeaders map[string]string cookies []Cookie
emulatedMediaType string userAgent string
omitBackground bool extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
omitBackground bool
) )
form := ctx.FormData(). form := ctx.FormData().
@@ -56,6 +59,20 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil return nil
}). }).
Custom("failOnResourceHttpStatusCodes", func(value string) error {
if value == "" {
failOnResourceHttpStatusCodes = defaultOptions.FailOnResourceHttpStatusCodes
return nil
}
err := json.Unmarshal([]byte(value), &failOnResourceHttpStatusCodes)
if err != nil {
return fmt.Errorf("unmarshal failOnResourceHttpStatusCodes: %w", err)
}
return nil
}).
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions). Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay). Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus). String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
@@ -86,12 +103,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 == "" {
@@ -110,17 +174,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground) Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{ options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent, SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes, FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnConsoleExceptions: failOnConsoleExceptions, FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
WaitDelay: waitDelay, FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
WaitWindowStatus: waitWindowStatus, FailOnConsoleExceptions: failOnConsoleExceptions,
WaitForExpression: waitForExpression, WaitDelay: waitDelay,
Cookies: cookies, WaitWindowStatus: waitWindowStatus,
UserAgent: userAgent, WaitForExpression: waitForExpression,
ExtraHttpHeaders: extraHttpHeaders, Cookies: cookies,
EmulatedMediaType: emulatedMediaType, UserAgent: userAgent,
OmitBackground: omitBackground, ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
} }
return form, options return form, options
@@ -139,6 +205,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
pageRanges string pageRanges string
headerTemplate, footerTemplate string headerTemplate, footerTemplate string
preferCssPageSize bool preferCssPageSize bool
generateDocumentOutline bool
) )
form. form.
@@ -155,24 +222,26 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges). String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate). Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate). Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize) Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize).
Bool("generateDocumentOutline", &generateDocumentOutline, defaultPdfOptions.GenerateDocumentOutline)
pdfOptions := PdfOptions{ pdfOptions := PdfOptions{
Options: options, Options: options,
Landscape: landscape, Landscape: landscape,
PrintBackground: printBackground, PrintBackground: printBackground,
Scale: scale, Scale: scale,
SinglePage: singlePage, SinglePage: singlePage,
PaperWidth: paperWidth, PaperWidth: paperWidth,
PaperHeight: paperHeight, PaperHeight: paperHeight,
MarginTop: marginTop, MarginTop: marginTop,
MarginBottom: marginBottom, MarginBottom: marginBottom,
MarginLeft: marginLeft, MarginLeft: marginLeft,
MarginRight: marginRight, MarginRight: marginRight,
PageRanges: pageRanges, PageRanges: pageRanges,
HeaderTemplate: headerTemplate, HeaderTemplate: headerTemplate,
FooterTemplate: footerTemplate, FooterTemplate: footerTemplate,
PreferCssPageSize: preferCssPageSize, PreferCssPageSize: preferCssPageSize,
GenerateDocumentOutline: generateDocumentOutline,
} }
return form, pdfOptions return form, pdfOptions
@@ -678,12 +747,22 @@ func handleChromiumError(err error, options Options) error {
) )
} }
if errors.Is(err, ErrInvalidResourceHttpStatusCode) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Invalid HTTP status code from resources:\n%s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidResourceHttpStatusCode.Error()), "")),
),
)
}
if errors.Is(err, ErrConsoleExceptions) { if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError( return api.WrapError(
err, err,
api.NewSentinelHttpError( api.NewSentinelHttpError(
http.StatusConflict, http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")), fmt.Sprintf("Chromium console exceptions:\n%s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
), ),
) )
} }
@@ -698,5 +777,15 @@ func handleChromiumError(err error, options Options) error {
) )
} }
if errors.Is(err, ErrResourceLoadingFailed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Chromium failed to load resources: %v", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrResourceLoadingFailed.Error()), "")),
),
)
}
return err return err
} }

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,46 @@ 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 failOnResourceHttpStatusCodes form field",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"failOnResourceHttpStatusCodes": {
"foo",
},
})
return ctx
}(),
expectedOptions: func() Options {
options := DefaultOptions()
options.FailOnResourceHttpStatusCodes = nil
return options
}(),
compareWithoutDeepEqual: false,
expectValidationError: true,
},
{
scenario: "valid failOnResourceHttpStatusCodes form field",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"failOnResourceHttpStatusCodes": {
`[399,499,599]`,
},
})
return ctx
}(),
expectedOptions: func() Options {
options := DefaultOptions()
options.FailOnResourceHttpStatusCodes = []int64{399, 499, 599}
return options
}(),
compareWithoutDeepEqual: false,
expectValidationError: false,
}, },
{ {
scenario: "invalid cookies form field", scenario: "invalid cookies form field",
@@ -73,7 +121,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 +143,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 +166,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 +180,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 +220,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 +254,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 +273,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 +335,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 +366,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 +419,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 +437,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 +455,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 +473,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 +491,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 +509,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 +527,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 +545,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 +579,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 +630,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 +680,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 +697,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)
}
}) })
} }
} }
@@ -1423,6 +1630,18 @@ func TestConvertUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict, expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "ErrInvalidResourceHttpStatusCode",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrInvalidResourceHttpStatusCode
}},
options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0,
},
{ {
scenario: "ErrConsoleExceptions", scenario: "ErrConsoleExceptions",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1447,6 +1666,18 @@ func TestConvertUrl(t *testing.T) {
expectHttpStatus: http.StatusBadRequest, expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "ErrResourceLoadingFailed",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return ErrResourceLoadingFailed
}},
options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0,
},
{ {
scenario: "error from Chromium", scenario: "error from Chromium",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1633,6 +1864,18 @@ func TestScreenshotUrl(t *testing.T) {
expectHttpStatus: http.StatusConflict, expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "ErrInvalidResourceHttpStatusCode",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return ErrInvalidResourceHttpStatusCode
}},
options: DefaultScreenshotOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0,
},
{ {
scenario: "ErrConsoleExceptions", scenario: "ErrConsoleExceptions",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1657,6 +1900,18 @@ func TestScreenshotUrl(t *testing.T) {
expectHttpStatus: http.StatusBadRequest, expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "ErrResourceLoadingFailed",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return ErrResourceLoadingFailed
}},
options: DefaultScreenshotOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusConflict,
expectOutputPathsCount: 0,
},
{ {
scenario: "error from Chromium", scenario: "error from Chromium",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},

View File

@@ -48,6 +48,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
WithMarginRight(options.MarginRight). WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges). WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize). WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// Does not seem to work. // Does not seem to work.
// See https://github.com/gotenberg/gotenberg/issues/831. // See https://github.com/gotenberg/gotenberg/issues/831.
WithGenerateTaggedPDF(false) WithGenerateTaggedPDF(false)
@@ -298,28 +299,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

@@ -12,12 +12,23 @@ import (
) )
type multiPdfEngines struct { type multiPdfEngines struct {
engines []gotenberg.PdfEngine mergeEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMedataEngines []gotenberg.PdfEngine
writeMedataEngines []gotenberg.PdfEngine
} }
func newMultiPdfEngines(engines ...gotenberg.PdfEngine) *multiPdfEngines { func newMultiPdfEngines(
mergeEngines,
convertEngines,
readMetadataEngines,
writeMedataEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{ return &multiPdfEngines{
engines: engines, mergeEngines: mergeEngines,
convertEngines: convertEngines,
readMedataEngines: readMetadataEngines,
writeMedataEngines: writeMedataEngines,
} }
} }
@@ -27,7 +38,7 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
for _, engine := range multi.engines { for _, engine := range multi.mergeEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath) errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
}(engine) }(engine)
@@ -52,7 +63,7 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
for _, engine := range multi.engines { for _, engine := range multi.convertEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath) errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
}(engine) }(engine)
@@ -80,16 +91,16 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
var err error var err error
var mu sync.Mutex // to safely append errors. var mu sync.Mutex // to safely append errors.
resultChan := make(chan readMetadataResult, len(multi.engines)) resultChan := make(chan readMetadataResult, len(multi.readMedataEngines))
for _, engine := range multi.engines { for _, engine := range multi.readMedataEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath) metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err} resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine) }(engine)
} }
for range multi.engines { for range multi.readMedataEngines {
select { select {
case result := <-resultChan: case result := <-resultChan:
if result.err != nil { if result.err != nil {
@@ -111,7 +122,7 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
for _, engine := range multi.engines { for _, engine := range multi.writeMedataEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath) errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
}(engine) }(engine)

View File

@@ -20,11 +20,16 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ []gotenberg.PdfEngine{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { &gotenberg.PdfEngineMock{
return nil MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
}, },
}, },
nil,
nil,
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
expectError: false, expectError: false,
@@ -32,16 +37,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ []gotenberg.PdfEngine{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { &gotenberg.PdfEngineMock{
return errors.New("foo") MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
}, return errors.New("foo")
}, },
&gotenberg.PdfEngineMock{ },
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { &gotenberg.PdfEngineMock{
return nil MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
}, },
}, },
nil,
nil,
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
expectError: false, expectError: false,
@@ -49,16 +59,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ []gotenberg.PdfEngine{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { &gotenberg.PdfEngineMock{
return errors.New("foo") MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
}, return errors.New("foo")
}, },
&gotenberg.PdfEngineMock{ },
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { &gotenberg.PdfEngineMock{
return errors.New("foo") MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
}, },
}, },
nil,
nil,
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
@@ -66,11 +81,16 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ []gotenberg.PdfEngine{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { &gotenberg.PdfEngineMock{
return nil MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
}, },
}, },
nil,
nil,
nil,
), ),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -105,43 +125,58 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { []gotenberg.PdfEngine{
return nil &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
}, },
}, },
nil,
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { []gotenberg.PdfEngine{
return errors.New("foo") &gotenberg.PdfEngineMock{
}, ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
}, return errors.New("foo")
&gotenberg.PdfEngineMock{ },
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { },
return nil &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
}, },
}, },
nil,
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { []gotenberg.PdfEngine{
return errors.New("foo") &gotenberg.PdfEngineMock{
}, ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
}, return errors.New("foo")
&gotenberg.PdfEngineMock{ },
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { },
return errors.New("foo") &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
}, },
}, },
nil,
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
@@ -149,11 +184,16 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { []gotenberg.PdfEngine{
return nil &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
}, },
}, },
nil,
nil,
), ),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -188,43 +228,58 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { nil,
return make(map[string]interface{}), nil []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
}, },
}, },
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { nil,
return nil, errors.New("foo") []gotenberg.PdfEngine{
}, &gotenberg.PdfEngineMock{
}, ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
&gotenberg.PdfEngineMock{ return nil, errors.New("foo")
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { },
return make(map[string]interface{}), nil },
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
}, },
}, },
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { nil,
return nil, errors.New("foo") []gotenberg.PdfEngine{
}, &gotenberg.PdfEngineMock{
}, ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
&gotenberg.PdfEngineMock{ return nil, errors.New("foo")
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { },
return nil, errors.New("foo") },
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
},
}, },
}, },
nil,
), ),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
@@ -232,11 +287,16 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { nil,
return make(map[string]interface{}), nil []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
}, },
}, },
nil,
), ),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@@ -271,9 +331,14 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { nil,
return nil nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
}, },
}, },
), ),
@@ -282,14 +347,19 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { nil,
return errors.New("foo") nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
}, },
}, &gotenberg.PdfEngineMock{
&gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return nil
return nil },
}, },
}, },
), ),
@@ -298,14 +368,19 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { nil,
return errors.New("foo") nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
}, },
}, &gotenberg.PdfEngineMock{
&gotenberg.PdfEngineMock{ WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { return errors.New("foo")
return errors.New("foo") },
}, },
}, },
), ),
@@ -315,9 +390,14 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{ nil,
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { nil,
return nil nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
}, },
}, },
), ),

View File

@@ -27,9 +27,12 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if // the [api.Router] interface to expose relevant PDF processing routes if
// enabled. // enabled.
type PdfEngines struct { type PdfEngines struct {
names []string mergeNames []string
engines []gotenberg.PdfEngine convertNames []string
disableRoutes bool readMetadataNames []string
writeMedataNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
} }
// Descriptor returns a PdfEngines' module descriptor. // Descriptor returns a PdfEngines' module descriptor.
@@ -38,9 +41,18 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
ID: "pdfengines", ID: "pdfengines",
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError) fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines and their order - all by default") fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes") fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
if err != nil {
panic(err)
}
return fs return fs
}(), }(),
New: func() gotenberg.Module { return new(PdfEngines) }, New: func() gotenberg.Module { return new(PdfEngines) },
@@ -51,7 +63,10 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
// selected by the user thanks to the "engines" flag. // selected by the user thanks to the "engines" flag.
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
names := flags.MustStringSlice("pdfengines-engines") mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine)) engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -65,26 +80,37 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.engines[i] = engine.(gotenberg.PdfEngine) mod.engines[i] = engine.(gotenberg.PdfEngine)
} }
if len(names) > 0 { defaultNames := make([]string, len(mod.engines))
// Selection from user. for i, engine := range mod.engines {
mod.names = names defaultNames[i] = engine.(gotenberg.Module).Descriptor().ID
// Example in case of deprecated module name.
//for i, name := range names {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// mod.names[i] = "libreoffice-pdfengine"
// }
//}
return nil
} }
// No selection from user, use all PDF engines available. // Example in case of deprecated module name.
mod.names = make([]string, len(mod.engines)) //for i, name := range defaultNames {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// mod.defaultNames[i] = "libreoffice-pdfengine"
// }
//}
for i, engine := range mod.engines { mod.mergeNames = defaultNames
mod.names[i] = engine.(gotenberg.Module).Descriptor().ID if len(mergeNames) > 0 {
mod.mergeNames = mergeNames
}
mod.convertNames = defaultNames
if len(convertNames) > 0 {
mod.convertNames = convertNames
}
mod.readMetadataNames = defaultNames
if len(readMetadataNames) > 0 {
mod.readMetadataNames = readMetadataNames
}
mod.writeMedataNames = defaultNames
if len(writeMetadataNames) > 0 {
mod.writeMedataNames = writeMetadataNames
} }
return nil return nil
@@ -105,22 +131,40 @@ func (mod *PdfEngines) Validate() error {
} }
nonExistingEngines := make([]string, 0) nonExistingEngines := make([]string, 0)
findNonExistingEngines := func(names []string) {
for _, name := range names {
engineExists := false
for _, name := range mod.names { for _, engine := range mod.engines {
engineExists := false if name == engine.(gotenberg.Module).Descriptor().ID {
engineExists = true
break
}
}
for _, engine := range mod.engines { if engineExists {
if name == engine.(gotenberg.Module).Descriptor().ID { continue
engineExists = true }
break
alreadyInSlice := false
for _, engine := range nonExistingEngines {
if engine == name {
alreadyInSlice = true
break
}
}
if !alreadyInSlice {
nonExistingEngines = append(nonExistingEngines, name)
} }
} }
if !engineExists {
nonExistingEngines = append(nonExistingEngines, name)
}
} }
findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMedataNames)
if len(nonExistingEngines) == 0 { if len(nonExistingEngines) == 0 {
return nil return nil
} }
@@ -132,24 +176,35 @@ func (mod *PdfEngines) Validate() error {
// modules. // modules.
func (mod *PdfEngines) SystemMessages() []string { func (mod *PdfEngines) SystemMessages() []string {
return []string{ return []string{
strings.Join(mod.names[:], " "), fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
} }
} }
// PdfEngine returns a [gotenberg.PdfEngine]. // PdfEngine returns a [gotenberg.PdfEngine].
func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines := make([]gotenberg.PdfEngine, len(mod.names)) engines := func(names []string) []gotenberg.PdfEngine {
list := make([]gotenberg.PdfEngine, len(names))
for i, name := range mod.names { for i, name := range names {
for _, engine := range mod.engines { for _, engine := range mod.engines {
if name == engine.(gotenberg.Module).Descriptor().ID { if name == engine.(gotenberg.Module).Descriptor().ID {
engines[i] = engine list[i] = engine
break break
}
} }
} }
return list
} }
return newMultiPdfEngines(engines...), nil return newMultiPdfEngines(
engines(mod.mergeNames),
engines(mod.convertNames),
engines(mod.readMetadataNames),
engines(mod.writeMedataNames),
), nil
} }
// Routes returns the HTTP routes. // Routes returns the HTTP routes.

View File

@@ -2,6 +2,7 @@ package pdfengines
import ( import (
"errors" "errors"
"fmt"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@@ -22,10 +23,13 @@ func TestPdfEngines_Descriptor(t *testing.T) {
func TestPdfEngines_Provision(t *testing.T) { func TestPdfEngines_Provision(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
ctx *gotenberg.Context ctx *gotenberg.Context
expectedPdfEngines []string expectedMergePdfEngines []string
expectError bool expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string
expectError bool
}{ }{
{ {
scenario: "no selection from user", scenario: "no selection from user",
@@ -61,8 +65,11 @@ func TestPdfEngines_Provision(t *testing.T) {
}, },
) )
}(), }(),
expectedPdfEngines: []string{"bar"}, expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectError: false, expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"},
expectError: false,
}, },
{ {
scenario: "selection from user", scenario: "selection from user",
@@ -100,7 +107,7 @@ func TestPdfEngines_Provision(t *testing.T) {
} }
fs := new(PdfEngines).Descriptor().FlagSet fs := new(PdfEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-engines=b", "--pdfengines-engines=a"}) err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
@@ -116,8 +123,12 @@ func TestPdfEngines_Provision(t *testing.T) {
}, },
) )
}(), }(),
expectedPdfEngines: []string{"b", "a"},
expectError: false, expectedMergePdfEngines: []string{"b"},
expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"},
expectError: false,
}, },
{ {
scenario: "no valid PDF engine", scenario: "no valid PDF engine",
@@ -167,13 +178,43 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatal("expected error but got none") t.Fatal("expected error but got none")
} }
if len(tc.expectedPdfEngines) != len(mod.names) { if len(tc.expectedMergePdfEngines) != len(mod.mergeNames) {
t.Fatalf("expected %d names but got %d", len(tc.expectedPdfEngines), len(mod.names)) t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
} }
for index, name := range mod.names { if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
if name != tc.expectedPdfEngines[index] { t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
t.Fatalf("expected scenario at index %d to be %s, but got: %s", index, name, tc.expectedPdfEngines[index]) }
if len(tc.expectedReadMetadataPdfEngines) != len(mod.readMetadataNames) {
t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
}
if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) {
t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames))
}
for index, name := range mod.mergeNames {
if name != tc.expectedMergePdfEngines[index] {
t.Fatalf("expected merge name at index %d to be %s, but got: %s", index, name, tc.expectedMergePdfEngines[index])
}
}
for index, name := range mod.convertNames {
if name != tc.expectedConvertPdfEngines[index] {
t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
}
}
for index, name := range mod.readMetadataNames {
if name != tc.expectedReadMetadataPdfEngines[index] {
t.Fatalf("expected read metadata name at index %d to be %s, but got: %s", index, name, tc.expectedReadMetadataPdfEngines[index])
}
}
for index, name := range mod.writeMedataNames {
if name != tc.expectedWriteMetadataPdfEngines[index] {
t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
} }
} }
}) })
@@ -239,8 +280,11 @@ func TestPdfEngines_Validate(t *testing.T) {
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
mod := PdfEngines{ mod := PdfEngines{
names: tc.names, mergeNames: tc.names,
engines: tc.engines, convertNames: tc.names,
readMetadataNames: tc.names,
writeMedataNames: tc.names,
engines: tc.engines,
} }
err := mod.Validate() err := mod.Validate()
@@ -258,22 +302,36 @@ func TestPdfEngines_Validate(t *testing.T) {
func TestPdfEngines_SystemMessages(t *testing.T) { func TestPdfEngines_SystemMessages(t *testing.T) {
mod := new(PdfEngines) mod := new(PdfEngines)
mod.names = []string{"foo", "bar"} mod.mergeNames = []string{"foo", "bar"}
mod.convertNames = []string{"foo", "bar"}
mod.readMetadataNames = []string{"foo", "bar"}
mod.writeMedataNames = []string{"foo", "bar"}
messages := mod.SystemMessages() messages := mod.SystemMessages()
if len(messages) != 1 { if len(messages) != 4 {
t.Errorf("expected one and only one message, but got %d", len(messages)) t.Errorf("expected one and only one message, but got %d", len(messages))
} }
expect := strings.Join(mod.names[:], " ") expect := []string{
if messages[0] != expect { fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
t.Errorf("expected message '%s', but got '%s'", expect, messages[0]) fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
}
for i, message := range messages {
if message != expect[i] {
t.Errorf("expected message at index %d to be %s, but got %s", i, message, expect[i])
}
} }
} }
func TestPdfEngines_PdfEngine(t *testing.T) { func TestPdfEngines_PdfEngine(t *testing.T) {
mod := PdfEngines{ mod := PdfEngines{
names: []string{"foo", "bar"}, mergeNames: []string{"foo", "bar"},
convertNames: []string{"foo", "bar"},
readMetadataNames: []string{"foo", "bar"},
writeMedataNames: []string{"foo", "bar"},
engines: func() []gotenberg.PdfEngine { engines: func() []gotenberg.PdfEngine {
engine1 := &struct { engine1 := &struct {
gotenberg.ModuleMock gotenberg.ModuleMock

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 \

View File

@@ -2,6 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<!-- net::ERR_FILE_NOT_FOUND -->
<link rel="stylesheet" type="text/css" href="doesnotexist.css">
<!-- net::ERR_CONNECTION_REFUSED -->
<link rel="stylesheet" type="text/css" href="http://localhost:100/style.css">
<!-- 400 Bad Request -->
<link rel="stylesheet" type="text/css" href="https://httpstat.us/400">
<link rel="stylesheet" type="text/css" href="style.css"> <link rel="stylesheet" type="text/css" href="style.css">
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
<title>Gutenberg</title> <title>Gutenberg</title>
@@ -23,7 +29,7 @@
<blockquote cite="https://sites.google.com/site/johanngutenbergper5/q"> <blockquote cite="https://sites.google.com/site/johanngutenbergper5/q">
<p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p> <p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p>
<footer><a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></cite></footer> <footer><a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></footer>
</blockquote> </blockquote>
</div> </div>

View File

@@ -2,6 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<!-- net::ERR_FILE_NOT_FOUND -->
<link rel="stylesheet" type="text/css" href="doesnotexist.css">
<!-- net::ERR_CONNECTION_REFUSED -->
<link rel="stylesheet" type="text/css" href="http://localhost:100/style.css">
<!-- 400 Bad Request -->
<link rel="stylesheet" type="text/css" href="https://httpstat.us/400">
<link rel="stylesheet" type="text/css" href="style.css"> <link rel="stylesheet" type="text/css" href="style.css">
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
<title>Gutenberg</title> <title>Gutenberg</title>
@@ -15,7 +21,7 @@
<blockquote cite="https://sites.google.com/site/johanngutenbergper5/q"> <blockquote cite="https://sites.google.com/site/johanngutenbergper5/q">
<p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p> <p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p>
<footer><a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></cite></footer> <footer><a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></footer>
</blockquote> </blockquote>
</div> </div>