Compare commits

..

51 Commits

Author SHA1 Message Date
Julien Neuhart
0f0a6ce042 feat(chromium): add clip options to screenshot routes 2024-04-25 17:24:23 +02:00
Julien Neuhart
a890968ee4 chore(deps): update Go dependencies 2024-04-25 16:06:28 +02:00
Julien Neuhart
2267c5be00 feat(api): add version route 2024-04-17 09:55:38 +02:00
Julien Neuhart
e853cfb98a chore(deps): update Go dependencies 2024-04-17 09:08:43 +02:00
Julien Neuhart
f89a0db471 chore(deps): update Go dependencies 2024-04-11 09:57:55 +02:00
Julien Neuhart
1d66b43da1 feat(chromium): set cookies 2024-04-10 13:43:31 +02:00
Julien Neuhart
75c4a4f253 feat(api): add basic auth support 2024-04-05 17:20:34 +02:00
Julien Neuhart
6a9c95b3b1 chore(deps): update Go dependencies 2024-04-05 14:53:50 +02:00
Julien Neuhart
c11a2d0081 chore(golangci-lint): remove unused linters 2024-04-03 11:55:24 +02:00
Martin
43a13ff2c5 feat(libreoffice): add singlePageSheets option 2024-03-25 15:46:48 +01:00
Julien Neuhart
8454d163eb chore(golangci-lint): add missing EOF 2024-03-23 20:01:26 +01:00
Julien Neuhart
4a11f6a39f chore(deps): update Go dependencies 2024-03-23 16:50:07 +01:00
Julien Neuhart
c63235cd3d feat(exiftool): handle boolean metadata 2024-03-23 16:47:28 +01:00
Julien Neuhart
f4ce196a34 fix(pdfengines): return a 400 Bad Request if no metadata to write 2024-03-23 16:47:28 +01:00
Julien Neuhart
e644b27201 feat(pdfengines): add write metadata option on merge route 2024-03-23 16:47:28 +01:00
Julien Neuhart
d2b14582ee feat(pdfengines): add write metadata route 2024-03-23 16:47:28 +01:00
Julien Neuhart
ca9194fd96 fix(pdfengine): missing mock tests 2024-03-23 16:47:28 +01:00
Julien Neuhart
773d3ab13c feat(pdfengines): add read metadata route 2024-03-23 16:47:28 +01:00
Julien Neuhart
a02a4a07a5 chore(golangci-lint): update conf 2024-03-23 16:47:28 +01:00
Julien Neuhart
0a8625227f feat(exiftool): refactor read write metadata 2024-03-23 16:47:28 +01:00
Julien Neuhart
dc613aa2bf chore(deps): update Go dependencies 2024-03-23 16:47:28 +01:00
Julien Neuhart
2008ac5663 chore: improve the godoc of all PDF engine packages 2024-03-23 16:47:28 +01:00
Julien Neuhart
da326f1925 fix(Dockerfile): missing ExitTool in RUN comment 2024-03-23 16:47:28 +01:00
Piyush Srivastava
71911cb1a7 feat(ExifTool): add capability to overwrite metadata of the PDF generated (#776)
* add new metadata functions to pdf engine interface

* add exiftool module with relevant test cases

* use exiftool to overwrite metadata in libreoffice

* use exiftool to overwrite metadata in chromium

* fix linter issues

* fix more linter issues

* more test cases for better coverage

* remove utils

* minor changes

* remove metadata from pdfoptions

* correct indentation

* read/write metadata one file at a time.
2024-03-23 16:47:28 +01:00
Julien Neuhart
31e7582216 feat(libreoffice): improve ExportFormFields godoc 2024-03-22 15:50:34 +01:00
Martin
91f63047e0 feat(libreoffice): add exportFormFields option 2024-03-22 15:50:34 +01:00
Andrea
c9aa4938f4 feat(libreoffice): add more extensions according to online docs (#841)
* #840: added more extensions according to online docs @ libreoffice

* #840: align test

---------

Co-authored-by: Andrea Esposito <a.esposito@hyperborea.com>
2024-03-19 15:46:36 +01:00
Julien Neuhart
b9e6a38037 fix(tests): test script now returns the correct exit code 2024-03-19 14:23:34 +01:00
Julien Neuhart
eb054146d2 feat(chromium): support more units for paper size and margins 2024-03-17 14:34:28 +01:00
Julien Neuhart
a780373cfc chore(go): update to 1.22 2024-03-15 16:23:27 +01:00
Julien Neuhart
5ad90ccdb8 fix: file rename now correctly works for zip archive 2024-03-15 16:12:18 +01:00
Julien Neuhart
ef97d81ca8 fix(libreoffice): wrong number of file extensions 2024-03-15 11:37:34 +01:00
Julien Neuhart
c9f7b642c5 fix(api): wrong expected message in tests 2024-03-15 11:25:55 +01:00
Julien Neuhart
770962be3f chore(deps): update golangci-lint 2024-03-15 11:22:32 +01:00
Julien Neuhart
d6c857f4a8 chore(libreoffice): disable PDF tagging by default - for consistency with Chromium 2024-03-15 11:11:51 +01:00
Julien Neuhart
7b7ffb427d fix(chromium): disable PDF tagging 2024-03-15 10:56:35 +01:00
Julien Neuhart
b80318d1b6 chore(deps): update Go dependencies 2024-03-15 09:50:19 +01:00
dependabot[bot]
3997d69e49 chore(deps): bump google.golang.org/protobuf from 1.32.0 to 1.33.0
Bumps google.golang.org/protobuf from 1.32.0 to 1.33.0.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-14 07:10:17 +01:00
Julien Neuhart
4af41b33c0 chore: update Go dependencies 2024-03-04 11:33:10 +01:00
Julien Neuhart
0f8bbead66 fix(api): do not catch gotenberg.ErrPdfEngineMethodNotSupported as it may hides other errors 2024-03-04 11:17:36 +01:00
Julien Neuhart
804fcff5b6 fix(chromium): remove tagged PDF by default 2024-03-04 10:28:54 +01:00
Julien Neuhart
d18e6920fc chore(libreoffice): order ext list alphabetically 2024-03-04 10:09:42 +01:00
eMerzh
c95174ff11 feat(libreoffice): add macOS *.key support 2024-02-26 16:47:52 +01:00
Julien Neuhart
c70329c22b chore(api): improve gotenberg.ErrPdfFormatNotSupported HTTP message 2024-02-24 14:55:48 +01:00
Julien Neuhart
34f69263a2 feat(libreoffice): explicity set tagged PDF as default 2024-02-24 13:45:01 +01:00
Julien Neuhart
bf5056f8fe feat(chromium): single page PDF 2024-02-23 21:27:18 +01:00
Julien Neuhart
c47f5b06ba feat(libreoffice): add macOS *.pages support 2024-02-23 18:16:42 +01:00
Julien Neuhart
63055e3df2 feat: better file sorting 2024-02-23 17:13:37 +01:00
Julien Neuhart
8ee555140b chore: rename regexp file to filter 2024-02-23 17:13:37 +01:00
Julien Neuhart
859f78e986 chore: update Go dependencies 2024-02-23 17:13:37 +01:00
Julien Neuhart
366eebb68c chore: using error middleware to handle common errors and lighten codebase 2024-02-23 14:52:43 +01:00
65 changed files with 3513 additions and 1154 deletions

View File

@@ -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@v4
with:
version: v1.55.2
version: v1.56.2
tests:
needs:

View File

@@ -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

View File

@@ -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.56.2 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build
build: ## Build the Gotenberg's Docker image
@@ -33,6 +33,9 @@ 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
@@ -79,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) \
@@ -88,6 +93,7 @@ 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) \

View File

@@ -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

View File

@@ -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)

39
go.mod
View File

@@ -5,48 +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-20240214232516-ad4608604e9e
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20240421230201-ab917191657d
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/klauspost/compress v1.17.8 // 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.0
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.19.0 // indirect
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.21.0
golang.org/x/sync v0.6.0
golang.org/x/sys v0.17.0 // indirect
golang.org/x/term v0.17.0
golang.org/x/net v0.24.0
golang.org/x/sync v0.7.0
golang.org/x/sys v0.19.0 // indirect
golang.org/x/term v0.19.0
golang.org/x/text v0.14.0
)
require github.com/dlclark/regexp2 v1.10.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/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
@@ -55,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.6.0 // indirect
github.com/prometheus/common v0.47.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.53.0 // indirect
github.com/prometheus/procfs v0.14.0 // 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.33.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

85
go.sum
View File

@@ -5,21 +5,24 @@ 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/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-20240214232516-ad4608604e9e h1:kXEolCWQZzuEFcuaTzfqXToX+e29OcvK87BcBiBBJ1c=
github.com/chromedp/cdproto v0.0.0-20240214232516-ad4608604e9e/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20240421230201-ab917191657d h1:x9d0XwRV3aWw1gAZtv0LrI39U+Efjp0mtyXRyikGb9Y=
github.com/chromedp/cdproto v0.0.0-20240421230201-ab917191657d/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.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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=
@@ -29,6 +32,8 @@ 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/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=
@@ -53,8 +58,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.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/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=
@@ -63,8 +68,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=
@@ -87,8 +92,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=
@@ -96,14 +101,14 @@ 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.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
github.com/prometheus/common v0.47.0 h1:p5Cz0FNHo7SnWOmWmoRozVcjEp0bIVU8cV7OShpjL1k=
github.com/prometheus/common v0.47.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
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.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
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.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s=
github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -113,47 +118,51 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
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.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
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.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
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.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
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/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
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/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.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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
View 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
View 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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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
View 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)
)

View 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)
}
})
}
}

4
pkg/gotenberg/version.go Normal file
View File

@@ -0,0 +1,4 @@
package gotenberg
// Version is the... version of the Gotenberg application.
var Version = "snapshot"

View File

@@ -5,9 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
@@ -34,6 +32,8 @@ type Api struct {
timeout time.Duration
rootPath string
traceHeader string
basicAuthUsername string
basicAuthPassword string
disableHealthCheckLogging bool
routes []Route
@@ -163,8 +163,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
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) },
@@ -184,24 +184,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 {
@@ -319,8 +323,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 +399,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 +414,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 +443,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 +454,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()

View File

@@ -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{
@@ -671,6 +697,8 @@ func TestApi_Start(t *testing.T) {
mod.port = 3000
mod.startTimeout = time.Duration(30) * time.Second
mod.rootPath = "/"
mod.basicAuthUsername = "foo"
mod.basicAuthPassword = "bar"
mod.disableHealthCheckLogging = true
mod.routes = []Route{
{
@@ -755,12 +783,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 +829,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
}

View File

@@ -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),
@@ -191,14 +197,19 @@ func (ctx *Context) FormData() *FormData {
}
// GeneratePath generates a path within the context's working directory.
// It either generates a new UUID-based filename or uses the provided filename.
// It does not create a file.
func (ctx *Context) GeneratePath(filename, extension string) string {
if filename == "" {
// Generate a new UUID-based filename
filename = uuid.New().String()
// 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().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 fmt.Sprintf("%s/%s%s", ctx.dirPath, filename, extension)
return nil
}
// AddOutputPaths adds the given paths. Those paths will be used later to build
@@ -250,7 +261,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
ImplicitTopLevelFolder: false,
}
archivePath := ctx.GeneratePath("", ".zip")
archivePath := ctx.GeneratePath(".zip")
err := z.Archive(ctx.outputPaths, archivePath)
if err != nil {
@@ -273,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)
)

View File

@@ -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
@@ -190,14 +246,44 @@ func TestContext_GeneratePath(t *testing.T) {
dirPath: "/foo",
}
path := ctx.GeneratePath("", ".pdf")
path := ctx.GeneratePath(".pdf")
if !strings.HasPrefix(path, ctx.dirPath) {
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
}
}
path = ctx.GeneratePath("foo.txt", ".pdf")
if !strings.Contains(path, "foo.txt.pdf") {
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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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,7 @@ 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),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
@@ -246,6 +249,7 @@ 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),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
@@ -254,6 +258,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),
})
}

View File

@@ -579,6 +579,41 @@ 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: "extra HTTP headers",
browser: newChromiumBrowser(
@@ -950,6 +985,41 @@ 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(
@@ -1090,6 +1160,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",
@@ -1514,6 +1585,41 @@ 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(
@@ -1890,6 +1996,54 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
"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 extra HTTP headers",
"navigate to",
"default white background not hidden",
"no emulated media type",
"no wait delay",
"no wait expression",
"set device metrics override",
},
},
{
@@ -1929,12 +2083,14 @@ func TestChromiumBrowser_screenshot(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",
"no emulated media type",
"no wait delay",
"no wait expression",
"set device metrics override",
},
},
} {

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/alexliesenfeld/health"
"github.com/chromedp/cdproto/network"
flag "github.com/spf13/pflag"
"go.uber.org/zap"
@@ -103,6 +104,10 @@ type Options struct {
// Optional.
WaitForExpression string
// Cookies are the cookies to put in the Chromium cookies' jar.
// Optional
Cookies []Cookie
// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
// the HTML document.
// Optional.
@@ -128,6 +133,7 @@ func DefaultOptions() Options {
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
Cookies: nil,
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
@@ -150,6 +156,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
@@ -209,6 +220,7 @@ func DefaultPdfOptions() PdfOptions {
Landscape: false,
PrintBackground: false,
Scale: 1.0,
SinglePage: false,
PaperWidth: 8.5,
PaperHeight: 11,
MarginTop: 0.39,
@@ -227,6 +239,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.
@@ -246,12 +271,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

View File

@@ -34,6 +34,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
cookies []Cookie
extraHttpHeaders map[string]string
emulatedMediaType string
omitBackground bool
@@ -58,6 +59,25 @@ 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
}).
Custom("extraHttpHeaders", func(value string) error {
if value == "" {
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
@@ -78,7 +98,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 +114,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
@@ -109,7 +130,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 +142,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 +159,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 +182,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 +233,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 +262,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 +287,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 +297,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 +347,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 +358,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 +409,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 +429,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)
}
@@ -494,7 +543,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
)
}
inputPath = ctx.GeneratePath("", ".html")
inputPath = ctx.GeneratePath(".html")
err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
if err != nil {
@@ -504,11 +553,11 @@ 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 {
outputPath := ctx.GeneratePath("", ".pdf")
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(
@@ -549,30 +598,10 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
zeroValued := gotenberg.PdfFormats{}
if pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath("", ".pdf")
convertOutputPath := ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
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)
}
@@ -580,6 +609,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)
@@ -590,10 +627,10 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error {
ext := fmt.Sprintf(".%s", options.Format)
outputPath := ctx.GeneratePath("", ext)
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)
}
@@ -606,31 +643,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, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
if errors.Is(err, gotenberg.ErrFiltered) {
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

View File

@@ -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,37 +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: "ErrMaximumQueueSizeExceeded",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return gotenberg.ErrMaximumQueueSizeExceeded
}},
options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
expectOutputPathsCount: 0,
},
{
scenario: "ErrFiltered",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return gotenberg.ErrFiltered
}},
options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
expectOutputPathsCount: 0,
},
{
scenario: "ErrOmitBackgroundWithoutPrintBackground",
ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1354,39 +1447,7 @@ func TestConvertUrl(t *testing.T) {
expectOutputPathsCount: 0,
},
{
scenario: "ErrMaximumQueueSizeExceeded (PDF engine)",
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.ErrMaximumQueueSizeExceeded
}},
pdfFormats: gotenberg.PdfFormats{PdfA: "foo"},
options: DefaultPdfOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
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
@@ -1394,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
@@ -1409,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 {
@@ -1436,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,
@@ -1444,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)
@@ -1490,30 +1581,6 @@ func TestScreenshotUrl(t *testing.T) {
expectHttpStatus int
expectOutputPathsCount int
}{
{
scenario: "ErrMaximumQueueSizeExceeded",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return gotenberg.ErrMaximumQueueSizeExceeded
}},
options: DefaultScreenshotOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
expectOutputPathsCount: 0,
},
{
scenario: "ErrFiltered",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return gotenberg.ErrFiltered
}},
options: DefaultScreenshotOptions(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
expectOutputPathsCount: 0,
},
{
scenario: "ErrInvalidEvaluationExpression (without waitForExpression form field)",
ctx: &api.ContextMock{Context: new(api.Context)},

View File

@@ -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,55 @@ 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 extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(extraHttpHeaders) == 0 {

View 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

View 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)
)

View 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)
}
}
}
}
})
}
}

View File

@@ -50,6 +50,14 @@ 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
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA.
// Optional.
@@ -266,85 +274,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",
}
}

View File

@@ -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)

View File

@@ -273,6 +273,14 @@ 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")
}
switch options.PdfFormats.PdfA {
case "":
case gotenberg.PdfA1b:
@@ -288,8 +296,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",
)
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -1,10 +1,10 @@
package libreoffice
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"github.com/labstack/echo/v4"
@@ -28,20 +28,34 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
inputPaths []string
landscape bool
nativePageRanges string
exportFormFields bool
singlePageSheets 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).
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,11 +69,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
// Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
// document.docx -> document.docx.pdf.
outputPaths[i] = ctx.GeneratePath(filepath.Base(inputPath), ".pdf")
outputPaths[i] = ctx.GeneratePath(".pdf")
options := libreofficeapi.Options{
Landscape: landscape,
PageRanges: nativePageRanges,
Landscape: landscape,
PageRanges: nativePageRanges,
ExportFormFields: exportFormFields,
SinglePageSheets: singlePageSheets,
}
if nativePdfFormats {
@@ -68,16 +83,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options)
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
if errors.Is(err, libreofficeapi.ErrInvalidPdfFormats) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
@@ -99,119 +104,65 @@ 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")
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), outputPaths, outputPath)
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("merge PDFs: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
return fmt.Errorf("merge PDFs: %w", err)
}
// Now, let's check if the client want to convert this
// resulting 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.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
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))
for i, outputPath := range outputPaths {
convertInputPath := outputPath
// document.docx -> document.docx.pdf.
convertOutputPaths[i] = ctx.GeneratePath(filepath.Base(inputPaths[i]), ".pdf")
convertOutputPaths[i] = ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
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)

View File

@@ -40,25 +40,25 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
scenario: "ErrMaximumQueueSizeExceeded",
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{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return gotenberg.ErrMaximumQueueSizeExceeded
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
@@ -132,18 +132,16 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
scenario: "ErrMaximumQueueSizeExceeded (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": {
gotenberg.PdfA1b,
},
"nativePdfFormats": {
"false",
"merge": {
"true",
},
})
return ctx
@@ -157,52 +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.ErrMaximumQueueSizeExceeded
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfFormatNotSupported (single file)",
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{
"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{
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 (single file)",
scenario: "PDF engine convert error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -236,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{
@@ -262,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
}(),
@@ -274,10 +311,20 @@ 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,
expectOutputPaths: []string{"/document.docx.pdf"},
},
{
scenario: "success (many files)",
@@ -288,6 +335,23 @@ func TestConvertRoute(t *testing.T) {
"document2.docx": "/document2.docx",
"document2.doc": "/document2.doc",
})
ctx.SetValues(map[string][]string{
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
"nativePdfFormats": {
"false",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
@@ -298,57 +362,28 @@ func TestConvertRoute(t *testing.T) {
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: 3,
expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"},
},
{
scenario: "success with non-native PDF/A & PDF/UA (many files)",
scenario: "success with native PDF/A & PDF/UA",
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{
"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{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 2,
expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf"},
},
{
scenario: "success with native PDF/A & PDF/UA (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",
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"pdfa": {
@@ -368,355 +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: 2,
expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf"},
},
{
scenario: "ErrMaximumQueueSizeExceeded (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 gotenberg.ErrMaximumQueueSizeExceeded
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
expectOutputPathsCount: 0,
},
{
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: "ErrMaximumQueueSizeExceeded (convert post-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 gotenberg.ErrMaximumQueueSizeExceeded
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
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,

View File

@@ -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

View File

@@ -54,6 +54,16 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats g
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.
var (
_ gotenberg.Module = (*PdfCpu)(nil)

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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")
}
})
}
}

View File

@@ -168,6 +168,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{
mergeRoute(engine),
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),
}, nil
}

View File

@@ -312,7 +312,7 @@ func TestPdfEngines_Routes(t *testing.T) {
}{
{
scenario: "routes not disabled",
expectRoutes: 2,
expectRoutes: 4,
disableRoutes: false,
},
{

View File

@@ -1,11 +1,11 @@
package pdfengines
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
@@ -27,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)
@@ -44,21 +54,10 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
// Alright, let's merge the PDFs.
outputPath := ctx.GeneratePath("", ".pdf")
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("merge PDFs: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
return fmt.Errorf("merge PDFs: %w", err)
}
@@ -68,30 +67,10 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
zeroValued := gotenberg.PdfFormats{}
if pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath("", ".pdf")
convertOutputPath := ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
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)
}
@@ -99,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)
@@ -112,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,
@@ -154,46 +140,29 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
)
}
// Alright, let's convert the PDFs.s
// Alright, let's convert the PDFs.
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
if len(outputPaths) > 1 {
// If .zip archive, keep the original filenames.
outputPaths[i] = ctx.GeneratePath(strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath)), ".pdf")
} else {
outputPaths[i] = ctx.GeneratePath("", ".pdf")
}
outputPaths[i] = ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, inputPath, outputPaths[i])
if err != nil {
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusTooManyRequests,
"The maximum queue size has been reached",
),
)
}
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)
}
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)
@@ -203,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
},
}
}

View File

@@ -4,7 +4,9 @@ import (
"context"
"errors"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/labstack/echo/v4"
@@ -33,28 +35,27 @@ func TestMergeHandler(t *testing.T) {
expectOutputPathsCount: 0,
},
{
scenario: "ErrMaximumQueueSizeExceeded (merge)",
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
}(),
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return gotenberg.ErrMaximumQueueSizeExceeded
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "error from PDF engine",
scenario: "PDF engine merge error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -72,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 {
@@ -94,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: "ErrMaximumQueueSizeExceeded (convert)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -123,33 +159,8 @@ func TestMergeHandler(t *testing.T) {
"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 gotenberg.ErrMaximumQueueSizeExceeded
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfFormatNotSupported",
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": {
"foo",
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
return ctx
@@ -159,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
},
},
@@ -298,54 +254,6 @@ func TestConvertHandler(t *testing.T) {
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrMaximumQueueSizeExceeded",
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.ErrMaximumQueueSizeExceeded
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusTooManyRequests,
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": {
"foo",
},
})
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 {
@@ -419,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 {
@@ -435,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{
@@ -493,3 +434,295 @@ func TestConvertHandler(t *testing.T) {
})
}
}
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())
}
}
})
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -13,7 +13,6 @@ import (
"strings"
"time"
"github.com/dlclark/regexp2"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
@@ -52,31 +51,12 @@ 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 *regexp2.Regexp, deadline time.Time) error {
err := gotenberg.FilterDeadline(allowList, denyList, url, deadline)
if err == nil {
return nil
}
if errors.Is(err, gotenberg.ErrFiltered) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusForbidden,
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URL", header, url),
),
)
}
return err
}
err := filter(webhookUrl, "Gotenberg-Webhook-Url", w.allowList, w.denyList, deadline)
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, deadline)
err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline)
if err != nil {
return fmt.Errorf("filter webhook error URL: %w", err)
}
@@ -179,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),
}

View File

@@ -119,10 +119,8 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
mod.allowList = regexp2.MustCompile("bar", 0)
return mod
}(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
noDeadline: false,
expectError: true,
},
{
scenario: "webhook URL is denied",
@@ -137,10 +135,8 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
mod.denyList = regexp2.MustCompile("foo", 0)
return mod
}(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
noDeadline: false,
expectError: true,
},
{
scenario: "webhook error URL is not allowed",
@@ -155,10 +151,8 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
mod.errorAllowList = regexp2.MustCompile("foo", 0)
return mod
}(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
noDeadline: false,
expectError: true,
},
{
scenario: "webhook error URL is denied",
@@ -173,10 +167,8 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
mod.errorDenyList = regexp2.MustCompile("bar", 0)
return mod
}(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
noDeadline: false,
expectError: true,
},
{
scenario: "invalid webhook method (GET)",
@@ -384,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),
@@ -398,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),
@@ -438,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),
@@ -501,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

View File

@@ -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"

View File

@@ -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