Compare commits

...

10 Commits

Author SHA1 Message Date
Julien Neuhart
36b4d435c3 fix(supervisor): if process is already restarting, requeue a task (#754) 2023-12-18 10:32:35 +01:00
Julien Neuhart
a8bde9d396 fix(api): wait for modules readiness before starting server (#752) 2023-12-18 10:32:08 +01:00
Julien Neuhart
5c6f29095f chore: add deprecrated warning for PDF/A-1a 2023-12-18 10:29:29 +01:00
Julien Neuhart
0d3942848c fix(libreoffice): accept PDF/A-1b instead of PDF/A-1a (#751) 2023-12-18 10:27:19 +01:00
Julien Neuhart
9256a203ef feat: upgrade pdfcpu 2023-12-18 08:55:00 +01:00
Julien Neuhart
f13e045e7f fix(armhf): now download latest working version from snapshots 2023-12-18 08:54:40 +01:00
Julien Neuhart
a60cb33ff1 fix(typo): Pdf -> PDF 2023-12-18 08:54:25 +01:00
dependabot[bot]
dec32d981f chore(deps): bump actions/setup-go from 4 to 5 (#744)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 5.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-18 08:54:13 +01:00
Julien Neuhart
3046615c3c chore(chromium): typo in a comment 2023-12-18 08:54:02 +01:00
Julien Neuhart
130b94aa8c fix: special characters issues with filenames (#736) 2023-12-18 08:53:40 +01:00
28 changed files with 738 additions and 250 deletions

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v4 uses: actions/setup-go@v5
with: with:
go-version: '1.21' go-version: '1.21'
cache: false cache: false

View File

@@ -29,13 +29,14 @@ build: ## Build the Gotenberg's Docker image
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_ROOT_PATH=/ API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace API_TRACE_HEADER=Gotenberg-Trace
API_DISABLE_HEALTH_CHECK_LOGGING=false API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0 CHROMIUM_RESTART_AFTER=0
CHROMIUM_AUTO_START=false CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=10s CHROMIUM_START_TIMEOUT=20s
CHROMIUM_INCOGNITO=false CHROMIUM_INCOGNITO=false
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
@@ -49,7 +50,7 @@ CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10 LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_AUTO_START=false LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=10s LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_DISABLE_ROUTES=false LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT=auto LOG_FORMAT=auto
@@ -79,6 +80,7 @@ run: ## Start a Gotenberg container
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \ --api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \ --api-port-from-env=$(API_PORT_FROM_ENV) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \ --api-timeout=$(API_TIMEOUT) \
--api-root-path=$(API_ROOT_PATH) \ --api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \ --api-trace-header=$(API_TRACE_HEADER) \

View File

@@ -2,7 +2,6 @@
# concatenate them. Also, we have to repeat ARG instructions in each build # concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them. # stage that uses them.
ARG GOLANG_VERSION ARG GOLANG_VERSION
ARG GOTENBERG_VERSION
# ---------------------------------------------- # ----------------------------------------------
# Gotenberg binary build stage # Gotenberg binary build stage
@@ -134,7 +133,12 @@ RUN \
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \ mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \ elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
apt-get update -qq &&\ apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium-common="$TMP_CHOMIUM_VERSION_ARMHF" chromium="$TMP_CHOMIUM_VERSION_ARMHF"; \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" "./binary-chromium/chromium_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \ else \
apt-get update -qq &&\ apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \

View File

@@ -84,7 +84,6 @@ func Run() {
startupMessage := app.StartupMessage() startupMessage := app.StartupMessage()
if startupMessage == "" { if startupMessage == "" {
fmt.Printf("[SYSTEM] %s: application started\n", id) fmt.Printf("[SYSTEM] %s: application started\n", id)
return return
} }
@@ -144,7 +143,6 @@ func Run() {
} }
fmt.Printf("[SYSTEM] %s: application stopped\n", id) fmt.Printf("[SYSTEM] %s: application stopped\n", id)
return nil return nil
} }
}(a.(gotenberg.App))) }(a.(gotenberg.App)))

18
go.mod
View File

@@ -5,13 +5,13 @@ go 1.21
require ( require (
github.com/alexliesenfeld/health v0.8.0 github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.0.6 // indirect github.com/andybalholm/brotli v1.0.6 // indirect
github.com/chromedp/cdproto v0.0.0-20231114014204-3e458d5176f9 github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61
github.com/chromedp/chromedp v0.9.3 github.com/chromedp/chromedp v0.9.3
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.4.0 github.com/google/uuid v1.5.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 github.com/hashicorp/go-retryablehttp v0.7.5
github.com/klauspost/compress v1.17.3 // indirect github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.11.3 github.com/labstack/echo/v4 v4.11.3
github.com/labstack/gommon v0.4.1 github.com/labstack/gommon v0.4.1
@@ -19,20 +19,20 @@ require (
github.com/mholt/archiver/v3 v3.5.1 github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.26 github.com/microcosm-cc/bluemonday v1.0.26
github.com/nwaples/rardecode v1.1.3 // indirect github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pdfcpu/pdfcpu v0.5.0 github.com/pdfcpu/pdfcpu v0.6.0
github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pierrec/lz4/v4 v4.1.19 // indirect
github.com/prometheus/client_golang v1.17.0 github.com/prometheus/client_golang v1.17.0
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.11 // indirect github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.26.0 go.uber.org/zap v1.26.0
golang.org/x/crypto v0.15.0 // indirect golang.org/x/crypto v0.16.0 // indirect
golang.org/x/image v0.14.0 // indirect golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.18.0 golang.org/x/net v0.19.0
golang.org/x/sync v0.5.0 golang.org/x/sync v0.5.0
golang.org/x/sys v0.14.0 // indirect golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.14.0 golang.org/x/term v0.15.0
golang.org/x/text v0.14.0 golang.org/x/text v0.14.0
) )

36
go.sum
View File

@@ -10,8 +10,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 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.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20231114014204-3e458d5176f9 h1:e3tMnG8i9SfKOilykpprojNk3a49O4dn+wqZsam1qYQ= github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61 h1:XD280QPATe9jaz20dylKe3vBsNcH1w3mkssGY0lidn8=
github.com/chromedp/cdproto v0.0.0-20231114014204-3e458d5176f9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg= github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg=
github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA= github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
@@ -35,8 +35,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -53,8 +53,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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
@@ -89,11 +89,11 @@ github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9l
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pdfcpu/pdfcpu v0.5.0 h1:F3wC4bwPbaJM+RPgm1D0Q4SAUwxElw7BhwNvL3iPgDo= github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI=
github.com/pdfcpu/pdfcpu v0.5.0/go.mod h1:UPcHdWcMw1V6Bo5tcWHd3jZfkG8cwUwrJkQOlB6o+7g= github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= github.com/pierrec/lz4/v4 v4.1.19 h1:tYLzDnjDXh9qIxSTKHwXwOYmm9d887Y7Y1ZkyXYHAN4=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.19/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -136,20 +136,20 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 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/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -2,12 +2,17 @@ package gotenberg
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sync/atomic" "sync/atomic"
"go.uber.org/zap" "go.uber.org/zap"
) )
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
// to restart an already restarting [Process].
var ErrProcessAlreadyRestarting = errors.New("process already restarting")
// Process is an interface that represents an abstract process // Process is an interface that represents an abstract process
// and provides methods for starting, stopping, and checking the health of the // and provides methods for starting, stopping, and checking the health of the
// process. // process.
@@ -122,7 +127,7 @@ func (s *processSupervisor) restart() error {
if s.isRestarting.Load() { if s.isRestarting.Load() {
s.logger.Debug("process already restarting, skip restart") s.logger.Debug("process already restarting, skip restart")
return nil return ErrProcessAlreadyRestarting
} }
s.logger.Debug("restart process") s.logger.Debug("restart process")
@@ -164,53 +169,66 @@ func (s *processSupervisor) Healthy() bool {
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error { func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
s.reqQueueSize.Add(1) s.reqQueueSize.Add(1)
select { for {
case s.mutexChan <- struct{}{}: err := func() error {
logger.Debug("process lock acquired") select {
s.reqQueueSize.Add(-1) case s.mutexChan <- struct{}{}:
s.reqCounter.Add(1) logger.Debug("process lock acquired")
s.reqQueueSize.Add(-1)
s.reqCounter.Add(1)
defer func() { defer func() {
logger.Debug("process lock released") logger.Debug("process lock released")
<-s.mutexChan <-s.mutexChan
}()
if !s.firstStart.Load() {
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
}
}
if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting...")
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
return s.runWithDeadline(ctx, task)
case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1)
return fmt.Errorf("acquire process lock: %w", ctx.Err())
}
}() }()
if !s.firstStart.Load() { if errors.Is(err, ErrProcessAlreadyRestarting) {
err := s.runWithDeadline(ctx, func() error { logger.Debug("process is already restarting, trying to acquire process lock again...")
return s.Launch() s.reqQueueSize.Add(1)
}) continue
if err != nil {
return fmt.Errorf("process first start: %w", err)
}
} }
if !s.Healthy() { // Note: no error wrapping because it leaks on Chromium console exceptions output.
s.logger.Debug("process is unhealthy, cannot handle task, restarting...") return err
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting...")
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
// FIXME: no error wrapping because it leaks on Chromium console exceptions output.
return s.runWithDeadline(ctx, task)
case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1)
return fmt.Errorf("acquire process lock: %w", ctx.Err())
} }
} }

View File

@@ -115,11 +115,13 @@ func TestProcessSupervisor_restart(t *testing.T) {
startError error startError error
stopError error stopError error
expectError bool expectError bool
expectedError error
}{ }{
{ {
scenario: "already restarting", scenario: "already restarting",
initiallyRestarting: true, initiallyRestarting: true,
expectError: false, expectError: true,
expectedError: ErrProcessAlreadyRestarting,
}, },
{ {
scenario: "successful restart", scenario: "successful restart",
@@ -166,6 +168,10 @@ func TestProcessSupervisor_restart(t *testing.T) {
if tc.expectError && err == nil { if tc.expectError && err == nil {
t.Fatal("expected error but got none") 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)
}
}) })
} }
} }
@@ -232,12 +238,14 @@ func TestProcessSupervisor_Run(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
initiallyStarted bool initiallyStarted bool
isRestarting bool
startError error startError error
processHealthy bool processHealthy bool
maxReqLimit int64 maxReqLimit int64
tasksToRun int tasksToRun int
taskError error taskError error
expectError bool expectError bool
skipCallsCheck bool
expectedStartCalls int64 expectedStartCalls int64
expectedHealthyCalls int64 expectedHealthyCalls int64
expectedStopCalls int64 expectedStopCalls int64
@@ -245,6 +253,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
{ {
scenario: "successfully run task on non-started process", scenario: "successfully run task on non-started process",
initiallyStarted: false, initiallyStarted: false,
isRestarting: false,
processHealthy: true, processHealthy: true,
maxReqLimit: 2, maxReqLimit: 2,
tasksToRun: 1, tasksToRun: 1,
@@ -256,6 +265,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
{ {
scenario: "cannot launch non-started process", scenario: "cannot launch non-started process",
initiallyStarted: false, initiallyStarted: false,
isRestarting: false,
startError: errors.New("launch error"), startError: errors.New("launch error"),
processHealthy: true, processHealthy: true,
maxReqLimit: 2, maxReqLimit: 2,
@@ -268,6 +278,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
{ {
scenario: "run task with unhealthy process causing restart", scenario: "run task with unhealthy process causing restart",
initiallyStarted: true, initiallyStarted: true,
isRestarting: false,
processHealthy: false, processHealthy: false,
maxReqLimit: 2, maxReqLimit: 2,
tasksToRun: 1, tasksToRun: 1,
@@ -280,6 +291,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
scenario: "cannot restart unhealthy process", scenario: "cannot restart unhealthy process",
startError: errors.New("start error"), startError: errors.New("start error"),
initiallyStarted: true, initiallyStarted: true,
isRestarting: false,
processHealthy: false, processHealthy: false,
maxReqLimit: 2, maxReqLimit: 2,
tasksToRun: 1, tasksToRun: 1,
@@ -288,9 +300,20 @@ func TestProcessSupervisor_Run(t *testing.T) {
expectedHealthyCalls: 1, expectedHealthyCalls: 1,
expectedStopCalls: 1, expectedStopCalls: 1,
}, },
{
scenario: "ErrProcessAlreadyRestarting",
initiallyStarted: true,
isRestarting: true,
processHealthy: false,
maxReqLimit: 1,
tasksToRun: 1,
expectError: true,
skipCallsCheck: true,
},
{ {
scenario: "run tasks reaching max request limit causing restart", scenario: "run tasks reaching max request limit causing restart",
initiallyStarted: true, initiallyStarted: true,
isRestarting: false,
processHealthy: true, processHealthy: true,
maxReqLimit: 2, maxReqLimit: 2,
tasksToRun: 3, tasksToRun: 3,
@@ -303,6 +326,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
scenario: "cannot restart after reaching max request limit", scenario: "cannot restart after reaching max request limit",
startError: errors.New("start error"), startError: errors.New("start error"),
initiallyStarted: true, initiallyStarted: true,
isRestarting: false,
processHealthy: true, processHealthy: true,
maxReqLimit: 2, maxReqLimit: 2,
tasksToRun: 2, tasksToRun: 2,
@@ -314,6 +338,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
{ {
scenario: "task error", scenario: "task error",
initiallyStarted: true, initiallyStarted: true,
isRestarting: false,
processHealthy: true, processHealthy: true,
maxReqLimit: 0, maxReqLimit: 0,
tasksToRun: 1, tasksToRun: 1,
@@ -351,6 +376,9 @@ func TestProcessSupervisor_Run(t *testing.T) {
if tc.initiallyStarted { if tc.initiallyStarted {
ps.firstStart.Store(true) ps.firstStart.Store(true)
} }
if tc.isRestarting {
ps.isRestarting.Store(true)
}
task := func() error { task := func() error {
return tc.taskError return tc.taskError
@@ -386,6 +414,10 @@ func TestProcessSupervisor_Run(t *testing.T) {
} }
} }
if tc.skipCallsCheck {
return
}
if startCalls.Load() != tc.expectedStartCalls { if startCalls.Load() != tc.expectedStartCalls {
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load()) t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
} }

View File

@@ -17,6 +17,7 @@ import (
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
) )
@@ -31,6 +32,7 @@ type Api struct {
port int port int
readTimeout time.Duration readTimeout time.Duration
writeTimeout time.Duration writeTimeout time.Duration
startTimeout time.Duration
timeout time.Duration timeout time.Duration
rootPath string rootPath string
traceHeader string traceHeader string
@@ -39,6 +41,7 @@ type Api struct {
routes []Route routes []Route
externalMiddlewares []Middleware externalMiddlewares []Middleware
healthChecks []health.CheckerOption healthChecks []health.CheckerOption
readyFn []func() error
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
logger *zap.Logger logger *zap.Logger
srv *echo.Echo srv *echo.Echo
@@ -147,6 +150,7 @@ type Middleware struct {
// See https://github.com/alexliesenfeld/health for more details. // See https://github.com/alexliesenfeld/health for more details.
type HealthChecker interface { type HealthChecker interface {
Checks() ([]health.CheckerOption, error) Checks() ([]health.CheckerOption, error)
Ready() error
} }
// Descriptor returns an [Api]'s module descriptor. // Descriptor returns an [Api]'s module descriptor.
@@ -157,6 +161,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("api", flag.ExitOnError) fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int("api-port", 3000, "Set the port on which the API should listen") fs.Int("api-port", 3000, "Set the port on which the API should listen")
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port") fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body") fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body")
fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request") fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request")
fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response") fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
@@ -184,6 +189,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
func (a *Api) Provision(ctx *gotenberg.Context) error { func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port") a.port = flags.MustInt("api-port")
a.startTimeout = flags.MustDuration("api-start-timeout")
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout") a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout") a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout") a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
@@ -275,6 +281,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
} }
a.healthChecks = append(a.healthChecks, checks...) a.healthChecks = append(a.healthChecks, checks...)
a.readyFn = append(a.readyFn, healthChecker.Ready)
} }
// Logger. // Logger.
@@ -446,12 +453,25 @@ func (a *Api) Start() error {
func() echo.HandlerFunc { func() echo.HandlerFunc {
checks := append(a.healthChecks, health.WithTimeout(a.timeout)) checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...) checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker)) return echo.WrapHandler(health.NewHandler(checker))
}(), }(),
hardTimeoutMiddleware(hardTimeout), hardTimeoutMiddleware(hardTimeout),
) )
// Wait for all modules to be ready.
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
defer cancel()
eg, _ := errgroup.WithContext(ctx)
for _, f := range a.readyFn {
eg.Go(f)
}
err := eg.Wait()
if err != nil {
return fmt.Errorf("waiting for modules readiness: %w", err)
}
// As the following code is blocking, run it in a goroutine. // As the following code is blocking, run it in a goroutine.
go func() { go func() {
server := &http2.Server{} server := &http2.Server{}

View File

@@ -10,6 +10,7 @@ import (
"os" "os"
"reflect" "reflect"
"testing" "testing"
"time"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -222,9 +223,6 @@ func TestApi_Provision(t *testing.T) {
mod.ValidateMock = func() error { mod.ValidateMock = func() error {
return errors.New("foo") return errors.New("foo")
} }
mod.ChecksMock = func() ([]health.CheckerOption, error) {
return nil, nil
}
return gotenberg.NewContext( return gotenberg.NewContext(
gotenberg.ParsedFlags{ gotenberg.ParsedFlags{
FlagSet: new(Api).Descriptor().FlagSet, FlagSet: new(Api).Descriptor().FlagSet,
@@ -347,6 +345,9 @@ func TestApi_Provision(t *testing.T) {
mod3.ChecksMock = func() ([]health.CheckerOption, error) { mod3.ChecksMock = func() ([]health.CheckerOption, error) {
return []health.CheckerOption{health.WithDisabledAutostart()}, nil return []health.CheckerOption{health.WithDisabledAutostart()}, nil
} }
mod3.ReadyMock = func() error {
return nil
}
mod4 := &struct { mod4 := &struct {
gotenberg.ModuleMock gotenberg.ModuleMock
@@ -643,141 +644,176 @@ func TestApi_Validate(t *testing.T) {
} }
func TestApi_Start(t *testing.T) { func TestApi_Start(t *testing.T) {
mod := new(Api) for _, tc := range []struct {
mod.port = 3000 scenario string
mod.rootPath = "/" readyFn []func() error
mod.disableHealthCheckLogging = true expectError bool
mod.routes = []Route{ }{
{ {
Method: http.MethodPost, scenario: "at least one module not ready",
Path: "/forms/foo", readyFn: []func() error{
IsMultipart: true, func() error { return nil },
DisableLogging: true, func() error { return errors.New("not ready") },
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*Context)
ctx.outputPaths = []string{
"/tests/test/testdata/api/sample1.txt",
}
return nil
}, },
expectError: true,
}, },
{ {
Method: http.MethodPost, scenario: "success",
Path: "/forms/bar", readyFn: []func() error{
IsMultipart: true, func() error { return nil },
Handler: func(_ echo.Context) error { return errors.New("foo") }, func() error { return nil },
},
expectError: false,
}, },
} } {
mod.externalMiddlewares = []Middleware{ t.Run(tc.scenario, func(t *testing.T) {
{ mod := new(Api)
Stack: PreRouterStack, mod.port = 3000
Handler: func() echo.MiddlewareFunc { mod.startTimeout = time.Duration(30) * time.Second
return func(next echo.HandlerFunc) echo.HandlerFunc { mod.rootPath = "/"
return func(c echo.Context) error { mod.disableHealthCheckLogging = true
return next(c) mod.routes = []Route{
} {
} Method: http.MethodPost,
}(), Path: "/forms/foo",
}, IsMultipart: true,
{ DisableLogging: true,
Stack: MultipartStack, Handler: func(c echo.Context) error {
Handler: func() echo.MiddlewareFunc { ctx := c.Get("context").(*Context)
return func(next echo.HandlerFunc) echo.HandlerFunc { ctx.outputPaths = []string{
return func(c echo.Context) error { "/tests/test/testdata/api/sample1.txt",
return next(c) }
}
}
}(),
},
{
Stack: DefaultStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
}
mod.fs = gotenberg.NewFileSystem()
mod.logger = zap.NewNop()
err := mod.Start() return nil
if err != nil { },
t.Fatalf("expected no error but got: %v", err) },
} {
Method: http.MethodPost,
Path: "/forms/bar",
IsMultipart: true,
Handler: func(_ echo.Context) error { return errors.New("foo") },
},
}
mod.externalMiddlewares = []Middleware{
{
Stack: PreRouterStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Stack: DefaultStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
{
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}(),
},
}
mod.readyFn = tc.readyFn
mod.fs = gotenberg.NewFileSystem()
mod.logger = zap.NewNop()
// health request. err := mod.Start()
recorder := httptest.NewRecorder() if !tc.expectError && err != nil {
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthRequest)
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{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
}()
err := writer.WriteField("foo", "foo") if tc.expectError && err == nil {
if err != nil { t.Fatal("expected error but got none")
t.Fatalf("expected no error but got: %v", err) }
}
part, err := writer.CreateFormFile("foo.txt", "foo.txt") if tc.expectError {
if err != nil { return
t.Fatalf("expected no error but got: %v", err) }
}
_, err = part.Write([]byte("foo")) // health request.
if err != nil { recorder := httptest.NewRecorder()
t.Fatalf("expected no error but got: %v", err) healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
}
req := httptest.NewRequest(http.MethodPost, url, body) mod.srv.ServeHTTP(recorder, healthRequest)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
return req // "multipart/form-data" request.
} multipartRequest := func(url string) *http.Request {
body := &bytes.Buffer{}
recorder = httptest.NewRecorder() writer := multipart.NewWriter(body)
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
if recorder.Code != http.StatusOK { defer func() {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) err := writer.Close()
} if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
recorder = httptest.NewRecorder() err := writer.WriteField("foo", "foo")
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar")) if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if recorder.Code != http.StatusInternalServerError { part, err := writer.CreateFormFile("foo.txt", "foo.txt")
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code) if err != nil {
} t.Fatalf("expected no error but got: %v", err)
}
err = mod.srv.Shutdown(context.TODO()) _, err = part.Write([]byte("foo"))
if err != nil { if err != nil {
t.Errorf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, url, body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}
recorder = httptest.NewRecorder()
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
recorder = httptest.NewRecorder()
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar"))
if recorder.Code != http.StatusInternalServerError {
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code)
}
err = mod.srv.Shutdown(context.TODO())
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
})
} }
} }

View File

@@ -12,14 +12,11 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"unicode"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3" "github.com/mholt/archiver/v3"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
@@ -124,15 +121,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.files = make(map[string]string) ctx.files = make(map[string]string)
copyToDisk := func(fh *multipart.FileHeader) error { copyToDisk := func(fh *multipart.FileHeader) error {
// Avoid directory traversal and normalize filename.
// See https://github.com/gotenberg/gotenberg/issues/104.
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
if err != nil {
return fmt.Errorf("transform filename: %w", err)
}
in, err := fh.Open() in, err := fh.Open()
if err != nil { if err != nil {
return fmt.Errorf("open multipart file: %w", err) return fmt.Errorf("open multipart file: %w", err)
@@ -145,6 +133,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
}() }()
// Avoid directory traversal and make sure filename characters are
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename) path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
out, err := os.Create(path) out, err := os.Create(path)

View File

@@ -105,12 +105,17 @@ func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) {
// HealthCheckerMock is mock for the [HealthChecker] interface. // HealthCheckerMock is mock for the [HealthChecker] interface.
type HealthCheckerMock struct { type HealthCheckerMock struct {
ChecksMock func() ([]health.CheckerOption, error) ChecksMock func() ([]health.CheckerOption, error)
ReadyMock func() error
} }
func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) { func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) {
return mod.ChecksMock() return mod.ChecksMock()
} }
func (mod *HealthCheckerMock) Ready() error {
return mod.ReadyMock()
}
// Interface guards. // Interface guards.
var ( var (
_ Router = (*RouterMock)(nil) _ Router = (*RouterMock)(nil)

View File

@@ -148,10 +148,18 @@ func TestHealthCheckerMock(t *testing.T) {
ChecksMock: func() ([]health.CheckerOption, error) { ChecksMock: func() ([]health.CheckerOption, error) {
return nil, nil return nil, nil
}, },
ReadyMock: func() error {
return nil
},
} }
_, err := mock.Checks() _, err := mock.Checks()
if err != nil { if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err) t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
} }
err = mock.Ready()
if err != nil {
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
}
} }

View File

@@ -137,7 +137,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
b.ctxMu.Lock() b.ctxMu.Lock()
defer b.ctxMu.Unlock() defer b.ctxMu.Unlock()
// We have to keep the context around, as we need it to create a new tabs // We have to keep the context around, as we need it to create new tabs
// later. // later.
b.ctx = ctx b.ctx = ctx
b.cancelFunc = func() { b.cancelFunc = func() {

View File

@@ -231,7 +231,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature") fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion") fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for Chromium to start or restart") fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode") fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost") fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors") fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
@@ -408,6 +408,34 @@ func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
}, nil }, nil
} }
// Ready returns no error if the module is ready.
func (mod *Chromium) Ready() error {
if !mod.autoStart {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), mod.args.wsUrlReadTimeout)
defer cancel()
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while waiting for Chromium to be ready: %w", ctx.Err())
case <-ticker.C:
ok := mod.browser.Healthy(mod.logger)
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
// Chromium returns an [Api] for interacting with Chromium for converting HTML // Chromium returns an [Api] for interacting with Chromium for converting HTML
// documents to PDF. // documents to PDF.
func (mod *Chromium) Chromium() (Api, error) { func (mod *Chromium) Chromium() (Api, error) {

View File

@@ -399,6 +399,61 @@ func TestChromium_Checks(t *testing.T) {
} }
} }
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
browser browser
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.autoStart = tc.autoStart
mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout}
mod.browser = tc.browser
err := mod.Ready()
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 TestChromium_Chromium(t *testing.T) { func TestChromium_Chromium(t *testing.T) {
mod := new(Chromium) mod := new(Chromium)

View File

@@ -179,7 +179,6 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
// completed or an error is encountered. // completed or an error is encountered.
func runBatch(ctx context.Context, fn ...func() error) error { func runBatch(ctx context.Context, fn ...func() error) error {
eg, _ := errgroup.WithContext(ctx) eg, _ := errgroup.WithContext(ctx)
for _, f := range fn { for _, f := range fn {
eg.Go(f) eg.Go(f)
} }

View File

@@ -734,7 +734,7 @@ func TestConvertUrl(t *testing.T) {
engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { engine: &gotenberg.PdfEngineMock{ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil return nil
}}, }},
pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1a}, pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
options: DefaultOptions(), options: DefaultOptions(),
expectError: false, expectError: false,
expectHttpError: false, expectHttpError: false,

View File

@@ -47,11 +47,11 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
WithFooterTemplate(options.FooterTemplate) WithFooterTemplate(options.FooterTemplate)
} }
logger.Debug(fmt.Sprintf("print to Pdf with: %+v", printToPdf)) logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf))
_, stream, err := printToPdf.Do(ctx) _, stream, err := printToPdf.Do(ctx)
if err != nil { if err != nil {
return fmt.Errorf("print to Pdf: %w", err) return fmt.Errorf("print to PDF: %w", err)
} }
reader := &streamReader{ reader := &streamReader{
@@ -295,12 +295,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
select { select {
case <-ctx.Done(): case <-ctx.Done():
ticker.Stop() ticker.Stop()
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err()) return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
case <-ticker.C: case <-ticker.C:
var ok bool var ok bool
evaluate := chromedp.Evaluate(expression, &ok) evaluate := chromedp.Evaluate(expression, &ok)
err := evaluate.Do(ctx) err := evaluate.Do(ctx)
if err != nil { if err != nil {
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression) return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
@@ -308,7 +307,6 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
if ok { if ok {
ticker.Stop() ticker.Stop()
return nil return nil
} }

View File

@@ -51,7 +51,7 @@ type Options struct {
// Optional. // Optional.
PageRanges string PageRanges string
// PdfFormats allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b, // PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA. // PDF/A-3b and PDF/UA.
// Optional. // Optional.
PdfFormats gotenberg.PdfFormats PdfFormats gotenberg.PdfFormats
@@ -97,7 +97,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature") fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion") fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for LibreOffice to start or restart") fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
return fs return fs
}(), }(),
@@ -277,6 +277,34 @@ func (a *Api) Checks() ([]health.CheckerOption, error) {
}, nil }, nil
} }
// Ready returns no error if the module is ready.
func (a *Api) Ready() error {
if !a.autoStart {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), a.args.startTimeout)
defer cancel()
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
select {
case <-ctx.Done():
ticker.Stop()
return fmt.Errorf("context done while waiting for LibreOffice to be ready: %w", ctx.Err())
case <-ticker.C:
ok := a.libreOffice.Healthy(a.logger)
if ok {
ticker.Stop()
return nil
}
continue
}
}
}
// LibreOffice returns a [Uno] for interacting with LibreOffice. // LibreOffice returns a [Uno] for interacting with LibreOffice.
func (a *Api) LibreOffice() (Uno, error) { func (a *Api) LibreOffice() (Uno, error) {
return a, nil return a, nil

View File

@@ -364,6 +364,61 @@ func TestApi_Checks(t *testing.T) {
} }
} }
func TestChromium_Ready(t *testing.T) {
for _, tc := range []struct {
scenario string
autoStart bool
startTimeout time.Duration
libreOffice libreOffice
expectError bool
}{
{
scenario: "no auto-start",
autoStart: false,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: false,
},
{
scenario: "auto-start: context done",
autoStart: true,
startTimeout: time.Duration(200) * time.Millisecond,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return false
}}},
expectError: true,
},
{
scenario: "auto-start success",
autoStart: true,
startTimeout: time.Duration(30) * time.Second,
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
return true
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
a.autoStart = tc.autoStart
a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
a.libreOffice = tc.libreOffice
err := a.Ready()
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 TestApi_LibreOffice(t *testing.T) { func TestApi_LibreOffice(t *testing.T) {
a := new(Api) a := new(Api)

View File

@@ -4,12 +4,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"os" "os"
"path/filepath"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg" "github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
@@ -273,6 +276,9 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
switch options.PdfFormats.PdfA { switch options.PdfFormats.PdfA {
case "": case "":
case gotenberg.PdfA1a: case gotenberg.PdfA1a:
logger.Warn("PDF/A-1a is no more supported by LibreOffice (use PDF/A-1b instead)")
args = append(args, "--export", "SelectPdfVersion=1")
case gotenberg.PdfA1b:
args = append(args, "--export", "SelectPdfVersion=1") args = append(args, "--export", "SelectPdfVersion=1")
case gotenberg.PdfA2b: case gotenberg.PdfA2b:
args = append(args, "--export", "SelectPdfVersion=2") args = append(args, "--export", "SelectPdfVersion=2")
@@ -290,6 +296,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
) )
} }
inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath)
if err != nil {
return fmt.Errorf("non-basic latin characters guard: %w", err)
}
args = append(args, "--output", outputPath, inputPath) args = append(args, "--output", outputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...)
@@ -321,6 +332,65 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)
} }
// LibreOffice cannot convert a file with a name containing non-basic Latin
// characters.
// See:
// https://github.com/gotenberg/gotenberg/issues/104
// https://github.com/gotenberg/gotenberg/issues/730
func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) {
hasNonBasicLatinChars := func(str string) bool {
for _, r := range str {
// Check if the character is outside basic Latin.
if r != '.' && (r < ' ' || r > '~') {
return true
}
}
return false
}
filename := filepath.Base(inputPath)
if !hasNonBasicLatinChars(filename) {
logger.Debug("no non-basic latin characters in filename, skip copy")
return inputPath, nil
}
logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename")
basePath := filepath.Dir(inputPath)
ext := filepath.Ext(inputPath)
newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext))
in, err := os.Open(inputPath)
if err != nil {
return "", fmt.Errorf("open file: %w", err)
}
defer func() {
err := in.Close()
if err != nil {
logger.Error(fmt.Sprintf("close file: %s", err))
}
}()
out, err := os.Create(newInputPath)
if err != nil {
return "", fmt.Errorf("create new file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close new file: %s", err))
}
}()
_, err = io.Copy(out, in)
if err != nil {
return "", fmt.Errorf("copy file to new file: %w", err)
}
return newInputPath, nil
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Process = (*libreOfficeProcess)(nil) _ gotenberg.Process = (*libreOfficeProcess)(nil)

View File

@@ -394,7 +394,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
expectError: false, expectError: false,
}, },
{ {
scenario: "success (PDF/A-1a)", scenario: "success (PDF/A-1b)",
libreOffice: newLibreOfficeProcess( libreOffice: newLibreOfficeProcess(
libreOfficeArguments{ libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"), binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
@@ -417,7 +417,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
return fs return fs
}(), }(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1a}}, options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
cancelledCtx: false, cancelledCtx: false,
start: true, start: true,
expectError: false, expectError: false,
@@ -545,7 +545,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
err := tc.libreOffice.pdf( err := tc.libreOffice.pdf(
ctx, ctx,
logger, logger,
fmt.Sprintf("file://%s/document.txt", tc.fs.WorkingDirPath()), fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()), fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
tc.options, tc.options,
) )
@@ -564,3 +564,87 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}) })
} }
} }
func TestNonBasicLatinCharactersGuard(t *testing.T) {
for _, tc := range []struct {
scenario string
fs *gotenberg.FileSystem
filename string
expectSameInputPath bool
expectError bool
}{
{
scenario: "basic latin characters",
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("Basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "document.txt",
expectSameInputPath: true,
expectError: false,
},
{
scenario: "non-basic latin characters",
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/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
filename: "éèßàùä.txt",
expectSameInputPath: false,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up, but got: %v", err)
}
}()
inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
newInputPath, err := nonBasicLatinCharactersGuard(
zap.NewNop(),
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.expectSameInputPath && newInputPath != inputPath {
t.Fatalf("expected same input path, but got '%s'", newInputPath)
}
if !tc.expectSameInputPath && newInputPath == inputPath {
t.Fatalf("expected different input path, but got same '%s'", newInputPath)
}
})
}
}

View File

@@ -18,7 +18,7 @@ func init() {
// LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API // LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API
// and implements the [gotenberg.PdfEngine] interface. // and implements the [gotenberg.PdfEngine] interface.
type LibreOfficePdfEngine struct { type LibreOfficePdfEngine struct {
unoAPI api.Uno unoApi api.Uno
} }
// Descriptor returns a [LibreOfficePdfEngine]'s module descriptor. // Descriptor returns a [LibreOfficePdfEngine]'s module descriptor.
@@ -36,12 +36,12 @@ func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error {
return fmt.Errorf("get LibreOffice Uno provider: %w", err) return fmt.Errorf("get LibreOffice Uno provider: %w", err)
} }
unoAPI, err := provider.(api.Provider).LibreOffice() unoApi, err := provider.(api.Provider).LibreOffice()
if err != nil { if err != nil {
return fmt.Errorf("get LibreOffice Uno: %w", err) return fmt.Errorf("get LibreOffice Uno: %w", err)
} }
engine.unoAPI = unoAPI engine.unoApi = unoApi
return nil return nil
} }
@@ -52,11 +52,11 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
} }
// Convert converts the given PDF to a specific PDF format. Currently, only the // Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1a, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
// error. // error.
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
err := engine.unoAPI.Pdf(ctx, logger, inputPath, outputPath, api.Options{ err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, api.Options{
PdfFormats: formats, PdfFormats: formats,
}) })

View File

@@ -153,7 +153,7 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
engine := &LibreOfficePdfEngine{unoAPI: tc.api} engine := &LibreOfficePdfEngine{unoApi: tc.api}
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !tc.expectError && err != nil { if !tc.expectError && err != nil {

View File

@@ -88,9 +88,12 @@ func TestConvertRoute(t *testing.T) {
"document.docx": "/document.docx", "document.docx": "/document.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfFormat": { "pdfa": {
"foo", "foo",
}, },
"nativePdfFormats": {
"false",
},
}) })
return ctx return ctx
}(), }(),
@@ -120,8 +123,11 @@ func TestConvertRoute(t *testing.T) {
"document.docx": "/document.docx", "document.docx": "/document.docx",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfFormat": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
},
"nativePdfFormats": {
"false",
}, },
}) })
return ctx return ctx
@@ -209,7 +215,7 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 2, expectOutputPathsCount: 2,
}, },
{ {
scenario: "success with non-native PDF/A (single file)", scenario: "success with non-native PDF/A & PDF/UA (single file)",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
@@ -217,7 +223,10 @@ func TestConvertRoute(t *testing.T) {
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
},
"pdfua": {
"true",
}, },
"nativePdfFormats": { "nativePdfFormats": {
"false", "false",
@@ -254,13 +263,13 @@ func TestConvertRoute(t *testing.T) {
"true", "true",
}, },
"pdfFormat": { "pdfFormat": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
"nativePdfFormat": { "nativePdfFormat": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
"pdfua": { "pdfua": {
"true", "true",
@@ -375,7 +384,7 @@ func TestConvertRoute(t *testing.T) {
"true", "true",
}, },
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
"nativePdfFormats": { "nativePdfFormats": {
"false", "false",
@@ -469,7 +478,7 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 1, expectOutputPathsCount: 1,
}, },
{ {
scenario: "success with non-native PDF/A (merge)", scenario: "success with non-native PDF/A & PDF/UA (merge)",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
@@ -481,7 +490,54 @@ func TestConvertRoute(t *testing.T) {
"true", "true",
}, },
"pdfa": { "pdfa": {
gotenberg.PdfA1a, 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": { "nativePdfFormats": {
"false", "false",

View File

@@ -41,7 +41,7 @@ func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error {
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, engine.conf) err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, false, engine.conf)
if err == nil { if err == nil {
return nil return nil
} }

View File

@@ -99,7 +99,7 @@ func TestMergeHandler(t *testing.T) {
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
}) })
return ctx return ctx
@@ -127,7 +127,7 @@ func TestMergeHandler(t *testing.T) {
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
}) })
return ctx return ctx
@@ -157,7 +157,7 @@ func TestMergeHandler(t *testing.T) {
gotenberg.PdfA1a, gotenberg.PdfA1a,
}, },
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
"pdfua": { "pdfua": {
"true", "true",
@@ -259,7 +259,7 @@ func TestConvertHandler(t *testing.T) {
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
}) })
return ctx return ctx
@@ -283,7 +283,7 @@ func TestConvertHandler(t *testing.T) {
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
}) })
return ctx return ctx
@@ -306,7 +306,7 @@ func TestConvertHandler(t *testing.T) {
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
}) })
ctx.SetCancelled(true) ctx.SetCancelled(true)
@@ -333,7 +333,7 @@ func TestConvertHandler(t *testing.T) {
gotenberg.PdfA1a, gotenberg.PdfA1a,
}, },
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
"pdfua": { "pdfua": {
"true", "true",
@@ -363,7 +363,7 @@ func TestConvertHandler(t *testing.T) {
gotenberg.PdfA1a, gotenberg.PdfA1a,
}, },
"pdfa": { "pdfa": {
gotenberg.PdfA1a, gotenberg.PdfA1b,
}, },
"pdfua": { "pdfua": {
"true", "true",