mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697fa08d8b | ||
|
|
669a35afef | ||
|
|
b1d94a3845 | ||
|
|
06869cf491 | ||
|
|
ed67d8514c | ||
|
|
07ec2f3d7b | ||
|
|
73d02e85c4 | ||
|
|
c1cf1eb37a | ||
|
|
7aecc94049 | ||
|
|
b55fbfeb8e | ||
|
|
20b8991fa4 | ||
|
|
e50de4201d | ||
|
|
ba2b876ee4 | ||
|
|
9b1399c9d5 | ||
|
|
020a2f00c2 | ||
|
|
8713946f35 | ||
|
|
bed8c25e40 | ||
|
|
7c51a09068 | ||
|
|
0f0a6ce042 | ||
|
|
a890968ee4 | ||
|
|
2267c5be00 | ||
|
|
e853cfb98a | ||
|
|
f89a0db471 | ||
|
|
1d66b43da1 | ||
|
|
75c4a4f253 | ||
|
|
6a9c95b3b1 | ||
|
|
c11a2d0081 | ||
|
|
43a13ff2c5 | ||
|
|
8454d163eb | ||
|
|
4a11f6a39f | ||
|
|
c63235cd3d | ||
|
|
f4ce196a34 | ||
|
|
e644b27201 | ||
|
|
d2b14582ee | ||
|
|
ca9194fd96 | ||
|
|
773d3ab13c | ||
|
|
a02a4a07a5 | ||
|
|
0a8625227f | ||
|
|
dc613aa2bf | ||
|
|
2008ac5663 | ||
|
|
da326f1925 | ||
|
|
71911cb1a7 | ||
|
|
31e7582216 | ||
|
|
91f63047e0 | ||
|
|
c9aa4938f4 | ||
|
|
b9e6a38037 | ||
|
|
eb054146d2 | ||
|
|
a780373cfc | ||
|
|
5ad90ccdb8 | ||
|
|
ef97d81ca8 | ||
|
|
c9f7b642c5 | ||
|
|
770962be3f | ||
|
|
d6c857f4a8 | ||
|
|
7b7ffb427d | ||
|
|
b80318d1b6 | ||
|
|
3997d69e49 | ||
|
|
4af41b33c0 | ||
|
|
0f8bbead66 | ||
|
|
804fcff5b6 | ||
|
|
d18e6920fc | ||
|
|
c95174ff11 | ||
|
|
c70329c22b | ||
|
|
34f69263a2 | ||
|
|
bf5056f8fe | ||
|
|
c47f5b06ba | ||
|
|
63055e3df2 | ||
|
|
8ee555140b | ||
|
|
859f78e986 | ||
|
|
366eebb68c | ||
|
|
25ce0b9aee | ||
|
|
376a7db545 | ||
|
|
f906ed741d | ||
|
|
ad152e62e5 | ||
|
|
7f3a61ef43 | ||
|
|
a7ba8c8213 | ||
|
|
b1f3d6d4c0 | ||
|
|
5707ae027f | ||
|
|
770208024d | ||
|
|
975a9f5344 | ||
|
|
a45008411e | ||
|
|
9091ffd1b3 | ||
|
|
089f161d1e | ||
|
|
e6d7131701 | ||
|
|
ed82170c2c |
15
.github/workflows/continuous_integration.yml
vendored
15
.github/workflows/continuous_integration.yml
vendored
@@ -17,14 +17,14 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.22'
|
||||
cache: false
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.55.2
|
||||
version: v1.59.0
|
||||
|
||||
tests:
|
||||
needs:
|
||||
@@ -43,9 +43,12 @@ jobs:
|
||||
- name: Build testing environment
|
||||
run: make build build-tests
|
||||
- name: Run tests
|
||||
run: |
|
||||
make tests-once
|
||||
bash <(curl -s https://codecov.io/bash)
|
||||
run: make tests-once
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
verbose: true
|
||||
|
||||
multiarch_build:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
/coverage.html
|
||||
/coverage.txt
|
||||
/TODO.txt
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
@@ -10,20 +10,41 @@ linters-settings:
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errname
|
||||
- exhaustive
|
||||
- exportloopref
|
||||
- gci
|
||||
- gofmt
|
||||
- goimports
|
||||
- gofumpt
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- importas
|
||||
- misspell
|
||||
- prealloc
|
||||
- promlinter
|
||||
#- sloglint
|
||||
- staticcheck
|
||||
- tenv
|
||||
- testableexamples
|
||||
- tparallel
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- wastedassign
|
||||
- whitespace
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
@@ -31,6 +52,5 @@ run:
|
||||
tests: false
|
||||
|
||||
output:
|
||||
format: 'colored-line-number'
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
print-linter-name: true
|
||||
|
||||
24
Makefile
24
Makefile
@@ -5,14 +5,14 @@ help: ## Show the help
|
||||
.PHONY: it
|
||||
it: build build-tests ## Initialize the development environment
|
||||
|
||||
GOLANG_VERSION=1.21
|
||||
GOLANG_VERSION=1.22
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
GOTENBERG_VERSION=snapshot
|
||||
GOTENBERG_USER_GID=1001
|
||||
GOTENBERG_USER_UID=1001
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.042 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||
GOLANGCI_LINT_VERSION=v1.55.2 # See https://github.com/golangci/golangci-lint/releases.
|
||||
GOLANGCI_LINT_VERSION=v1.59.0 # See https://github.com/golangci/golangci-lint/releases.
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
@@ -33,8 +33,12 @@ API_START_TIMEOUT=30s
|
||||
API_TIMEOUT=30s
|
||||
API_ROOT_PATH=/
|
||||
API_TRACE_HEADER=Gotenberg-Trace
|
||||
API_ENABLE_BASIC_AUTH=false
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||
CHROMIUM_RESTART_AFTER=0
|
||||
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||
CHROMIUM_AUTO_START=false
|
||||
CHROMIUM_START_TIMEOUT=20s
|
||||
CHROMIUM_INCOGNITO=false
|
||||
@@ -45,12 +49,13 @@ CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
||||
CHROMIUM_HOST_RESOLVER_RULES=
|
||||
CHROMIUM_PROXY_SERVER=
|
||||
CHROMIUM_ALLOW_LIST=
|
||||
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
|
||||
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||
CHROMIUM_CLEAR_CACHE=false
|
||||
CHROMIUM_CLEAR_COOKIES=false
|
||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||
CHROMIUM_DISABLE_ROUTES=false
|
||||
LIBREOFFICE_RESTART_AFTER=10
|
||||
LIBREOFFICE_MAX_QUEUE_SIZE=0
|
||||
LIBREOFFICE_AUTO_START=false
|
||||
LIBREOFFICE_START_TIMEOUT=20s
|
||||
LIBREOFFICE_DISABLE_ROUTES=false
|
||||
@@ -77,6 +82,8 @@ WEBHOOK_DISABLE=false
|
||||
run: ## Start a Gotenberg container
|
||||
docker run --rm -it \
|
||||
-p $(API_PORT):$(API_PORT) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
gotenberg \
|
||||
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
||||
@@ -86,9 +93,11 @@ run: ## Start a Gotenberg container
|
||||
--api-timeout=$(API_TIMEOUT) \
|
||||
--api-root-path=$(API_ROOT_PATH) \
|
||||
--api-trace-header=$(API_TRACE_HEADER) \
|
||||
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
|
||||
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
|
||||
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
||||
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
||||
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
|
||||
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
|
||||
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
|
||||
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
|
||||
@@ -97,13 +106,14 @@ run: ## Start a Gotenberg container
|
||||
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
|
||||
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
|
||||
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
|
||||
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
|
||||
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
|
||||
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
|
||||
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
|
||||
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
|
||||
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
|
||||
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
|
||||
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
|
||||
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
|
||||
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
|
||||
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
|
||||
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
|
||||
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
|
||||
@@ -116,8 +126,8 @@ run: ## Start a Gotenberg container
|
||||
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
|
||||
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
|
||||
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
|
||||
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
|
||||
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
|
||||
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
|
||||
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
|
||||
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
|
||||
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
|
||||
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
|
||||
|
||||
@@ -164,19 +164,20 @@ RUN \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN \
|
||||
# Install PDFtk & QPDF (PDF engines).
|
||||
# Install PDFtk, QPDF & ExifTool (PDF engines).
|
||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
||||
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
|
||||
chmod a+x /usr/bin/pdftk-all.jar &&\
|
||||
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||
chmod +x /usr/bin/pdftk &&\
|
||||
apt-get update -qq &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
|
||||
# See https://github.com/nextcloud/docker/issues/380.
|
||||
mkdir -p /usr/share/man/man1 &&\
|
||||
# Verify installations.
|
||||
pdftk --version &&\
|
||||
qpdf --version &&\
|
||||
exiftool --version &&\
|
||||
# Cleanup.
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
@@ -195,6 +196,7 @@ ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH /usr/bin/unoconverter
|
||||
ENV PDFTK_BIN_PATH /usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH /usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH /usr/bin/exiftool
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -35,6 +35,7 @@ var Version = "snapshot"
|
||||
// Run starts the Gotenberg application. Call this in the main of your program.
|
||||
func Run() {
|
||||
fmt.Printf(banner, Version)
|
||||
gotenberg.Version = Version
|
||||
|
||||
// Create the root FlagSet and adds the modules flags to it.
|
||||
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
||||
@@ -75,7 +76,6 @@ func Run() {
|
||||
go func(app gotenberg.App) {
|
||||
id := app.(gotenberg.Module).Descriptor().ID
|
||||
err = app.Start()
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
|
||||
os.Exit(1)
|
||||
|
||||
49
go.mod
49
go.mod
@@ -5,46 +5,50 @@ go 1.21
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.0
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732
|
||||
github.com/barasher/go-exiftool v1.10.0
|
||||
github.com/chromedp/cdproto v0.0.0-20240602235142-49d0e97b7881
|
||||
github.com/chromedp/chromedp v0.9.5
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/labstack/echo/v4 v4.11.4
|
||||
github.com/labstack/echo/v4 v4.12.0
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.6.0
|
||||
github.com/pdfcpu/pdfcpu v0.8.0
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/prometheus/client_golang v1.18.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/ulikunitz/xz v0.5.11 // indirect
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.18.0 // indirect
|
||||
golang.org/x/image v0.15.0 // indirect
|
||||
golang.org/x/net v0.20.0
|
||||
golang.org/x/sync v0.6.0
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
golang.org/x/term v0.16.0
|
||||
golang.org/x/text v0.14.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.24.0 // indirect
|
||||
golang.org/x/image v0.17.0 // indirect
|
||||
golang.org/x/net v0.26.0
|
||||
golang.org/x/sync v0.7.0
|
||||
golang.org/x/sys v0.21.0 // indirect
|
||||
golang.org/x/term v0.21.0
|
||||
golang.org/x/text v0.16.0
|
||||
)
|
||||
|
||||
require github.com/dlclark/regexp2 v1.11.0
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.2 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // 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
|
||||
@@ -53,13 +57,14 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.46.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.6 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.54.0 // 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/fasttemplate v1.2.2 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
111
go.sum
111
go.sum
@@ -5,43 +5,54 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
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/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
|
||||
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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732 h1:XYUCaZrW8ckGWlCRJKCSoh/iFwlpX316a8yY9IFEzv8=
|
||||
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/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/cdproto v0.0.0-20240602235142-49d0e97b7881 h1:RAUqkPvbEDGPgCYVc4GefBqAorWJAjKpVHgsRZyJmGE=
|
||||
github.com/chromedp/cdproto v0.0.0-20240602235142-49d0e97b7881/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
|
||||
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
|
||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
|
||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
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=
|
||||
@@ -50,8 +61,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||
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.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
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.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
@@ -60,8 +71,8 @@ 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/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
|
||||
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
|
||||
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
|
||||
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
@@ -84,8 +95,8 @@ github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9l
|
||||
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/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI=
|
||||
github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo=
|
||||
github.com/pdfcpu/pdfcpu v0.8.0 h1:SuEB4uVsPFz1nb802r38YpFpj9TtZh/oB0bGG34IRZw=
|
||||
github.com/pdfcpu/pdfcpu v0.8.0/go.mod h1:jj03y/KKrwigt5xCi8t7px2mATcKuOzkIOoCX62yMho=
|
||||
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/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
@@ -93,63 +104,67 @@ 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
|
||||
github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
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/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8=
|
||||
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
|
||||
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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
|
||||
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
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/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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
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/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco=
|
||||
golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
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=
|
||||
|
||||
35
pkg/gotenberg/env.go
Normal file
35
pkg/gotenberg/env.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// StringEnv retrieves the value of the environment variable named by the key.
|
||||
// If the variable is present in the environment and not empty, the value is
|
||||
// returned.
|
||||
func StringEnv(key string) (string, error) {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("environment variable '%s' does not exist", key)
|
||||
}
|
||||
if val == "" {
|
||||
return "", fmt.Errorf("environment variable '%s' is empty", key)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// IntEnv relies on [StringEnv] and converts the values if it exists and is not
|
||||
// empty.
|
||||
func IntEnv(key string) (int, error) {
|
||||
val, err := StringEnv(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
intVal, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err)
|
||||
}
|
||||
return intVal, nil
|
||||
}
|
||||
134
pkg/gotenberg/env_test.go
Normal file
134
pkg/gotenberg/env_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStringEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
key string
|
||||
setEnv func()
|
||||
expectVal string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "non-existing environment variable",
|
||||
key: "NON_EXISTING",
|
||||
expectVal: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "empty environment variable",
|
||||
key: "EMPTY_STRING",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EMPTY_STRING", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
key: "EXISTING_STRING_VALUE",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EXISTING_STRING_VALUE", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: "foo",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv()
|
||||
}
|
||||
|
||||
val, err := StringEnv(tc.key)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if tc.expectVal != val {
|
||||
t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntEnv(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
key string
|
||||
setEnv func()
|
||||
expectVal int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "empty environment variable",
|
||||
key: "EMPTY_INT",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EMPTY_INT", "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "non-integer value",
|
||||
key: "NON_INTEGER",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("NON_INTEGER", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
key: "EXISTING_INT_VALUE",
|
||||
setEnv: func() {
|
||||
err := os.Setenv("EXISTING_INT_VALUE", "123")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectVal: 123,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.setEnv != nil {
|
||||
tc.setEnv()
|
||||
}
|
||||
|
||||
val, err := IntEnv(tc.key)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if tc.expectVal != val {
|
||||
t.Errorf("expected value %d but got %d", tc.expectVal, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
pkg/gotenberg/filter.go
Normal file
53
pkg/gotenberg/filter.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
||||
var ErrFiltered = errors.New("value filtered")
|
||||
|
||||
// FilterDeadline checks if given value is allowed and not denied according to
|
||||
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
|
||||
// to process.
|
||||
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
|
||||
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
||||
if allowed.String() != "" {
|
||||
allow := regexp2.MustCompile(allowed.String(), 0)
|
||||
allow.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := allow.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
if denied.String() != "" {
|
||||
deny := regexp2.MustCompile(denied.String(), 0)
|
||||
deny.MatchTimeout = time.Until(deadline)
|
||||
|
||||
ok, err := deny.MatchString(s)
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
|
||||
}
|
||||
if ok {
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
83
pkg/gotenberg/filter_test.go
Normal file
83
pkg/gotenberg/filter_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
)
|
||||
|
||||
func TestFilterDeadline(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
allowed *regexp2.Regexp
|
||||
denied *regexp2.Regexp
|
||||
s string
|
||||
deadline time.Time
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "DeadlineExceeded (allowed)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||
expectError: true,
|
||||
expectedError: context.DeadlineExceeded,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered (allowed)",
|
||||
allowed: regexp2.MustCompile("foo", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "bar",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: true,
|
||||
expectedError: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "DeadlineExceeded (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||
expectError: true,
|
||||
expectedError: context.DeadlineExceeded,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered (denied)",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("foo", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: true,
|
||||
expectedError: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
allowed: regexp2.MustCompile("", 0),
|
||||
denied: regexp2.MustCompile("", 0),
|
||||
s: "foo",
|
||||
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected an error but got none")
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/gommon/bytes"
|
||||
flag "github.com/spf13/pflag"
|
||||
)
|
||||
@@ -199,19 +199,19 @@ func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string,
|
||||
|
||||
// MustRegexp returns the regular expression of a flag given by name.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
|
||||
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
||||
val, err := f.GetString(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return regexp.MustCompile(val)
|
||||
return regexp2.MustCompile(val, 0)
|
||||
}
|
||||
|
||||
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
||||
// it was explicitly set or the regular expression of the new flag.
|
||||
// It panics if an error occurs.
|
||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
|
||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp2.Regexp {
|
||||
if f.Changed(deprecated) {
|
||||
return f.MustRegexp(deprecated)
|
||||
}
|
||||
|
||||
@@ -51,3 +51,11 @@ func (fs *FileSystem) MkdirAll() (string, error) {
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// PathRename defines the method signature for renaming files. Implement this
|
||||
// interface if you don't want to rely on [os.Rename], notably for testing
|
||||
// purpose.
|
||||
type PathRename interface {
|
||||
// Rename uses the same signature as [os.Rename].
|
||||
Rename(oldpath, newpath string) error
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ func (mod *ValidatorMock) Validate() error {
|
||||
|
||||
// PdfEngineMock is a mock for the [PdfEngine] interface.
|
||||
type PdfEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
@@ -47,6 +49,14 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, fo
|
||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
|
||||
}
|
||||
|
||||
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||
type PdfEngineProviderMock struct {
|
||||
PdfEngineMock func() (PdfEngine, error)
|
||||
@@ -127,6 +137,15 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
|
||||
return provider.MetricsMock()
|
||||
}
|
||||
|
||||
// PathRenameMock is a mock for the [PathRename] interface.
|
||||
type PathRenameMock struct {
|
||||
RenameMock func(oldpath, newpath string) error
|
||||
}
|
||||
|
||||
func (rename *PathRenameMock) Rename(oldpath, newpath string) error {
|
||||
return rename.RenameMock(oldpath, newpath)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ Module = (*ModuleMock)(nil)
|
||||
|
||||
@@ -55,6 +55,12 @@ func TestPDFEngineMock(t *testing.T) {
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
},
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
@@ -66,6 +72,16 @@ func TestPDFEngineMock(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
|
||||
}
|
||||
|
||||
_, err = mock.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineMock.ReadMetadata, but got: %v", err)
|
||||
}
|
||||
|
||||
err = mock.WriteMetadata(context.Background(), zap.NewNop(), map[string]interface{}{}, "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PdfEngineMock.WriteMetadata but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFEngineProviderMock(t *testing.T) {
|
||||
@@ -188,3 +204,16 @@ func TestMetricsProviderMock(t *testing.T) {
|
||||
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathRenameMock(t *testing.T) {
|
||||
mock := &PathRenameMock{
|
||||
RenameMock: func(oldpath, newpath string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := mock.Rename("", "")
|
||||
if err != nil {
|
||||
t.Errorf("expected no error from PathRenameMock.Rename, but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ var (
|
||||
// ErrPdfFormatNotSupported is returned when the Convert method of the
|
||||
// PdfEngine interface does not support a requested PDF format conversion.
|
||||
ErrPdfFormatNotSupported = errors.New("PDF format not supported")
|
||||
|
||||
// ErrPdfEngineMetadataValueNotSupported is returned when a metadata value
|
||||
// is not supported.
|
||||
ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -64,6 +68,12 @@ type PdfEngine interface {
|
||||
// Convert transforms a given PDF to the specified formats defined in
|
||||
// PdfFormats. If no format, it does nothing.
|
||||
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||
}
|
||||
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
|
||||
69
pkg/gotenberg/sort.go
Normal file
69
pkg/gotenberg/sort.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// AlphanumericSort implements sort.Interface and helps to sort strings
|
||||
// alphanumerically.
|
||||
//
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/805.
|
||||
type AlphanumericSort []string
|
||||
|
||||
func (s AlphanumericSort) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s AlphanumericSort) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
||||
func (s AlphanumericSort) Less(i, j int) bool {
|
||||
numI, restI := extractPrefix(s[i])
|
||||
numJ, restJ := extractPrefix(s[j])
|
||||
|
||||
// Compares numerical prefixes if they exist.
|
||||
if numI != -1 && numJ != -1 {
|
||||
if numI != numJ {
|
||||
return numI < numJ
|
||||
}
|
||||
// If numbers are equal, falls back to string comparison of the rest.
|
||||
return restI < restJ
|
||||
}
|
||||
|
||||
// If one has a numerical prefix and the other doesn't, the one with the
|
||||
// number comes first.
|
||||
if numI != -1 {
|
||||
return true
|
||||
}
|
||||
if numJ != -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If neither has a numerical prefix, compare as strings
|
||||
return s[i] < s[j]
|
||||
}
|
||||
|
||||
// extractPrefix attempts to extract a numerical prefix and the rest of the filename
|
||||
func extractPrefix(filename string) (int, string) {
|
||||
matches := numPrefixRegexp.FindStringSubmatch(filename)
|
||||
if len(matches) > 2 {
|
||||
prefix, err := strconv.Atoi(matches[1])
|
||||
if err == nil {
|
||||
return prefix, matches[2]
|
||||
}
|
||||
}
|
||||
|
||||
// Returns -1 if no numerical prefix is found, indicating to just compare
|
||||
// as strings.
|
||||
return -1, filename
|
||||
}
|
||||
|
||||
var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
|
||||
|
||||
// Interface guard.
|
||||
var (
|
||||
_ sort.Interface = (*AlphanumericSort)(nil)
|
||||
)
|
||||
34
pkg/gotenberg/sort_test.go
Normal file
34
pkg/gotenberg/sort_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlphanumericSort(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
values []string
|
||||
expectedSort []string
|
||||
}{
|
||||
{
|
||||
scenario: "numeric and letters",
|
||||
values: []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
|
||||
expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
|
||||
},
|
||||
{
|
||||
scenario: "hrtime (PHP library)",
|
||||
values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},
|
||||
expectedSort: []string{"245654773394369", "245654773394919", "245654773395039", "245654773395149", "245654773395259"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
sort.Sort(AlphanumericSort(tc.values))
|
||||
|
||||
if !reflect.DeepEqual(tc.values, tc.expectedSort) {
|
||||
t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
// to restart an already restarting [Process].
|
||||
var ErrProcessAlreadyRestarting = errors.New("process already restarting")
|
||||
|
||||
// ErrMaximumQueueSizeExceeded happens if Run() is called but the maximum queue
|
||||
// size is already used.
|
||||
var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
|
||||
|
||||
// Process is an interface that represents an abstract process
|
||||
// and provides methods for starting, stopping, and checking the health of the
|
||||
// process.
|
||||
@@ -74,6 +78,7 @@ type processSupervisor struct {
|
||||
logger *zap.Logger
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
mutexChan chan struct{}
|
||||
firstStart atomic.Bool
|
||||
reqCounter atomic.Int64
|
||||
@@ -83,12 +88,13 @@ type processSupervisor struct {
|
||||
}
|
||||
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit int64) ProcessSupervisor {
|
||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
|
||||
b := &processSupervisor{
|
||||
logger: logger,
|
||||
process: process,
|
||||
mutexChan: make(chan struct{}, 1),
|
||||
maxReqLimit: maxReqLimit,
|
||||
logger: logger,
|
||||
process: process,
|
||||
mutexChan: make(chan struct{}, 1),
|
||||
maxReqLimit: maxReqLimit,
|
||||
maxQueueSize: maxQueueSize,
|
||||
}
|
||||
b.reqCounter.Store(0)
|
||||
b.reqQueueSize.Store(0)
|
||||
@@ -167,6 +173,11 @@ func (s *processSupervisor) Healthy() bool {
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
currentQueueSize := s.reqQueueSize.Load()
|
||||
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
|
||||
return ErrMaximumQueueSizeExceeded
|
||||
}
|
||||
|
||||
s.reqQueueSize.Add(1)
|
||||
|
||||
for {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.firstStartSet {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0)
|
||||
err := ps.Shutdown()
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
@@ -154,7 +154,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.initiallyRestarting {
|
||||
ps.isRestarting.Store(true)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
@@ -249,6 +249,8 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
expectedStartCalls int64
|
||||
expectedHealthyCalls int64
|
||||
expectedStopCalls int64
|
||||
currentQueueSize int64
|
||||
maxQueueSize int64
|
||||
}{
|
||||
{
|
||||
scenario: "successfully run task on non-started process",
|
||||
@@ -348,6 +350,34 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 0,
|
||||
},
|
||||
{
|
||||
scenario: "queue size exceeded",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 0,
|
||||
expectedHealthyCalls: 0,
|
||||
expectedStopCalls: 0,
|
||||
currentQueueSize: 1,
|
||||
maxQueueSize: 1,
|
||||
},
|
||||
{
|
||||
scenario: "queue size not exceeded",
|
||||
initiallyStarted: false,
|
||||
isRestarting: false,
|
||||
processHealthy: true,
|
||||
maxReqLimit: 2,
|
||||
tasksToRun: 1,
|
||||
expectError: true,
|
||||
expectedStartCalls: 1,
|
||||
expectedHealthyCalls: 1,
|
||||
expectedStopCalls: 0,
|
||||
currentQueueSize: 1,
|
||||
maxQueueSize: 2,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
@@ -372,13 +402,16 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
|
||||
if tc.initiallyStarted {
|
||||
ps.firstStart.Store(true)
|
||||
}
|
||||
if tc.isRestarting {
|
||||
ps.isRestarting.Store(true)
|
||||
}
|
||||
if tc.currentQueueSize > 0 {
|
||||
ps.reqQueueSize.Store(tc.currentQueueSize)
|
||||
}
|
||||
|
||||
task := func() error {
|
||||
return tc.taskError
|
||||
@@ -451,13 +484,13 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if tc.ctxDone {
|
||||
cancel()
|
||||
} else {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
err := ps.runWithDeadline(ctx, func() error {
|
||||
@@ -485,7 +518,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
return true
|
||||
},
|
||||
}
|
||||
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||
|
||||
// Simulating a lock.
|
||||
ps.mutexChan <- struct{}{}
|
||||
@@ -586,7 +619,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||
|
||||
for i := 0; i < tc.restartAttempts; i++ {
|
||||
|
||||
4
pkg/gotenberg/version.go
Normal file
4
pkg/gotenberg/version.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package gotenberg
|
||||
|
||||
// Version is the... version of the Gotenberg application.
|
||||
var Version = "snapshot"
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -30,10 +28,14 @@ func init() {
|
||||
// middlewares or health checks.
|
||||
type Api struct {
|
||||
port int
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
startTimeout time.Duration
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
traceHeader string
|
||||
basicAuthUsername string
|
||||
basicAuthPassword string
|
||||
disableHealthCheckLogging bool
|
||||
|
||||
routes []Route
|
||||
@@ -159,12 +161,14 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
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-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.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.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.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-disable-health-check-logging", false, "Disable health check logging")
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Api) },
|
||||
@@ -175,6 +179,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.port = flags.MustInt("api-port")
|
||||
a.tlsCertFile = flags.MustString("api-tls-cert-file")
|
||||
a.tlsKeyFile = flags.MustString("api-tls-key-file")
|
||||
a.startTimeout = flags.MustDuration("api-start-timeout")
|
||||
a.timeout = flags.MustDuration("api-timeout")
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
@@ -184,24 +190,28 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
// Port from env?
|
||||
portEnvVar := flags.MustString("api-port-from-env")
|
||||
if portEnvVar != "" {
|
||||
val, ok := os.LookupEnv(portEnvVar)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
|
||||
}
|
||||
|
||||
if val == "" {
|
||||
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(val)
|
||||
port, err := gotenberg.IntEnv(portEnvVar)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
|
||||
return fmt.Errorf("get API port from env: %w", err)
|
||||
}
|
||||
|
||||
a.port = port
|
||||
}
|
||||
|
||||
// Enable basic auth?
|
||||
enableBasicAuth := flags.MustBool("api-enable-basic-auth")
|
||||
if enableBasicAuth {
|
||||
basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth username from env: %w", err)
|
||||
}
|
||||
basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth password from env: %w", err)
|
||||
}
|
||||
a.basicAuthUsername = basicAuthUsername
|
||||
a.basicAuthPassword = basicAuthPassword
|
||||
}
|
||||
|
||||
// Get routes from modules.
|
||||
mods, err := ctx.Modules(new(Router))
|
||||
if err != nil {
|
||||
@@ -297,6 +307,12 @@ func (a *Api) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("both TLS certificate and key files must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("root path must start with /"),
|
||||
@@ -319,8 +335,9 @@ func (a *Api) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
routesMap := make(map[string]string, len(a.routes)+1)
|
||||
routesMap := make(map[string]string, len(a.routes)+2)
|
||||
routesMap["/health"] = "/health"
|
||||
routesMap["/version"] = "/version"
|
||||
|
||||
for _, route := range a.routes {
|
||||
if route.Path == "" {
|
||||
@@ -394,6 +411,13 @@ func (a *Api) Start() error {
|
||||
loggerMiddleware(a.logger, disableLoggingForPaths),
|
||||
)
|
||||
|
||||
// Basic auth?
|
||||
if a.basicAuthUsername != "" {
|
||||
a.srv.Pre(
|
||||
basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword),
|
||||
)
|
||||
}
|
||||
|
||||
// Add the modules' middlewares in their respective stacks.
|
||||
var externalMultipartMiddlewares []Middleware
|
||||
for _, externalMiddleware := range a.externalMiddlewares {
|
||||
@@ -402,7 +426,7 @@ func (a *Api) Start() error {
|
||||
a.srv.Pre(externalMiddleware.Handler)
|
||||
case MultipartStack:
|
||||
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
|
||||
default:
|
||||
case DefaultStack:
|
||||
a.srv.Use(externalMiddleware.Handler)
|
||||
}
|
||||
}
|
||||
@@ -431,7 +455,7 @@ func (a *Api) Start() error {
|
||||
)
|
||||
}
|
||||
|
||||
// Let's not forget the health check route.
|
||||
// Let's not forget the health check route...
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
@@ -442,6 +466,14 @@ func (a *Api) Start() error {
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
|
||||
// ...and the version route.
|
||||
a.srv.GET(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "version"),
|
||||
func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, gotenberg.Version)
|
||||
},
|
||||
)
|
||||
|
||||
// Wait for all modules to be ready.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
|
||||
defer cancel()
|
||||
@@ -458,8 +490,15 @@ func (a *Api) Start() error {
|
||||
|
||||
// As the following code is blocking, run it in a goroutine.
|
||||
go func() {
|
||||
server := &http2.Server{}
|
||||
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
||||
var err error
|
||||
if a.tlsCertFile != "" && a.tlsKeyFile != "" {
|
||||
// Start an HTTPS server (supports HTTP/2).
|
||||
err = a.srv.StartTLS(fmt.Sprintf(":%d", a.port), a.tlsCertFile, a.tlsKeyFile)
|
||||
} else {
|
||||
// Start an HTTP/2 Cleartext (non-HTTPS) server.
|
||||
server := &http2.Server{}
|
||||
err = a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
a.logger.Fatal(err.Error())
|
||||
}
|
||||
|
||||
@@ -58,10 +58,28 @@ func TestApi_Provision(t *testing.T) {
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "port from env: empty environment variable",
|
||||
scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable",
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
err := fs.Parse([]string{"--api-enable-basic-auth=true"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_PASSWORD environment variable",
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-enable-basic-auth=true"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -74,7 +92,7 @@ func TestApi_Provision(t *testing.T) {
|
||||
)
|
||||
}(),
|
||||
setEnv: func() {
|
||||
err := os.Setenv("PORT", "")
|
||||
err := os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -361,7 +379,7 @@ func TestApi_Provision(t *testing.T) {
|
||||
}
|
||||
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT", "--api-enable-basic-auth=true"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -383,6 +401,14 @@ func TestApi_Provision(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = os.Setenv("GOTENBERG_API_BASIC_AUTH_PASSWORD", "bar")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectPort: 1337,
|
||||
expectMiddlewares: []Middleware{
|
||||
@@ -436,6 +462,8 @@ func TestApi_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
port int
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
rootPath string
|
||||
traceHeader string
|
||||
routes []Route
|
||||
@@ -460,6 +488,26 @@ func TestApi_Validate(t *testing.T) {
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid TLS files: only cert file provided",
|
||||
port: 10,
|
||||
tlsCertFile: "cert.pem",
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid TLS files: only key file provided",
|
||||
port: 10,
|
||||
tlsKeyFile: "key.pem",
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid root path: missing / prefix",
|
||||
port: 10,
|
||||
@@ -621,10 +669,33 @@ func TestApi_Validate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "success with TLS",
|
||||
port: 10,
|
||||
tlsCertFile: "cert.pem",
|
||||
tlsKeyFile: "key.pem",
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/forms/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
IsMultipart: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := Api{
|
||||
port: tc.port,
|
||||
tlsCertFile: tc.tlsCertFile,
|
||||
tlsKeyFile: tc.tlsKeyFile,
|
||||
rootPath: tc.rootPath,
|
||||
traceHeader: tc.traceHeader,
|
||||
routes: tc.routes,
|
||||
@@ -647,6 +718,8 @@ func TestApi_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
readyFn []func() error
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
@@ -665,12 +738,26 @@ func TestApi_Start(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success with TLS",
|
||||
readyFn: []func() error{
|
||||
func() error { return nil },
|
||||
func() error { return nil },
|
||||
},
|
||||
tlsCertFile: "/tests/test/testdata/api/cert.pem",
|
||||
tlsKeyFile: "/tests/test/testdata/api/key.pem",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Api)
|
||||
mod.port = 3000
|
||||
mod.tlsCertFile = tc.tlsCertFile
|
||||
mod.tlsKeyFile = tc.tlsKeyFile
|
||||
mod.startTimeout = time.Duration(30) * time.Second
|
||||
mod.rootPath = "/"
|
||||
mod.basicAuthUsername = "foo"
|
||||
mod.basicAuthPassword = "bar"
|
||||
mod.disableHealthCheckLogging = true
|
||||
mod.routes = []Route{
|
||||
{
|
||||
@@ -755,12 +842,22 @@ func TestApi_Start(t *testing.T) {
|
||||
// health request.
|
||||
recorder := httptest.NewRecorder()
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
healthRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||
|
||||
mod.srv.ServeHTTP(recorder, healthRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
// version request.
|
||||
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
|
||||
versionRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||
|
||||
mod.srv.ServeHTTP(recorder, versionRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
// "multipart/form-data" request.
|
||||
multipartRequest := func(url string) *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
@@ -791,6 +888,7 @@ func TestApi_Start(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, url, body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
req.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -34,18 +34,24 @@ var (
|
||||
|
||||
// Context is the request context for a "multipart/form-data" requests.
|
||||
type Context struct {
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
|
||||
dirPath string
|
||||
values map[string][]string
|
||||
files map[string]string
|
||||
outputPaths []string
|
||||
cancelled bool
|
||||
|
||||
cancelled bool
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
logger *zap.Logger
|
||||
echoCtx echo.Context
|
||||
pathRename gotenberg.PathRename
|
||||
context.Context
|
||||
}
|
||||
|
||||
type osPathRename struct{}
|
||||
|
||||
func (o *osPathRename) Rename(oldpath, newpath string) error {
|
||||
return os.Rename(oldpath, newpath)
|
||||
}
|
||||
|
||||
// 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) (*Context, context.CancelFunc, error) {
|
||||
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
|
||||
@@ -55,6 +61,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
cancelled: false,
|
||||
logger: logger,
|
||||
echoCtx: echoCtx,
|
||||
pathRename: new(osPathRename),
|
||||
Context: processCtx,
|
||||
}
|
||||
|
||||
@@ -86,7 +93,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
|
||||
form, err := echoCtx.MultipartForm()
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, http.ErrNotMultipart) {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("get multipart form: %w", err),
|
||||
@@ -164,7 +170,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
for _, files := range form.File {
|
||||
for _, fh := range files {
|
||||
err = copyToDisk(fh)
|
||||
|
||||
if err != nil {
|
||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||
}
|
||||
@@ -191,10 +196,20 @@ func (ctx *Context) FormData() *FormData {
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePath generates a path within the context's working directory. It
|
||||
// does not create a file.
|
||||
// GeneratePath generates a path within the context's working directory.
|
||||
// It generates a new UUID-based filename. It does not create a file.
|
||||
func (ctx *Context) GeneratePath(extension string) string {
|
||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
|
||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
|
||||
}
|
||||
|
||||
// Rename is just a wrapper around [os.Rename], as we need to mock this
|
||||
// behavior in our tests.
|
||||
func (ctx *Context) Rename(oldpath, newpath string) error {
|
||||
err := ctx.pathRename.Rename(oldpath, newpath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename path: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddOutputPaths adds the given paths. Those paths will be used later to build
|
||||
@@ -269,3 +284,8 @@ func (ctx *Context) OutputFilename(outputPath string) string {
|
||||
|
||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||
}
|
||||
|
||||
// Interface guard.
|
||||
var (
|
||||
_ gotenberg.PathRename = (*osPathRename)(nil)
|
||||
)
|
||||
|
||||
@@ -3,21 +3,77 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestOsPathRename_Rename(t *testing.T) {
|
||||
dirPath, err := gotenberg.NewFileSystem().MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("create working directory: %v", err)
|
||||
}
|
||||
|
||||
path := "/tests/test/testdata/api/sample1.txt"
|
||||
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
|
||||
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open file: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := in.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := os.Create(copyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create new file: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close new file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
t.Fatalf("copy file to new file: %v", err)
|
||||
}
|
||||
|
||||
rename := new(osPathRename)
|
||||
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
|
||||
|
||||
err = rename.Rename(copyPath, newPath)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(dirPath)
|
||||
if err != nil {
|
||||
t.Fatalf("remove working directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContext(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
@@ -191,12 +247,46 @@ func TestContext_GeneratePath(t *testing.T) {
|
||||
}
|
||||
|
||||
path := ctx.GeneratePath(".pdf")
|
||||
|
||||
if !strings.HasPrefix(path, ctx.dirPath) {
|
||||
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_Rename(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "failure",
|
||||
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return errors.New("cannot rename")
|
||||
}}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return nil
|
||||
}}},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.ctx.Rename("", "")
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_AddOutputPaths(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// FormData is a helper for validating and hydrating values from a
|
||||
@@ -143,6 +146,91 @@ func (form *FormData) MandatoryDuration(key string, target *time.Duration) *Form
|
||||
return form.mustMandatoryField(key, target)
|
||||
}
|
||||
|
||||
// Inches binds a form field to a float64 variable. It populates an error
|
||||
// if the value cannot be computed back to inches.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().Inches("foo", &foo, 2.0)
|
||||
func (form *FormData) Inches(key string, target *float64, defaultValue float64) *FormData {
|
||||
form.inches(key, target)
|
||||
if *target == -math.MaxFloat64 {
|
||||
*target = defaultValue
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// MandatoryInches binds a form field to a float64 variable. It populates
|
||||
// an error if the value cannot be computed back to inches, is empty, or the
|
||||
// "key" does not exist.
|
||||
//
|
||||
// var foo float64
|
||||
//
|
||||
// ctx.FormData().MandatoryInches("foo", &foo)
|
||||
func (form *FormData) MandatoryInches(key string, target *float64) *FormData {
|
||||
val, ok := form.values[key]
|
||||
if !ok || val[0] == "" {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is required", key),
|
||||
)
|
||||
return form
|
||||
}
|
||||
return form.inches(key, target)
|
||||
}
|
||||
|
||||
// inches tries to compute a string value to inches.
|
||||
func (form *FormData) inches(key string, target *float64) *FormData {
|
||||
var value string
|
||||
form.mustValue(key, &value, "")
|
||||
|
||||
if value == "" {
|
||||
*target = -math.MaxFloat64
|
||||
return form
|
||||
}
|
||||
|
||||
for _, unit := range []string{"pt", "px", "in", "mm", "cm", "pc"} {
|
||||
if !strings.HasSuffix(value, unit) {
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(strings.TrimSuffix(value, unit), 64)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case "pt":
|
||||
*target = val * (1.0 / 72.0)
|
||||
case "px":
|
||||
*target = val * (1.0 / 96.0)
|
||||
case "in":
|
||||
*target = val
|
||||
case "mm":
|
||||
*target = val * (1.0 / 25.4)
|
||||
case "cm":
|
||||
*target = val * (1.0 / 2.54)
|
||||
case "pc":
|
||||
*target = val * (1.0 / 6.0)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
val, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
form.append(
|
||||
fmt.Errorf("form field '%s' is invalid (got '%s', resulting to %w)", key, value, err),
|
||||
)
|
||||
return form
|
||||
}
|
||||
|
||||
*target = val
|
||||
return form
|
||||
}
|
||||
|
||||
// Custom helps to define a custom binding function for a form field.
|
||||
//
|
||||
// var foo map[string]string
|
||||
@@ -304,7 +392,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||
sort.Strings(*target)
|
||||
sort.Sort(gotenberg.AlphanumericSort(*target))
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -768,6 +769,333 @@ func TestFormData_MandatoryDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_Inches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
form *FormData
|
||||
defaultValue float64
|
||||
expect float64
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "key does not exist, fallback to default zero value",
|
||||
form: &FormData{},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does not exist, fallback to default value",
|
||||
form: &FormData{},
|
||||
defaultValue: 2.5,
|
||||
expect: 2.5,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist, but empty value, fallback to default value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist, value has a unit, but the rest is not float64 compatible",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foomm",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist, but value has no unit and is invalid",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a pt value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"72pt",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a px value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"96px",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with an in value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"1in",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a mm value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"25.4mm",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a cm value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"2.54cm",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with a pc value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"6pc",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "key does exist with no unit in the value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"100",
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultValue: 0.0,
|
||||
expect: 100,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var actual float64
|
||||
|
||||
tc.form.Inches("foo", &actual, tc.defaultValue)
|
||||
|
||||
if fmt.Sprintf("%.1f", actual) != fmt.Sprintf("%.1f", tc.expect) {
|
||||
t.Errorf("expected %.1f but got %.1f", tc.expect, actual)
|
||||
}
|
||||
|
||||
if tc.expectError && tc.form.errors == nil {
|
||||
t.Fatal("expected error but got none", tc.form.errors)
|
||||
}
|
||||
|
||||
if !tc.expectError && tc.form.errors != nil {
|
||||
t.Fatalf("expected no error but got: %v", tc.form.errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_MandatoryInches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
form *FormData
|
||||
expect float64
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "missing mandatory key",
|
||||
form: &FormData{},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory value is empty",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory value has a unit, but the rest is not float64 compatible",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foomm",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "mandatory value has no unit and is invalid",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 0.0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "a pt mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"72pt",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a px mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"96px",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "an in mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"1in",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a mm mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"25.4mm",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a cm mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"2.54cm",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "a pc mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"6pc",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 1.0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "no unit in the mandatory value",
|
||||
form: &FormData{
|
||||
values: map[string][]string{
|
||||
"foo": {
|
||||
"100",
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: 100,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
var actual float64
|
||||
|
||||
tc.form.MandatoryInches("foo", &actual)
|
||||
|
||||
if fmt.Sprintf("%.1f", actual) != fmt.Sprintf("%.1f", tc.expect) {
|
||||
t.Errorf("expected %.1f but got %.1f", tc.expect, actual)
|
||||
}
|
||||
|
||||
if tc.expectError && tc.form.errors == nil {
|
||||
t.Fatal("expected error but got none", tc.form.errors)
|
||||
}
|
||||
|
||||
if !tc.expectError && tc.form.errors != nil {
|
||||
t.Fatalf("expected no error but got: %v", tc.form.errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormData_Custom(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -10,14 +11,21 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ErrAsyncProcess happens when a handler or middleware handles a request in an
|
||||
// asynchronous fashion.
|
||||
var ErrAsyncProcess = errors.New("async process")
|
||||
var (
|
||||
// ErrAsyncProcess happens when a handler or middleware handles a request
|
||||
// in an asynchronous fashion.
|
||||
ErrAsyncProcess = errors.New("async process")
|
||||
|
||||
// ErrNoOutputFile happens when a handler or middleware handles a request
|
||||
// without sending any output file.
|
||||
ErrNoOutputFile = errors.New("no output file")
|
||||
)
|
||||
|
||||
// ParseError parses an error and returns the corresponding HTTP status and
|
||||
// HTTP message.
|
||||
@@ -32,6 +40,22 @@ func ParseError(err error) (int, string) {
|
||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||
return http.StatusForbidden, http.StatusText(http.StatusForbidden)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
|
||||
}
|
||||
|
||||
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||
}
|
||||
|
||||
var httpErr HttpError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr.HttpError()
|
||||
@@ -90,7 +114,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("rootPath", rootPath)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
@@ -195,6 +218,17 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuthMiddleware manages basic authentication.
|
||||
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
|
||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||
@@ -230,6 +264,13 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.Mid
|
||||
|
||||
defer cancel()
|
||||
|
||||
if errors.Is(err, ErrNoOutputFile) {
|
||||
// A middleware/handler tells us that it's handling the process
|
||||
// in an asynchronous fashion. Therefore, we must not cancel
|
||||
// the context nor send an output file.
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -29,9 +29,24 @@ func TestParseError(t *testing.T) {
|
||||
expectMessage: http.StatusText(http.StatusInternalServerError),
|
||||
},
|
||||
{
|
||||
err: context.DeadlineExceeded,
|
||||
expectStatus: http.StatusServiceUnavailable,
|
||||
expectMessage: http.StatusText(http.StatusServiceUnavailable),
|
||||
err: gotenberg.ErrFiltered,
|
||||
expectStatus: http.StatusForbidden,
|
||||
expectMessage: http.StatusText(http.StatusForbidden),
|
||||
},
|
||||
{
|
||||
err: gotenberg.ErrMaximumQueueSizeExceeded,
|
||||
expectStatus: http.StatusTooManyRequests,
|
||||
expectMessage: http.StatusText(http.StatusTooManyRequests),
|
||||
},
|
||||
{
|
||||
err: gotenberg.ErrPdfFormatNotSupported,
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues",
|
||||
},
|
||||
{
|
||||
err: gotenberg.ErrPdfEngineMetadataValueNotSupported,
|
||||
expectStatus: http.StatusBadRequest,
|
||||
expectMessage: "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues",
|
||||
},
|
||||
{
|
||||
err: WrapError(
|
||||
@@ -221,6 +236,56 @@ func TestTraceMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddleware(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
request *http.Request
|
||||
username string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid basic auth",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("invalid", "invalid")
|
||||
return req
|
||||
}(),
|
||||
username: "foo",
|
||||
password: "bar",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "valid basic auth",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("foo", "bar")
|
||||
return req
|
||||
}(),
|
||||
username: "foo",
|
||||
password: "bar",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error {
|
||||
return nil
|
||||
})(c)
|
||||
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 TestLoggerMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
@@ -320,6 +385,15 @@ func TestContextMiddleware(t *testing.T) {
|
||||
}(),
|
||||
expectStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
return ErrNoOutputFile
|
||||
}
|
||||
}(),
|
||||
expectStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
request: buildMultipartFormDataRequest(),
|
||||
next: func() echo.HandlerFunc {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// ContextMock is a helper for tests.
|
||||
@@ -76,7 +78,7 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
|
||||
ctx.logger = logger
|
||||
}
|
||||
|
||||
// SetEchoContext sets the echo.Context.
|
||||
// SetEchoContext sets the [echo.Context].
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setEchoContext(c)
|
||||
@@ -84,6 +86,14 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) {
|
||||
ctx.Context.echoCtx = c
|
||||
}
|
||||
|
||||
// SetPathRename sets the [gotenberg.PathRename].
|
||||
//
|
||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
// ctx.setPathRename(rename)
|
||||
func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) {
|
||||
ctx.Context.pathRename = rename
|
||||
}
|
||||
|
||||
// RouterMock is a mock for the [Router] interface.
|
||||
type RouterMock struct {
|
||||
RoutesMock func() ([]Route, error)
|
||||
|
||||
@@ -117,6 +117,19 @@ func TestContextMock_SetEchoContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMock_SetPathRename(t *testing.T) {
|
||||
mock := ContextMock{&Context{}}
|
||||
|
||||
expect := new(osPathRename)
|
||||
mock.SetPathRename(expect)
|
||||
|
||||
actual := mock.pathRename
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %v but got %v", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMock(t *testing.T) {
|
||||
mock := &RouterMock{
|
||||
RoutesMock: func() ([]Route, error) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -39,8 +39,8 @@ type browserArguments struct {
|
||||
wsUrlReadTimeout time.Duration
|
||||
|
||||
// Tasks specific.
|
||||
allowList *regexp.Regexp
|
||||
denyList *regexp.Regexp
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
clearCache bool
|
||||
clearCookies bool
|
||||
disableJavaScript bool
|
||||
@@ -90,6 +90,8 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
|
||||
// https://github.com/puppeteer/puppeteer/issues/2410
|
||||
chromedp.Flag("font-render-hinting", "none"),
|
||||
chromedp.UserDataDir(b.userProfileDirPath),
|
||||
// See https://github.com/gotenberg/gotenberg/issues/831.
|
||||
chromedp.Flag("disable-pdf-tagging", true),
|
||||
)
|
||||
|
||||
if b.arguments.incognito {
|
||||
@@ -164,7 +166,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
|
||||
go func() {
|
||||
// FIXME: Chromium seems to recreate the user profile directory
|
||||
// right after its deletion if we do not wait a certain amount
|
||||
// of time before re-deleting it.
|
||||
// of time before deleting it.
|
||||
<-time.After(10 * time.Second)
|
||||
|
||||
err := os.RemoveAll(userProfileDirPath)
|
||||
@@ -224,6 +226,8 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
||||
@@ -246,6 +250,8 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
|
||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
|
||||
@@ -254,6 +260,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
|
||||
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
|
||||
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
|
||||
// Screenshot specific.
|
||||
setDeviceMetricsOverride(logger, options.Width, options.Height),
|
||||
captureScreenshotActionFunc(logger, outputPath, options),
|
||||
})
|
||||
}
|
||||
@@ -263,20 +270,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
return errors.New("browser not started, cannot handle tasks")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allow / deny lists.
|
||||
if !b.arguments.allowList.MatchString(url) {
|
||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", url, ErrUrlNotAuthorized)
|
||||
}
|
||||
|
||||
if b.arguments.denyList.String() != "" && b.arguments.denyList.MatchString(url) {
|
||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", url, ErrUrlNotAuthorized)
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
// We validate the "main" URL against our allow / deny lists.
|
||||
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
b.ctxMu.RLock()
|
||||
defer b.ctxMu.RUnlock()
|
||||
|
||||
@@ -310,7 +314,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||
}
|
||||
|
||||
err := chromedp.Run(taskCtx, tasks...)
|
||||
err = chromedp.Run(taskCtx, tasks...)
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
@@ -268,46 +268,9 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized: main URL does not match the allowed list",
|
||||
scenario: "context has no deadline",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp.MustCompile("^file:///[^tmp].*"),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: ErrUrlNotAuthorized,
|
||||
},
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized: main URL does match the denied list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile("^file:///tmp.*"),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: ErrUrlNotAuthorized,
|
||||
},
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized: main URL does match the denied list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
@@ -316,14 +279,48 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
start: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered: main URL does not match the allowed list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: gotenberg.ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered: main URL does match the denied list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("^file:///tmp.*", 0),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: gotenberg.ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "a request does not match the allowed list",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile("^file:///tmp.*"),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("^file:///tmp.*", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -354,8 +351,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile("^file:///[^tmp].*"),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -386,8 +383,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -421,8 +418,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -454,8 +451,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -487,8 +484,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
clearCache: true,
|
||||
},
|
||||
),
|
||||
@@ -520,8 +517,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
clearCookies: true,
|
||||
},
|
||||
),
|
||||
@@ -553,8 +550,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
disableJavaScript: true,
|
||||
},
|
||||
),
|
||||
@@ -582,14 +579,84 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
"JavaScript disabled, skipping wait expression",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "set cookies",
|
||||
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>Set cookies</h1>"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: PdfOptions{
|
||||
Options: Options{Cookies: []Cookie{{Name: "foo", Value: "bar", Domain: ".foo.bar"}}},
|
||||
},
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
"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: PdfOptions{
|
||||
Options: Options{UserAgent: "foo"},
|
||||
},
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
fmt.Sprintf("user agent override: foo"),
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "extra HTTP headers",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -625,8 +692,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -658,8 +725,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -694,8 +761,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -727,8 +794,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -762,8 +829,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -797,8 +864,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -832,8 +899,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -878,8 +945,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -913,8 +980,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -953,14 +1020,49 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
"wait until 'window.globalVar === 'ready'' is true before print",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "single page",
|
||||
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>Custom header and footer</h1>"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: PdfOptions{
|
||||
SinglePage: true,
|
||||
},
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
"single page PDF",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "custom header and footer",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -995,8 +1097,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1033,8 +1135,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1066,8 +1168,8 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1093,6 +1195,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
"cache not cleared",
|
||||
"cookies not cleared",
|
||||
"JavaScript not disabled",
|
||||
"no cookies to set",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"default white background not hidden",
|
||||
@@ -1202,45 +1305,12 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized: main URL does not match the allowed list",
|
||||
scenario: "context has not deadline",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp.MustCompile("^file:///[^tmp].*"),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: ErrUrlNotAuthorized,
|
||||
},
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized: main URL does match the denied list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile("^file:///tmp.*"),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: ErrUrlNotAuthorized,
|
||||
},
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized: main URL does match the denied list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
@@ -1250,14 +1320,48 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
start: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered: main URL does not match the allowed list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: gotenberg.ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "ErrFiltered: main URL does match the denied list",
|
||||
browser: func() browser {
|
||||
b := new(chromiumBrowser)
|
||||
b.arguments = browserArguments{
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("^file:///tmp.*", 0),
|
||||
}
|
||||
b.isStarted.Store(true)
|
||||
return b
|
||||
}(),
|
||||
fs: gotenberg.NewFileSystem(),
|
||||
noDeadline: false,
|
||||
start: false,
|
||||
expectError: true,
|
||||
expectedError: gotenberg.ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "a request does not match the allowed list",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile("^file:///tmp.*"),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("^file:///tmp.*", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1288,8 +1392,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile("^file:///[^tmp].*"),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1320,8 +1424,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1355,8 +1459,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1388,8 +1492,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1421,8 +1525,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
clearCache: true,
|
||||
},
|
||||
),
|
||||
@@ -1454,8 +1558,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
clearCookies: true,
|
||||
},
|
||||
),
|
||||
@@ -1487,8 +1591,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
disableJavaScript: true,
|
||||
},
|
||||
),
|
||||
@@ -1516,14 +1620,49 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
"JavaScript disabled, skipping wait expression",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "set cookies",
|
||||
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>Set cookies</h1>"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: ScreenshotOptions{
|
||||
Options: Options{Cookies: []Cookie{{Name: "fpp", Value: "bar", Domain: ".foo.bar"}}},
|
||||
},
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
"set cookie",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "extra HTTP headers",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1559,8 +1698,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1594,8 +1733,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1627,8 +1766,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1662,8 +1801,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1697,8 +1836,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1732,8 +1871,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1778,8 +1917,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1813,8 +1952,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1859,8 +1998,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1886,12 +2025,62 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
"cache not cleared",
|
||||
"cookies not cleared",
|
||||
"JavaScript not disabled",
|
||||
"no user agent override",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"default white background not hidden",
|
||||
"no emulated media type",
|
||||
"no wait delay",
|
||||
"no wait expression",
|
||||
"set device metrics override",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "success (clip)",
|
||||
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>Default options</h1>"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: func() ScreenshotOptions {
|
||||
options := DefaultScreenshotOptions()
|
||||
options.Clip = true
|
||||
return options
|
||||
}(),
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
"cache not cleared",
|
||||
"cookies not cleared",
|
||||
"JavaScript not disabled",
|
||||
"no cookies to set",
|
||||
"no user agent override",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"default white background not hidden",
|
||||
"no emulated media type",
|
||||
"no wait delay",
|
||||
"no wait expression",
|
||||
"set device metrics override",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1900,8 +2089,8 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
@@ -1931,12 +2120,15 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
"cache not cleared",
|
||||
"cookies not cleared",
|
||||
"JavaScript not disabled",
|
||||
"no cookies to set",
|
||||
"no user agent override",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"default white background not hidden",
|
||||
"no emulated media type",
|
||||
"no wait delay",
|
||||
"no wait expression",
|
||||
"set device metrics override",
|
||||
},
|
||||
},
|
||||
} {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/chromedp/cdproto/network"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -20,10 +21,6 @@ func init() {
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrUrlNotAuthorized happens if a URL is not acceptable according to the
|
||||
// allowed/denied lists.
|
||||
ErrUrlNotAuthorized = errors.New("URL not authorized")
|
||||
|
||||
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
|
||||
// "screen" nor "print". Empty value are allowed though.
|
||||
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
|
||||
@@ -107,8 +104,16 @@ type Options struct {
|
||||
// Optional.
|
||||
WaitForExpression string
|
||||
|
||||
// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
|
||||
// the HTML document.
|
||||
// Cookies are the cookies to put in the Chromium cookies' jar.
|
||||
// Optional
|
||||
Cookies []Cookie
|
||||
|
||||
// UserAgent overrides the default 'User-Agent' HTTP header.
|
||||
// Optional.
|
||||
UserAgent string
|
||||
|
||||
// ExtraHttpHeaders are extra HTTP headers to send by Chromium while
|
||||
// loading he HTML document.
|
||||
// Optional.
|
||||
ExtraHttpHeaders map[string]string
|
||||
|
||||
@@ -132,6 +137,8 @@ func DefaultOptions() Options {
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
Cookies: nil,
|
||||
UserAgent: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
OmitBackground: false,
|
||||
@@ -154,6 +161,11 @@ type PdfOptions struct {
|
||||
// Optional.
|
||||
Scale float64
|
||||
|
||||
// SinglePage defines whether to print the entire content in one single
|
||||
// page.
|
||||
// Optional.
|
||||
SinglePage bool
|
||||
|
||||
// PaperWidth is the paper width, in inches.
|
||||
// Optional.
|
||||
PaperWidth float64
|
||||
@@ -213,6 +225,7 @@ func DefaultPdfOptions() PdfOptions {
|
||||
Landscape: false,
|
||||
PrintBackground: false,
|
||||
Scale: 1.0,
|
||||
SinglePage: false,
|
||||
PaperWidth: 8.5,
|
||||
PaperHeight: 11,
|
||||
MarginTop: 0.39,
|
||||
@@ -231,6 +244,19 @@ func DefaultPdfOptions() PdfOptions {
|
||||
type ScreenshotOptions struct {
|
||||
Options
|
||||
|
||||
// Width is the device screen width in pixels.
|
||||
// Optional.
|
||||
Width int
|
||||
|
||||
// Height is the device screen height in pixels.
|
||||
// Optional.
|
||||
Height int
|
||||
|
||||
// Clip defines whether to clip the screenshot according to the device
|
||||
// dimensions.
|
||||
// Optional.
|
||||
Clip bool
|
||||
|
||||
// Format is the image compression format, either "png" or "jpeg" or
|
||||
// "webp".
|
||||
// Optional.
|
||||
@@ -250,12 +276,47 @@ type ScreenshotOptions struct {
|
||||
func DefaultScreenshotOptions() ScreenshotOptions {
|
||||
return ScreenshotOptions{
|
||||
Options: DefaultOptions(),
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
Clip: false,
|
||||
Format: "png",
|
||||
Quality: 100,
|
||||
OptimizeForSpeed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie gathers the available entries for setting a cookie in the Chromium
|
||||
// cookies' jar.
|
||||
type Cookie struct {
|
||||
// Name is the cookie name.
|
||||
// Required.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Value is the cookie value.
|
||||
// Required.
|
||||
Value string `json:"value"`
|
||||
|
||||
// Domain is the cookie domain.
|
||||
// Required.
|
||||
Domain string `json:"domain"`
|
||||
|
||||
// Path is the cookie path.
|
||||
// Optional.
|
||||
Path string `json:"path,omitempty"`
|
||||
|
||||
// Secure sets the cookie secure if true.
|
||||
// Optional.
|
||||
Secure bool `json:"secure,omitempty"`
|
||||
|
||||
// HttpOnly sets the cookie as HTTP-only if true.
|
||||
// Optional.
|
||||
HttpOnly bool `json:"httpOnly,omitempty"`
|
||||
|
||||
// SameSite is cookie 'Same-Site' status.
|
||||
// Optional.
|
||||
SameSite network.CookieSameSite `json:"sameSite,omitempty"`
|
||||
}
|
||||
|
||||
// Api helps to interact with Chromium for converting HTML documents to PDF.
|
||||
type Api interface {
|
||||
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
|
||||
@@ -280,6 +341,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
||||
fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
|
||||
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
|
||||
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
||||
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
||||
@@ -290,7 +352,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
||||
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
|
||||
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
|
||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||
@@ -344,7 +406,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
mod.browser = newChromiumBrowser(mod.args)
|
||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"))
|
||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"))
|
||||
|
||||
// PDF Engine.
|
||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||
|
||||
@@ -3,7 +3,6 @@ package chromium
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
@@ -13,14 +12,17 @@ import (
|
||||
"github.com/chromedp/cdproto/page"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/dlclark/regexp2"
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
// listenForEventRequestPaused listens for requests to check if they are
|
||||
// allowed or not.
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) {
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
case *fetch.EventRequestPaused:
|
||||
@@ -28,13 +30,15 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
|
||||
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||
allow := true
|
||||
|
||||
if !allowList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL))
|
||||
allow = false
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
logger.Error("context has no deadline, cannot filter URL")
|
||||
return
|
||||
}
|
||||
|
||||
if denyList.String() != "" && denyList.MatchString(e.Request.URL) {
|
||||
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL))
|
||||
err := gotenberg.FilterDeadline(allowList, denyList, e.Request.URL, deadline)
|
||||
if err != nil {
|
||||
logger.Warn(err.Error())
|
||||
allow = false
|
||||
}
|
||||
|
||||
@@ -43,16 +47,15 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
|
||||
|
||||
if allow {
|
||||
req := fetch.ContinueRequest(e.RequestID)
|
||||
err := req.Do(executorCtx)
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||
err := req.Do(executorCtx)
|
||||
err = req.Do(executorCtx)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
waitDelay time.Duration
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
cookies []Cookie
|
||||
userAgent string
|
||||
extraHttpHeaders map[string]string
|
||||
emulatedMediaType string
|
||||
omitBackground bool
|
||||
@@ -58,6 +60,26 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
||||
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
|
||||
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
|
||||
Custom("cookies", func(value string) error {
|
||||
if value == "" {
|
||||
cookies = defaultOptions.Cookies
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(value), &cookies)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal cookies: %w", err)
|
||||
}
|
||||
|
||||
for i, cookie := range cookies {
|
||||
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
|
||||
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}).
|
||||
String("userAgent", &userAgent, defaultOptions.UserAgent).
|
||||
Custom("extraHttpHeaders", func(value string) error {
|
||||
if value == "" {
|
||||
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
|
||||
@@ -78,7 +100,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
}
|
||||
|
||||
if value != "screen" && value != "print" {
|
||||
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
|
||||
return errors.New("wrong value, expected either 'screen', 'print' or empty")
|
||||
}
|
||||
|
||||
emulatedMediaType = value
|
||||
@@ -94,6 +116,8 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
WaitDelay: waitDelay,
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
Cookies: cookies,
|
||||
UserAgent: userAgent,
|
||||
ExtraHttpHeaders: extraHttpHeaders,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
OmitBackground: omitBackground,
|
||||
@@ -109,7 +133,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
|
||||
defaultPdfOptions := DefaultPdfOptions()
|
||||
|
||||
var (
|
||||
landscape, printBackground bool
|
||||
landscape, printBackground, singlePage bool
|
||||
scale, paperWidth, paperHeight float64
|
||||
marginTop, marginBottom, marginLeft, marginRight float64
|
||||
pageRanges string
|
||||
@@ -121,12 +145,13 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
|
||||
Bool("landscape", &landscape, defaultPdfOptions.Landscape).
|
||||
Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
|
||||
Float64("scale", &scale, defaultPdfOptions.Scale).
|
||||
Float64("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
|
||||
Float64("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
|
||||
Float64("marginTop", &marginTop, defaultPdfOptions.MarginTop).
|
||||
Float64("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
|
||||
Float64("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
|
||||
Float64("marginRight", &marginRight, defaultPdfOptions.MarginRight).
|
||||
Bool("singlePage", &singlePage, defaultPdfOptions.SinglePage).
|
||||
Inches("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
|
||||
Inches("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
|
||||
Inches("marginTop", &marginTop, defaultPdfOptions.MarginTop).
|
||||
Inches("marginBottom", &marginBottom, defaultPdfOptions.MarginBottom).
|
||||
Inches("marginLeft", &marginLeft, defaultPdfOptions.MarginLeft).
|
||||
Inches("marginRight", &marginRight, defaultPdfOptions.MarginRight).
|
||||
String("nativePageRanges", &pageRanges, defaultPdfOptions.PageRanges).
|
||||
Content("header.html", &headerTemplate, defaultPdfOptions.HeaderTemplate).
|
||||
Content("footer.html", &footerTemplate, defaultPdfOptions.FooterTemplate).
|
||||
@@ -137,6 +162,7 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
|
||||
Landscape: landscape,
|
||||
PrintBackground: printBackground,
|
||||
Scale: scale,
|
||||
SinglePage: singlePage,
|
||||
PaperWidth: paperWidth,
|
||||
PaperHeight: paperHeight,
|
||||
MarginTop: marginTop,
|
||||
@@ -159,12 +185,17 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
defaultScreenshotOptions := DefaultScreenshotOptions()
|
||||
|
||||
var (
|
||||
width, height int
|
||||
clip bool
|
||||
format string
|
||||
quality int
|
||||
optimizeForSpeed bool
|
||||
)
|
||||
|
||||
form.
|
||||
Int("width", &width, defaultScreenshotOptions.Width).
|
||||
Int("height", &height, defaultScreenshotOptions.Height).
|
||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||
Custom("format", func(value string) error {
|
||||
if value == "" {
|
||||
format = defaultScreenshotOptions.Format
|
||||
@@ -205,6 +236,9 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
||||
|
||||
screenshotOptions := ScreenshotOptions{
|
||||
Options: options,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Clip: clip,
|
||||
Format: format,
|
||||
Quality: quality,
|
||||
OptimizeForSpeed: optimizeForSpeed,
|
||||
@@ -231,6 +265,21 @@ func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats {
|
||||
}
|
||||
}
|
||||
|
||||
// FormDataPdfMetadata creates metadata object from the form data.
|
||||
func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
|
||||
var metadata map[string]interface{}
|
||||
form.Custom("metadata", func(value string) error {
|
||||
if len(value) > 0 {
|
||||
err := json.Unmarshal([]byte(value), &metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return metadata
|
||||
}
|
||||
|
||||
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
||||
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
@@ -241,6 +290,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
metadata := FormDataPdfMetadata(form)
|
||||
|
||||
var url string
|
||||
err := form.
|
||||
@@ -250,7 +300,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||
}
|
||||
@@ -300,6 +350,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
metadata := FormDataPdfMetadata(form)
|
||||
|
||||
var inputPath string
|
||||
err := form.
|
||||
@@ -310,7 +361,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s", inputPath)
|
||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||
}
|
||||
@@ -361,6 +412,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
form, options := FormDataChromiumPdfOptions(ctx)
|
||||
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||
metadata := FormDataPdfMetadata(form)
|
||||
|
||||
var (
|
||||
inputPath string
|
||||
@@ -380,7 +432,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||
}
|
||||
|
||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
||||
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||
}
|
||||
@@ -504,15 +556,15 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
|
||||
return fmt.Sprintf("file://%s", inputPath), nil
|
||||
}
|
||||
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options PdfOptions) error {
|
||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
|
||||
err = handleChromiumError(err, url, options.Options)
|
||||
err = handleChromiumError(err, options.Options)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
fmt.Errorf("convert to PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
"omitBackground requires printBackground set to true",
|
||||
@@ -552,18 +604,7 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
@@ -571,6 +612,14 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
||||
outputPath = convertOutputPath
|
||||
}
|
||||
|
||||
// Writes and potentially overrides metadata entries, if any.
|
||||
if len(metadata) > 0 {
|
||||
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
@@ -584,7 +633,7 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
||||
outputPath := ctx.GeneratePath(ext)
|
||||
|
||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||
err = handleChromiumError(err, url, options.Options)
|
||||
err = handleChromiumError(err, options.Options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("screenshot: %w", err)
|
||||
}
|
||||
@@ -597,21 +646,11 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleChromiumError(err error, url string, options Options) error {
|
||||
func handleChromiumError(err error, options Options) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrUrlNotAuthorized) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusForbidden,
|
||||
fmt.Sprintf("'%s' does not match the authorized URLs", url),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrInvalidEvaluationExpression) {
|
||||
if options.WaitForExpression == "" {
|
||||
// We do not expect the 'waitWindowStatus' form field to return
|
||||
|
||||
@@ -62,6 +62,59 @@ func TestFormDataChromiumOptions(t *testing.T) {
|
||||
return options
|
||||
}(),
|
||||
},
|
||||
{
|
||||
scenario: "invalid cookies form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"cookies": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectedOptions: DefaultOptions(),
|
||||
},
|
||||
{
|
||||
scenario: "invalid cookies form field (missing required values)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"cookies": {
|
||||
"[{}]",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectedOptions: func() Options {
|
||||
options := DefaultOptions()
|
||||
// No validation in this method, so it still instantiates
|
||||
// an empty item.
|
||||
options.Cookies = []Cookie{{}}
|
||||
return options
|
||||
}(),
|
||||
},
|
||||
{
|
||||
scenario: "valid cookies form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"cookies": {
|
||||
`[{"name":"foo","value":"bar","domain":".foo.bar"}]`,
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectedOptions: func() Options {
|
||||
options := DefaultOptions()
|
||||
options.Cookies = []Cookie{{
|
||||
Name: "foo",
|
||||
Value: "bar",
|
||||
Domain: ".foo.bar",
|
||||
}}
|
||||
return options
|
||||
}(),
|
||||
},
|
||||
{
|
||||
scenario: "invalid extraHttpHeaders form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
@@ -332,6 +385,15 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"width": {
|
||||
"1280",
|
||||
},
|
||||
"height": {
|
||||
"800",
|
||||
},
|
||||
"clip": {
|
||||
"true",
|
||||
},
|
||||
"optimizeForSpeed": {
|
||||
"true",
|
||||
},
|
||||
@@ -343,6 +405,9 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
|
||||
}(),
|
||||
expectedOptions: func() ScreenshotOptions {
|
||||
options := DefaultScreenshotOptions()
|
||||
options.Width = 1280
|
||||
options.Height = 800
|
||||
options.Clip = true
|
||||
options.OptimizeForSpeed = true
|
||||
options.EmulatedMediaType = "screen"
|
||||
return options
|
||||
@@ -399,6 +464,57 @@ func TestFormDataChromiumPdfFormats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataPdfMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *api.ContextMock
|
||||
expectedMetadata map[string]interface{}
|
||||
}{
|
||||
{
|
||||
scenario: "no metadata form field",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
expectedMetadata: nil,
|
||||
},
|
||||
{
|
||||
scenario: "invalid metadata form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectedMetadata: nil,
|
||||
},
|
||||
{
|
||||
scenario: "valid metadata form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"{\"foo\":\"bar\"}",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectedMetadata: map[string]interface{}{
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
tc.ctx.SetLogger(zap.NewNop())
|
||||
actual := FormDataPdfMetadata(tc.ctx.Context.FormData())
|
||||
|
||||
if !reflect.DeepEqual(actual, tc.expectedMetadata) {
|
||||
t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertUrlRoute(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
@@ -1223,25 +1339,14 @@ func TestConvertUrl(t *testing.T) {
|
||||
ctx *api.ContextMock
|
||||
api Api
|
||||
engine gotenberg.PdfEngine
|
||||
pdfFormats gotenberg.PdfFormats
|
||||
options PdfOptions
|
||||
pdfFormats gotenberg.PdfFormats
|
||||
metadata map[string]interface{}
|
||||
expectError bool
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
expectOutputPathsCount int
|
||||
}{
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return ErrUrlNotAuthorized
|
||||
}},
|
||||
options: DefaultPdfOptions(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusForbidden,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrOmitBackgroundWithoutPrintBackground",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
@@ -1342,23 +1447,7 @@ func TestConvertUrl(t *testing.T) {
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
}},
|
||||
engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPdfFormatNotSupported
|
||||
}},
|
||||
pdfFormats: gotenberg.PdfFormats{PdfA: "foo"},
|
||||
options: DefaultPdfOptions(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from PDF engine",
|
||||
scenario: "PDF engine convert error",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
@@ -1366,14 +1455,14 @@ func TestConvertUrl(t *testing.T) {
|
||||
engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
pdfFormats: gotenberg.PdfFormats{PdfA: "foo"},
|
||||
options: DefaultPdfOptions(),
|
||||
pdfFormats: gotenberg.PdfFormats{PdfA: "foo"},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "success with pdfa form field",
|
||||
scenario: "success with PDF formats",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
@@ -1381,12 +1470,29 @@ func TestConvertUrl(t *testing.T) {
|
||||
engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
}},
|
||||
pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
|
||||
options: DefaultPdfOptions(),
|
||||
pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine write metadata error",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
}},
|
||||
engine: &gotenberg.PdfEngineMock{WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return errors.New("foo")
|
||||
}},
|
||||
options: DefaultPdfOptions(),
|
||||
metadata: map[string]interface{}{
|
||||
"Creator": "foo",
|
||||
"Producer": "bar",
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths",
|
||||
ctx: func() *api.ContextMock {
|
||||
@@ -1408,7 +1514,20 @@ func TestConvertUrl(t *testing.T) {
|
||||
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return nil
|
||||
}},
|
||||
options: DefaultPdfOptions(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
options: DefaultPdfOptions(),
|
||||
pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
|
||||
metadata: map[string]interface{}{
|
||||
"Creator": "foo",
|
||||
"Producer": "bar",
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
@@ -1416,7 +1535,7 @@ func TestConvertUrl(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
tc.ctx.SetLogger(zap.NewNop())
|
||||
err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.pdfFormats, tc.options)
|
||||
err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.pdfFormats, tc.metadata)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
@@ -1462,18 +1581,6 @@ func TestScreenshotUrl(t *testing.T) {
|
||||
expectHttpStatus int
|
||||
expectOutputPathsCount int
|
||||
}{
|
||||
{
|
||||
scenario: "ErrUrlNotAuthorized",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return ErrUrlNotAuthorized
|
||||
}},
|
||||
options: DefaultScreenshotOptions(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusForbidden,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
|
||||
@@ -3,6 +3,7 @@ package chromium
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
@@ -17,19 +18,39 @@ import (
|
||||
|
||||
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
paperHeight := options.PaperHeight
|
||||
pageRanges := options.PageRanges
|
||||
|
||||
if options.SinglePage {
|
||||
logger.Debug("single page PDF")
|
||||
|
||||
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get layout metrics: %w", err)
|
||||
}
|
||||
|
||||
// There are 96 CSS pixels per inch.
|
||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||
paperHeight = cssContentSize.Height / 96
|
||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||
}
|
||||
|
||||
printToPdf := page.PrintToPDF().
|
||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||
WithLandscape(options.Landscape).
|
||||
WithPrintBackground(options.PrintBackground).
|
||||
WithScale(options.Scale).
|
||||
WithPaperWidth(options.PaperWidth).
|
||||
WithPaperHeight(options.PaperHeight).
|
||||
WithPaperHeight(paperHeight).
|
||||
WithMarginTop(options.MarginTop).
|
||||
WithMarginBottom(options.MarginBottom).
|
||||
WithMarginLeft(options.MarginLeft).
|
||||
WithMarginRight(options.MarginRight).
|
||||
WithPageRanges(options.PageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize)
|
||||
WithPageRanges(pageRanges).
|
||||
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||
// Does not seem to work.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/831.
|
||||
WithGenerateTaggedPDF(false)
|
||||
|
||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||
@@ -100,6 +121,14 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
|
||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||
|
||||
if options.Clip {
|
||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||
Width: float64(options.Width),
|
||||
Height: float64(options.Height),
|
||||
Scale: 1,
|
||||
})
|
||||
}
|
||||
|
||||
if options.Format == "jpeg" {
|
||||
captureScreenshot = captureScreenshot.
|
||||
WithQuality(int64(options.Quality))
|
||||
@@ -133,6 +162,19 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
|
||||
}
|
||||
}
|
||||
|
||||
func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
logger.Debug("set device metrics override")
|
||||
|
||||
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set device metrics override: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
// See https://github.com/gotenberg/gotenberg/issues/753.
|
||||
@@ -190,6 +232,72 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti
|
||||
}
|
||||
}
|
||||
|
||||
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(cookies) == 0 {
|
||||
logger.Debug("no cookies to set")
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("context has no deadline, cannot set cookies")
|
||||
}
|
||||
epochTime := cdp.TimeSinceEpoch(deadline)
|
||||
|
||||
cookiePretty := func(c *network.SetCookieParams) string {
|
||||
return fmt.Sprintf(
|
||||
"Name: '%s', Value: '%s', Domain: '%s', Path: '%s', Secure: %t, HTTPOnly: %t, SameSite: '%s', Expires: %s",
|
||||
c.Name,
|
||||
c.Value,
|
||||
c.Domain,
|
||||
c.Path,
|
||||
c.Secure,
|
||||
c.HTTPOnly,
|
||||
c.SameSite.String(),
|
||||
c.Expires.Time().String(),
|
||||
)
|
||||
}
|
||||
|
||||
for _, cookie := range cookies {
|
||||
cookieParams := network.
|
||||
SetCookie(cookie.Name, cookie.Value).
|
||||
WithDomain(cookie.Domain).
|
||||
WithPath(cookie.Path).
|
||||
WithSecure(cookie.Secure).
|
||||
WithHTTPOnly(cookie.HttpOnly).
|
||||
WithSameSite(cookie.SameSite).
|
||||
WithExpires(&epochTime)
|
||||
|
||||
err := cookieParams.Do(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(userAgent) == 0 {
|
||||
logger.Debug("no user agent override")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
|
||||
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set user agent override: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(extraHttpHeaders) == 0 {
|
||||
|
||||
11
pkg/modules/exiftool/doc.go
Normal file
11
pkg/modules/exiftool/doc.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the ExifTool command-line tool. This package allows for:
|
||||
//
|
||||
// 1. The reading of metadata.
|
||||
// 2. The writing of metadata.
|
||||
//
|
||||
// The path to the exiftool binary must be specified using the
|
||||
// EXIFTOOL_BIN_PATH environment variable.
|
||||
//
|
||||
// See: https://exiftool.org.
|
||||
package exiftool
|
||||
143
pkg/modules/exiftool/exiftool.go
Normal file
143
pkg/modules/exiftool/exiftool.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/barasher/go-exiftool"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gotenberg.MustRegisterModule(new(ExifTool))
|
||||
}
|
||||
|
||||
// ExifTool abstracts the CLI tool ExifTool and implements the
|
||||
// [gotenberg.PdfEngine] interface.
|
||||
type ExifTool struct {
|
||||
binPath string
|
||||
}
|
||||
|
||||
// Descriptor returns [ExifTool]'s module descriptor.
|
||||
func (engine *ExifTool) Descriptor() gotenberg.ModuleDescriptor {
|
||||
return gotenberg.ModuleDescriptor{
|
||||
ID: "exiftool",
|
||||
New: func() gotenberg.Module { return new(ExifTool) },
|
||||
}
|
||||
}
|
||||
|
||||
// Provision sets the module properties.
|
||||
func (engine *ExifTool) Provision(ctx *gotenberg.Context) error {
|
||||
binPath, ok := os.LookupEnv("EXIFTOOL_BIN_PATH")
|
||||
if !ok {
|
||||
return errors.New("EXIFTOOL_BIN_PATH environment variable is not set")
|
||||
}
|
||||
|
||||
engine.binPath = binPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate validates the module properties.
|
||||
func (engine *ExifTool) Validate() error {
|
||||
_, err := os.Stat(engine.binPath)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("ExifTool binary path does not exist: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge is not available in this implementation.
|
||||
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Convert is not available in this implementation.
|
||||
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new ExifTool: %w", err)
|
||||
}
|
||||
|
||||
defer func(exifTool *exiftool.Exiftool) {
|
||||
err := exifTool.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||
}
|
||||
}(exifTool)
|
||||
|
||||
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
return fileMetadata[0].Fields, nil
|
||||
}
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new ExifTool: %w", err)
|
||||
}
|
||||
|
||||
defer func(exifTool *exiftool.Exiftool) {
|
||||
err := exifTool.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||
}
|
||||
}(exifTool)
|
||||
|
||||
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
for key, value := range metadata {
|
||||
switch val := value.(type) {
|
||||
case string:
|
||||
fileMetadata[0].SetString(key, val)
|
||||
case []string:
|
||||
fileMetadata[0].SetStrings(key, val)
|
||||
case bool:
|
||||
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
|
||||
case int:
|
||||
fileMetadata[0].SetInt(key, int64(val))
|
||||
case int64:
|
||||
fileMetadata[0].SetInt(key, val)
|
||||
case float32:
|
||||
fileMetadata[0].SetFloat(key, float64(val))
|
||||
case float64:
|
||||
fileMetadata[0].SetFloat(key, val)
|
||||
// TODO: support more complex cases, e.g., arrays and nested objects
|
||||
// (limitations in underlying library).
|
||||
default:
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %w", gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||
}
|
||||
}
|
||||
|
||||
exifTool.WriteMetadata(fileMetadata)
|
||||
if fileMetadata[0].Err != nil {
|
||||
return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*ExifTool)(nil)
|
||||
_ gotenberg.Provisioner = (*ExifTool)(nil)
|
||||
_ gotenberg.Validator = (*ExifTool)(nil)
|
||||
_ gotenberg.PdfEngine = (*ExifTool)(nil)
|
||||
)
|
||||
322
pkg/modules/exiftool/exiftool_test.go
Normal file
322
pkg/modules/exiftool/exiftool_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package exiftool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
)
|
||||
|
||||
func TestExifTool_Descriptor(t *testing.T) {
|
||||
descriptor := new(ExifTool).Descriptor()
|
||||
|
||||
actual := reflect.TypeOf(descriptor.New())
|
||||
expect := reflect.TypeOf(new(ExifTool))
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExifTool_Provision(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
|
||||
|
||||
err := engine.Provision(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExifTool_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("EXIFTOOL_BIN_PATH"),
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
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 TestExiftool_Merge(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftool_Convert(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftool_ReadMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
inputPath string
|
||||
expectMetadata map[string]interface{}
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid input path",
|
||||
inputPath: "foo",
|
||||
expectMetadata: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
expectMetadata: map[string]interface{}{
|
||||
"FileName": "sample1.pdf",
|
||||
"FileTypeExtension": "pdf",
|
||||
"MIMEType": "application/pdf",
|
||||
"PDFVersion": 1.4,
|
||||
"PageCount": float64(3),
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Provision(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected error but got: %v", err)
|
||||
}
|
||||
|
||||
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), tc.inputPath)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if tc.expectMetadata != nil && err == nil {
|
||||
for k, v := range tc.expectMetadata {
|
||||
if v2, ok := metadata[k]; !ok || v != v2 {
|
||||
t.Errorf("expected entry %s with value %v to exists", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExiftool_WriteMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
createCopy bool
|
||||
inputPath string
|
||||
metadata map[string]interface{}
|
||||
expectMetadata map[string]interface{}
|
||||
expectError bool
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
scenario: "invalid input path",
|
||||
createCopy: false,
|
||||
inputPath: "foo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported",
|
||||
createCopy: true,
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
metadata: map[string]interface{}{
|
||||
"Unsupported": map[string]interface{}{},
|
||||
},
|
||||
expectError: true,
|
||||
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
createCopy: true,
|
||||
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||
metadata: map[string]interface{}{
|
||||
"Author": "Julien Neuhart",
|
||||
"Copyright": "Julien Neuhart",
|
||||
"CreationDate": "2006-09-18T16:27:50-04:00",
|
||||
"Creator": "Gotenberg",
|
||||
"Keywords": []string{
|
||||
"first",
|
||||
"second",
|
||||
},
|
||||
"Marked": true,
|
||||
"ModDate": "2006-09-18T16:27:50-04:00",
|
||||
"PDFVersion": 1.7,
|
||||
"Producer": "Gotenberg",
|
||||
"Subject": "Sample",
|
||||
"Title": "Sample",
|
||||
"Trapped": "Unknown",
|
||||
// Those are not valid PDF metadata.
|
||||
"int": 1,
|
||||
"int64": int64(2),
|
||||
"float32": float32(2.2),
|
||||
"float64": 3.3,
|
||||
},
|
||||
expectMetadata: map[string]interface{}{
|
||||
"Author": "Julien Neuhart",
|
||||
"Copyright": "Julien Neuhart",
|
||||
"CreationDate": "2006:09:18 16:27:50-04:00",
|
||||
"Creator": "Gotenberg",
|
||||
"Keywords": []interface{}{
|
||||
"first",
|
||||
"second",
|
||||
},
|
||||
"Marked": true,
|
||||
"ModDate": "2006:09:18 16:27:50-04:00",
|
||||
"PDFVersion": 1.7,
|
||||
"Producer": "Gotenberg",
|
||||
"Subject": "Sample",
|
||||
"Title": "Sample",
|
||||
"Trapped": "Unknown",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
engine := new(ExifTool)
|
||||
err := engine.Provision(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
var destinationPath string
|
||||
if tc.createCopy {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
outputDir, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
t.Fatalf("expected error no but got: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = os.RemoveAll(fs.WorkingDirPath())
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
|
||||
source, err := os.Open(tc.inputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open source file: %v", err)
|
||||
}
|
||||
|
||||
defer func(source *os.File) {
|
||||
err := source.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
}(source)
|
||||
|
||||
destination, err := os.Create(destinationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create destination file: %v", err)
|
||||
}
|
||||
|
||||
defer func(destination *os.File) {
|
||||
err := destination.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
}(destination)
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
if err != nil {
|
||||
t.Fatalf("copy source into destination: %v", err)
|
||||
}
|
||||
} else {
|
||||
destinationPath = tc.inputPath
|
||||
}
|
||||
|
||||
err = engine.WriteMetadata(context.Background(), zap.NewNop(), tc.metadata, destinationPath)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
|
||||
if tc.expectError {
|
||||
return
|
||||
}
|
||||
|
||||
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectMetadata != nil && err == nil {
|
||||
for k, v := range tc.expectMetadata {
|
||||
v2, ok := metadata[k]
|
||||
if !ok {
|
||||
t.Errorf("expected entry %s with value %v to exists, but got none", k, v)
|
||||
continue
|
||||
}
|
||||
|
||||
switch v2.(type) {
|
||||
case []interface{}:
|
||||
for i, entry := range v.([]interface{}) {
|
||||
if entry != v2.([]interface{})[i] {
|
||||
t.Errorf("expected entry %s to contain value %v, but got %v", k, entry, v2.([]interface{})[i])
|
||||
}
|
||||
}
|
||||
default:
|
||||
if v != v2 {
|
||||
t.Errorf("expected entry %s with value %v to exists, but got %v", k, v, v2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,25 @@ type Options struct {
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// ExportFormFields allows to... export form fields in the resulting PDF.
|
||||
// Optional.
|
||||
ExportFormFields bool
|
||||
|
||||
// SinglePageSheets allows to output each sheet as a single page in the
|
||||
// resulting PDF.
|
||||
// Optional
|
||||
SinglePageSheets bool
|
||||
|
||||
// LosslessImageCompression allows turning lossless compression on or off
|
||||
// to tweak image conversion performance.
|
||||
// Optional
|
||||
LosslessImageCompression bool
|
||||
|
||||
// ReduceImageResolution allows turning on or off image resolution
|
||||
// reduction to tweak image conversion performance.
|
||||
// Optional
|
||||
ReduceImageResolution bool
|
||||
|
||||
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
|
||||
// PDF/A-3b and PDF/UA.
|
||||
// Optional.
|
||||
@@ -80,6 +99,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
FlagSet: func() *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
|
||||
fs.Int64("libreoffice-max-queue-size", 0, "Maximum request queue size for LibreOffice. Set to 0 to disable this feature")
|
||||
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
||||
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
||||
|
||||
@@ -123,7 +143,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
|
||||
// Process.
|
||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"))
|
||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -265,85 +285,136 @@ func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath
|
||||
// FIXME: don't care, take all on the route level?
|
||||
func (a *Api) Extensions() []string {
|
||||
return []string{
|
||||
".123",
|
||||
".602",
|
||||
".abw",
|
||||
".bib",
|
||||
".doc",
|
||||
".xml",
|
||||
".docx",
|
||||
".fodt",
|
||||
".html",
|
||||
".ltx",
|
||||
".txt",
|
||||
".odt",
|
||||
".ott",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".psw",
|
||||
".rtf",
|
||||
".sdw",
|
||||
".stw",
|
||||
".sxw",
|
||||
".uot",
|
||||
".vor",
|
||||
".wps",
|
||||
".epub",
|
||||
".png",
|
||||
".bmp",
|
||||
".cdr",
|
||||
".cgm",
|
||||
".cmx",
|
||||
".csv",
|
||||
".cwk",
|
||||
".dbf",
|
||||
".dif",
|
||||
".doc",
|
||||
".docm",
|
||||
".docx",
|
||||
".dot",
|
||||
".dotm",
|
||||
".dotx",
|
||||
".dxf",
|
||||
".emf",
|
||||
".eps",
|
||||
".epub",
|
||||
".fodg",
|
||||
".fodp",
|
||||
".fods",
|
||||
".fodt",
|
||||
".fopd",
|
||||
".gif",
|
||||
".jpg",
|
||||
".htm",
|
||||
".html",
|
||||
".hwp",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".key",
|
||||
".ltx",
|
||||
".lwp",
|
||||
".mcw",
|
||||
".met",
|
||||
".mml",
|
||||
".mw",
|
||||
".numbers",
|
||||
".odd",
|
||||
".odg",
|
||||
".odm",
|
||||
".odp",
|
||||
".ods",
|
||||
".odt",
|
||||
".otg",
|
||||
".oth",
|
||||
".otp",
|
||||
".ots",
|
||||
".ott",
|
||||
".pages",
|
||||
".pbm",
|
||||
".pcd",
|
||||
".pct",
|
||||
".pcx",
|
||||
".pdb",
|
||||
".pdf",
|
||||
".pgm",
|
||||
".png",
|
||||
".pot",
|
||||
".potm",
|
||||
".potx",
|
||||
".ppm",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pptm",
|
||||
".pptx",
|
||||
".psd",
|
||||
".psw",
|
||||
".pub",
|
||||
".pwp",
|
||||
".pxl",
|
||||
".ras",
|
||||
".rtf",
|
||||
".sda",
|
||||
".sdc",
|
||||
".sdd",
|
||||
".sdp",
|
||||
".sdw",
|
||||
".sgl",
|
||||
".slk",
|
||||
".smf",
|
||||
".stc",
|
||||
".std",
|
||||
".sti",
|
||||
".stw",
|
||||
".svg",
|
||||
".svm",
|
||||
".swf",
|
||||
".sxc",
|
||||
".sxd",
|
||||
".sxg",
|
||||
".sxi",
|
||||
".sxm",
|
||||
".sxw",
|
||||
".tga",
|
||||
".tif",
|
||||
".tiff",
|
||||
".xhtml",
|
||||
".xpm",
|
||||
".odp",
|
||||
".fodp",
|
||||
".potm",
|
||||
".pot",
|
||||
".pptx",
|
||||
".pps",
|
||||
".ppt",
|
||||
".pwp",
|
||||
".sda",
|
||||
".sdd",
|
||||
".sti",
|
||||
".sxi",
|
||||
".txt",
|
||||
".uof",
|
||||
".uop",
|
||||
".wmf",
|
||||
".csv",
|
||||
".dbf",
|
||||
".dif",
|
||||
".fods",
|
||||
".ods",
|
||||
".ots",
|
||||
".pxl",
|
||||
".sdc",
|
||||
".slk",
|
||||
".stc",
|
||||
".sxc",
|
||||
".uos",
|
||||
".uot",
|
||||
".vdx",
|
||||
".vor",
|
||||
".vsd",
|
||||
".vsdm",
|
||||
".vsdx",
|
||||
".wb2",
|
||||
".wk1",
|
||||
".wks",
|
||||
".wmf",
|
||||
".wpd",
|
||||
".wpg",
|
||||
".wps",
|
||||
".xbm",
|
||||
".xhtml",
|
||||
".xls",
|
||||
".xlt",
|
||||
".xlsb",
|
||||
".xlsm",
|
||||
".xlsx",
|
||||
".odg",
|
||||
".dotx",
|
||||
".xlt",
|
||||
".xltm",
|
||||
".xltx",
|
||||
".xlw",
|
||||
".xml",
|
||||
".xpm",
|
||||
".zabw",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -450,7 +450,7 @@ func TestApi_Extensions(t *testing.T) {
|
||||
extensions := a.Extensions()
|
||||
|
||||
actual := len(extensions)
|
||||
expect := 79
|
||||
expect := 130
|
||||
|
||||
if actual != expect {
|
||||
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
||||
|
||||
@@ -273,6 +273,22 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
if !options.ExportFormFields {
|
||||
args = append(args, "--export", "ExportFormFields=false")
|
||||
}
|
||||
|
||||
if options.SinglePageSheets {
|
||||
args = append(args, "--export", "SinglePageSheets=true")
|
||||
}
|
||||
|
||||
if options.LosslessImageCompression {
|
||||
args = append(args, "--export", "UseLosslessCompression=true")
|
||||
}
|
||||
|
||||
if !options.ReduceImageResolution {
|
||||
args = append(args, "--export", "ReduceImageResolution=false")
|
||||
}
|
||||
|
||||
switch options.PdfFormats.PdfA {
|
||||
case "":
|
||||
case gotenberg.PdfA1b:
|
||||
@@ -288,8 +304,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
if options.PdfFormats.PdfUa {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "EnableTextAccessForAccessibilityTools=true",
|
||||
"--export", "UseTaggedPDF=true",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=true",
|
||||
)
|
||||
} else {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "UseTaggedPDF=false",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=false",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -364,6 +364,64 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (disable form fields)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
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/document.txt", fs.WorkingDirPath()), []byte("DisableFormFields"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{ExportFormFields: false},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (single page sheets)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
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/document.txt", fs.WorkingDirPath()), []byte("SinglePageSheets"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{SinglePageSheets: true},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (page ranges)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
@@ -393,6 +451,64 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success LosslessImageCompression",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
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/document.txt", fs.WorkingDirPath()), []byte("LosslessImageCompression"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{LosslessImageCompression: true},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success ReduceImageResolution",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
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/document.txt", fs.WorkingDirPath()), []byte("ReduceImageResolution"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{ReduceImageResolution: false},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (PDF/A-1b)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Package pdfengine provides a module which interacts with LibreOffice via the
|
||||
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
|
||||
// interface.
|
||||
// interface. This package allows for:
|
||||
//
|
||||
// 1. The conversion to specific PDF formats.
|
||||
package pdfengine
|
||||
|
||||
@@ -71,6 +71,16 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log
|
||||
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err)
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// WriteMetadata is not available in this implementation.
|
||||
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||
|
||||
@@ -166,3 +166,21 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
|
||||
engine := new(LibreOfficePdfEngine)
|
||||
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package libreoffice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -24,23 +25,41 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
|
||||
// Let's get the data from the form and validate them.
|
||||
var (
|
||||
inputPaths []string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
pdfa string
|
||||
pdfua bool
|
||||
nativePdfFormats bool
|
||||
merge bool
|
||||
inputPaths []string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
exportFormFields bool
|
||||
singlePageSheets bool
|
||||
losslessImageCompression bool
|
||||
reduceImageResolution bool
|
||||
pdfa string
|
||||
pdfua bool
|
||||
nativePdfFormats bool
|
||||
merge bool
|
||||
metadata map[string]interface{}
|
||||
)
|
||||
|
||||
err := ctx.FormData().
|
||||
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
|
||||
Bool("landscape", &landscape, false).
|
||||
String("nativePageRanges", &nativePageRanges, "").
|
||||
Bool("exportFormFields", &exportFormFields, true).
|
||||
Bool("singlePageSheets", &singlePageSheets, false).
|
||||
Bool("losslessImageCompression", &losslessImageCompression, false).
|
||||
Bool("reduceImageResolution", &reduceImageResolution, true).
|
||||
String("pdfa", &pdfa, "").
|
||||
Bool("pdfua", &pdfua, false).
|
||||
Bool("nativePdfFormats", &nativePdfFormats, true).
|
||||
Bool("merge", &merge, false).
|
||||
Custom("metadata", func(value string) error {
|
||||
if len(value) > 0 {
|
||||
err := json.Unmarshal([]byte(value), &metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
@@ -55,10 +74,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
outputPaths := make([]string, len(inputPaths))
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
|
||||
options := libreofficeapi.Options{
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
ExportFormFields: exportFormFields,
|
||||
SinglePageSheets: singlePageSheets,
|
||||
LosslessImageCompression: losslessImageCompression,
|
||||
ReduceImageResolution: reduceImageResolution,
|
||||
}
|
||||
|
||||
if nativePdfFormats {
|
||||
@@ -88,9 +110,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
}
|
||||
}
|
||||
|
||||
// So far so good, let's check if we have to merge the PDFs. Quick
|
||||
// win: if there is only one PDF, skip this step.
|
||||
|
||||
// So far so good, let's check if we have to merge the PDFs.
|
||||
if len(outputPaths) > 1 && merge {
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
@@ -99,45 +119,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
return fmt.Errorf("merge PDFs: %w", err)
|
||||
}
|
||||
|
||||
// Now, let's check if the client want to convert this result
|
||||
// PDF to specific PDF formats.
|
||||
zeroValued := gotenberg.PdfFormats{}
|
||||
if !nativePdfFormats && pdfFormats != zeroValued {
|
||||
convertInputPath := outputPath
|
||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
// Important: the output path is now the converted file.
|
||||
outputPath = convertOutputPath
|
||||
}
|
||||
|
||||
// Last but not least, add the output path to the context so that
|
||||
// the Uno is able to send it as a response to the client.
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
// Only one output path.
|
||||
outputPaths = []string{outputPath}
|
||||
}
|
||||
|
||||
// Ok, we don't have to merge the PDFs. Let's check if the client
|
||||
// want to convert each PDF to a specific PDF format.
|
||||
// Let's check if the client want to convert each PDF to a specific
|
||||
// PDF format.
|
||||
zeroValued := gotenberg.PdfFormats{}
|
||||
if !nativePdfFormats && pdfFormats != zeroValued {
|
||||
convertOutputPaths := make([]string, len(outputPaths))
|
||||
@@ -148,28 +135,40 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Important: the output paths are now the converted files.
|
||||
outputPaths = convertOutputPaths
|
||||
}
|
||||
|
||||
// Last but not least, add the output paths to the context so that
|
||||
// the Uno is able to send them as a response to the client.
|
||||
// Writes and potentially overrides metadata entries, if any.
|
||||
if len(metadata) > 0 {
|
||||
for _, outputPath := range outputPaths {
|
||||
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(outputPaths) > 1 {
|
||||
// If .zip archive, document.docx -> document.docx.pdf.
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPath := fmt.Sprintf("%s.pdf", inputPath)
|
||||
|
||||
err = ctx.Rename(outputPaths[i], outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename output path: %w", err)
|
||||
}
|
||||
|
||||
outputPaths[i] = outputPath
|
||||
}
|
||||
}
|
||||
|
||||
// Last but not least, add the output paths to the context so that
|
||||
// the API is able to send them as a response to the client.
|
||||
err = ctx.AddOutputPaths(outputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -25,6 +26,7 @@ func TestConvertRoute(t *testing.T) {
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
expectOutputPathsCount int
|
||||
expectOutputPaths []string
|
||||
}{
|
||||
{
|
||||
scenario: "missing at least one mandatory file",
|
||||
@@ -37,6 +39,28 @@ func TestConvertRoute(t *testing.T) {
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid metadata form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
}},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported (nativePdfFormats)",
|
||||
ctx: func() *api.ContextMock {
|
||||
@@ -108,18 +132,16 @@ func TestConvertRoute(t *testing.T) {
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported (single file)",
|
||||
scenario: "PDF engine merge error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
"foo",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
@@ -133,17 +155,16 @@ func TestConvertRoute(t *testing.T) {
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPdfFormatNotSupported
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine convert error (single file)",
|
||||
scenario: "PDF engine convert error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
@@ -177,7 +198,64 @@ func TestConvertRoute(t *testing.T) {
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths (single file)",
|
||||
scenario: "PDF engine write metadata error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot rename many files",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
"document2.doc": "/document2.doc",
|
||||
})
|
||||
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return errors.New("cannot rename")
|
||||
}})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx", ".doc"}
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
@@ -203,7 +281,25 @@ func TestConvertRoute(t *testing.T) {
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
@@ -215,6 +311,17 @@ func TestConvertRoute(t *testing.T) {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
@@ -226,27 +333,7 @@ func TestConvertRoute(t *testing.T) {
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 2,
|
||||
},
|
||||
{
|
||||
scenario: "success with non-native PDF/A & PDF/UA (single file)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.doc": "/document2.doc",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
@@ -258,7 +345,13 @@ func TestConvertRoute(t *testing.T) {
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return nil
|
||||
}})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
@@ -266,20 +359,27 @@ func TestConvertRoute(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
return []string{".docx", ".doc"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
expectOutputPathsCount: 3,
|
||||
expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"},
|
||||
},
|
||||
{
|
||||
scenario: "success with native PDF/A & PDF/UA (single file)",
|
||||
scenario: "success with native PDF/A & PDF/UA",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
@@ -303,279 +403,6 @@ func TestConvertRoute(t *testing.T) {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "merge error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
"foo",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPdfFormatNotSupported
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine convert error (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
ctx.SetCancelled(true)
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "success (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "success with non-native PDF/A & PDF/UA (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "success with non-native PDF/A & PDF/UA (merge)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
"nativePdfFormats": {
|
||||
"false",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return nil
|
||||
},
|
||||
ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
},
|
||||
},
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
@@ -617,6 +444,12 @@ func TestConvertRoute(t *testing.T) {
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
|
||||
for _, path := range tc.expectOutputPaths {
|
||||
if !slices.Contains(tc.ctx.OutputPaths(), path) {
|
||||
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func newLogEncoder(format string) (zapcore.Encoder, error) {
|
||||
// If interactive terminal, make output more human-readable by default.
|
||||
// Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671.
|
||||
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
|
||||
encoder.AppendString(ts.UTC().Format("2006/01/02 15:04:05.000"))
|
||||
encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000"))
|
||||
}
|
||||
|
||||
if format == textLoggingFormat || format == autoLoggingFormat {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the pdfcpu library. This package allows for the merging of
|
||||
// PDF files but does not support conversion to specific PDF formats.
|
||||
// interface using the pdfcpu library. This package allows for:
|
||||
//
|
||||
// 1. The merging of PDF files.
|
||||
//
|
||||
// See: https://github.com/pdfcpu/pdfcpu.
|
||||
package pdfcpu
|
||||
|
||||
@@ -46,12 +46,22 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("merge PDFs with PDFcpu: %w", err)
|
||||
return fmt.Errorf("merge PDFs with pdfcpu: %w", err)
|
||||
}
|
||||
|
||||
// Convert is not available in this implementation.
|
||||
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return fmt.Errorf("convert PDF to '%+v' with PDFcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// WriteMetadata is not available in this implementation.
|
||||
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
|
||||
@@ -102,3 +102,21 @@ func TestPdfCpu_Convert(t *testing.T) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
|
||||
engine := new(PdfCpu)
|
||||
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
|
||||
engine := new(PdfCpu)
|
||||
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package pdfengines
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
"go.uber.org/zap"
|
||||
@@ -70,6 +71,65 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
|
||||
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
|
||||
}
|
||||
|
||||
type readMetadataResult struct {
|
||||
metadata map[string]interface{}
|
||||
err error
|
||||
}
|
||||
|
||||
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
var err error
|
||||
var mu sync.Mutex // to safely append errors.
|
||||
|
||||
resultChan := make(chan readMetadataResult, len(multi.engines))
|
||||
|
||||
for _, engine := range multi.engines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
|
||||
resultChan <- readMetadataResult{metadata: metadata, err: err}
|
||||
}(engine)
|
||||
}
|
||||
|
||||
for range multi.engines {
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
mu.Lock()
|
||||
err = multierr.Append(err, result.err)
|
||||
mu.Unlock()
|
||||
} else {
|
||||
return result.metadata, nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
|
||||
}
|
||||
|
||||
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
var err error
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
for _, engine := range multi.engines {
|
||||
go func(engine gotenberg.PdfEngine) {
|
||||
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
|
||||
}(engine)
|
||||
|
||||
select {
|
||||
case writeMetadataErr := <-errChan:
|
||||
errored := multierr.AppendInto(&err, writeMetadataErr)
|
||||
if !errored {
|
||||
return nil
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
|
||||
|
||||
@@ -177,3 +177,169 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
engine *multiPdfEngines
|
||||
ctx context.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return make(map[string]interface{}), nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
scenario: "at least one engine does not return an error",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
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 make(map[string]interface{}), nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
scenario: "all engines return an error",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
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")
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "context expired",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return make(map[string]interface{}), nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
_, err := tc.engine.ReadMetadata(tc.ctx, zap.NewNop(), "")
|
||||
|
||||
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 TestMultiPdfEngines_WriteMetadata(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
engine *multiPdfEngines
|
||||
ctx context.Context
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "nominal behavior",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
scenario: "at least one engine does not return an error",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
&gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
},
|
||||
{
|
||||
scenario: "all engines return an error",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
&gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: context.Background(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "context expired",
|
||||
engine: newMultiPdfEngines(
|
||||
&gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx: func() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
return ctx
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
err := tc.engine.WriteMetadata(tc.ctx, zap.NewNop(), nil, "")
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
||||
return []api.Route{
|
||||
mergeRoute(engine),
|
||||
convertRoute(engine),
|
||||
readMetadataRoute(engine),
|
||||
writeMetadataRoute(engine),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestPdfEngines_Routes(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
scenario: "routes not disabled",
|
||||
expectRoutes: 2,
|
||||
expectRoutes: 4,
|
||||
disableRoutes: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package pdfengines
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
@@ -25,12 +27,22 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
inputPaths []string
|
||||
pdfa string
|
||||
pdfua bool
|
||||
metadata map[string]interface{}
|
||||
)
|
||||
|
||||
err := ctx.FormData().
|
||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||
String("pdfa", &pdfa, "").
|
||||
Bool("pdfua", &pdfua, false).
|
||||
Custom("metadata", func(value string) error {
|
||||
if len(value) > 0 {
|
||||
err := json.Unmarshal([]byte(value), &metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
@@ -42,7 +54,6 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
|
||||
// Alright, let's merge the PDFs.
|
||||
|
||||
outputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
|
||||
@@ -59,18 +70,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
@@ -78,9 +78,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
outputPath = convertOutputPath
|
||||
}
|
||||
|
||||
// Writes and potentially overrides metadata entries, if any.
|
||||
if len(metadata) > 0 {
|
||||
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Last but not least, add the output path to the context so that
|
||||
// the API is able to send it as a response to the client.
|
||||
|
||||
err = ctx.AddOutputPaths(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output path: %w", err)
|
||||
@@ -91,8 +98,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
}
|
||||
}
|
||||
|
||||
// convertRoute returns an [api.Route] which can convert a PDF to a specific
|
||||
// PDF format.
|
||||
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
|
||||
// format.
|
||||
func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
@@ -135,30 +142,27 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
|
||||
// Alright, let's convert the PDFs.
|
||||
outputPaths := make([]string, len(inputPaths))
|
||||
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
|
||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, inputPath, outputPaths[i])
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("convert PDF: %w", err),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
||||
),
|
||||
)
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
}
|
||||
|
||||
if len(outputPaths) > 1 {
|
||||
// If .zip archive, keep the original filename.
|
||||
err = ctx.Rename(outputPaths[i], inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename output path: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("convert PDF: %w", err)
|
||||
outputPaths[i] = inputPath
|
||||
}
|
||||
}
|
||||
|
||||
// Last but not least, add the output paths to the context so that
|
||||
// the API is able to send them as a response to the client.
|
||||
|
||||
err = ctx.AddOutputPaths(outputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
@@ -168,3 +172,98 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// readMetadataRoute returns an [api.Route] which returns the metadata of PDFs.
|
||||
func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/metadata/read",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
// Let's get the data from the form and validate them.
|
||||
var inputPaths []string
|
||||
|
||||
err := ctx.FormData().
|
||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
// Alright, let's read the metadata.
|
||||
res := make(map[string]map[string]interface{}, len(inputPaths))
|
||||
for _, inputPath := range inputPaths {
|
||||
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read metadata: %w", err)
|
||||
}
|
||||
|
||||
res[filepath.Base(inputPath)] = metadata
|
||||
}
|
||||
|
||||
err = c.JSON(http.StatusOK, res)
|
||||
if err != nil {
|
||||
return fmt.Errorf("return JSON response: %w", err)
|
||||
}
|
||||
|
||||
return api.ErrNoOutputFile
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// writeMetadataRoute returns an [api.Route] which can write metadata into
|
||||
// PDFs.
|
||||
func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
|
||||
return api.Route{
|
||||
Method: http.MethodPost,
|
||||
Path: "/forms/pdfengines/metadata/write",
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
|
||||
// Let's get the data from the form and validate them.
|
||||
var (
|
||||
inputPaths []string
|
||||
metadata map[string]interface{}
|
||||
)
|
||||
|
||||
err := ctx.FormData().
|
||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||
MandatoryCustom("metadata", func(value string) error {
|
||||
if len(value) > 0 {
|
||||
err := json.Unmarshal([]byte(value), &metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
return errors.New("no metadata")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate form data: %w", err)
|
||||
}
|
||||
|
||||
// Alright, let's convert the PDFs.
|
||||
for _, inputPath := range inputPaths {
|
||||
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Last but not least, add the output paths to the context so that
|
||||
// the API is able to send them as a response to the client.
|
||||
err = ctx.AddOutputPaths(inputPaths...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add output paths: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -32,7 +35,27 @@ func TestMergeHandler(t *testing.T) {
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from PDF engine",
|
||||
scenario: "invalid metadata form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
"file2.pdf": "/file2.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine merge error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
@@ -50,6 +73,60 @@ func TestMergeHandler(t *testing.T) {
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine convert error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
"file2.pdf": "/file2.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "PDF engine write metadata error",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
"file2.pdf": "/file2.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths",
|
||||
ctx: func() *api.ContextMock {
|
||||
@@ -72,25 +149,6 @@ func TestMergeHandler(t *testing.T) {
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
"file2.pdf": "/file2.pdf",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
@@ -101,6 +159,9 @@ func TestMergeHandler(t *testing.T) {
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
@@ -109,64 +170,9 @@ func TestMergeHandler(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPdfFormatNotSupported
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from PDF engine (convert)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
"file2.pdf": "/file2.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "success with PDF/A & PDF/UA form fields",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
"file2.pdf": "/file2.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -224,6 +230,7 @@ func TestConvertHandler(t *testing.T) {
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
expectOutputPathsCount int
|
||||
expectOutputPaths []string
|
||||
}{
|
||||
{
|
||||
scenario: "missing at least one mandatory file",
|
||||
@@ -247,30 +254,6 @@ func TestConvertHandler(t *testing.T) {
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrPdfFormatNotSupported",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return gotenberg.ErrPdfFormatNotSupported
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from PDF engine",
|
||||
ctx: func() *api.ContextMock {
|
||||
@@ -344,6 +327,36 @@ func TestConvertHandler(t *testing.T) {
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
{
|
||||
scenario: "cannot rename many files",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
"file2.pdf": "/file2.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"pdfa": {
|
||||
gotenberg.PdfA1b,
|
||||
},
|
||||
"pdfua": {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return errors.New("cannot rename")
|
||||
}})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "success with PDF/A & PDF/UA form fields (many files)",
|
||||
ctx: func() *api.ContextMock {
|
||||
@@ -360,6 +373,9 @@ func TestConvertHandler(t *testing.T) {
|
||||
"true",
|
||||
},
|
||||
})
|
||||
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||
return nil
|
||||
}})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
@@ -370,6 +386,7 @@ func TestConvertHandler(t *testing.T) {
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 2,
|
||||
expectOutputPaths: []string{"/file.pdf", "/file2.pdf"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
@@ -408,6 +425,304 @@ func TestConvertHandler(t *testing.T) {
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
|
||||
for _, path := range tc.expectOutputPaths {
|
||||
if !slices.Contains(tc.ctx.OutputPaths(), path) {
|
||||
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetadataHandler(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *api.ContextMock
|
||||
engine gotenberg.PdfEngine
|
||||
expectError bool
|
||||
expectedError error
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
expectedJson string
|
||||
}{
|
||||
{
|
||||
scenario: "missing at least one mandatory file",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "error from PDF engine",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"foo": "bar",
|
||||
"bar": "foo",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectedError: api.ErrNoOutputFile,
|
||||
expectHttpError: false,
|
||||
expectedJson: `{"file.pdf":{"bar":"foo","foo":"bar"}}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
tc.ctx.SetLogger(zap.NewNop())
|
||||
req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/metadata/read", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(req, rec)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
|
||||
err := readMetadataRoute(tc.engine).Handler(c)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
var httpErr api.HttpError
|
||||
isHttpError := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHttpError && !isHttpError {
|
||||
t.Errorf("expected an HTTP error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectHttpError && isHttpError {
|
||||
t.Errorf("expected no HTTP error but got one: %v", httpErr)
|
||||
}
|
||||
|
||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHttpError && isHttpError {
|
||||
status, _ := httpErr.HttpError()
|
||||
if status != tc.expectHttpStatus {
|
||||
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectedJson != "" && tc.expectedJson != strings.TrimSpace(rec.Body.String()) {
|
||||
t.Errorf("expected '%s' as HTTP response but got '%s'", tc.expectedJson, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMetadataHandler(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
ctx *api.ContextMock
|
||||
engine gotenberg.PdfEngine
|
||||
expectError bool
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
expectOutputPathsCount int
|
||||
expectOutputPaths []string
|
||||
}{
|
||||
{
|
||||
scenario: "missing at least one mandatory file",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "no metadata form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid metadata form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "no metadata",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"{}",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from PDF engine",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return errors.New("foo")
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "cannot add output paths",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
ctx.SetCancelled(true)
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"file.pdf": "/file.pdf",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"metadata": {
|
||||
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
engine: &gotenberg.PdfEngineMock{
|
||||
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
expectOutputPathsCount: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
tc.ctx.SetLogger(zap.NewNop())
|
||||
c := echo.New().NewContext(nil, nil)
|
||||
c.Set("context", tc.ctx.Context)
|
||||
|
||||
err := writeMetadataRoute(tc.engine).Handler(c)
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none", err)
|
||||
}
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
var httpErr api.HttpError
|
||||
isHttpError := errors.As(err, &httpErr)
|
||||
|
||||
if tc.expectHttpError && !isHttpError {
|
||||
t.Errorf("expected an HTTP error but got: %v", err)
|
||||
}
|
||||
|
||||
if !tc.expectHttpError && isHttpError {
|
||||
t.Errorf("expected no HTTP error but got one: %v", httpErr)
|
||||
}
|
||||
|
||||
if err != nil && tc.expectHttpError && isHttpError {
|
||||
status, _ := httpErr.HttpError()
|
||||
if status != tc.expectHttpStatus {
|
||||
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
|
||||
}
|
||||
}
|
||||
|
||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||
}
|
||||
|
||||
for _, path := range tc.expectOutputPaths {
|
||||
if !slices.Contains(tc.ctx.OutputPaths(), path) {
|
||||
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Package pdftk provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the PDFtk command-line tool. This package allows for the
|
||||
// merging of PDF files but does not support conversion to specific PDF
|
||||
// formats. The path to the PDFtk binary must be specified using the
|
||||
// PDFTK_BIN_PATH environment variable.
|
||||
// interface using the PDFtk command-line tool. This package allows for:
|
||||
//
|
||||
// 1. The merging of PDF files.
|
||||
//
|
||||
// The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH
|
||||
// environment variable.
|
||||
//
|
||||
// See: https://gitlab.com/pdftk-java/pdftk.
|
||||
package pdftk
|
||||
|
||||
@@ -75,6 +75,16 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats go
|
||||
return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// WriteMetadata is not available in this implementation.
|
||||
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ gotenberg.Module = (*PdfTk)(nil)
|
||||
|
||||
@@ -148,3 +148,21 @@ func TestPdfTk_Convert(t *testing.T) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
|
||||
engine := new(PdfTk)
|
||||
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
|
||||
engine := new(PdfTk)
|
||||
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// Package qpdf provides an implementation of the gotenberg.PdfEngine
|
||||
// interface using the QPDF command-line tool. This package allows for the
|
||||
// merging of PDF files but does not support conversion to specific PDF
|
||||
// formats. The path to the QPDF binary must be specified using the
|
||||
// QPDK_BIN_PATH environment variable.
|
||||
// interface using the QPDF command-line tool. This package allows for:
|
||||
//
|
||||
// 1. The merging of PDF files.
|
||||
//
|
||||
// The path to the QPDF binary must be specified using the QPDK_BIN_PATH
|
||||
// environment variable.
|
||||
//
|
||||
// See: https://github.com/qpdf/qpdf.
|
||||
package qpdf
|
||||
|
||||
@@ -77,6 +77,16 @@ func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats got
|
||||
return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// ReadMetadata is not available in this implementation.
|
||||
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
// WriteMetadata is not available in this implementation.
|
||||
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||
return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||
}
|
||||
|
||||
var (
|
||||
_ gotenberg.Module = (*QPdf)(nil)
|
||||
_ gotenberg.Provisioner = (*QPdf)(nil)
|
||||
|
||||
@@ -148,3 +148,21 @@ func TestQPdf_Convert(t *testing.T) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
|
||||
engine := new(QPdf)
|
||||
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
|
||||
engine := new(QPdf)
|
||||
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
|
||||
|
||||
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
)
|
||||
|
||||
@@ -44,38 +44,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
)
|
||||
}
|
||||
|
||||
// Let's check if the webhook URLs are acceptable according to our
|
||||
// allowed/denied lists.
|
||||
filter := func(URL, header string, allowList, denyList *regexp.Regexp) error {
|
||||
if !allowList.MatchString(URL) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("'%s' does not match the expression from the allowed list", URL),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusForbidden,
|
||||
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if denyList.String() != "" && denyList.MatchString(URL) {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("'%s' matches the expression from the denied list", URL),
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusForbidden,
|
||||
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
err := filter(webhookUrl, "Gotenberg-Webhook-Url", w.allowList, w.denyList)
|
||||
// Let's check if the webhook URLs are acceptable according to our
|
||||
// allowed/denied lists.
|
||||
err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter webhook URL: %w", err)
|
||||
}
|
||||
|
||||
err = filter(webhookErrorUrl, "Gotenberg-Webhook-Error-Url", w.errorAllowList, w.errorDenyList)
|
||||
err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter webhook error URL: %w", err)
|
||||
}
|
||||
@@ -178,7 +159,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
echo.HeaderContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
||||
echo.HeaderContentType: echo.MIMEApplicationJSON,
|
||||
c.Get("traceHeader").(string): c.Get("trace").(string),
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -47,10 +47,10 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
|
||||
buildWebhookModule := func() *Webhook {
|
||||
return &Webhook{
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
errorAllowList: regexp.MustCompile(""),
|
||||
errorDenyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
errorAllowList: regexp2.MustCompile("", 0),
|
||||
errorDenyList: regexp2.MustCompile("", 0),
|
||||
maxRetry: 0,
|
||||
retryMinWait: 0,
|
||||
retryMaxWait: 0,
|
||||
@@ -63,6 +63,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
request *http.Request
|
||||
mod *Webhook
|
||||
next echo.HandlerFunc
|
||||
noDeadline bool
|
||||
expectError bool
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
@@ -76,6 +77,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
}(),
|
||||
noDeadline: false,
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
},
|
||||
@@ -87,10 +89,23 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "context has no deadline",
|
||||
request: func() *http.Request {
|
||||
req := buildMultipartFormDataRequest()
|
||||
req.Header.Set("Gotenberg-Webhook-Url", "foo")
|
||||
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: true,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "webhook URL is not allowed",
|
||||
request: func() *http.Request {
|
||||
@@ -101,12 +116,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
}(),
|
||||
mod: func() *Webhook {
|
||||
mod := buildWebhookModule()
|
||||
mod.allowList = regexp.MustCompile("bar")
|
||||
mod.allowList = regexp2.MustCompile("bar", 0)
|
||||
return mod
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusForbidden,
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "webhook URL is denied",
|
||||
@@ -118,12 +132,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
}(),
|
||||
mod: func() *Webhook {
|
||||
mod := buildWebhookModule()
|
||||
mod.denyList = regexp.MustCompile("foo")
|
||||
mod.denyList = regexp2.MustCompile("foo", 0)
|
||||
return mod
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusForbidden,
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "webhook error URL is not allowed",
|
||||
@@ -135,12 +148,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
}(),
|
||||
mod: func() *Webhook {
|
||||
mod := buildWebhookModule()
|
||||
mod.errorAllowList = regexp.MustCompile("foo")
|
||||
mod.errorAllowList = regexp2.MustCompile("foo", 0)
|
||||
return mod
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusForbidden,
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "webhook error URL is denied",
|
||||
@@ -152,12 +164,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
}(),
|
||||
mod: func() *Webhook {
|
||||
mod := buildWebhookModule()
|
||||
mod.errorDenyList = regexp.MustCompile("bar")
|
||||
mod.errorDenyList = regexp2.MustCompile("bar", 0)
|
||||
return mod
|
||||
}(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusForbidden,
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid webhook method (GET)",
|
||||
@@ -169,6 +180,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
@@ -183,6 +195,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
@@ -198,6 +211,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
@@ -213,6 +227,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
@@ -228,6 +243,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
@@ -242,6 +258,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
return req
|
||||
}(),
|
||||
mod: buildWebhookModule(),
|
||||
noDeadline: false,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
@@ -254,15 +271,20 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||
|
||||
c := srv.NewContext(tc.request, httptest.NewRecorder())
|
||||
|
||||
ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
ctx.SetEchoContext(c)
|
||||
|
||||
c.Set("context", ctx.Context)
|
||||
c.Set("cancel", func() context.CancelFunc {
|
||||
return func() {
|
||||
return
|
||||
}
|
||||
}())
|
||||
if tc.noDeadline {
|
||||
ctx := &api.ContextMock{Context: &api.Context{Context: context.Background()}}
|
||||
ctx.SetEchoContext(c)
|
||||
c.Set("context", ctx.Context)
|
||||
c.Set("cancel", func() context.CancelFunc {
|
||||
return nil
|
||||
}())
|
||||
} else {
|
||||
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
|
||||
ctx.SetEchoContext(c)
|
||||
c.Set("context", ctx.Context)
|
||||
c.Set("cancel", cancel)
|
||||
}
|
||||
|
||||
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
|
||||
|
||||
@@ -320,10 +342,10 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
|
||||
buildWebhookModule := func() *Webhook {
|
||||
return &Webhook{
|
||||
allowList: regexp.MustCompile(""),
|
||||
denyList: regexp.MustCompile(""),
|
||||
errorAllowList: regexp.MustCompile(""),
|
||||
errorDenyList: regexp.MustCompile(""),
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
errorAllowList: regexp2.MustCompile("", 0),
|
||||
errorDenyList: regexp2.MustCompile("", 0),
|
||||
maxRetry: 0,
|
||||
retryMinWait: 0,
|
||||
retryMaxWait: 0,
|
||||
@@ -354,7 +376,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
return errors.New("foo")
|
||||
}
|
||||
}(),
|
||||
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
||||
expectWebhookContentType: echo.MIMEApplicationJSON,
|
||||
expectWebhookMethod: http.MethodPost,
|
||||
expectWebhookErrorStatus: http.StatusInternalServerError,
|
||||
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
|
||||
@@ -368,7 +390,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
return api.NewSentinelHttpError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
|
||||
}
|
||||
}(),
|
||||
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
||||
expectWebhookContentType: echo.MIMEApplicationJSON,
|
||||
expectWebhookMethod: http.MethodPost,
|
||||
expectWebhookErrorStatus: http.StatusBadRequest,
|
||||
expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest),
|
||||
@@ -408,7 +430,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
}
|
||||
}(),
|
||||
returnedError: echo.ErrInternalServerError,
|
||||
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
||||
expectWebhookContentType: echo.MIMEApplicationJSON,
|
||||
expectWebhookMethod: http.MethodPost,
|
||||
expectWebhookErrorStatus: http.StatusInternalServerError,
|
||||
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
|
||||
@@ -426,22 +448,17 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
c.Set("trace", "foo")
|
||||
c.Set("startTime", time.Now())
|
||||
|
||||
ctx := &api.ContextMock{Context: &api.Context{}}
|
||||
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
|
||||
ctx.SetLogger(zap.NewNop())
|
||||
ctx.SetEchoContext(c)
|
||||
|
||||
c.Set("context", ctx.Context)
|
||||
c.Set("cancel", func() context.CancelFunc {
|
||||
return func() {
|
||||
return
|
||||
}
|
||||
}())
|
||||
c.Set("cancel", cancel)
|
||||
|
||||
webhook := echo.New()
|
||||
webhook.HideBanner = true
|
||||
webhook.HidePort = true
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
webhookPort := rand.Intn(65535-1025+1) + 1025
|
||||
|
||||
c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
|
||||
@@ -476,7 +493,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if contentType == echo.MIMEApplicationJSONCharsetUTF8 {
|
||||
if contentType == echo.MIMEApplicationJSON {
|
||||
body, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
flag "github.com/spf13/pflag"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -17,10 +17,10 @@ func init() {
|
||||
// Webhook is a module which provides a middleware for uploading output files
|
||||
// to any destinations in an asynchronous fashion.
|
||||
type Webhook struct {
|
||||
allowList *regexp.Regexp
|
||||
denyList *regexp.Regexp
|
||||
errorAllowList *regexp.Regexp
|
||||
errorDenyList *regexp.Regexp
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
errorAllowList *regexp2.Regexp
|
||||
errorDenyList *regexp2.Regexp
|
||||
maxRetry int
|
||||
retryMinWait time.Duration
|
||||
retryMaxWait time.Duration
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
// Standard Gotenberg modules.
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/chromium"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/exiftool"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/pdfengine"
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
# Credits: https://github.com/thecodingmachine/docker-images-php.
|
||||
|
||||
set +e
|
||||
mkdir testing_file_system_rights.foo
|
||||
mkdir -p testing_file_system_rights.foo
|
||||
chmod 700 testing_file_system_rights.foo
|
||||
su gotenberg -c "touch testing_file_system_rights.foo/foo > /dev/null 2>&1"
|
||||
HAS_CONSISTENT_RIGHTS=$?
|
||||
|
||||
if [[ "$HAS_CONSISTENT_RIGHTS" != "0" ]]; then
|
||||
# If not specified, the DOCKER_USER is the owner of the current working directory (heuristic!).
|
||||
DOCKER_USER=$(stat -c '%U' "$(pwd)")
|
||||
DOCKER_USER=$(stat -c '%u' "$(pwd)")
|
||||
else
|
||||
# macOs or Windows.
|
||||
# Note: in most cases, we don't care about the rights (they are not respected).
|
||||
FILE_OWNER=$(stat -c '%U' "testing_file_system_rights.foo/foo")
|
||||
if [[ "$FILE_OWNER" == "root" ]]; then
|
||||
FILE_OWNER=$(stat -c '%u' "testing_file_system_rights.foo/foo")
|
||||
if [[ "$FILE_OWNER" == "0" ]]; then
|
||||
# If root, we are likely on a Windows host.
|
||||
# All files will belong to root, but it does not matter as everybody can write/delete
|
||||
# those (0777 access rights).
|
||||
@@ -56,4 +56,4 @@ go mod tidy
|
||||
set +x
|
||||
|
||||
# Run the command with the correct user.
|
||||
exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"
|
||||
exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"
|
||||
|
||||
@@ -3,4 +3,8 @@
|
||||
set -x
|
||||
|
||||
go test -race -covermode=atomic -coverprofile=/tests/coverage.txt ./...
|
||||
go tool cover -html=coverage.txt -o /tests/coverage.html
|
||||
RESULT=$?
|
||||
|
||||
go tool cover -html=coverage.txt -o /tests/coverage.html
|
||||
|
||||
exit $RESULT
|
||||
14
test/testdata/api/README.md
vendored
Normal file
14
test/testdata/api/README.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
To generate a valid certificate and private key use the following command:
|
||||
|
||||
```bash
|
||||
# In OpenSSL ≥ 1.1.1
|
||||
openssl req -x509 -newkey rsa:4096 -sha256 -days 9999 -nodes \
|
||||
-keyout key.pem -out cert.pem -subj "/CN=localhost" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
```
|
||||
|
||||
To check a certificate use the following command:
|
||||
|
||||
```bash
|
||||
openssl x509 -in cert.pem -text
|
||||
```
|
||||
30
test/testdata/api/cert.pem
vendored
Normal file
30
test/testdata/api/cert.pem
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFOjCCAyKgAwIBAgIUZaht2QoZwWDK8wEuwm+wmip9sqwwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI0MDUyNjA1MDExNFoYDzIwNTEx
|
||||
MDExMDUwMTE0WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEB
|
||||
AQUAA4ICDwAwggIKAoICAQCm3TNckD55ae6pxXS03iV2P3X/edZT4vRa8D15elsi
|
||||
CSRf6mdU2TVAQyg22GEcMXgsbba7sotJVg7559LkWAgb3Dj9rbPputdS29ebncat
|
||||
x1nzo1QHMCS/v7nWoPe9fQVlDEf3ebM198f+5vhccSYFxFa+YvsUmSw9yMF8z1nA
|
||||
6POZTpm6ErByAY9EGvC8EcNOQd9xLOfBZc0e+mY/rMRxmL+0N+sSRE/ACU+2zxHN
|
||||
KcCiFX5WQdLDYW1ZjX5xAVatCPNlxSQpj97/ktGsnqwGnJWSTeMUOPv7L0LrQasf
|
||||
k1VXBvfTW2/ZqhI9WA8qfdC5XOuzVwfU14gv+6vv9AGZnUFmOjeo9XG4xWBxlIKj
|
||||
AQAzeNwAGFltPkp5a3yPczxMjKY4xLsMGcDfpaNtv1B24+7fV77Nyty/gLLT9pUa
|
||||
FtXLuRfIPSaUK3kFhAuYzGUL/sO9O9jJkNlxxygxQ+VyIgmdw0zWrUi3q+Lfmdya
|
||||
FFjHdM7LnLrLCjSv3dEoMINngR3+IejsY+98Ehd25O3oW+ZnGIQX0VwykV8sOQWq
|
||||
rpnnZ+2iHMtF6Vsj40z/Y9y7yVE18AIJFC+Yr/dD/nQyg6SE70wdNcOx0Ay1xHbP
|
||||
WuV3fqKOfhONR7NEBSO2UbHi6GZAiYMMwwul+4yKAQ4+lHOEXG4JQcKvtZasxq9v
|
||||
GwIDAQABo4GBMH8wHQYDVR0OBBYEFPPMaH3C0rAkD/E0gtBZwM0x8N9MMB8GA1Ud
|
||||
IwQYMBaAFPPMaH3C0rAkD/E0gtBZwM0x8N9MMA8GA1UdEwEB/wQFMAMBAf8wLAYD
|
||||
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
|
||||
SIb3DQEBCwUAA4ICAQB0DXz59JfQgfJvRJfraa+Cv/3nG4NNmHYtNGhrid5sagH3
|
||||
LM8jkbA+JKRAn34SmsqUZb1f2XFI64TwrScgMfAvfi+5ibRqBjM/YL06bTlG61Ua
|
||||
t2IQchV2kcX4zQnaMCIYG0dUj6JoXIspiFULMHyGDs7G9BKVcZxlaLrEvPXBqQjd
|
||||
nwF80KHS/rCConmICU9uWHPgHxS1VmhQiwYGbrakETilau1ppRS0bpH2k/stVyjc
|
||||
plttIr1DUD6xaRV4mZ0NZVlIXl2nDf78iQvreJeMrGl6AcNHzV+/rGHpY/gOrziP
|
||||
PQkB1VtSiHtBe/VMKXcVB69SEzsoGEknqs4GPXuVYm47POo3/U5HmGU+FadSATNP
|
||||
qtorRECUMbVn0IfDzJX2jBLUjZlhPI9gqO9DhiAT8FWHRV2yPsZKuiBBg5/hUvyb
|
||||
Qsswtmi/YV5e6AnXEuvylwE3wYzlqDzxwlZM08bWFyZYnSf38d3hAjVwUHAugiaX
|
||||
4sgE6GPAiONW5FkTZ4Z2oXYiN213QDIIcQeFAl04qSEFkGBV8x2hM0uXgFH1S++j
|
||||
JePdKBbV/CjxegyyYsmnln+eO1kC8pCalB6k6pEtYMqNYOnmimC8UKrJATrH9EVq
|
||||
eAehGBiIe514Oz06Ws2Vm4PTiY2x0KVbqphwkuz85ohSF9UXOHUcMryusr1zlg==
|
||||
-----END CERTIFICATE-----
|
||||
52
test/testdata/api/key.pem
vendored
Normal file
52
test/testdata/api/key.pem
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQCm3TNckD55ae6p
|
||||
xXS03iV2P3X/edZT4vRa8D15elsiCSRf6mdU2TVAQyg22GEcMXgsbba7sotJVg75
|
||||
59LkWAgb3Dj9rbPputdS29ebncatx1nzo1QHMCS/v7nWoPe9fQVlDEf3ebM198f+
|
||||
5vhccSYFxFa+YvsUmSw9yMF8z1nA6POZTpm6ErByAY9EGvC8EcNOQd9xLOfBZc0e
|
||||
+mY/rMRxmL+0N+sSRE/ACU+2zxHNKcCiFX5WQdLDYW1ZjX5xAVatCPNlxSQpj97/
|
||||
ktGsnqwGnJWSTeMUOPv7L0LrQasfk1VXBvfTW2/ZqhI9WA8qfdC5XOuzVwfU14gv
|
||||
+6vv9AGZnUFmOjeo9XG4xWBxlIKjAQAzeNwAGFltPkp5a3yPczxMjKY4xLsMGcDf
|
||||
paNtv1B24+7fV77Nyty/gLLT9pUaFtXLuRfIPSaUK3kFhAuYzGUL/sO9O9jJkNlx
|
||||
xygxQ+VyIgmdw0zWrUi3q+LfmdyaFFjHdM7LnLrLCjSv3dEoMINngR3+IejsY+98
|
||||
Ehd25O3oW+ZnGIQX0VwykV8sOQWqrpnnZ+2iHMtF6Vsj40z/Y9y7yVE18AIJFC+Y
|
||||
r/dD/nQyg6SE70wdNcOx0Ay1xHbPWuV3fqKOfhONR7NEBSO2UbHi6GZAiYMMwwul
|
||||
+4yKAQ4+lHOEXG4JQcKvtZasxq9vGwIDAQABAoICAA3s7URkT/0XTMZOUZcRPanI
|
||||
p12oGq2Vhd8GUvBjDbKhdgUAgu8fJsUzgD+o1Ic4eaxQ26x+Z1wPb+CF6vZeLQ8Q
|
||||
1JDB1lSYiy/BuRSmQ4EX30yTQjKiCe8Ws7j9u1nM/Tju9XKUbX2rgr1QeURTDQvz
|
||||
fr8UyO9xzly5oawmLHoKTDTBzS0s3Md8Pys9NFSoXCxDwxSm3WXTy8fcuHvH51Je
|
||||
SmFik/OfpSgu9BVwN9lvX+wm7wwzzMBmzmwS1Y0zbLsJbDFUv9fYHJF8/JKeC1fh
|
||||
6Xme67yG+kB+3o7TmXxhHKt5TBAPfsjOMLIojzDJ9IlY/91PqLpSjRhetdKorRlf
|
||||
gvTUGLPY8lHXwz6UBpdHmMhNHTBBvgHcOVAMD46Y81SdqMhH6TAYSh7XQVuA06Ql
|
||||
MTWzGqzKriaQDWmJ6Nx8eZCRDZGXDq4rKiJFvRFUrGYYr818qU8yC+qv5uLrOF1w
|
||||
YZV0WnZ7DtcaFliBrJVUfeTmrSJBa3yoIKbeaTDuh3TMHqgAbTx0lBdGoI6noRxx
|
||||
v7fCGho/aSOJ2rtFOjRoLQ5K8onZ226arJEyfkTh16wztQgh9DC430OkL4cQkfA0
|
||||
BxN8tGh4P0lqwcBnCOyzi4NV1NkwaaVv+pt757Cz+Cy6e2DQOtDkrFEh4p56NqDr
|
||||
KcJz88LYbbChoFKlb/kBAoIBAQDhcuI0KWTErojWu/dB2Okth8OFaTdvAPEvo+qO
|
||||
ItJgxyYL8JIdWUzS9vKro6vMctwIXvNa3K2AAwdZ1kHvaxDZaikQbnjnSjOqRlZa
|
||||
4rVnI6SSKrYj6xmKcsxxTi9YP8b2msjdLuGhPfO8Dw4EQgOt6xg0rq4msnv6bYki
|
||||
G7vsk/InnKLm8SEhR1XsmtVrpC7KbXniLQEehgs1Kp0nMLptSsOTnA1kGoZDVlXc
|
||||
u+kBBai6MM0MMDpsJgM1IFkUousQl3ItBcTXD2ZCMcg5psQJbdEhu7oXiGkHdKlL
|
||||
1UEIdSGNlzHiVM62uNxhNfwh7I6SPesDksJbE0Yd//jz/7FRAoIBAQC9ee+pDsSs
|
||||
yqunQjMWImTpD5h9fSRTzWT8h0dXsJyYi7s9Dj4mQIuoarscMI6L7njeTqHE8ptc
|
||||
b9DzIEWL+233nTOS59grCTD84Kyrn+Hw6EebgKYmtpZnvMw5cccoZ3qUsxVGurhA
|
||||
lzVaKpHtPKWT1fduNmnqt6RkZ7aRBLZre/i9UntZ7VSZYRKMOBvJLpLdqmiSPkvM
|
||||
w8ra+cedHwDegqLkoX0sxdI1yNYO1GSkJ+w1I1m1rzuCd9X5QXvyQhPezVB9wUwr
|
||||
anNGb6/YgvthzvMQAXYqv4pmb769przieMJ6geQL3O8QEtvpDAV4SvrwnA+H/wfs
|
||||
BvwI4MTHNJ6rAoIBADEfnzpOaq6QeTCQ9GdcpDJPisLfEj0Vr3f593nRDJYZzqh7
|
||||
WtsaQisVs/rCRwTdYiRQzCXYP9XM0yU4ElXgtmMpRplV+PIssOVBPj+/dldq5MkU
|
||||
lWtuJrqMGQ/3QUmxW+EQCQRo2rEPlrQ7c4pp9/NFbpjMFxZHfcwrYd4UvPXnprQK
|
||||
5VP85oMh0A8mDarOs4NYJ16o/71u57JF/sgzTevShpr7Pc+6F9dEUKEwMK0QVpt7
|
||||
VrJ5L8Gw5rIEwmREu4N5/F7jCujxag6yWjZ1p/GGBDOdfAb0qi39tYXYibsWCqGX
|
||||
eD8gy4n07dAguzeJG+expiu5JrBBmt2ekNhZtoECggEAQ90VoraAaOcaPgju5UGi
|
||||
ZPtKAcA9r6pEPlJxkg3WuKhrgr3EMs0xeDvqEnmtxJ7AOdduB21hieUesMXhQvbl
|
||||
9ycd3sMdYKSlIB2Ums/kC/YWF7MnT3xcOIGdoAGJdIZDmFjgCPo9nhzKZiYTKCZt
|
||||
o5qiSZ5Bp2jo/3FKKXzHnY3ElIKjXZ0sjNcA3qoGAbOltLEhTSf03D1QPcfy3+rH
|
||||
0+p0T3ErtUqRNORfDuaxuT33Uzz7F6DQrrUfNWxn6WgTV9yvtKr0DiTNbu/3ypN3
|
||||
WBvYYOY1HbCG8JObfK/ovTDFbz1woXMbSOWIUFsW5nPslxs+UNZeTwJdaQygTj0h
|
||||
iwKCAQBuXOPtjWGZINgfjo/n67Q8FuzUXxDK/IuXzuDXhi+B3/O3/PcVEDHFByhI
|
||||
KHOh2mTD25vgFWh+8dNg1R+m5uIeQ5zbZ5U0205XzmrZgBXXCAHKNruTwqZfQ6er
|
||||
np+8wHhcCbBuy0Z0ponzBGlrDYmbxO9anXGxoe/rE3+pWbzMneZ+cHgNjXCtfZCY
|
||||
XGfLeJWW3+B8y5Lff39ADawmjm8VWVEdd8DP5o2zulbXhgMMnAsSVsR/XgwBqpbf
|
||||
82k1bqvN2v08ID5nmTRdzD+D51Hjw80Uoqhm5DJL4IwaoK/YstCu2TCbmLGiyqSk
|
||||
lrSOZMPTLX1tN/cWa+Ii+/WALnkI
|
||||
-----END PRIVATE KEY-----
|
||||
4
test/testdata/chromium/html/footer.html
vendored
4
test/testdata/chromium/html/footer.html
vendored
@@ -2,8 +2,8 @@
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-size: 30rem;
|
||||
margin: 4rem auto;
|
||||
font-size: 12px;
|
||||
margin: auto 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
4
test/testdata/chromium/html/header.html
vendored
4
test/testdata/chromium/html/header.html
vendored
@@ -2,8 +2,8 @@
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-size: 8rem;
|
||||
margin: 4rem auto;
|
||||
font-size: 12px;
|
||||
margin: auto 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
8
test/testdata/chromium/html/index.html
vendored
8
test/testdata/chromium/html/index.html
vendored
@@ -59,6 +59,14 @@
|
||||
<p id="javascript" style="display: none">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
||||
</div>
|
||||
|
||||
<div class="page-break-after">
|
||||
<h2>/etc/passwd</h2>
|
||||
<iframe src="/etc/passwd"></iframe>
|
||||
|
||||
<h2>\\localhost/etc/passwd</h2>
|
||||
<iframe src="\\localhost/etc/passwd"></iframe>
|
||||
</div>
|
||||
|
||||
<script type="application/javascript">
|
||||
var globalVar = 'notReady'
|
||||
|
||||
|
||||
4
test/testdata/chromium/markdown/footer.html
vendored
4
test/testdata/chromium/markdown/footer.html
vendored
@@ -2,8 +2,8 @@
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-size: 8rem;
|
||||
margin: 4rem auto;
|
||||
font-size: 12px;
|
||||
margin: auto 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
4
test/testdata/chromium/markdown/header.html
vendored
4
test/testdata/chromium/markdown/header.html
vendored
@@ -2,8 +2,8 @@
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-size: 8rem;
|
||||
margin: 4rem auto;
|
||||
font-size: 12px;
|
||||
margin: auto 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user