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
114 changed files with 3338 additions and 12209 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: [gulien]

4
.github/stale.yml vendored
View File

@@ -1,7 +1,7 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 7
daysUntilStale: 15
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 3
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- bug

View File

@@ -23,4 +23,4 @@ jobs:
- name: Build and push Docker image for release
run: |
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REGISTRY=thecodingmachine
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REPOSITORY=thecodingmachine

View File

@@ -17,18 +17,18 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
go-version: '1.21'
cache: false
- name: Checkout source code
uses: actions/checkout@v4
- name: Run linters
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v3
with:
version: v1.61.0
version: v1.54.2
tests:
needs:
- lint
- Lint
name: Tests
# TODO: once arm64 actions are available, also run the tests on this architecture.
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
@@ -43,39 +43,15 @@ jobs:
- name: Build testing environment
run: make build build-tests
- name: Run tests
run: make tests-once
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
run: |
make tests-once
bash <(curl -s https://codecov.io/bash)
snapshot_release:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot release
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Log in to Docker Hub Container Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push snapshot Docker image (linux/amd64)
run: make release GOTENBERG_VERSION=${{ github.head_ref }} DOCKER_REPOSITORY=snapshot LINUX_AMD64_RELEASE=true
edge_release:
multiarch_build:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge release
- Tests
name: Multi-arch build
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
@@ -92,4 +68,4 @@ jobs:
- name: Build and push Docker image for main branch
run: |
make release GOTENBERG_VERSION=edge
make release GOTENBERG_VERSION=edge DOCKER_REGISTRY=thecodingmachine
make release GOTENBERG_VERSION=edge DOCKER_REPOSITORY=thecodingmachine

4
.gitignore vendored
View File

@@ -1,5 +1,3 @@
/coverage.html
/coverage.txt
.idea
.vscode
.DS_Store
/TODO.txt

View File

@@ -3,58 +3,35 @@ linters-settings:
sections:
- standard
- default
- prefix(github.com/gotenberg/gotenberg/v8)
- prefix(github.com/gotenberg/gotenberg/v7)
skip-generated: true
skip-vendor: true
custom-order: true
# Until https://github.com/securego/gosec/issues/1187 is resolved.
gosec:
excludes:
- G115
linters:
disable-all: true
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- copyloopvar
- errcheck
- errname
- exhaustive
- 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
deadline: 5m
issues-exit-code: 1
tests: false
output:
format: 'colored-line-number'
print-issued-lines: true
print-linter-name: true
print-linter-name: true

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Julien Neuhart
Copyright (c) 2023 Julien Neuhart
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -5,16 +5,14 @@ help: ## Show the help
.PHONY: it
it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.23
DOCKER_REGISTRY=gotenberg
GOLANG_VERSION=1.21
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
NOTO_COLOR_EMOJI_VERSION=v2.040 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.61.0 # See https://github.com/golangci/golangci-lint/releases.
GOLANGCI_LINT_VERSION=v1.54.2 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build
build: ## Build the Gotenberg's Docker image
@@ -25,29 +23,18 @@ build: ## Build the Gotenberg's Docker image
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
-f build/Dockerfile .
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000
API_PORT_FROM_ENV=
API_BIND_IP=
API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_BODY_LIMIT=
API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST=
API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
CHROMIUM_INCOGNITO=false
@@ -58,13 +45,10 @@ CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER=
CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_DISABLE_ROUTES=false
@@ -72,11 +56,6 @@ LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES=
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_DISABLE_ROUTES=false
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
@@ -96,28 +75,18 @@ 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_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-bind-ip=$(API_BIND_IP) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \
--api-body-limit="$(API_BODY_LIMIT)" \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
@@ -126,14 +95,11 @@ run: ## Start a Gotenberg container
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
@@ -141,18 +107,13 @@ run: ## Start a Gotenberg container
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
@@ -165,25 +126,24 @@ run: ## Start a Gotenberg container
build-tests: ## Build the tests' Docker image
docker build \
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
-f test/Dockerfile .
.PHONY: tests
tests: ## Start the testing environment
docker run --rm -it \
-v $(PWD):/tests \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
bash
.PHONY: tests-once
tests-once: ## Run the tests once (prefer the "tests" command while developing)
docker run --rm \
-v $(PWD):/tests \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
gotest
# go install mvdan.cc/gofumpt@latest
@@ -191,19 +151,17 @@ tests-once: ## Run the tests once (prefer the "tests" command while developing)
.PHONY: fmt
fmt: ## Format the code and "optimize" the dependencies
gofumpt -l -w .
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order .
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v7)" --skip-generated --skip-vendor --custom-order .
go mod tidy
# go install golang.org/x/tools/cmd/godoc@latest
.PHONY: godoc
godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7)
godoc -http=:6060
LINUX_AMD64_RELEASE=false
.PHONY: release
release: ## Build the Gotenberg's Docker image and push it to a Docker repository
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
./scripts/release.sh \
$(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \
@@ -211,8 +169,4 @@ release: ## Build the Gotenberg's Docker image and push it to a Docker repositor
$(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \
$(PDFCPU_VERSION) \
$(DOCKER_REGISTRY) \
$(DOCKER_REPOSITORY) \
$(LINUX_AMD64_RELEASE)
$(DOCKER_REPOSITORY)

View File

@@ -2,20 +2,12 @@
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for PDF files</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<br>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg"></a>
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
<a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a>
</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
</p>
---
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
## Quick Start
@@ -23,13 +15,13 @@ numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, an
Open a terminal and run the following command:
```
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
```
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
```
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
```
The API is now available on your host at http://localhost:3000.
@@ -40,11 +32,14 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
<p align="center">
<a href="https://thecodingmachine.com">
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
</a>
<a href="https://zolsec.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/gotenberg/gotenberg/assets/8983173/707ccc97-a79b-4dcb-8fc8-6827366e5be3" alt="Zolsec Logo" width="333" height="163" />
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" />
</a>
</p>
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️
## Badges
[![Docker pulls](https://img.shields.io/docker/pulls/gotenberg/gotenberg)](https://hub.docker.com/r/gotenberg/gotenberg)
[![Docker pulls](https://img.shields.io/docker/pulls/thecodingmachine/gotenberg)](https://hub.docker.com/r/thecodingmachine/gotenberg)
[![Continuous Integration](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg)](https://pkg.go.dev/github.com/gotenberg/gotenberg/v7)
[![Codecov](https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg)](https://codecov.io/gh/gotenberg/gotenberg)

View File

@@ -3,38 +3,13 @@
# stage that uses them.
ARG GOLANG_VERSION
# ----------------------------------------------
# pdfcpu binary build stage
# ----------------------------------------------
# Note: this stage is required as pdfcpu does not release an armhf variant by
# default.
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
ARG PDFCPU_VERSION
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
tar --strip-components=1 -xvzf pdfcpu.tar.gz
# Install module dependencies.
RUN go mod download &&\
go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
# Verify installation.
./pdfcpu version
# ----------------------------------------------
# Gotenberg binary build stage
# ----------------------------------------------
FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage
FROM golang:$GOLANG_VERSION AS binary-stage
ARG GOTENBERG_VERSION
ENV CGO_ENABLED=0
ENV CGO_ENABLED 0
# Define the working directory outside of $GOPATH (we're using go modules).
WORKDIR /home
@@ -49,7 +24,7 @@ RUN go mod download &&\
COPY cmd ./cmd
COPY pkg ./pkg
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
# ----------------------------------------------
# Final stage
@@ -189,20 +164,19 @@ RUN \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install PDFtk, QPDF & ExifTool (PDF engines).
# Install PDFtk & QPDF (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 exiftool &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
# 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/*
@@ -212,20 +186,15 @@ RUN \
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy the pdfcpu binary from the pdfcpu-binary-stage.
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
# Copy the Gotenberg binary from the gotenberg-binary-stage.
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Copy the Gotenberg binary from the binary stage.
COPY --from=binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
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
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
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
USER gotenberg
WORKDIR /home/gotenberg

View File

@@ -1,8 +1,7 @@
ARG DOCKER_REGISTRY
ARG DOCKER_REPOSITORY
ARG GOTENBERG_VERSION
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
USER root

View File

@@ -11,7 +11,7 @@ import (
flag "github.com/spf13/pflag"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
@@ -35,7 +35,6 @@ 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)
@@ -76,6 +75,7 @@ func Run() {
go func(app gotenberg.App) {
id := app.(gotenberg.Module).Descriptor().ID
err = app.Start()
if err != nil {
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
os.Exit(1)

View File

@@ -1,9 +1,9 @@
package main
import (
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
gotenbergcmd "github.com/gotenberg/gotenberg/v7/cmd"
// Gotenberg modules.
_ "github.com/gotenberg/gotenberg/v8/pkg/standard"
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
)
func main() {

744
docs/openapi.yaml Normal file
View File

@@ -0,0 +1,744 @@
openapi: 3.0.3
info:
title: Gotenberg
version: 7.x
license:
name: MIT
url: 'https://github.com/gotenberg/gotenberg/blob/main/LICENSE'
contact:
url: 'https://github.com/gotenberg/gotenberg'
description: >-
A Docker-powered stateless API for PDF files.
externalDocs:
url: https://gotenberg.dev
servers:
- url: 'http://localhost:3000'
description: Local server with the default Docker image and port
tags:
- name: chromium
description: Operations of the Chromium module
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
- name: libreoffice
description: Operations of the Libreoffice module
externalDocs:
url: https://gotenberg.dev/docs/modules/libreoffice
- name: pdfengines
description: Operations of the PDF Engines module
externalDocs:
url: https://gotenberg.dev/docs/modules/pdf-engines
paths:
/forms/chromium/convert/url:
post:
tags:
- chromium
summary: Convert the contents of a given URL to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
description: >-
Send a URL in your API request via the `url` form field
Send a remote URL in your API request via the `remoteURL` parameter, and
get the resulting PDF file. The API will fetch the given URL and render
the page to PDF using the underlying headless Chrome instance.
You can optionally include `header.html` and `footer.html` files as part of the request as well.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
required: true
description: >-
The request must be `multipart/form-data` that includes a `url` form field.
The API uses a headless Chrome instance to do the conversion, therefore print
parameter such as margins and paper size are also accepted as optional parameters.
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/URLConvertRequestBody'
examples: { }
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/chromium/convert/html:
post:
tags:
- chromium
summary: Convert a given HTML file to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
description: >-
Send an HTML file called `index.html` as a multipart form request, and
get the resulting PDF file. You can optionally include `header.html` and
`footer.html` files as part of the request as well.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/HTMLConvertRequestBody'
description: >-
The request body must have an `index.html` file in the `files` array,
as well as all the referred resources on the same level as the
`index.html` file. The request can also include `header.html` and
`footer.html`, given the limitations in the API description above.
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/chromium/convert/markdown:
post:
tags:
- chromium
summary: Convert a Markdown file to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/chromium
description: >-
Accepts an HTML file called `index.html` plus markdown files as a multipart
form request and embeds the markdown files into the HTML file using the Golang template
function `toHTML`.
The API will convert the markdown to HTML and embed it into your `index.html` file,
then render the resulting page. You can include your own styling and more in your HTML file.
Refer to the HTML conversion page for all the options you can use when converting
Markdown documents as well. You can optionally include `header.html` and
`footer.html` files as part of the request as well.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/MarkdownConvertRequestBody'
description: >-
The request body must have an `index.html` file in the `files` array,
as well as all the referred markdown resources on the same level as
the `index.html` file.
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/libreoffice/convert:
post:
tags:
- libreoffice
summary: Convert an Office document to PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/libreoffice
description: >-
This route accepts multipart/form-data requests and files with the following extensions:
.bib .doc .xml .docx .fodt .html .ltx .txt .odt .ott .pdb .pdf .psw .rtf
.sdw .stw .sxw .uot .vor .wps .epub .png .bmp .emf .eps .fodg .gif .jpg
.met .odd .otg .pbm .pct .pgm .ppm .ras .std .svg .svm .swf .sxd .sxw
.tiff .xhtml .xpm .fodp .potm .pot .pptx .pps .ppt .pwp .sda .sdd .sti
.sxi .uop .wmf .csv .dbf .dif .fods .ods .ots .pxl .sdc .slk .stc .sxc
.uos .xls .xlt .xlsx .tif .jpeg .odp
By default, if you send more than one file to convert, the route returns a ZIP archive of the
resulting PDF files. However, you may prefer to merge all the PDF files into an individual PDF file.
> **Attention:** The files will be merged alphabetically for the
resulting PDF.
You may also specify the page ranges to convert from the incoming Office
documents. The expected format is the same as the one from the print
options of LibreOffice, e.g. `1-1` or `1-4`.
> **Attention:** if more than one document, the page ranges will be
applied for each document.
See externalDocs for more details.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/OfficeConvertRequestBody'
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request, e.g. Both 'pdfFormat' and 'nativePdfA1aFormat' form values are provided
'503':
$ref: '#/components/responses/Timeout'
/forms/pdfengines/merge:
post:
tags:
- pdfengines
summary: Merge multiple PDFs into a single PDF
externalDocs:
url: https://gotenberg.dev/docs/modules/pdf-engines
description: >-
You can send multiple PDF files to this endpoint, the API will merge
them into a single PDF and return the resulting PDF file.
> **Attention:** The PDF files will be merged alphabetically.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
files:
type: array
items:
type: string
format: binary
pdfFormat:
type: string
description: The PDF format of the resulting PDF
example: PDF/A-1a
required:
- files
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: Bad Request
'503':
$ref: '#/components/responses/Timeout'
/forms/pdfengines/convert:
post:
tags:
- pdfengines
summary: Convert PDFs into the given formats
externalDocs:
url: https://gotenberg.dev/docs/modules/pdf-engines
description: >-
This route accepts PDF files and a form field pdfFormat for converting them into the specified format.
parameters:
- in: header
name: Gotenberg-Output-Filename
description: >-
By default, the API generates a UUID filename.
However, you may also specify the filename per request,
thanks to the Gotenberg-Output-Filename header.
Caution! The API adds the file extension automatically; you don't have to set it.
schema:
type: string
required: false
- in: header
name: Gotenberg-Trace
description: >-
The trace, or request ID, identifies a request in the logs.
By default, the API generates a UUID trace for each request.
However, you may also specify the trace per request, thanks to the Gotenberg-Trace header.
schema:
type: string
required: false
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
files:
type: array
items:
type: string
format: binary
pdfFormat:
type: string
description: The PDF format of the resulting PDF
example: PDF/A-1a
required:
- files
- pdfFormat
responses:
'200':
$ref: '#/components/responses/SuccessfulPDF'
'400':
description: >-
Bad Request, e.g. Invalid form data: no form file found for extensions: [.pdf]; form value 'pdfFormat' is required
'503':
$ref: '#/components/responses/Timeout'
components:
schemas:
HTMLConvertRequestBody:
title: HTML Conversion Request Body
type: object
properties:
files:
type: array
description: >-
List of HTML files to be converted to PDF. An `index.html` file is
required, and any other resources that are referenced through the
HTML file must be included as well. All the referenced files must be
on the same level as the `index.html` file.
items:
type: string
format: binary
marginTop:
type: number
example: 0
default: 1
description: Top margin for the page in inches.
marginBottom:
type: number
example: 0
default: 1
description: Bottom margin for the page in inches.
marginLeft:
type: number
example: 0
default: 1
description: Left margin for the page in inches.
marginRight:
type: number
example: 0
default: 1
description: Right margin for the page in inches.
paperWidth:
type: number
example: 8.27
description: >-
Paper width to be used while rendering the PDF. The default page
size is A4.
paperHeight:
type: number
example: 11.69
description: >-
Paper height to be used while rendering the PDF. The default page
size is A4.
preferCssPageSize:
type: boolean
description: >-
Define whether to prefer page size as defined by CSS (default false)
default: false
printBackground:
type: boolean
description: >-
Print the background graphics (default false)
default: false
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
scale:
type: number
minimum: 0.1
maximum: 2.0
example: 1.5
description: >-
The scale of the page rendering
default: 1.0
waitDelay:
type: string
example: 5s
description: >-
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
you're trying to generate.
waitWindowStatus:
type: string
example: done
description: >-
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
For instance
await promises()
window.status = 'ready'
Prefer this option over waitDelay.
extraHttpHeaders:
type: string
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
required:
- files
MarkdownConvertRequestBody:
title: Markdown Conversion Request Body
type: object
properties:
files:
type: array
items:
type: string
format: binary
marginTop:
type: number
example: 0
default: 1
description: Top margin for the page in inches.
marginBottom:
type: number
example: 0
default: 1
description: Bottom margin for the page in inches.
marginLeft:
type: number
example: 0
default: 1
description: Left margin for the page in inches.
marginRight:
type: number
example: 0
default: 1
description: Right margin for the page in inches.
paperWidth:
type: number
example: 8.27
description: >-
Paper width to be used while rendering the PDF. The default page
size is A4.
paperHeight:
type: number
example: 11.69
description: >-
Paper height to be used while rendering the PDF. The default page
size is A4.
preferCssPageSize:
type: boolean
description: >-
Define whether to prefer page size as defined by CSS (default false)
default: false
printBackground:
type: boolean
description: >-
Print the background graphics (default false)
default: false
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
scale:
type: number
minimum: 0.1
maximum: 2.0
example: 1.5
description: >-
The scale of the page rendering
default: 1.0
waitDelay:
type: string
example: 5s
description: >-
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
you're trying to generate.
waitWindowStatus:
type: string
example: done
description: >-
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
For instance
await promises()
window.status = 'ready'
Prefer this option over waitDelay.
extraHttpHeaders:
type: string
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
required:
- files
URLConvertRequestBody:
title: URL Conversion Request Body
type: object
properties:
url:
type: string
example: 'https://google.com'
files:
description: Optional files named header.html and footer.html
type: array
items:
type: string
format: binary
marginTop:
type: number
example: 0
default: 1
description: Top margin for the page in inches.
marginBottom:
type: number
example: 0
default: 1
description: Bottom margin for the page in inches.
marginLeft:
type: number
example: 0
default: 1
description: Left margin for the page in inches.
marginRight:
type: number
example: 0
default: 1
description: Right margin for the page in inches.
paperWidth:
type: number
example: 8.27
description: >-
Paper width to be used while rendering the PDF. The default page
size is A4.
paperHeight:
type: number
example: 11.69
description: >-
Paper height to be used while rendering the PDF. The default page
size is A4.
preferCssPageSize:
type: boolean
description: >-
Define whether to prefer page size as defined by CSS (default false)
default: false
printBackground:
type: boolean
description: >-
Print the background graphics (default false)
default: false
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
scale:
type: number
minimum: 0.1
maximum: 2.0
example: 1.5
description: >-
The scale of the page rendering
default: 1.0
waitDelay:
type: string
example: 5s
description: >-
When the page relies on JavaScript for rendering, and you don't have access to the page's code,
you may want to wait a certain amount of time to make sure Chromium has fully rendered the page
you're trying to generate.
waitWindowStatus:
type: string
example: done
description: >-
If you have access to the page's code, you may set the window status and tell Gotenberg to wait for a specific value.
For instance
await promises()
window.status = 'ready'
Prefer this option over waitDelay.
extraHttpHeaders:
type: string
description: HTTP headers to send by Chromium while loading the HTML document (JSON format)
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
required:
- url
OfficeConvertRequestBody:
title: Office Conversion Request Body
type: object
properties:
files:
type: array
items:
type: string
format: binary
nativePageRanges:
type: string
example: 1-4
description: >-
The page ranges to be converted to PDF for the incoming Office
documents. **If there are multiple files sent to the API, this page
range will apply to all of the documents**. Empty means all pages.
nativePdfA1aFormat:
type: boolean
description: >-
Use unoconv to convert the resulting PDF to the 'PDF/A-1a' format.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
pdfFormat:
type: string
description: >-
The PDF format of the resulting PDF.
Caution! You cannot use both nativePdfA1aFormat and pdfFormat form fields.
example: PDF/A-1a
landscape:
type: boolean
example: true
default: false
description: >-
The default orientation for rendering the page is "portrait" mode.
By sending "landscape" parameter, you can ask the output to be
landscape.
merge:
type: boolean
description: >-
Merge all PDF files into an individual PDF file.
required:
- files
MergeFilesRequestBody:
title: Merge Files Request Body
type: object
properties:
files:
type: array
items:
type: string
format: binary
required:
- files
securitySchemes: { }
responses:
SuccessfulPDF:
description: Resulting PDF file from the conversion.
content:
application/pdf:
schema:
type: string
format: binary
Timeout:
description: Service Unavailable

82
go.mod
View File

@@ -1,72 +1,66 @@
module github.com/gotenberg/gotenberg/v8
module github.com/gotenberg/gotenberg/v7
go 1.23.0
go 1.21
require (
github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6
github.com/chromedp/chromedp v0.11.2
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61
github.com/chromedp/chromedp v0.9.3
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0
github.com/google/uuid v1.5.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.11 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/labstack/echo/v4 v4.11.3
github.com/labstack/gommon v0.4.1
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27
github.com/microcosm-cc/bluemonday v1.0.26
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/prometheus/client_golang v1.20.5
github.com/pdfcpu/pdfcpu v0.6.0
github.com/pierrec/lz4/v4 v4.1.19 // indirect
github.com/prometheus/client_golang v1.17.0
github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/ulikunitz/xz v0.5.11 // indirect
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0
golang.org/x/sync v0.10.0
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
)
require (
github.com/dlclark/regexp2 v1.11.4
github.com/shirou/gopsutil/v4 v4.24.11
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.19.0
golang.org/x/sync v0.5.0
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.15.0
golang.org/x/text v0.14.0
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/gobwas/ws v1.3.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.61.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/rivo/uniseg v0.4.4 // 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
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

194
go.sum
View File

@@ -1,169 +1,165 @@
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6 h1:dAUcp/W5RpJSZW/HksEHfAAoMBIvSFFIwslAFEte+6g=
github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20231205062650-00455a960d61 h1:XD280QPATe9jaz20dylKe3vBsNcH1w3mkssGY0lidn8=
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/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU=
github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/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.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
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/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/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
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/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.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
github.com/labstack/gommon v0.4.1 h1:gqEff0p/hTENGMABzezPoPSRtIh1Cvw0ueMOe0/dfOk=
github.com/labstack/gommon v0.4.1/go.mod h1:TyTrpPqxR5KMk8LKVtLmfMjeQ5FEkBYdxLYPw/WfrOM=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
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/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.19 h1:tYLzDnjDXh9qIxSTKHwXwOYmm9d887Y7Y1ZkyXYHAN4=
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/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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/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/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=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
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.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
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/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
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/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
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/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -156,7 +156,7 @@ func (cmd *Cmd) pipeOutput() error {
r := bufio.NewReader(reader)
defer func(reader io.ReadCloser) {
err := reader.Close()
if err != nil && !strings.Contains(err.Error(), "file already closed") {
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
}(reader)

View File

@@ -104,8 +104,19 @@ func TestCmd_Wait(t *testing.T) {
expectWaitError: false,
},
{
scenario: "wait error",
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
scenario: "wait error",
cmd: func() *Cmd {
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
err := cmd.Start()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = cmd.Kill()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return cmd
}(),
expectWaitError: true,
},
}

View File

@@ -1,35 +0,0 @@
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
}

View File

@@ -1,134 +0,0 @@
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

@@ -1,53 +0,0 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"time"
"github.com/dlclark/regexp2"
)
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
var ErrFiltered = errors.New("value filtered")
// FilterDeadline checks if given value is allowed and not denied according to
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
// to process.
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
if allowed.String() != "" {
allow := regexp2.MustCompile(allowed.String(), 0)
allow.MatchTimeout = time.Until(deadline)
ok, err := allow.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
}
if !ok {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
}
}
if denied.String() != "" {
deny := regexp2.MustCompile(denied.String(), 0)
deny.MatchTimeout = time.Until(deadline)
ok, err := deny.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
}
}
return nil
}

View File

@@ -1,83 +0,0 @@
package gotenberg
import (
"context"
"errors"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func TestFilterDeadline(t *testing.T) {
for _, tc := range []struct {
scenario string
allowed *regexp2.Regexp
denied *regexp2.Regexp
s string
deadline time.Time
expectError bool
expectedError error
}{
{
scenario: "DeadlineExceeded (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
expectedError: context.DeadlineExceeded,
},
{
scenario: "ErrFiltered (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
s: "bar",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "DeadlineExceeded (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
expectedError: context.DeadlineExceeded,
},
{
scenario: "ErrFiltered (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "success",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("", 0),
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
if tc.expectError && err == nil {
t.Fatal("expected an error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}

View File

@@ -1,9 +1,9 @@
package gotenberg
import (
"regexp"
"time"
"github.com/dlclark/regexp2"
"github.com/labstack/gommon/bytes"
flag "github.com/spf13/pflag"
)
@@ -168,54 +168,50 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
return f.MustDuration(newName)
}
// MustHumanReadableBytes returns the human-readable bytes string of a flag
// given by name.
// MustHumanReadableBytesString returns the human-readable bytes string of a
// flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string {
val, err := f.GetString(name)
if err != nil {
panic(err)
}
if val == "" {
return 0
}
b, err := bytes.Parse(val)
_, err = bytes.Parse(val)
if err != nil {
panic(err)
}
return b
return val
}
// MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
// deprecated flag if it was explicitly set or the human-readable bytes string
// of the new flag.
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
// string of a deprecated flag if it was explicitly set or the human-readable
// bytes string of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
if f.Changed(deprecated) {
return f.MustHumanReadableBytes(deprecated)
return f.MustHumanReadableBytesString(deprecated)
}
return f.MustHumanReadableBytes(newName)
return f.MustHumanReadableBytesString(newName)
}
// MustRegexp returns the regular expression of a flag given by name.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
val, err := f.GetString(name)
if err != nil {
panic(err)
}
return regexp2.MustCompile(val, 0)
return regexp.MustCompile(val)
}
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
// it was explicitly set or the regular expression of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp2.Regexp {
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
if f.Changed(deprecated) {
return f.MustRegexp(deprecated)
}

View File

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

View File

@@ -3,56 +3,22 @@ package gotenberg
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
// MkdirAll defines the method signature for create a directory. Implement this
// interface if you don't want to rely on [os.MkdirAll], notably for testing
// purpose.
type MkdirAll interface {
// MkdirAll uses the same signature as [os.MkdirAll].
MkdirAll(path string, perm os.FileMode) error
}
// OsMkdirAll implements the [MkdirAll] interface with [os.MkdirAll].
type OsMkdirAll struct{}
// MkdirAll is a wrapper around [os.MkdirAll].
func (o *OsMkdirAll) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) }
// 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
}
// OsPathRename implements the [PathRename] interface with [os.Rename].
type OsPathRename struct{}
// Rename is a wrapper around [os.Rename].
func (o *OsPathRename) Rename(oldpath, newpath string) error {
return os.Rename(oldpath, newpath)
}
// FileSystem provides utilities for managing temporary directories. It creates
// unique directory names based on UUIDs to ensure isolation of temporary files
// for different modules.
type FileSystem struct {
workingDir string
mkdirAll MkdirAll
}
// NewFileSystem initializes a new [FileSystem] instance with a unique working
// directory.
func NewFileSystem(mkdirAll MkdirAll) *FileSystem {
func NewFileSystem() *FileSystem {
return &FileSystem{
workingDir: uuid.NewString(),
mkdirAll: mkdirAll,
}
}
@@ -78,35 +44,10 @@ func (fs *FileSystem) NewDirPath() string {
func (fs *FileSystem) MkdirAll() (string, error) {
path := fs.NewDirPath()
err := fs.mkdirAll.MkdirAll(path, 0o755)
err := os.MkdirAll(path, 0o755)
if err != nil {
return "", fmt.Errorf("create directory %s: %w", path, err)
}
return path, nil
}
// WalkDir walks through the root level of a directory and returns a list of
// files paths that match the specified file extension.
func WalkDir(dir, ext string) ([]string, error) {
var files []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if info.IsDir() {
return nil
}
if strings.EqualFold(filepath.Ext(info.Name()), ext) {
files = append(files, path)
}
return nil
})
return files, err
}
// Interface guards.
var (
_ MkdirAll = (*OsMkdirAll)(nil)
_ PathRename = (*OsPathRename)(nil)
)

View File

@@ -1,84 +1,14 @@
package gotenberg
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/google/uuid"
)
func TestOsMkdirAll_MkdirAll(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestOsPathRename_Rename(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).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 TestFileSystem_WorkingDir(t *testing.T) {
fs := NewFileSystem(new(MkdirAllMock))
fs := NewFileSystem()
dirName := fs.WorkingDir()
if dirName == "" {
@@ -87,7 +17,7 @@ func TestFileSystem_WorkingDir(t *testing.T) {
}
func TestFileSystem_WorkingDirPath(t *testing.T) {
fs := NewFileSystem(new(MkdirAllMock))
fs := NewFileSystem()
expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
if fs.WorkingDirPath() != expectedPath {
@@ -96,7 +26,7 @@ func TestFileSystem_WorkingDirPath(t *testing.T) {
}
func TestFileSystem_NewDirPath(t *testing.T) {
fs := NewFileSystem(new(MkdirAllMock))
fs := NewFileSystem()
newDir := fs.NewDirPath()
expectedPrefix := fs.WorkingDirPath()
@@ -106,117 +36,20 @@ func TestFileSystem_NewDirPath(t *testing.T) {
}
func TestFileSystem_MkdirAll(t *testing.T) {
for _, tc := range []struct {
scenario string
mkdirAll MkdirAll
expectError bool
}{
{
scenario: "error",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("foo")
},
},
expectError: true,
},
{
scenario: "success",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := NewFileSystem(tc.mkdirAll)
fs := NewFileSystem()
_, err := fs.MkdirAll()
newPath, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = os.Stat(newPath)
if os.IsNotExist(err) {
t.Errorf("expected directory '%s' to exist but it doesn't", newPath)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestWalkDir(t *testing.T) {
for _, tc := range []struct {
scenario string
dir string
ext string
expectError bool
expectFiles []string
}{
{
scenario: "directory does not exist",
dir: uuid.NewString(),
ext: ".pdf",
expectError: true,
},
{
scenario: "find PDF files",
dir: func() string {
path := fmt.Sprintf("%s/a_directory", os.TempDir())
err := os.MkdirAll(path, 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file.pdf", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_bar_file.PDF", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_baz_file.txt", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return path
}(),
ext: ".pdf",
expectError: false,
expectFiles: []string{"/tmp/a_directory/a_bar_file.PDF", "/tmp/a_directory/a_foo_file.pdf"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.dir)
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
files, err := WalkDir(tc.dir, tc.ext)
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.expectError && err != nil {
return
}
if !reflect.DeepEqual(files, tc.expectFiles) {
t.Errorf("expected files %+v, but got %+v", tc.expectFiles, files)
}
})
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}

View File

@@ -5,14 +5,13 @@ import (
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
// GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings and before a given experiation time.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
// names containing specific substrings.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) error {
logger = logger.Named("gc")
// To make sure that the next Walk method stays on
@@ -37,7 +36,7 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string,
}
for _, substr := range includeSubstr {
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
if strings.Contains(info.Name(), substr) || path == substr {
err := os.RemoveAll(path)
if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err)

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -65,7 +64,7 @@ func TestGarbageCollect(t *testing.T) {
}
}()
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr)
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)

View File

@@ -1,11 +1,6 @@
package gotenberg
import (
"fmt"
"github.com/hashicorp/go-retryablehttp"
"go.uber.org/zap"
)
import "go.uber.org/zap"
// LoggerProvider is an interface for a module that supplies a method for
// creating a [zap.Logger] instance for use by other modules.
@@ -17,41 +12,3 @@ import (
type LoggerProvider interface {
Logger(mod Module) (*zap.Logger, error)
}
// LeveledLogger is wrapper around a [zap.Logger] so that it may be used by a
// [retryablehttp.Client].
type LeveledLogger struct {
logger *zap.Logger
}
// NewLeveledLogger instantiates a [LeveledLogger].
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
return &LeveledLogger{
logger: logger,
}
}
// Error logs a message at error level using the wrapped zap.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at warning level using the wrapped zap.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at info level using the wrapped zap.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at debug level using the wrapped zap.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
)

View File

@@ -2,7 +2,6 @@ package gotenberg
import (
"context"
"os"
"go.uber.org/zap"
)
@@ -36,33 +35,18 @@ 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
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]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
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
}
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
}
func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
}
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
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)
@@ -143,24 +127,6 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
return provider.MetricsMock()
}
// MkdirAllMock is a mock for the [MkdirAll] interface.
type MkdirAllMock struct {
MkdirAllMock func(path string, perm os.FileMode) error
}
func (mkdirAll *MkdirAllMock) MkdirAll(path string, perm os.FileMode) error {
return mkdirAll.MkdirAllMock(path, perm)
}
// 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)
@@ -171,6 +137,4 @@ var (
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil)
)

View File

@@ -2,7 +2,6 @@ package gotenberg
import (
"context"
"os"
"testing"
"go.uber.org/zap"
@@ -53,18 +52,9 @@ func TestPDFEngineMock(t *testing.T) {
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
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, "")
@@ -72,25 +62,10 @@ func TestPDFEngineMock(t *testing.T) {
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
}
_, err = mock.Split(context.Background(), zap.NewNop(), SplitMode{}, "", "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Split, but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
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) {
@@ -213,29 +188,3 @@ func TestMetricsProviderMock(t *testing.T) {
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
}
}
func TestMkdirAllMock(t *testing.T) {
mock := &MkdirAllMock{
MkdirAllMock: func(dir string, perm os.FileMode) error {
return nil
},
}
err := mock.MkdirAll("/foo", 0o755)
if err != nil {
t.Errorf("expected no error from MkdirAllMock.MkdirAll, 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

@@ -83,7 +83,7 @@ type SystemLogger interface {
// gotenberg.MustRegisterModule(YourModule{})
// }
//
// Then, in the main command (github.com/gotenberg/gotenberg/v8/cmd/gotenberg),
// Then, in the main command (github.com/gotenberg/gotenberg/v7/cmd/gotenberg),
// import the module:
//
// imports (

View File

@@ -12,43 +12,11 @@ var (
// PdfEngine interface is not supported by its current implementation.
ErrPdfEngineMethodNotSupported = errors.New("method not supported")
// ErrPdfSplitModeNotSupported is returned when the Split method of the
// PdfEngine interface does not sumport a requested PDF split mode.
ErrPdfSplitModeNotSupported = errors.New("split mode not supported")
// 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 (
// SplitModeIntervals represents a mode where a PDF is split at specific
// intervals.
SplitModeIntervals string = "intervals"
// SplitModePages represents a mode where a PDF is split at specific page
// ranges.
SplitModePages string = "pages"
)
// SplitMode gathers the data required to split a PDF into multiple parts.
type SplitMode struct {
// Mode is either "intervals" or "pages".
Mode string
// Span is either the intervals or the page ranges to extract, depending on
// the selected mode.
Span string
// Unify specifies whether to put extracted pages into a single file or as
// many files as there are page ranges. Only works with "pages" mode.
Unify bool
}
const (
// PdfA1a represents the PDF/A-1a format.
PdfA1a string = "PDF/A-1a"
@@ -93,18 +61,9 @@ type PdfEngine interface {
// is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
// Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// 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].

View File

@@ -1,69 +0,0 @@
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

@@ -1,34 +0,0 @@
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)
}
})
}
}

View File

@@ -13,10 +13,6 @@ import (
// to restart an already restarting [Process].
var ErrProcessAlreadyRestarting = errors.New("process already restarting")
// ErrMaximumQueueSizeExceeded happens if Run() is called but the maximum queue
// size is already used.
var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
// Process is an interface that represents an abstract process
// and provides methods for starting, stopping, and checking the health of the
// process.
@@ -78,7 +74,6 @@ type processSupervisor struct {
logger *zap.Logger
process Process
maxReqLimit int64
maxQueueSize int64
mutexChan chan struct{}
firstStart atomic.Bool
reqCounter atomic.Int64
@@ -88,13 +83,12 @@ type processSupervisor struct {
}
// NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit int64) ProcessSupervisor {
b := &processSupervisor{
logger: logger,
process: process,
mutexChan: make(chan struct{}, 1),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
logger: logger,
process: process,
mutexChan: make(chan struct{}, 1),
maxReqLimit: maxReqLimit,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
@@ -173,25 +167,6 @@ func (s *processSupervisor) Healthy() bool {
}
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
// A user reported a potential issue:
//
// "Although the counting operation is atomic, nothing prevent 2 concurrent
// goroutines to retrieve the same 'currentQueueSize' and to compare its
// value against the max limit. Then, resulting queue size would be 1 above
// the allowed limit."
//
// However, he was unable to actually trigger this issue, even when sending
// a lot of requests.
//
// For now, the best option is to consider this issue to be unlikely to
// happen, and keep the code as it is because it is more readable this way.
//
// See https://github.com/gotenberg/gotenberg/issues/951.
currentQueueSize := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
}
s.reqQueueSize.Add(1)
for {
@@ -201,13 +176,10 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
logger.Debug("process lock acquired")
s.reqQueueSize.Add(-1)
s.reqCounter.Add(1)
releaseMutexChan := true
defer func() {
if releaseMutexChan {
logger.Debug("process lock released")
<-s.mutexChan
}
logger.Debug("process lock released")
<-s.mutexChan
}()
if !s.firstStart.Load() {
@@ -229,26 +201,18 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
}
}
err := s.runWithDeadline(ctx, task)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseMutexChan = false
go func() {
err := s.runWithDeadline(context.Background(), func() error {
return s.restart()
})
if err != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
}
logger.Debug("process lock released")
<-s.mutexChan
}()
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 err
return s.runWithDeadline(ctx, task)
case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1)

View File

@@ -46,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
if tc.firstStartSet {
ps.firstStart.Store(true)
}
@@ -94,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0)
ps := NewProcessSupervisor(logger, process, 5)
err := ps.Shutdown()
if !tc.expectError && err != nil {
@@ -154,7 +154,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
if tc.initiallyRestarting {
ps.isRestarting.Store(true)
}
@@ -217,7 +217,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -249,8 +249,6 @@ func TestProcessSupervisor_Run(t *testing.T) {
expectedStartCalls int64
expectedHealthyCalls int64
expectedStopCalls int64
currentQueueSize int64
maxQueueSize int64
}{
{
scenario: "successfully run task on non-started process",
@@ -325,7 +323,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
expectedStopCalls: 1,
},
{
scenario: "auto-restart after reaching max request limit",
scenario: "cannot restart after reaching max request limit",
startError: errors.New("start error"),
initiallyStarted: true,
isRestarting: false,
@@ -350,34 +348,6 @@ func TestProcessSupervisor_Run(t *testing.T) {
expectedHealthyCalls: 1,
expectedStopCalls: 0,
},
{
scenario: "queue size exceeded",
initiallyStarted: false,
isRestarting: false,
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 1,
expectError: true,
expectedStartCalls: 0,
expectedHealthyCalls: 0,
expectedStopCalls: 0,
currentQueueSize: 1,
maxQueueSize: 1,
},
{
scenario: "queue size not exceeded",
initiallyStarted: false,
isRestarting: false,
processHealthy: true,
maxReqLimit: 2,
tasksToRun: 1,
expectError: true,
expectedStartCalls: 1,
expectedHealthyCalls: 1,
expectedStopCalls: 0,
currentQueueSize: 1,
maxQueueSize: 2,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
@@ -402,16 +372,13 @@ func TestProcessSupervisor_Run(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
if tc.isRestarting {
ps.isRestarting.Store(true)
}
if tc.currentQueueSize > 0 {
ps.reqQueueSize.Store(tc.currentQueueSize)
}
task := func() error {
return tc.taskError
@@ -451,10 +418,6 @@ func TestProcessSupervisor_Run(t *testing.T) {
return
}
// Making sure restarts are finished.
ps.mutexChan <- struct{}{}
<-ps.mutexChan
if startCalls.Load() != tc.expectedStartCalls {
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
}
@@ -488,13 +451,13 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if tc.ctxDone {
cancel()
} else {
defer cancel()
}
err := ps.runWithDeadline(ctx, func() error {
@@ -522,7 +485,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
// Simulating a lock.
ps.mutexChan <- struct{}{}
@@ -623,7 +586,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ {

View File

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

View File

@@ -4,14 +4,14 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/alexliesenfeld/health"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
@@ -19,7 +19,7 @@ import (
"golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func init() {
@@ -30,17 +30,12 @@ func init() {
// middlewares or health checks.
type Api struct {
port int
bindIp string
tlsCertFile string
tlsKeyFile string
readTimeout time.Duration
writeTimeout time.Duration
startTimeout time.Duration
bodyLimit int64
timeout time.Duration
rootPath string
traceHeader string
basicAuthUsername string
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool
routes []Route
@@ -52,13 +47,6 @@ type Api struct {
srv *echo.Echo
}
type downloadFromConfig struct {
allowList *regexp2.Regexp
denyList *regexp2.Regexp
maxRetry int
disable bool
}
// Router is a module interface which adds routes to the [Api].
type Router interface {
Routes() ([]Route, error)
@@ -173,20 +161,24 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int("api-port", 3000, "Set the port on which the API should listen")
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
fs.String("api-bind-ip", "", "Set the IP address the API should bind to for incoming connections")
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
fs.Duration("api-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-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-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.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
var err error
err = multierr.Append(err, fs.MarkDeprecated("api-read-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-process-timeout", "use api-timeout instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-write-timeout", "use api-timeout instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the api module: %v", err))
}
return fs
}(),
New: func() gotenberg.Module { return new(Api) },
@@ -197,45 +189,33 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
func (a *Api) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
a.port = flags.MustInt("api-port")
a.bindIp = flags.MustString("api-bind-ip")
a.tlsCertFile = flags.MustString("api-tls-cert-file")
a.tlsKeyFile = flags.MustString("api-tls-key-file")
a.startTimeout = flags.MustDuration("api-start-timeout")
a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
// Port from env?
portEnvVar := flags.MustString("api-port-from-env")
if portEnvVar != "" {
port, err := gotenberg.IntEnv(portEnvVar)
if err != nil {
return fmt.Errorf("get API port from env: %w", err)
}
a.port = port
}
val, ok := os.LookupEnv(portEnvVar)
// 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)
if !ok {
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
}
basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
if err != nil {
return fmt.Errorf("get basic auth password from env: %w", err)
if val == "" {
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
}
a.basicAuthUsername = basicAuthUsername
a.basicAuthPassword = basicAuthPassword
port, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
}
a.port = port
}
// Get routes from modules.
@@ -318,7 +298,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.logger = logger
// File system.
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
a.fs = gotenberg.NewFileSystem()
return nil
}
@@ -333,16 +313,6 @@ func (a *Api) Validate() error {
)
}
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = multierr.Append(err,
errors.New("both TLS certificate and key files must be set"),
)
}
if !strings.HasPrefix(a.rootPath, "/") {
err = multierr.Append(err,
errors.New("root path must start with /"),
@@ -365,9 +335,8 @@ func (a *Api) Validate() error {
return err
}
routesMap := make(map[string]string, len(a.routes)+2)
routesMap := make(map[string]string, len(a.routes)+1)
routesMap["/health"] = "/health"
routesMap["/version"] = "/version"
for _, route := range a.routes {
if route.Path == "" {
@@ -411,10 +380,10 @@ func (a *Api) Start() error {
a.srv = echo.New()
a.srv.HideBanner = true
a.srv.HidePort = true
a.srv.Server.ReadTimeout = a.timeout
a.srv.Server.ReadTimeout = a.readTimeout
a.srv.Server.IdleTimeout = a.timeout
// See https://github.com/gotenberg/gotenberg/issues/396.
a.srv.Server.WriteTimeout = a.timeout + a.timeout
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes.
@@ -449,7 +418,7 @@ func (a *Api) Start() error {
a.srv.Pre(externalMiddleware.Handler)
case MultipartStack:
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
case DefaultStack:
default:
a.srv.Use(externalMiddleware.Handler)
}
}
@@ -460,13 +429,8 @@ func (a *Api) Start() error {
for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc
// Basic auth?
if a.basicAuthUsername != "" {
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
}
if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout))
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
@@ -483,33 +447,16 @@ func (a *Api) Start() error {
)
}
// Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
healthCheckHandler := health.NewHandler(checker)
// Let's not forget the health check route.
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
return echo.WrapHandler(healthCheckHandler)
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
return echo.WrapHandler(health.NewHandler(checker))
}(),
hardTimeoutMiddleware(hardTimeout),
)
a.srv.HEAD(
fmt.Sprintf("%s%s", a.rootPath, "health"),
func() echo.HandlerFunc {
return echo.WrapHandler(healthCheckHandler)
}(),
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)
@@ -527,15 +474,8 @@ func (a *Api) Start() error {
// As the following code is blocking, run it in a goroutine.
go func() {
var err error
if a.tlsCertFile != "" && a.tlsKeyFile != "" {
// Start an HTTPS server (supports HTTP/2).
err = a.srv.StartTLS(fmt.Sprintf("%s:%d", a.bindIp, a.port), a.tlsCertFile, a.tlsKeyFile)
} else {
// Start an HTTP/2 Cleartext (non-HTTPS) server.
server := &http2.Server{}
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
}
server := &http2.Server{}
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error())
}
@@ -546,11 +486,7 @@ func (a *Api) Start() error {
// StartupMessage returns a custom startup message.
func (a *Api) StartupMessage() string {
ip := a.bindIp
if a.bindIp == "" {
ip = "[::]"
}
return fmt.Sprintf("server started on %s:%d", ip, a.port)
return fmt.Sprintf("server listening on port %d", a.port)
}
// Stop stops the HTTP server.

View File

@@ -16,7 +16,7 @@ import (
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestApi_Descriptor(t *testing.T) {
@@ -57,6 +57,30 @@ func TestApi_Provision(t *testing.T) {
}(),
expectError: true,
},
{
scenario: "port from env: empty environment variable",
ctx: func() *gotenberg.Context {
fs := new(Api).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func() {
err := os.Setenv("PORT", "")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectError: true,
},
{
scenario: "port from env: invalid environment variable value",
ctx: func() *gotenberg.Context {
@@ -81,48 +105,6 @@ func TestApi_Provision(t *testing.T) {
},
expectError: true,
},
{
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-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)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
nil,
)
}(),
setEnv: func() {
err := os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectError: true,
},
{
scenario: "no valid routers",
ctx: func() *gotenberg.Context {
@@ -379,7 +361,7 @@ func TestApi_Provision(t *testing.T) {
}
fs := new(Api).Descriptor().FlagSet
err := fs.Parse([]string{"--api-port-from-env=PORT", "--api-enable-basic-auth=true"})
err := fs.Parse([]string{"--api-port-from-env=PORT"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -401,14 +383,6 @@ 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{
@@ -462,9 +436,6 @@ func TestApi_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
port int
bindIp string
tlsCertFile string
tlsKeyFile string
rootPath string
traceHeader string
routes []Route
@@ -474,7 +445,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid port (< 1)",
port: 0,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
@@ -484,39 +454,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid port (> 65535)",
port: 65536,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
middlewares: nil,
expectError: true,
},
{
scenario: "invalid IP",
port: 10,
bindIp: "foo",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
middlewares: nil,
expectError: true,
},
{
scenario: "invalid TLS files: only cert file provided",
port: 10,
bindIp: "127.0.0.1",
tlsCertFile: "cert.pem",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
middlewares: nil,
expectError: true,
},
{
scenario: "invalid TLS files: only key file provided",
port: 10,
bindIp: "127.0.0.1",
tlsKeyFile: "key.pem",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
@@ -526,7 +463,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid root path: missing / prefix",
port: 10,
bindIp: "127.0.0.1",
rootPath: "foo/",
traceHeader: "foo",
routes: nil,
@@ -536,7 +472,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid root path: missing / suffix",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo",
traceHeader: "foo",
routes: nil,
@@ -546,7 +481,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid trace header",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "",
routes: nil,
@@ -556,7 +490,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid route: empty path",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
@@ -570,7 +503,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid route: missing / prefix in path",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
@@ -584,7 +516,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid multipart route: no /forms prefix in path",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
@@ -599,7 +530,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid route: no method",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
@@ -614,7 +544,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid route: nil handler",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
@@ -630,7 +559,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid route: path already existing",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
@@ -651,7 +579,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "invalid middleware: nil handler",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: nil,
@@ -666,7 +593,6 @@ func TestApi_Validate(t *testing.T) {
{
scenario: "success",
port: 10,
bindIp: "127.0.0.1",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
@@ -695,34 +621,10 @@ func TestApi_Validate(t *testing.T) {
},
},
},
{
scenario: "success with TLS",
port: 10,
tlsCertFile: "cert.pem",
tlsKeyFile: "key.pem",
rootPath: "/foo/",
traceHeader: "foo",
routes: []Route{
{
Method: http.MethodGet,
Path: "/foo",
Handler: func(_ echo.Context) error { return nil },
},
{
Method: http.MethodGet,
Path: "/forms/foo",
Handler: func(_ echo.Context) error { return nil },
IsMultipart: true,
},
},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := Api{
port: tc.port,
bindIp: tc.bindIp,
tlsCertFile: tc.tlsCertFile,
tlsKeyFile: tc.tlsKeyFile,
rootPath: tc.rootPath,
traceHeader: tc.traceHeader,
routes: tc.routes,
@@ -745,8 +647,6 @@ func TestApi_Start(t *testing.T) {
for _, tc := range []struct {
scenario string
readyFn []func() error
tlsCertFile string
tlsKeyFile string
expectError bool
}{
{
@@ -765,26 +665,12 @@ func TestApi_Start(t *testing.T) {
},
expectError: false,
},
{
scenario: "success with TLS",
readyFn: []func() error{
func() error { return nil },
func() error { return nil },
},
tlsCertFile: "/tests/test/testdata/api/cert.pem",
tlsKeyFile: "/tests/test/testdata/api/key.pem",
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Api)
mod.port = 3000
mod.tlsCertFile = tc.tlsCertFile
mod.tlsKeyFile = tc.tlsKeyFile
mod.startTimeout = time.Duration(30) * time.Second
mod.rootPath = "/"
mod.basicAuthUsername = "foo"
mod.basicAuthPassword = "bar"
mod.disableHealthCheckLogging = true
mod.routes = []Route{
{
@@ -850,7 +736,7 @@ func TestApi_Start(t *testing.T) {
},
}
mod.readyFn = tc.readyFn
mod.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
mod.fs = gotenberg.NewFileSystem()
mod.logger = zap.NewNop()
err := mod.Start()
@@ -866,24 +752,11 @@ func TestApi_Start(t *testing.T) {
return
}
// health requests.
// health request.
recorder := httptest.NewRecorder()
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthGetRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil)
mod.srv.ServeHTTP(recorder, healthHeadRequest)
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)
mod.srv.ServeHTTP(recorder, versionRequest)
mod.srv.ServeHTTP(recorder, healthRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
@@ -891,6 +764,7 @@ func TestApi_Start(t *testing.T) {
// "multipart/form-data" request.
multipartRequest := func(url string) *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
@@ -917,7 +791,6 @@ 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
}
@@ -945,36 +818,15 @@ func TestApi_Start(t *testing.T) {
}
func TestApi_StartupMessage(t *testing.T) {
for _, tc := range []struct {
scenario string
port int
bindIp string
expectMessage string
}{
{
scenario: "no custom IP",
port: 3000,
bindIp: "",
expectMessage: "server started on [::]:3000",
},
{
scenario: "custom IP",
port: 3000,
bindIp: "127.0.0.1",
expectMessage: "server started on 127.0.0.1:3000",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := Api{
port: tc.port,
bindIp: tc.bindIp,
}
mod := Api{
port: 3000,
}
actual := mod.StartupMessage()
if actual != tc.expectMessage {
t.Errorf("expected '%s' but got '%s'", tc.expectMessage, actual)
}
})
actual := mod.StartupMessage()
expect := "server listening on port 3000"
if actual != expect {
t.Errorf("expected '%s' but got '%s'", expect, actual)
}
}

View File

@@ -3,28 +3,23 @@ package api
import (
"compress/flate"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"github.com/mholt/archiver/v3"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
var (
@@ -39,76 +34,27 @@ 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
outputPaths []string
cancelled bool
dirPath string
values map[string][]string
files map[string]string
logger *zap.Logger
echoCtx echo.Context
mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename
outputPaths []string
cancelled bool
logger *zap.Logger
echoCtx echo.Context
context.Context
}
type trackingReader struct {
R io.Reader
AddReadBytes func(n int64) error
}
func (t *trackingReader) Read(p []byte) (int, error) {
n, err := t.R.Read(p)
if n > 0 {
errAddRead := t.AddReadBytes(int64(n))
if errAddRead != nil {
return n, fmt.Errorf("add read bytes: %w", errAddRead)
}
}
if err != nil {
// It's a common practice in Go to return io.EOF unwrapped to signal
// the end of a data stream. Wrapping it can lead to unexpected
// behavior in standard library functions.
return n, err
}
return n, nil
}
type downloadFrom struct {
// Url is the URL to download a file from.
Url string `json:"url"`
// ExtraHttpHeaders are the HTTP headers to send alongside.
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
}
// 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, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
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)
// We want to make sure the multipart/form-data does not exceed a given
// limit. We consider: form fields (keys, values, files) and files
// downloaded remotely ("download from" feature).
var totalBytesRead atomic.Int64
addReadBytes := func(n int64) error {
newTotal := totalBytesRead.Add(n)
if bodyLimit != 0 && newTotal > bodyLimit {
return WrapError(
fmt.Errorf("body limit reached (> %d)", bodyLimit),
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
)
}
return nil
}
ctx := &Context{
outputPaths: make([]string, 0),
cancelled: false,
logger: logger,
echoCtx: echoCtx,
mkdirAll: new(gotenberg.OsMkdirAll),
pathRename: new(gotenberg.OsPathRename),
Context: processCtx,
}
@@ -140,6 +86,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
form, err := echoCtx.MultipartForm()
if err != nil {
if errors.Is(err, http.ErrNotMultipart) {
return nil, cancel, WrapError(
fmt.Errorf("get multipart form: %w", err),
@@ -164,19 +111,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
}
// This will ensure we do not exceed the body limit.
var formValuesSize int64
for key, valArray := range form.Value {
formValuesSize += int64(len(key))
for _, val := range valArray {
formValuesSize += int64(len(val))
}
}
err = addReadBytes(formValuesSize)
if err != nil {
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
}
dirPath, err := fs.MkdirAll()
if err != nil {
return nil, cancel, fmt.Errorf("create working directory: %w", err)
@@ -186,150 +120,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.values = form.Value
ctx.files = make(map[string]string)
// First, try to download files listed in the "downloadFrom" form field, if
// any.
raw, ok := ctx.values["downloadFrom"]
if !downloadFromCfg.disable && ok {
var dls []downloadFrom
err = json.Unmarshal([]byte(raw[0]), &dls)
if err != nil {
return nil, cancel, WrapError(
fmt.Errorf("unmarshal json: %w", err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: %s", err)),
)
}
eg, _ := errgroup.WithContext(ctx)
for i, dl := range dls {
eg.Go(func() error {
deadline, ok := ctx.Deadline()
if !ok {
// Should not happen, as context is created with a timeout.
return errors.New("context has no deadline")
}
if strings.TrimSpace(dl.Url) == "" {
return WrapError(
errors.New("empty download from URL"),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field entry %d: URL must be set", i)),
)
}
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
if err != nil {
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
}
req.Header.Set("User-Agent", "Gotenberg")
for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(traceHeader, trace)
client := &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: time.Until(deadline),
},
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),
Logger: gotenberg.NewLeveledLogger(logger),
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
}
resp, err := client.Do(req)
if err != nil {
return WrapError(
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
)
}
defer func() {
err := resp.Body.Close()
if err != nil {
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
}
}()
if resp.StatusCode != http.StatusOK {
return WrapError(
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
)
}
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
return WrapError(
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
)
}
// FIXME: the implementation of this method might not be
// complete, as it fails to parse an empty mediatype.
// See: https://github.com/golang/go/issues/69551.
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
return WrapError(
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
)
}
filename, ok := params["filename"]
if !ok {
return WrapError(
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
)
}
// Avoid directory traversal and make sure filename characters are
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
out, err := os.Create(path)
if err != nil {
return fmt.Errorf("create local file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err))
}
}()
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: resp.Body, AddReadBytes: addReadBytes}
_, err = io.Copy(out, reader)
if err != nil {
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
}
ctx.files[filename] = path
return nil
})
}
err = eg.Wait()
if err != nil {
return ctx, cancel, err
}
}
copyToDisk := func(fh *multipart.FileHeader) error {
in, err := fh.Open()
if err != nil {
@@ -343,9 +133,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
}
}()
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
// Avoid directory traversal and make sure filename characters are
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
@@ -356,6 +143,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
if err != nil {
return fmt.Errorf("create local file: %w", err)
}
defer func() {
err := out.Close()
if err != nil {
@@ -363,7 +151,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
}
}()
_, err = io.Copy(out, reader)
_, err = io.Copy(out, in)
if err != nil {
return fmt.Errorf("copy multipart file to local file: %w", err)
}
@@ -373,10 +161,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return nil
}
// Then, copy the form files, if any.
for _, files := range form.File {
for _, fh := range files {
err = copyToDisk(fh)
if err != nil {
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
}
@@ -385,7 +173,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err
}
@@ -404,32 +191,10 @@ func (ctx *Context) FormData() *FormData {
}
}
// GeneratePath generates a path within the context's working directory.
// It generates a new UUID-based filename. It does not create a file.
// GeneratePath generates a path within the context's working directory. 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)
}
// CreateSubDirectory creates a subdirectory within the context's working
// directory.
func (ctx *Context) CreateSubDirectory(dirName string) (string, error) {
path := fmt.Sprintf("%s/%s", ctx.dirPath, dirName)
err := ctx.mkdirAll.MkdirAll(path, 0o755)
if err != nil {
return "", fmt.Errorf("create sub-directory %s: %w", path, err)
}
return path, nil
}
// 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 {
ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath))
err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil {
return fmt.Errorf("rename path: %w", err)
}
return nil
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
}
// AddOutputPaths adds the given paths. Those paths will be used later to build

View File

@@ -2,7 +2,6 @@ package api
import (
"bytes"
"context"
"errors"
"mime/multipart"
"net/http"
@@ -13,36 +12,16 @@ import (
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestNewContext(t *testing.T) {
defaultAllowList, err := regexp2.Compile("", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
defaultDenyList, err := regexp2.Compile("", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
defaultDownloadFromCfg := downloadFromConfig{
allowList: defaultAllowList,
denyList: defaultDenyList,
maxRetry: 1,
disable: false,
}
for _, tc := range []struct {
scenario string
request *http.Request
bodyLimit int64
downloadFromCfg downloadFromConfig
downloadFromSrv *echo.Echo
expectContext *Context
expectError bool
expectHttpError bool
expectHttpStatus int
@@ -88,325 +67,6 @@ func TestNewContext(t *testing.T) {
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "request entity too large: form values",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("key", "value")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: downloadFrom",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 45, // form values = 44 bytes.
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "request entity too large: form files",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = part.Write([]byte("foo"))
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
bodyLimit: 1,
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusRequestEntityTooLarge,
},
{
scenario: "invalid downloadFrom form field: cannot unmarshal",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no URL",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: filtered URL",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"https://foo.bar"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: func() downloadFromConfig {
denyList, err := regexp2.Compile("https://foo.bar", 0)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return downloadFromConfig{allowList: defaultAllowList, denyList: denyList, maxRetry: 1, disable: false}
}(),
expectError: true,
},
{
scenario: "invalid downloadFrom form field: unreachable URL",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: invalid status code",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
return c.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no 'Content-Disposition' header",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: malformed 'Content-Disposition' header",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, ";;")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "invalid downloadFrom form field: no filename parameter in 'Content-Disposition' header",
request: func() *http.Request {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer func() {
err := writer.Close()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
}()
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentDisposition, "inline;")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "success",
request: func() *http.Request {
@@ -430,69 +90,17 @@ func TestNewContext(t *testing.T) {
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
return req
}(),
downloadFromSrv: func() *echo.Echo {
srv := echo.New()
srv.HideBanner = true
srv.GET("/", func(c echo.Context) error {
if c.Request().Header.Get("User-Agent") != "Gotenberg" {
t.Fatalf("expected 'Gotenberg' from header 'User-Agent', but got '%s'", c.Request().Header.Get("User-Agent"))
}
if c.Request().Header.Get("X-Foo") != "Bar" {
t.Fatalf("expected 'Bar' from header 'X-Foo', but got '%s'", c.Request().Header.Get("X-Foo"))
}
if c.Request().Header.Get("Gotenberg-Trace") != "123" {
t.Fatalf("expected '123' from header 'Gotenberg-Trace', but got '%s'", c.Request().Header.Get("Gotenberg-Trace"))
}
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
})
return srv
}(),
downloadFromCfg: defaultDownloadFromCfg,
expectContext: &Context{
values: map[string][]string{
"foo": {"foo"},
"downloadFrom": {
`[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`,
},
},
files: map[string]string{
"foo.txt": "foo.txt",
"bar.txt": "bar.txt", // downloadFrom.
},
},
expectError: false,
expectHttpError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.downloadFromSrv != nil {
go func() {
err := tc.downloadFromSrv.Start(":80")
if !errors.Is(err, http.ErrServerClosed) {
t.Error(err)
return
}
}()
defer func() {
err := tc.downloadFromSrv.Shutdown(context.TODO())
if err != nil {
t.Error(err)
}
}()
}
handler := func(c echo.Context) error {
ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
_, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second)
defer cancel()
// Context already cancelled.
defer cancel()
@@ -501,20 +109,6 @@ func TestNewContext(t *testing.T) {
return err
}
if tc.expectContext != nil {
if !reflect.DeepEqual(tc.expectContext.values, ctx.values) {
t.Fatalf("expected context.values to be %v but got %v", tc.expectContext.values, ctx.values)
}
if len(tc.expectContext.files) != len(ctx.files) {
t.Fatalf("expected context.files to contain %d items but got %d", len(tc.expectContext.files), len(ctx.files))
}
for key, value := range tc.expectContext.files {
if !strings.HasSuffix(ctx.files[key], value) {
t.Fatalf("expected context.files to contain '%s' but got '%s'", value, ctx.files[key])
}
}
}
return nil
}
@@ -591,89 +185,18 @@ func TestContext_FormData(t *testing.T) {
}
}
func TestContext_CreateSubDirectory(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "failure",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("cannot rename")
}}},
expectError: true,
},
{
scenario: "success",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
_, err := tc.ctx.CreateSubDirectory("foo")
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_GeneratePath(t *testing.T) {
ctx := &Context{
dirPath: "/foo",
}
path := ctx.GeneratePath(".pdf")
if !strings.HasPrefix(path, ctx.dirPath) {
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
}
}
func TestContext_Rename(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "failure",
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return errors.New("cannot rename")
}}},
expectError: true,
},
{
scenario: "success",
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
err := tc.ctx.Rename("", "")
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_AddOutputPaths(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -769,7 +292,7 @@ func TestContext_BuildOutputFile(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
dirPath, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected no erro but got: %v", err)

View File

@@ -2,7 +2,6 @@ package api
import (
"fmt"
"math"
"net/http"
"os"
"path/filepath"
@@ -12,8 +11,6 @@ import (
"time"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// FormData is a helper for validating and hydrating values from a
@@ -146,91 +143,6 @@ 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
@@ -392,7 +304,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
}
// See https://github.com/gotenberg/gotenberg/issues/139.
sort.Sort(gotenberg.AlphanumericSort(*target))
sort.Strings(*target)
return form
}

View File

@@ -3,7 +3,6 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"testing"
"time"
@@ -769,333 +768,6 @@ 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,7 +2,6 @@ package api
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"net/http"
@@ -11,21 +10,14 @@ 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"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
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")
)
// ErrAsyncProcess happens when a handler or middleware handles a request in an
// asynchronous fashion.
var ErrAsyncProcess = errors.New("async process")
// ParseError parses an error and returns the corresponding HTTP status and
// HTTP message.
@@ -40,26 +32,6 @@ 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.ErrPdfSplitModeNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
}
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()
@@ -118,6 +90,7 @@ 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)
}
@@ -222,17 +195,6 @@ 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
@@ -240,16 +202,14 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
//
// ctx := c.Get("context").(*api.Context)
// cancel := c.Get("cancel").(context.CancelFunc)
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
// We create a context with a timeout so that underlying processes are
// able to stop early and handle correctly a timeout scenario.
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
ctx, cancel, err := newContext(c, logger, fs, timeout)
if err != nil {
cancel()
@@ -270,13 +230,6 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
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

@@ -14,7 +14,7 @@ import (
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestParseError(t *testing.T) {
@@ -29,29 +29,9 @@ func TestParseError(t *testing.T) {
expectMessage: http.StatusText(http.StatusInternalServerError),
},
{
err: gotenberg.ErrFiltered,
expectStatus: http.StatusForbidden,
expectMessage: http.StatusText(http.StatusForbidden),
},
{
err: gotenberg.ErrMaximumQueueSizeExceeded,
expectStatus: http.StatusTooManyRequests,
expectMessage: http.StatusText(http.StatusTooManyRequests),
},
{
err: gotenberg.ErrPdfSplitModeNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues",
},
{
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: context.DeadlineExceeded,
expectStatus: http.StatusServiceUnavailable,
expectMessage: http.StatusText(http.StatusServiceUnavailable),
},
{
err: WrapError(
@@ -241,56 +221,6 @@ 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
@@ -390,15 +320,6 @@ 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 {
@@ -463,11 +384,10 @@ func TestContextMiddleware(t *testing.T) {
c := srv.NewContext(tc.request, recorder)
c.Set("logger", zap.NewNop())
c.Set("traceHeader", "Gotenberg-Trace")
c.Set("trace", "foo")
c.Set("startTime", time.Now())
err := contextMiddleware(gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second)(tc.next)(c)
if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err)

View File

@@ -4,8 +4,6 @@ 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.
@@ -78,7 +76,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)
@@ -86,22 +84,6 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c
}
// SetMkdirAll sets the [gotenberg.MkdirAll].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetMkdirAll(mkdirAll)
func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) {
ctx.Context.mkdirAll = mkdirAll
}
// 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

@@ -7,8 +7,6 @@ import (
"github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestContextMock_SetDirPath(t *testing.T) {
@@ -119,32 +117,6 @@ func TestContextMock_SetEchoContext(t *testing.T) {
}
}
func TestContextMock_SetMkdirAll(t *testing.T) {
mock := ContextMock{&Context{}}
expect := new(gotenberg.OsMkdirAll)
mock.SetMkdirAll(expect)
actual := mock.mkdirAll
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContextMock_SetPathRename(t *testing.T) {
mock := ContextMock{&Context{}}
expect := new(gotenberg.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

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"regexp"
"strings"
"sync"
"sync/atomic"
@@ -14,17 +15,14 @@ import (
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
type browser interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
}
type browserArguments struct {
@@ -40,10 +38,8 @@ type browserArguments struct {
wsUrlReadTimeout time.Duration
// Tasks specific.
allowList *regexp2.Regexp
denyList *regexp2.Regexp
clearCache bool
clearCookies bool
allowList *regexp.Regexp
denyList *regexp.Regexp
disableJavaScript bool
}
@@ -63,7 +59,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
fs: gotenberg.NewFileSystem(),
}
b.isStarted.Store(false)
@@ -91,8 +87,6 @@ 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 {
@@ -163,60 +157,21 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by Chromium.
copyUserProfileDirPath := b.userProfileDirPath
expirationTime := time.Now()
defer func(userProfileDirPath string, expirationTime time.Time) {
// See:
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChrome/chrome-cleanup.sh
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChromium/chrome-cleanup.sh
defer func(userProfileDirPath string) {
go func() {
// Clean up stuck processes.
ps, err := process.Processes()
if err != nil {
logger.Error(fmt.Sprintf("list processes: %v", err))
} else {
for _, p := range ps {
func() {
cmdline, err := p.Cmdline()
if err != nil {
return
}
if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") {
return
}
killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
err = p.KillWithContext(killCtx)
if err != nil {
logger.Error(fmt.Sprintf("kill process: %v", err))
} else {
logger.Info(fmt.Sprintf("Chromium process %d killed", p.Pid))
}
}()
}
}
// FIXME: Chromium seems to recreate the user profile directory
// right after its deletion if we do not wait a certain amount
// of time before deleting it.
// of time before re-deleting it.
<-time.After(10 * time.Second)
err = os.RemoveAll(userProfileDirPath)
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
} else {
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
}
// Also remove Chromium specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
if err != nil {
logger.Error(err.Error())
}
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
}()
}(copyUserProfileDirPath, expirationTime)
}(copyUserProfileDirPath)
b.ctxMu.Lock()
defer b.ctxMu.Unlock()
@@ -256,56 +211,18 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
return true
}
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
clearCacheActionFunc(logger, b.arguments.clearCache),
clearCookiesActionFunc(logger, b.arguments.clearCookies),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
// PDF specific.
printToPdfActionFunc(logger, outputPath, options),
})
}
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
clearCacheActionFunc(logger, b.arguments.clearCache),
clearCookiesActionFunc(logger, b.arguments.clearCookies),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
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),
})
}
func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error {
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks")
return errors.New("browser not started, cannot handle PDF conversion")
}
// We validate the "main" URL against our allow / deny lists.
if !b.arguments.allowList.MatchString(url) {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", url, ErrUrlNotAuthorized)
}
if b.arguments.denyList.String() != "" && b.arguments.denyList.MatchString(url) {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", url, ErrUrlNotAuthorized)
}
deadline, ok := ctx.Deadline()
@@ -313,12 +230,6 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return errors.New("context has no deadline")
}
// We validate the "main" URL against our allow / deny lists.
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
b.ctxMu.RLock()
defer b.ctxMu.RUnlock()
@@ -329,36 +240,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
defer taskCancel()
// We validate all others requests against our allow / deny lists.
// If a request does not pass the validation, we make it fail. It also set
// the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
extraHttpHeaders: options.ExtraHttpHeaders,
})
var (
invalidHttpStatusCode error
invalidHttpStatusCodeMu sync.RWMutex
invalidResourceHttpStatusCode error
invalidResourceHttpStatusCodeMu sync.RWMutex
)
// See:
// https://github.com/gotenberg/gotenberg/issues/613.
// https://github.com/gotenberg/gotenberg/issues/1021.
if len(options.FailOnHttpStatusCodes) != 0 || len(options.FailOnResourceHttpStatusCodes) != 0 {
listenForEventResponseReceived(taskCtx, logger, eventResponseReceivedOptions{
mainPageUrl: url,
failOnHttpStatusCodes: options.FailOnHttpStatusCodes,
invalidHttpStatusCode: &invalidHttpStatusCode,
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
})
}
// If a request does not pass the validation, we make it fail.
listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList)
var (
consoleExceptions error
@@ -370,25 +253,22 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
}
var (
loadingFailed error
loadingFailedMu sync.RWMutex
resourceLoadingFailed error
resourceLoadingFailedMu sync.RWMutex
)
tasks := chromedp.Tasks{
network.Enable(),
fetch.Enable(),
runtime.Enable(),
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
navigateActionFunc(logger, url),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
printToPdfActionFunc(logger, outputPath, options),
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021.
listenForEventLoadingFailed(taskCtx, logger, eventLoadingFailedOptions{
loadingFailed: &loadingFailed,
loadingFailedMu: &loadingFailedMu,
resourceLoadingFailed: &resourceLoadingFailed,
resourceLoadingFailedMu: &resourceLoadingFailedMu,
})
err = chromedp.Run(taskCtx, tasks...)
err := chromedp.Run(taskCtx, tasks...)
if err != nil {
errMessage := err.Error()
@@ -404,23 +284,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return ErrRpccMessageTooLarge
}
return fmt.Errorf("handle tasks: %w", err)
}
// See https://github.com/gotenberg/gotenberg/issues/613.
invalidHttpStatusCodeMu.RLock()
defer invalidHttpStatusCodeMu.RUnlock()
if invalidHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
return fmt.Errorf("print to PDF: %w", err)
}
// See https://github.com/gotenberg/gotenberg/issues/262.
@@ -431,23 +295,6 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
if options.FailOnResourceLoadingFailed {
if resourceLoadingFailed != nil {
return fmt.Errorf("%v: %w", resourceLoadingFailed, ErrResourceLoadingFailed)
}
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,12 @@ import (
"time"
"github.com/alexliesenfeld/health"
"github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
func init() {
@@ -22,6 +21,14 @@ func init() {
}
var (
// ErrUrlNotAuthorized happens if a URL is not acceptable according to the
// allowed/denied lists.
ErrUrlNotAuthorized = errors.New("URL not authorized")
// ErrOmitBackgroundWithoutPrintBackground happens if
// Options.OmitBackground is set to true but not Options.PrintBackground.
ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background")
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
// "screen" nor "print". Empty value are allowed though.
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
@@ -30,43 +37,22 @@ var (
// returns an exception or undefined.
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
// ErrInvalidPrinterSettings happens if the Options have one or more
// aberrant values.
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
// ErrPageRangesSyntaxError happens if the Options have an invalid page
// ranges.
ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
// ErrRpccMessageTooLarge happens when the messages received by
// ChromeDevTools are larger than 100 MB.
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
// ErrInvalidHttpStatusCode happens when the status code from the main page
// matches with one of the entry in [Options.FailOnHttpStatusCodes].
ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code")
// ErrInvalidResourceHttpStatusCode happens when the status code from one
// or more resources matches with one of the entry in
// [Options.FailOnResourceHttpStatusCodes].
ErrInvalidResourceHttpStatusCode = errors.New("invalid resource HTTP status code")
// ErrConsoleExceptions happens when there are exceptions in the Chromium
// console. It also happens only if the [Options.FailOnConsoleExceptions]
// is set to true.
ErrConsoleExceptions = errors.New("console exceptions")
// ErrLoadingFailed happens when the main page failed to load.
ErrLoadingFailed = errors.New("loading failed")
// ErrResourceLoadingFailed happens when one or more resources failed to load.
ErrResourceLoadingFailed = errors.New("resource loading failed")
// PDF specific.
// ErrOmitBackgroundWithoutPrintBackground happens if
// PdfOptions.OmitBackground is set to true but not PdfOptions.PrintBackground.
ErrOmitBackgroundWithoutPrintBackground = errors.New("omit background without print background")
// ErrInvalidPrinterSettings happens if the PdfOptions have one or more
// aberrant values.
ErrInvalidPrinterSettings = errors.New("invalid printer settings")
// ErrPageRangesSyntaxError happens if the PdfOptions have an invalid page
// ranges.
ErrPageRangesSyntaxError = errors.New("page ranges syntax error")
)
// Chromium is a module which provides both an [Api] and routes for converting
@@ -82,116 +68,81 @@ type Chromium struct {
engine gotenberg.PdfEngine
}
// Options are the common options for all conversions.
// Options are the available expectedOptions for converting HTML document to PDF.
type Options struct {
// SkipNetworkIdleEvent set if the conversion should wait for the
// "networkIdle" event, drastically improving the conversion speed. It may
// not be suitable for all HTML documents, as some may not be fully
// rendered until this event is fired.
SkipNetworkIdleEvent bool
// FailOnHttpStatusCodes sets if the conversion should fail if the status
// code from the main page matches with one of its entries.
FailOnHttpStatusCodes []int64
// FailOnResourceHttpStatusCodes sets if the conversion should fail if the
// status code from at least one resource matches with one if its entries.
FailOnResourceHttpStatusCodes []int64
// FailOnResourceLoadingFailed sets if the conversion should fail like the
// main page if Chromium fails to load at least one resource.
FailOnResourceLoadingFailed bool
// FailOnConsoleExceptions sets if the conversion should fail if there are
// exceptions in the Chromium console.
// Optional.
FailOnConsoleExceptions bool
// WaitDelay is the duration to wait when loading an HTML document before
// converting it.
// converting it to PDF.
// Optional.
WaitDelay time.Duration
// WaitWindowStatus is the window.status value to wait for before
// converting an HTML document.
// converting an HTML document to PDF.
// Optional.
WaitWindowStatus string
// WaitForExpression is the custom JavaScript expression to wait before
// converting an HTML document until it returns true
// converting an HTML document to PDF until it returns true
// Optional.
WaitForExpression string
// Cookies are the cookies to put in the Chromium cookies' jar.
Cookies []Cookie
// UserAgent overrides the default 'User-Agent' HTTP header.
UserAgent string
// ExtraHttpHeaders are extra HTTP headers to send by Chromium while
// loading he HTML document.
ExtraHttpHeaders []ExtraHttpHeader
// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
// the HTML document.
// Optional.
ExtraHttpHeaders map[string]string
// EmulatedMediaType is the media type to emulate, either "screen" or
// "print".
// Optional.
EmulatedMediaType string
// OmitBackground hides default white background and allows generating PDFs
// with transparency.
OmitBackground bool
}
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
FailOnResourceLoadingFailed: false,
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
}
}
// PdfOptions are the available options for converting an HTML document to PDF.
type PdfOptions struct {
Options
// Landscape sets the paper orientation.
// Optional.
Landscape bool
// PrintBackground prints the background graphics.
// Optional.
PrintBackground bool
// OmitBackground hides default white background and allows generating PDFs
// with transparency.
// Optional.
OmitBackground bool
// Scale is the scale of the page rendering.
// Optional.
Scale float64
// SinglePage defines whether to print the entire content in one single
// page.
SinglePage bool
// PaperWidth is the paper width, in inches.
// Optional.
PaperWidth float64
// PaperHeight is the paper height, in inches.
// Optional.
PaperHeight float64
// MarginTop is the top margin, in inches.
// Optional.
MarginTop float64
// MarginBottom is the bottom margin, in inches.
// Optional.
MarginBottom float64
// MarginLeft is the left margin, in inches.
// Optional.
MarginLeft float64
// MarginRight is the right margin, in inches.
// Optional.
MarginRight float64
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
// Optional.
PageRanges string
// HeaderTemplate is the HTML template of the header. It should be valid
@@ -204,29 +155,33 @@ type PdfOptions struct {
// - totalPages: total pages in the document
// For example, <span class=title></span> would generate span containing
// the title.
// Optional.
HeaderTemplate string
// FooterTemplate is the HTML template of the footer. It should use the
// same format as the HeaderTemplate.
// Optional.
FooterTemplate string
// PreferCssPageSize defines whether to prefer page size as defined by CSS.
// If false, the content will be scaled to fit the paper size.
// Optional.
PreferCssPageSize bool
// GenerateDocumentOutline defines whether the document outline should be
// embedded into the PDF.
GenerateDocumentOutline bool
}
// DefaultPdfOptions returns the default values for PdfOptions.
func DefaultPdfOptions() PdfOptions {
return PdfOptions{
Options: DefaultOptions(),
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
Landscape: false,
PrintBackground: false,
OmitBackground: false,
Scale: 1.0,
SinglePage: false,
PaperWidth: 8.5,
PaperHeight: 11,
MarginTop: 0.39,
@@ -237,102 +192,12 @@ func DefaultPdfOptions() PdfOptions {
HeaderTemplate: "<html><head></head><body></body></html>",
FooterTemplate: "<html><head></head><body></body></html>",
PreferCssPageSize: false,
GenerateDocumentOutline: false,
}
}
// ScreenshotOptions are the available options for capturing a screenshot from
// an HTML document.
type ScreenshotOptions struct {
Options
// Width is the device screen width in pixels.
Width int
// Height is the device screen height in pixels.
Height int
// Clip defines whether to clip the screenshot according to the device
// dimensions.
Clip bool
// Format is the image compression format, either "png" or "jpeg" or
// "webp".
Format string
// Quality is the compression quality from range [0..100] (jpeg only).
Quality int
// OptimizeForSpeed defines whether to optimize image encoding for speed,
// not for resulting size.
OptimizeForSpeed bool
}
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
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"`
}
// ExtraHttpHeader are extra HTTP headers to send by Chromium.
type ExtraHttpHeader struct {
// Name is the header name.
// Required.
Name string
// Value is the header value.
// Required.
Value string
// Scope is the header scope. If nil, the header will be applied to ALL
// requests from the page.
// Optional.
Scope *regexp2.Regexp
}
// Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface {
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
}
// Provider is a module interface which exposes a method for creating an [Api]
@@ -352,8 +217,19 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
ID: "chromium",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
// Deprecated flags.
fs.String("chromium-user-agent", "", "Override the default User-Agent header")
fs.Int("chromium-failed-starts-threshold", 5, "Set the number of consecutive failed starts after which the module is considered unhealthy - 0 means ignore")
var err error
err = multierr.Append(err, fs.MarkDeprecated("chromium-user-agent", "use the extraHttpHeaders form field instead"))
err = multierr.Append(err, fs.MarkDeprecated("chromium-failed-starts-threshold", "use the chromium-restart-after property instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the Chromium module: %v", err))
}
fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
@@ -364,9 +240,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
fs.Bool("chromium-disable-routes", false, "Disable the routes")
@@ -400,8 +274,6 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
allowList: flags.MustRegexp("chromium-allow-list"),
denyList: flags.MustRegexp("chromium-deny-list"),
clearCache: flags.MustBool("chromium-clear-cache"),
clearCookies: flags.MustBool("chromium-clear-cookies"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
}
@@ -418,7 +290,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Process.
mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"))
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"))
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -487,6 +359,22 @@ func (mod *Chromium) Stop(ctx context.Context) error {
// Metrics returns the metrics.
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
// TODO: remove deprecated.
Name: "chromium_active_instances_count",
Description: "Current number of active Chromium instances - deprecated.",
Read: func() float64 {
return 1
},
},
{
// TODO: remove deprecated.
Name: "chromium_failed_starts_count",
Description: "Current number of Chromium consecutive starting failures - deprecated.",
Read: func() float64 {
return 0
},
},
{
Name: "chromium_requests_queue_size",
Description: "Current number of Chromium conversion requests waiting to be treated.",
@@ -562,31 +450,19 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
return []api.Route{
convertUrlRoute(mod, mod.engine),
screenshotUrlRoute(mod),
convertHtmlRoute(mod, mod.engine),
screenshotHtmlRoute(mod),
convertMarkdownRoute(mod, mod.engine),
screenshotMarkdownRoute(mod),
}, nil
}
// Pdf converts a URL to PDF.
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
// FIXME: no error wrapping because it leaks on console exceptions output.
return mod.supervisor.Run(ctx, logger, func() error {
return mod.browser.pdf(ctx, logger, url, outputPath, options)
})
}
func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return mod.supervisor.Run(ctx, logger, func() error {
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
})
}
// Interface guards.
var (
_ gotenberg.Module = (*Chromium)(nil)

View File

@@ -11,7 +11,7 @@ import (
"github.com/alexliesenfeld/health"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestDefaultOptions(t *testing.T) {
@@ -23,24 +23,6 @@ func TestDefaultOptions(t *testing.T) {
}
}
func TestDefaultPdfOptions(t *testing.T) {
actual := DefaultPdfOptions()
notExpect := PdfOptions{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestDefaultScreenshotOptions(t *testing.T) {
actual := DefaultScreenshotOptions()
notExpect := ScreenshotOptions{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestChromium_Descriptor(t *testing.T) {
descriptor := new(Chromium).Descriptor()
@@ -352,16 +334,26 @@ func TestChromium_Metrics(t *testing.T) {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
if len(metrics) != 4 {
t.Fatalf("expected %d metrics, but got %d", 4, len(metrics))
}
actual := metrics[0].Read()
if actual != float64(1) {
t.Errorf("expected %f for chromium_active_instances_count, but got %f", float64(1), actual)
}
actual = metrics[1].Read()
if actual != float64(0) {
t.Errorf("expected %f for chromium_failed_starts_count, but got %f", float64(0), actual)
}
actual = metrics[2].Read()
if actual != float64(10) {
t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual)
}
actual = metrics[1].Read()
actual = metrics[3].Read()
if actual != float64(0) {
t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual)
}
@@ -479,7 +471,7 @@ func TestChromium_Routes(t *testing.T) {
}{
{
scenario: "routes not disabled",
expectRoutes: 6,
expectRoutes: 3,
disableRoutes: false,
},
{
@@ -513,14 +505,14 @@ func TestChromium_Pdf(t *testing.T) {
}{
{
scenario: "PDF task success",
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
return nil
}},
expectError: false,
},
{
scenario: "PDF task error",
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
return errors.New("PDF task error")
}},
expectError: true,
@@ -533,49 +525,7 @@ func TestChromium_Pdf(t *testing.T) {
}}
mod.browser = tc.browser
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
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_Screenshot(t *testing.T) {
for _, tc := range []struct {
scenario string
supervisor gotenberg.ProcessSupervisor
browser browser
expectError bool
}{
{
scenario: "Screenshot task success",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return nil
}},
expectError: false,
},
{
scenario: "Screenshot task error",
browser: &browserMock{screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return errors.New("screenshot task error")
}},
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := new(Chromium)
mod.supervisor = &gotenberg.ProcessSupervisorMock{RunMock: func(ctx context.Context, logger *zap.Logger, task func() error) error {
return task()
}}
mod.browser = tc.browser
err := mod.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)

View File

@@ -3,8 +3,7 @@ package chromium
import (
"context"
"fmt"
"net/http"
"slices"
"regexp"
"sync"
"github.com/chromedp/cdproto/cdp"
@@ -13,30 +12,14 @@ import (
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type eventRequestPausedOptions struct {
allowList, denyList *regexp2.Regexp
extraHttpHeaders []ExtraHttpHeader
}
// listenForEventRequestPaused listens for requests to check if they are
// allowed or not. It also set the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
if len(options.extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers")
} else {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
}
// allowed or not.
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch e := ev.(type) {
case *fetch.EventRequestPaused:
@@ -44,229 +27,39 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
allow := true
deadline, ok := ctx.Deadline()
if !ok {
logger.Error("context has no deadline, cannot filter URL")
return
if !allowList.MatchString(e.Request.URL) {
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL))
allow = false
}
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil {
logger.Warn(err.Error())
if denyList.String() != "" && denyList.MatchString(e.Request.URL) {
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL))
allow = false
}
cctx := chromedp.FromContext(ctx)
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
if !allow {
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx)
if allow {
req := fetch.ContinueRequest(e.RequestID)
err := req.Do(executorCtx)
if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err))
logger.Error(fmt.Sprintf("continue request: %s", err))
}
return
}
req := fetch.ContinueRequest(e.RequestID)
var extraHttpHeadersToSet []ExtraHttpHeader
if len(options.extraHttpHeaders) > 0 {
// The user want to set extra HTTP headers.
// First, we have to check if at least one header has to be
// set for current request.
for _, header := range options.extraHttpHeaders {
if header.Scope == nil {
// Non-scoped header.
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
continue
}
ok, err := header.Scope.MatchString(e.Request.URL)
if err != nil {
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
} else if ok {
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
} else {
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
}
}
}
if len(extraHttpHeadersToSet) > 0 {
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
originalHeaders := e.Request.Headers
headers := make(map[string]string)
for key, value := range originalHeaders {
strValue, ok := value.(string)
if ok {
headers[key] = strValue
} else {
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
}
}
var headersEntries []*fetch.HeaderEntry
for key, value := range headers {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: key,
Value: value,
})
}
for _, header := range extraHttpHeadersToSet {
headersEntries = append(headersEntries, &fetch.HeaderEntry{
Name: header.Name,
Value: header.Value,
})
}
req.Headers = headersEntries
}
err = req.Do(executorCtx)
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err := req.Do(executorCtx)
if err != nil {
logger.Error(fmt.Sprintf("continue request: %s", err))
logger.Error(fmt.Sprintf("fail request: %s", err))
}
}()
}
})
}
type eventResponseReceivedOptions struct {
mainPageUrl string
failOnHttpStatusCodes []int64
invalidHttpStatusCode *error
invalidHttpStatusCodeMu *sync.RWMutex
failOnResourceOnHttpStatusCode []int64
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
}
// listenForEventResponseReceived listens for an invalid HTTP status code that
// is returned by the main page or by one or more resources.
// See:
// https://github.com/gotenberg/gotenberg/issues/613.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventResponseReceived(
ctx context.Context,
logger *zap.Logger,
options eventResponseReceivedOptions,
) {
for _, code := range []int64{199, 299, 399, 499, 599} {
if slices.Contains(options.failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ {
options.failOnHttpStatusCodes = append(options.failOnHttpStatusCodes, i)
}
}
if slices.Contains(options.failOnResourceOnHttpStatusCode, code) {
for i := code - 99; i <= code; i++ {
options.failOnResourceOnHttpStatusCode = append(options.failOnResourceOnHttpStatusCode, i)
}
}
}
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventResponseReceived:
if ev.Response.URL == options.mainPageUrl {
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
options.invalidHttpStatusCodeMu.Lock()
defer options.invalidHttpStatusCodeMu.Unlock()
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
}
return
}
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
options.invalidResourceHttpStatusCodeMu.Lock()
defer options.invalidResourceHttpStatusCodeMu.Unlock()
*options.invalidResourceHttpStatusCode = multierr.Append(
*options.invalidResourceHttpStatusCode,
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
)
}
}
})
}
type eventLoadingFailedOptions struct {
loadingFailed *error
loadingFailedMu *sync.RWMutex
resourceLoadingFailed *error
resourceLoadingFailedMu *sync.RWMutex
}
// listenForEventLoadingFailed listens for an event indicating that the main
// page or one or more resources failed to load.
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
switch ev := ev.(type) {
case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
// We are looking for common errors.
// TODO: sufficient?
errors := []string{
"net::ERR_CONNECTION_CLOSED",
"net::ERR_CONNECTION_RESET",
"net::ERR_CONNECTION_REFUSED",
"net::ERR_CONNECTION_ABORTED",
"net::ERR_CONNECTION_FAILED",
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_INTERNET_DISCONNECTED",
"net::ERR_ADDRESS_UNREACHABLE",
"net::ERR_BLOCKED_BY_CLIENT",
"net::ERR_BLOCKED_BY_RESPONSE",
"net::ERR_FILE_NOT_FOUND",
}
if !slices.Contains(errors, ev.ErrorText) {
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
return
}
if ev.Type == network.ResourceTypeDocument {
// Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page.
logger.Debug("event EventLoadingFailed fired for main page")
options.loadingFailedMu.Lock()
defer options.loadingFailedMu.Unlock()
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
return
}
logger.Debug("event EventLoadingFailed fired for a resource")
options.resourceLoadingFailedMu.Lock()
defer options.resourceLoadingFailedMu.Unlock()
*options.resourceLoadingFailed = multierr.Append(
*options.resourceLoadingFailed,
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
)
}
})
}
// listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262.

View File

@@ -5,38 +5,28 @@ import (
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
// ApiMock is a mock for the [Api] interface.
type ApiMock struct {
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
}
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
return api.PdfMock(ctx, logger, url, outputPath, options)
}
func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return api.ScreenshotMock(ctx, logger, url, outputPath, options)
}
// browserMock is a mock for the [browser] interface.
type browserMock struct {
gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
}
func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
return b.pdfMock(ctx, logger, url, outputPath, options)
}
func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
return b.screenshotMock(ctx, logger, url, outputPath, options)
}
// Interface guards.
var (
_ Api = (*ApiMock)(nil)

View File

@@ -9,42 +9,26 @@ import (
func TestApiMock(t *testing.T) {
mock := &ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
},
ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
return nil
},
}
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
if err != nil {
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
}
err = mock.Screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if err != nil {
t.Errorf("expected no error from ApiMock.Screenshot, but got: %v", err)
}
}
func TestBrowserMock(t *testing.T) {
mock := &browserMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
},
screenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
return nil
},
}
err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
if err != nil {
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
}
err = mock.screenshot(context.Background(), zap.NewNop(), "", "", ScreenshotOptions{})
if err != nil {
t.Errorf("expected no error from browserMock.screenshot, but got: %v", err)
}
}

View File

@@ -9,229 +9,111 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday"
"github.com/russross/blackfriday/v2"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
// FormDataChromiumPdfOptions creates [Options] from the form data. Fallback to
// default value if the considered key is not present.
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
omitBackground bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
userAgent string
extraHttpHeaders map[string]string
emulatedMediaType string
landscape, printBackground, omitBackground bool
scale, paperWidth, paperHeight float64
marginTop, marginBottom, marginLeft, marginRight float64
pageRanges string
headerTemplate, footerTemplate string
preferCssPageSize bool
)
form := ctx.FormData().
Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
Custom("failOnHttpStatusCodes", func(value string) error {
if value == "" {
failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
return nil
}
err := json.Unmarshal([]byte(value), &failOnHttpStatusCodes)
if err != nil {
return fmt.Errorf("unmarshal failOnHttpStatusCodes: %w", err)
}
return nil
}).
Custom("failOnResourceHttpStatusCodes", func(value string) error {
if value == "" {
failOnResourceHttpStatusCodes = defaultOptions.FailOnResourceHttpStatusCodes
return nil
}
err := json.Unmarshal([]byte(value), &failOnResourceHttpStatusCodes)
if err != nil {
return fmt.Errorf("unmarshal failOnResourceHttpStatusCodes: %w", err)
}
return nil
}).
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
Custom("cookies", func(value string) error {
if value == "" {
cookies = defaultOptions.Cookies
return nil
}
err := json.Unmarshal([]byte(value), &cookies)
if err != nil {
return fmt.Errorf("unmarshal cookies: %w", err)
}
for i, cookie := range cookies {
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
}
}
return err
}).
String("userAgent", &userAgent, defaultOptions.UserAgent).
String("userAgent", &userAgent, ""). // FIXME: deprecated.
Custom("extraHttpHeaders", func(value string) error {
if value == "" {
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
return nil
}
var headers map[string]string
err := json.Unmarshal([]byte(value), &headers)
err := json.Unmarshal([]byte(value), &extraHttpHeaders)
if err != nil {
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
return fmt.Errorf("unmarshal extra HTTP headers: %w", err)
}
for k, v := range headers {
var scope string
var valueTokens []string
var invalidScopeToken bool
tokens := strings.Split(v, ";")
for _, token := range tokens {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
tokenNoSpaces := strings.Join(strings.Fields(token), "")
parts := strings.SplitN(tokenNoSpaces, "=", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
scope = parts[1]
} else {
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
invalidScopeToken = true
break
}
} else {
if token != "" {
valueTokens = append(valueTokens, token)
}
}
}
if invalidScopeToken {
continue
}
var scopeRegexp *regexp2.Regexp
if len(scope) > 0 {
p, errCompile := regexp2.Compile(scope, 0)
if errCompile != nil {
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
continue
}
scopeRegexp = p
}
extraHttpHeaders = append(extraHttpHeaders, ExtraHttpHeader{
Name: k,
Value: strings.Join(valueTokens, "; "),
Scope: scopeRegexp,
})
}
return err
return nil
}).
Custom("emulatedMediaType", func(value string) error {
if value == "" {
emulatedMediaType = defaultOptions.EmulatedMediaType
return nil
}
if value != "screen" && value != "print" {
return errors.New("wrong value, expected either 'screen', 'print' or empty")
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
}
emulatedMediaType = value
return nil
}).
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
Bool("landscape", &landscape, defaultOptions.Landscape).
Bool("printBackground", &printBackground, defaultOptions.PrintBackground).
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground).
Float64("scale", &scale, defaultOptions.Scale).
Float64("paperWidth", &paperWidth, defaultOptions.PaperWidth).
Float64("paperHeight", &paperHeight, defaultOptions.PaperHeight).
Float64("marginTop", &marginTop, defaultOptions.MarginTop).
Float64("marginBottom", &marginBottom, defaultOptions.MarginBottom).
Float64("marginLeft", &marginLeft, defaultOptions.MarginLeft).
Float64("marginRight", &marginRight, defaultOptions.MarginRight).
String("nativePageRanges", &pageRanges, defaultOptions.PageRanges).
Content("header.html", &headerTemplate, defaultOptions.HeaderTemplate).
Content("footer.html", &footerTemplate, defaultOptions.FooterTemplate).
Bool("preferCssPageSize", &preferCssPageSize, defaultOptions.PreferCssPageSize)
options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
// FIXME: deprecated.
if userAgent != "" {
ctx.Log().Warn("'userAgent' is deprecated; prefer the 'extraHttpHeaders' form field instead")
if extraHttpHeaders == nil {
extraHttpHeaders = make(map[string]string)
}
extraHttpHeaders["User-Agent"] = userAgent
}
return form, options
}
// FormDataChromiumPdfOptions creates [PdfOptions] from the form data. Fallback to
// default value if the considered key is not present.
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
form, options := FormDataChromiumOptions(ctx)
defaultPdfOptions := DefaultPdfOptions()
var (
landscape, printBackground, singlePage bool
scale, paperWidth, paperHeight float64
marginTop, marginBottom, marginLeft, marginRight float64
pageRanges string
headerTemplate, footerTemplate string
preferCssPageSize bool
generateDocumentOutline bool
)
form.
Bool("landscape", &landscape, defaultPdfOptions.Landscape).
Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
Float64("scale", &scale, defaultPdfOptions.Scale).
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).
Bool("preferCssPageSize", &preferCssPageSize, defaultPdfOptions.PreferCssPageSize).
Bool("generateDocumentOutline", &generateDocumentOutline, defaultPdfOptions.GenerateDocumentOutline)
pdfOptions := PdfOptions{
Options: options,
options := Options{
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
Landscape: landscape,
PrintBackground: printBackground,
OmitBackground: omitBackground,
Scale: scale,
SinglePage: singlePage,
PaperWidth: paperWidth,
PaperHeight: paperHeight,
MarginTop: marginTop,
@@ -242,79 +124,42 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, PdfOptions) {
HeaderTemplate: headerTemplate,
FooterTemplate: footerTemplate,
PreferCssPageSize: preferCssPageSize,
GenerateDocumentOutline: generateDocumentOutline,
}
return form, pdfOptions
return form, options
}
// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] from the form
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
// data. Fallback to default value if the considered key is not present.
func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, ScreenshotOptions) {
form, options := FormDataChromiumOptions(ctx)
defaultScreenshotOptions := DefaultScreenshotOptions()
func FormDataChromiumPdfFormats(ctx *api.Context) gotenberg.PdfFormats {
var (
width, height int
clip bool
format string
quality int
optimizeForSpeed bool
pdfFormat string
pdfa string
pdfua 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
return nil
}
ctx.FormData().
String("pdfFormat", &pdfFormat, "").
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false)
if value != "png" && value != "jpeg" && value != "webp" {
return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'")
}
// FIXME: deprecated.
// pdfa > pdfFormat.
var actualPdfArchive string
format = value
return nil
}).
Custom("quality", func(value string) error {
if value == "" {
quality = defaultScreenshotOptions.Quality
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 0 {
return errors.New("value is negative")
}
if intValue > 100 {
return errors.New("value is superior to 100")
}
quality = intValue
return nil
}).
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
screenshotOptions := ScreenshotOptions{
Options: options,
Width: width,
Height: height,
Clip: clip,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
if pdfFormat != "" {
ctx.Log().Warn("'pdfFormat' is deprecated; prefer the 'pdfa' form field instead")
actualPdfArchive = pdfFormat
}
return form, screenshotOptions
if pdfa != "" {
actualPdfArchive = pdfa
}
return gotenberg.PdfFormats{
PdfA: actualPdfArchive,
PdfUa: pdfua,
}
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
@@ -326,9 +171,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
pdfFormats := FormDataChromiumPdfFormats(ctx)
var url string
err := form.
@@ -338,7 +181,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -348,35 +191,6 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
}
// screenshotUrlRoute returns an [api.Route] which can take a screenshot from a
// URL.
func screenshotUrlRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/url",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var url string
err := form.
MandatoryString("url", &url).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("URL screenshot: %w", err)
}
return nil
},
}
}
// convertHtmlRoute returns an [api.Route] which can convert an HTML file to
// PDF.
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
@@ -387,9 +201,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
pdfFormats := FormDataChromiumPdfFormats(ctx)
var inputPath string
err := form.
@@ -400,7 +212,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -410,36 +222,6 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
}
// screenshotHtmlRoute returns an [api.Route] which can take a screenshot from
// an HTML file.
func screenshotHtmlRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/html",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var inputPath string
err := form.
MandatoryPath("index.html", &inputPath).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
url := fmt.Sprintf("file://%s", inputPath)
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("HTML screenshot: %w", err)
}
return nil
},
}
}
// convertMarkdownRoute returns an [api.Route] which can convert markdown files
// to PDF.
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
@@ -450,9 +232,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx)
mode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
pdfFormats := FormDataChromiumPdfFormats(ctx)
var (
inputPath string
@@ -467,12 +247,80 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
// We have to convert each markdown file referenced in the HTML
// file to... HTML. Thanks to the "html/template" package, we are
// able to provide the "toHTML" function which the user may call
// directly inside the HTML file.
var markdownFilesNotFoundErr error
tmpl, err := template.
New(filepath.Base(inputPath)).
Funcs(template.FuncMap{
"toHTML": func(filename string) (template.HTML, error) {
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
if filename == markdownFilename {
path = markdownPath
break
}
}
if path == "" {
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)
return "", nil
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
}
unsafe := blackfriday.Run(b)
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
// #nosec
return template.HTML(sanitized), nil
},
}).ParseFiles(inputPath)
if err != nil {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
return fmt.Errorf("parse template file: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, &struct{}{})
if err != nil {
return fmt.Errorf("execute template: %w", err)
}
if markdownFilesNotFoundErr != nil {
return api.WrapError(
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
),
)
}
inputPath = ctx.GeneratePath(".html")
err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
if err != nil {
return fmt.Errorf("write template result: %w", err)
}
url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -482,126 +330,23 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
}
// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
// from markdown files.
func screenshotMarkdownRoute(chromium Api) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/chromium/screenshot/markdown",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumScreenshotOptions(ctx)
var (
inputPath string
markdownPaths []string
)
err := form.
MandatoryPath("index.html", &inputPath).
MandatoryPaths([]string{".md"}, &markdownPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("markdown screenshot: %w", err)
}
return nil
},
}
}
func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) (string, error) {
// We have to convert each markdown file referenced in the HTML
// file to... HTML. Thanks to the "html/template" package, we are
// able to provide the "toHTML" function which the user may call
// directly inside the HTML file.
var markdownFilesNotFoundErr error
tmpl, err := template.
New(filepath.Base(inputPath)).
Funcs(template.FuncMap{
"toHTML": func(filename string) (template.HTML, error) {
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
if filename == markdownFilename {
path = markdownPath
break
}
}
if path == "" {
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)
return "", nil
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read markdown file '%s': %w", filename, err)
}
unsafe := blackfriday.Run(b)
sanitized := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
// #nosec
return template.HTML(sanitized), nil
},
}).ParseFiles(inputPath)
if err != nil {
return "", fmt.Errorf("parse template file: %w", err)
}
var buffer bytes.Buffer
err = tmpl.Execute(&buffer, &struct{}{})
if err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
if markdownFilesNotFoundErr != nil {
return "", api.WrapError(
fmt.Errorf("markdown files not found: %w", markdownFilesNotFoundErr),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Markdown file(s) not found: %s", markdownFilesNotFoundErr),
),
)
}
inputPath = ctx.GeneratePath(".html")
err = os.WriteFile(inputPath, buffer.Bytes(), 0o600)
if err != nil {
return "", fmt.Errorf("write template result: %w", err)
}
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
// convertUrl is a stub which is called by the other methods of this file.
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options Options) error {
outputPath := ctx.GeneratePath(".pdf")
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, options.Options)
if err != nil {
if errors.Is(err, ErrUrlNotAuthorized) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusForbidden,
fmt.Sprintf("'%s' does not match the authorized URLs", url),
),
)
}
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
@@ -612,6 +357,23 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
)
}
if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return
// an ErrInvalidEvaluationExpression error. In such a scenario,
// we return a 500.
return fmt.Errorf("convert to PDF: %w", err)
}
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
),
)
}
if errors.Is(err, ErrInvalidPrinterSettings) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
@@ -632,55 +394,45 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
)
}
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
return fmt.Errorf("convert to PDF: %w", err)
}
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
if err != nil {
return fmt.Errorf("split PDF: %w", err)
}
// So far so good, the URL has been converted to PDF.
// Now, let's check if the client want to convert the resulting PDF
// to specific formats.
zeroValued := gotenberg.PdfFormats{}
if pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF(s): %w", err)
}
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
// The PDF has been split and split parts have been converted to a
// specific format. We want to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
if err != nil {
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
),
)
}
return fmt.Errorf("convert PDF: %w", err)
}
} else {
outputPaths = convertOutputPaths
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
}
func screenshotUrl(ctx *api.Context, chromium Api, url string, options ScreenshotOptions) error {
ext := fmt.Sprintf(".%s", options.Format)
outputPath := ctx.GeneratePath(ext)
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
err = handleChromiumError(err, options.Options)
if err != nil {
return fmt.Errorf("screenshot: %w", err)
// Important: the output path is now the converted file.
outputPath = convertOutputPath
}
err = ctx.AddOutputPaths(outputPath)
@@ -690,78 +442,3 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
return nil
}
func handleChromiumError(err error, options Options) error {
if err == nil {
return nil
}
if errors.Is(err, ErrInvalidEvaluationExpression) {
if options.WaitForExpression == "" {
// We do not expect the 'waitWindowStatus' form field to return
// an ErrInvalidEvaluationExpression error. In such a scenario,
// we return a 500.
return err
}
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The expression '%s' (waitForExpression) returned an exception or undefined", options.WaitForExpression),
),
)
}
if errors.Is(err, ErrInvalidHttpStatusCode) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Invalid HTTP status code from the main page: %s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidHttpStatusCode.Error()), "")),
),
)
}
if errors.Is(err, ErrInvalidResourceHttpStatusCode) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Invalid HTTP status code from resources:\n%s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidResourceHttpStatusCode.Error()), "")),
),
)
}
if errors.Is(err, ErrConsoleExceptions) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Chromium console exceptions:\n%s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
),
)
}
if errors.Is(err, ErrLoadingFailed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Chromium returned %v", err),
),
)
}
if errors.Is(err, ErrResourceLoadingFailed) {
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusConflict,
fmt.Sprintf("Chromium failed to load resources: %v", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrResourceLoadingFailed.Error()), "")),
),
)
}
return err
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ package chromium
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"time"
@@ -16,45 +15,24 @@ import (
"go.uber.org/zap"
)
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options) 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(paperHeight).
WithPaperHeight(options.PaperHeight).
WithMarginTop(options.MarginTop).
WithMarginBottom(options.MarginBottom).
WithMarginLeft(options.MarginLeft).
WithMarginRight(options.MarginRight).
WithPageRanges(pageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize).
WithGenerateDocumentOutline(options.GenerateDocumentOutline).
// Does not seem to work.
// See https://github.com/gotenberg/gotenberg/issues/831.
WithGenerateTaggedPDF(false)
WithPageRanges(options.PageRanges).
WithPreferCSSPageSize(options.PreferCssPageSize)
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
hasCustomHeaderFooter := options.HeaderTemplate != DefaultOptions().HeaderTemplate ||
options.FooterTemplate != DefaultOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.Debug("no custom header nor footer")
@@ -85,7 +63,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
}
defer func() {
err = reader.Close()
err := reader.Close()
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
}
@@ -97,7 +75,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
}
defer func() {
err = file.Close()
err := file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
@@ -114,111 +92,12 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
}
}
func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
captureScreenshot := page.CaptureScreenshot().
WithCaptureBeyondViewport(true).
WithFromSurface(true).
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))
}
logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
buffer, err := captureScreenshot.Do(ctx)
if err != nil {
return fmt.Errorf("capture screenshot: %w", err)
}
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open output path: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
}
}()
_, err = file.Write(buffer)
if err != nil {
return fmt.Errorf("write result to output path: %w", err)
}
return nil
}
}
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.
if !clear {
logger.Debug("cache not cleared")
return nil
}
logger.Debug("clear cache")
err := network.ClearBrowserCache().Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("clear cache: %w", err)
}
}
func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
if !clear {
logger.Debug("cookies not cleared")
return nil
}
logger.Debug("clear cookies")
err := network.ClearBrowserCookies().Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("clear cookies: %w", err)
}
}
func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/175.
if !disable {
logger.Debug("JavaScript not disabled")
return nil
}
@@ -233,101 +112,31 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti
}
}
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(cookies) == 0 {
logger.Debug("no cookies to set")
if len(extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers")
return nil
}
deadline, ok := ctx.Deadline()
if !ok {
return errors.New("context has no deadline, cannot set cookies")
}
epochTime := cdp.TimeSinceEpoch(deadline)
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
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(),
)
headers := make(network.Headers, len(extraHttpHeaders))
for key, value := range extraHttpHeaders {
headers[key] = value
}
for _, cookie := range cookies {
cookieParams := network.
SetCookie(cookie.Name, cookie.Value).
WithDomain(cookie.Domain).
WithPath(cookie.Path).
WithSecure(cookie.Secure).
WithHTTPOnly(cookie.HttpOnly).
WithSameSite(cookie.SameSite).
WithExpires(&epochTime)
err := cookieParams.Do(ctx)
if err != nil {
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
}
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
}
return nil
}
}
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(userAgent) == 0 {
logger.Debug("no user agent override")
return nil
}
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
err := network.SetExtraHTTPHeaders(headers).Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("set user agent override: %w", err)
return fmt.Errorf("set extra HTTP headers: %w", err)
}
}
// This code has been replaced with the listenForEventRequestPaused function.
// Indeed, the user may want to scope the headers per domain, but using
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
// See https://github.com/gotenberg/gotenberg/issues/1011.
//
//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
// if len(extraHttpHeaders) == 0 {
// logger.Debug("no extra HTTP headers")
// return nil
// }
//
// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
//
// headers := make(network.Headers, len(extraHttpHeaders))
// for key, value := range extraHttpHeaders {
// headers[key] = value
// }
//
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
// if err == nil {
// return nil
// }
//
// return fmt.Errorf("set extra HTTP headers: %w", err)
// }
//}
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
func navigateActionFunc(logger *zap.Logger, url string) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
@@ -336,21 +145,12 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo
return fmt.Errorf("navigate to '%s': %w", url, err)
}
waitFunc := []func() error{
waitForEventDomContentEventFired(ctx, logger),
waitForEventLoadEventFired(ctx, logger),
waitForEventLoadingFinished(ctx, logger),
}
if !skipNetworkIdleEvent {
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
} else {
logger.Debug("skipping network idle event")
}
err = runBatch(
ctx,
waitFunc...,
waitForEventDomContentEventFired(ctx, logger),
waitForEventLoadEventFired(ctx, logger),
waitForEventNetworkIdle(ctx, logger),
waitForEventLoadingFinished(ctx, logger),
)
if err == nil {
@@ -366,6 +166,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
// See https://github.com/gotenberg/gotenberg/issues/226.
if !omitBackground {
logger.Debug("default white background not hidden")
return nil
}
@@ -424,6 +225,7 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.A
return func(ctx context.Context) error {
if mediaType == "" {
logger.Debug("no emulated media type")
return nil
}
@@ -447,11 +249,13 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool,
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait delay")
return nil
}
if delay <= 0 {
logger.Debug("no wait delay")
return nil
}
@@ -472,11 +276,13 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait expression")
return nil
}
if expression == "" {
logger.Debug("no wait expression")
return nil
}

View File

@@ -1,11 +0,0 @@
// 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

@@ -1,160 +0,0 @@
package exiftool
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"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)
}
// Split is not available in this implementation.
func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF 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 []interface{}:
// See https://github.com/gotenberg/gotenberg/issues/1048.
strings := make([]string, len(val))
for i, entry := range val {
if str, ok := entry.(string); ok {
strings[i] = str
continue
}
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
fileMetadata[0].SetStrings(key, strings)
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: %s %+v %s %w", key, val, reflect.TypeOf(val), 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

@@ -1,362 +0,0 @@
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_Split(t *testing.T) {
engine := new(ExifTool)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
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 (not string array)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Unsupported": []interface{}{
"foo",
1,
},
},
expectError: true,
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
},
{
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (default)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Unsupported": map[string]interface{}{},
},
expectError: true,
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
},
{
scenario: "success (interface array to string array)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Keywords": []interface{}{
"first",
"second",
},
},
expectMetadata: map[string]interface{}{
"Keywords": []interface{}{
"first",
"second",
},
},
expectError: false,
},
{
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(new(gotenberg.OsMkdirAll))
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

@@ -12,8 +12,8 @@ import (
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
func init() {
@@ -21,20 +21,13 @@ func init() {
}
var (
// ErrInvalidPdfFormats happens if the PDF formats option cannot be handled
// ErrInvalidPdfFormat happens if the PDF format option cannot be handled
// by LibreOffice.
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
ErrInvalidPdfFormat = errors.New("invalid PDF format")
// ErrUnoException happens when unoconverter returns an exit code 5.
ErrUnoException = errors.New("uno exception")
// ErrRuntimeException happens when unoconverter returns an exit code 6.
ErrRuntimeException = errors.New("uno exception")
// ErrCoreDumped happens randomly; sometime a conversion will work as
// expected, and some other time the same conversion will fail.
// See https://github.com/gotenberg/gotenberg/issues/639.
ErrCoreDumped = errors.New("core dumped")
// ErrMalformedPageRanges happens if the page ranges option cannot be
// interpreted by LibreOffice.
ErrMalformedPageRanges = errors.New("page ranges are malformed")
)
// Api is a module which provides a [Uno] to interact with LibreOffice.
@@ -48,133 +41,22 @@ type Api struct {
}
// Options gathers available options when converting a document to PDF.
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
type Options struct {
// Password specifies the password for opening the source file.
Password string
// Landscape allows to change the orientation of the resulting PDF.
// Optional.
Landscape bool
// PageRanges allows to select the pages to convert.
// TODO: should prefer a method form PdfEngine.
// Optional.
PageRanges string
// ExportFormFields specifies whether form fields are exported as widgets
// or only their fixed print representation is exported.
ExportFormFields bool
// AllowDuplicateFieldNames specifies whether multiple form fields exported
// are allowed to have the same field name.
AllowDuplicateFieldNames bool
// ExportBookmarks specifies if bookmarks are exported to PDF.
ExportBookmarks bool
// ExportBookmarksToPdfDestination specifies that the bookmarks contained
// in the source LibreOffice file should be exported to the PDF file as
// Named Destination.
ExportBookmarksToPdfDestination bool
// ExportPlaceholders exports the placeholders fields visual markings only.
// The exported placeholder is ineffective.
ExportPlaceholders bool
// ExportNotes specifies if notes are exported to PDF.
ExportNotes bool
// ExportNotesPages specifies if notes pages are exported to PDF.
// Notes pages are available in Impress documents only.
ExportNotesPages bool
// ExportOnlyNotesPages specifies, if the property ExportNotesPages is set
// to true, if only notes pages are exported to PDF.
ExportOnlyNotesPages bool
// ExportNotesInMargin specifies if notes in margin are exported to PDF.
ExportNotesInMargin bool
// ConvertOooTargetToPdfTarget specifies that the target documents with
// .od[tpgs] extension, will have that extension changed to .pdf when the
// link is exported to PDF. The source document remains untouched.
ConvertOooTargetToPdfTarget bool
// ExportLinksRelativeFsys specifies that the file system related
// hyperlinks (file:// protocol) present in the document will be exported
// as relative to the source document location.
ExportLinksRelativeFsys bool
// ExportHiddenSlides exports, for LibreOffice Impress, slides that are not
// included in slide shows.
ExportHiddenSlides bool
// SkipEmptyPages specifies that automatically inserted empty pages are
// suppressed. This option is active only if storing Writer documents.
SkipEmptyPages bool
// AddOriginalDocumentAsStream specifies that a stream is inserted to the
// PDF file which contains the original document for archiving purposes.
AddOriginalDocumentAsStream bool
// SinglePageSheets ignores each sheets paper size, print ranges and
// shown/hidden status and puts every sheet (even hidden sheets) on exactly
// one page.
SinglePageSheets bool
// LosslessImageCompression specifies if images are exported to PDF using
// a lossless compression format like PNG or compressed using the JPEG
// format.
LosslessImageCompression bool
// Quality specifies the quality of the JPG export. A higher value produces
// a higher-quality image and a larger file. Between 1 and 100.
Quality int
// ReduceImageResolution specifies if the resolution of each image is
// reduced to the resolution specified by the property MaxImageResolution.
ReduceImageResolution bool
// MaxImageResolution, if the property ReduceImageResolution is set to
// true, tells if all images will be reduced to the given value in DPI.
// Possible values are: 75, 150, 300, 600 and 1200.
MaxImageResolution int
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA.
// Optional.
PdfFormats gotenberg.PdfFormats
}
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
Password: "",
Landscape: false,
PageRanges: "",
ExportFormFields: true,
AllowDuplicateFieldNames: false,
ExportBookmarks: true,
ExportBookmarksToPdfDestination: false,
ExportPlaceholders: false,
ExportNotes: false,
ExportNotesPages: false,
ExportOnlyNotesPages: false,
ExportNotesInMargin: false,
ConvertOooTargetToPdfTarget: false,
ExportLinksRelativeFsys: false,
ExportHiddenSlides: false,
SkipEmptyPages: false,
AddOriginalDocumentAsStream: false,
SinglePageSheets: false,
LosslessImageCompression: false,
Quality: 90,
ReduceImageResolution: false,
MaxImageResolution: 300,
PdfFormats: gotenberg.PdfFormats{
PdfA: "",
PdfUa: false,
},
}
}
// Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface {
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
@@ -198,8 +80,22 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
ID: "libreoffice-api",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("api", flag.ExitOnError)
// Deprecated flags.
fs.Duration("uno-listener-start-timeout", time.Duration(10)*time.Second, "Time limit for restarting the LibreOffice")
fs.Int64("uno-listener-restart-threshold", 10, "Conversions limit after which the LibreOffice listener is restarted - 0 means no restart")
fs.Bool("unoconv-disable-listener", false, "Do not start a long-running listener - save resources in detriment of unitary performance")
var err error
err = multierr.Append(err, fs.MarkDeprecated("uno-listener-start-timeout", "use the libreOffice-start-timeout property instead"))
err = multierr.Append(err, fs.MarkDeprecated("uno-listener-restart-threshold", "use the libreOffice-restart-after property instead"))
err = multierr.Append(err, fs.MarkDeprecated("unoconv-disable-listener", "use the libreOffice-auto-start property instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the LibreOffice module: %v", err))
}
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
fs.Int64("libreoffice-max-queue-size", 0, "Maximum request queue size for LibreOffice. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
@@ -227,7 +123,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.args = libreOfficeArguments{
binPath: libreOfficeBinPath,
unoBinPath: unoBinPath,
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
startTimeout: flags.MustDeprecatedDuration("uno-listener-start-timeout", "libreoffice-start-timeout"),
}
// Logger.
@@ -243,7 +139,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustDeprecatedInt64("uno-listener-restart-threshold", "libreoffice-restart-after"))
return nil
}
@@ -308,6 +204,46 @@ func (a *Api) Stop(ctx context.Context) error {
// Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
// TODO: remove deprecated.
{
Name: "unoconv_active_instances_count",
Description: "Current number of active unoconv instances - deprecated.",
Read: func() float64 {
return 1
},
},
// TODO: remove deprecated.
{
Name: "libreoffice_listener_active_instances_count",
Description: "Current number of active LibreOffice listener instances - deprecated.",
Read: func() float64 {
return 1
},
},
// TODO: remove deprecated.
{
Name: "unoconv_listener_active_instances_count",
Description: "Current number of active unoconv listener instances- deprecated.",
Read: func() float64 {
return 1
},
},
// TODO: remove deprecated.
{
Name: "libreoffice_listener_queue_length",
Description: "Current number of processes in the LibreOffice listener queue - deprecated, prefer libreoffice_requests_queue_size.",
Read: func() float64 {
return float64(a.supervisor.ReqQueueSize())
},
},
// TODO: remove deprecated.
{
Name: "unoconv_listener_queue_length",
Description: "Current number of processes in the queue - deprecated, prefer libreoffice_requests_queue_size.",
Read: func() float64 {
return float64(a.supervisor.ReqQueueSize())
},
},
{
Name: "libreoffice_requests_queue_size",
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
@@ -376,157 +312,94 @@ func (a *Api) LibreOffice() (Uno, error) {
// Pdf converts a document to PDF.
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
err := a.supervisor.Run(ctx, logger, func() error {
return a.supervisor.Run(ctx, logger, func() error {
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
if err == nil {
return nil
}
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options)
}
return fmt.Errorf("supervisor run task: %w", err)
}
// Extensions returns the file extensions available for conversions.
// FIXME: don't care, take all on the route level?
func (a *Api) Extensions() []string {
return []string{
".123",
".602",
".abw",
".bib",
".bmp",
".cdr",
".cgm",
".cmx",
".csv",
".cwk",
".dbf",
".dif",
".doc",
".docm",
".xml",
".docx",
".dot",
".dotm",
".dotx",
".dxf",
".emf",
".eps",
".epub",
".fodg",
".fodp",
".fods",
".fodt",
".fopd",
".gif",
".htm",
".html",
".hwp",
".jpeg",
".jpg",
".key",
".ltx",
".lwp",
".mcw",
".met",
".mml",
".mw",
".numbers",
".odd",
".odg",
".odm",
".odp",
".ods",
".txt",
".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",
".sxw",
".uot",
".vor",
".wps",
".epub",
".png",
".bmp",
".emf",
".eps",
".fodg",
".gif",
".jpg",
".jpeg",
".met",
".odd",
".otg",
".pbm",
".pct",
".pgm",
".ppm",
".ras",
".std",
".svg",
".svm",
".swf",
".sxc",
".sxd",
".sxg",
".sxi",
".sxm",
".sxw",
".tga",
".tif",
".tiff",
".txt",
".uof",
".uop",
".uos",
".uot",
".vdx",
".vor",
".vsd",
".vsdm",
".vsdx",
".wb2",
".wk1",
".wks",
".wmf",
".wpd",
".wpg",
".wps",
".xbm",
".xhtml",
".xls",
".xlsb",
".xlsm",
".xlsx",
".xlt",
".xltm",
".xltx",
".xlw",
".xml",
".xpm",
".zabw",
".odp",
".fodp",
".potm",
".pot",
".pptx",
".pps",
".ppt",
".pwp",
".sda",
".sdd",
".sti",
".sxi",
".uop",
".wmf",
".csv",
".dbf",
".dif",
".fods",
".ods",
".ots",
".pxl",
".sdc",
".slk",
".stc",
".sxc",
".uos",
".xls",
".xlt",
".xlsx",
".odg",
".dotx",
".xltx",
}
}

View File

@@ -11,18 +11,9 @@ import (
"github.com/alexliesenfeld/health"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestDefaultOptions(t *testing.T) {
actual := DefaultOptions()
notExpect := Options{}
if reflect.DeepEqual(actual, notExpect) {
t.Errorf("expected %v and got identical %v", actual, notExpect)
}
}
func TestApi_Descriptor(t *testing.T) {
descriptor := new(Api).Descriptor()
@@ -293,16 +284,41 @@ func TestApi_Metrics(t *testing.T) {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 2 {
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
if len(metrics) != 7 {
t.Fatalf("expected %d metrics, but got %d", 7, len(metrics))
}
actual := metrics[0].Read()
if actual != float64(1) {
t.Errorf("expected %f for unoconv_active_instances_count, but got %f", float64(1), actual)
}
actual = metrics[1].Read()
if actual != float64(1) {
t.Errorf("expected %f for libreoffice_listener_active_instances_count, but got %f", float64(1), actual)
}
actual = metrics[2].Read()
if actual != float64(1) {
t.Errorf("expected %f for unoconv_listener_active_instances_count, but got %f", float64(1), actual)
}
actual = metrics[3].Read()
if actual != float64(10) {
t.Errorf("expected %f for libreoffice_listener_queue_length, but got %f", float64(10), actual)
}
actual = metrics[4].Read()
if actual != float64(10) {
t.Errorf("expected %f for unoconv_listener_queue_length, but got %f", float64(10), actual)
}
actual = metrics[5].Read()
if actual != float64(10) {
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
}
actual = metrics[1].Read()
actual = metrics[6].Read()
if actual != float64(0) {
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
}
@@ -433,13 +449,6 @@ func TestApi_Pdf(t *testing.T) {
}},
expectError: true,
},
{
scenario: "ErrCoreDumped",
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
a := new(Api)
@@ -466,7 +475,7 @@ func TestApi_Extensions(t *testing.T) {
extensions := a.Extensions()
actual := len(extensions)
expect := 130
expect := 79
if actual != expect {
t.Errorf("expected %d extensions, but got %d", expect, actual)

View File

@@ -8,7 +8,6 @@ import (
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
@@ -16,7 +15,7 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
type libreOffice interface {
@@ -44,7 +43,7 @@ type libreOfficeProcess struct {
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
p := &libreOfficeProcess{
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
fs: gotenberg.NewFileSystem(),
}
p.isStarted.Store(false)
@@ -190,23 +189,22 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by LibreOffice.
copyUserProfileDirPath := p.userProfileDirPath
expirationTime := time.Now()
defer func(userProfileDirPath string, expirationTime time.Time) {
defer func(userProfileDirPath string) {
go func() {
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
} else {
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
// Also remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"})
if err != nil {
logger.Error(err.Error())
}
}()
}(copyUserProfileDirPath, expirationTime)
}(copyUserProfileDirPath)
p.cfgMu.Lock()
defer p.cfgMu.Unlock()
@@ -267,10 +265,6 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "-vvv")
}
if options.Password != "" {
args = append(args, "--password", options.Password)
}
if options.Landscape {
args = append(args, "--printer", "PaperOrientation=landscape")
}
@@ -279,29 +273,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
}
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
args = append(args, "--export", fmt.Sprintf("ExportBookmarksToPDFDestination=%t", options.ExportBookmarksToPdfDestination))
args = append(args, "--export", fmt.Sprintf("ExportPlaceholders=%t", options.ExportPlaceholders))
args = append(args, "--export", fmt.Sprintf("ExportNotes=%t", options.ExportNotes))
args = append(args, "--export", fmt.Sprintf("ExportNotesPages=%t", options.ExportNotesPages))
args = append(args, "--export", fmt.Sprintf("ExportOnlyNotesPages=%t", options.ExportOnlyNotesPages))
args = append(args, "--export", fmt.Sprintf("ExportNotesInMargin=%t", options.ExportNotesInMargin))
args = append(args, "--export", fmt.Sprintf("ConvertOOoTargetToPDFTarget=%t", options.ConvertOooTargetToPdfTarget))
args = append(args, "--export", fmt.Sprintf("ExportLinksRelativeFsys=%t", options.ExportLinksRelativeFsys))
args = append(args, "--export", fmt.Sprintf("ExportHiddenSlides=%t", options.ExportHiddenSlides))
args = append(args, "--export", fmt.Sprintf("IsSkipEmptyPages=%t", options.SkipEmptyPages))
args = append(args, "--export", fmt.Sprintf("IsAddStream=%t", options.AddOriginalDocumentAsStream))
args = append(args, "--export", fmt.Sprintf("SinglePageSheets=%t", options.SinglePageSheets))
args = append(args, "--export", fmt.Sprintf("UseLosslessCompression=%t", options.LosslessImageCompression))
args = append(args, "--export", fmt.Sprintf("Quality=%d", options.Quality))
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
switch options.PdfFormats.PdfA {
case "":
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")
case gotenberg.PdfA2b:
@@ -309,22 +285,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
case gotenberg.PdfA3b:
args = append(args, "--export", "SelectPdfVersion=3")
default:
return ErrInvalidPdfFormats
return ErrInvalidPdfFormat
}
if options.PdfFormats.PdfUa {
args = append(
args,
"--export", "PDFUACompliance=true",
"--export", "UseTaggedPDF=true",
"--export", "EnableTextAccessForAccessibilityTools=true",
)
} else {
args = append(
args,
"--export", "PDFUACompliance=false",
"--export", "UseTaggedPDF=false",
"--export", "EnableTextAccessForAccessibilityTools=false",
"--export", "UseTaggedPDF=true",
)
}
@@ -348,24 +316,19 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
}
// LibreOffice's errors are not explicit.
// For instance, an exit code 5 may be explained by a malformed page
// ranges, but also by a not required password.
// We may want to retry in case of a core dumped event.
// See https://github.com/gotenberg/gotenberg/issues/639.
if strings.Contains(err.Error(), "core dumped") {
return ErrCoreDumped
}
if exitCode == 5 {
// Potentially malformed page ranges or password not required.
return ErrUnoException
}
if exitCode == 6 {
// Password potentially required or invalid.
return ErrRuntimeException
// That's why we have to make an educated guess according to the exit code
// and given inputs.
if exitCode == 5 && options.PageRanges != "" {
return ErrMalformedPageRanges
}
// Possible errors:
// 1. LibreOffice failed for some reason.
// 2. Context done.
//
// On the second scenario, LibreOffice might not have time to remove some
// of its temporary files, as it has been killed without warning. The
// garbage collector will delete them for us (if the module is loaded).
return fmt.Errorf("convert to PDF: %w", err)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"testing"
"time"
@@ -12,7 +11,7 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestLibreOfficeProcess_Start(t *testing.T) {
@@ -230,28 +229,28 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
p.isStarted.Store(false)
return p
}(),
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
fs: gotenberg.NewFileSystem(),
cancelledCtx: false,
start: false,
expectError: true,
},
{
scenario: "ErrInvalidPdfFormats",
scenario: "ErrInvalidPdfFormat",
libreOffice: func() libreOffice {
p := new(libreOfficeProcess)
p.socketPort = 12345
p.isStarted.Store(true)
return p
}(),
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
fs: gotenberg.NewFileSystem(),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
cancelledCtx: false,
start: false,
expectError: true,
expectedError: ErrInvalidPdfFormats,
expectedError: ErrInvalidPdfFormat,
},
{
scenario: "ErrUnoException",
scenario: "ErrMalformedPageRanges",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
@@ -261,14 +260,14 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
),
options: Options{PageRanges: "foo"},
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
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("Context done"), 0o755)
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -278,61 +277,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
cancelledCtx: false,
start: true,
expectError: true,
expectedError: ErrUnoException,
},
{
scenario: "ErrRuntimeException",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
startTimeout: 5 * time.Second,
},
),
options: Options{Password: "foo"},
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
in, err := os.Open("/tests/test/testdata/libreoffice/protected.docx")
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
}()
out, err := os.Create(fmt.Sprintf("%s/protected.docx", fs.WorkingDirPath()))
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
return fs
}(),
cancelledCtx: false,
start: true,
expectError: true,
expectedError: ErrRuntimeException,
expectedError: ErrMalformedPageRanges,
},
{
scenario: "context done",
@@ -344,7 +289,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
@@ -372,7 +317,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
@@ -391,7 +336,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
expectError: false,
},
{
scenario: "success (not default options)",
scenario: "success (landscape)",
libreOffice: newLibreOfficeProcess(
libreOfficeArguments{
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
@@ -400,44 +345,50 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
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("Success"), 0o755)
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{
Password: "", // Ok, the only exception in this list.
Landscape: true,
PageRanges: "1",
ExportFormFields: false,
AllowDuplicateFieldNames: true,
ExportBookmarks: false,
ExportBookmarksToPdfDestination: true,
ExportPlaceholders: true,
ExportNotes: true,
ExportNotesPages: true,
ExportOnlyNotesPages: true,
ExportNotesInMargin: true,
ConvertOooTargetToPdfTarget: true,
ExportLinksRelativeFsys: true,
ExportHiddenSlides: true,
SkipEmptyPages: true,
AddOriginalDocumentAsStream: true,
SinglePageSheets: true,
LosslessImageCompression: true,
Quality: 100,
ReduceImageResolution: true,
MaxImageResolution: 600,
},
options: Options{Landscape: true},
cancelledCtx: false,
start: true,
expectError: false,
},
{
scenario: "success (page ranges)",
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("Landscape"), 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return fs
}(),
options: Options{PageRanges: "1-1"},
cancelledCtx: false,
start: true,
expectError: false,
@@ -452,7 +403,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
@@ -481,7 +432,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
@@ -510,7 +461,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
@@ -539,7 +490,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
},
),
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
@@ -625,7 +576,7 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) {
{
scenario: "basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {
@@ -646,7 +597,7 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) {
{
scenario: "non-basic latin characters",
fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil {

View File

@@ -2,11 +2,10 @@ package api
import (
"context"
"errors"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
// ApiMock is a mock for the [Uno] interface.
@@ -34,21 +33,12 @@ func (provider *ProviderMock) LibreOffice() (Uno, error) {
// libreOfficeMock is a mock for the [libreOffice] interface.
type libreOfficeMock struct {
errCoreDumpedCount int
gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
}
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
if errors.Is(err, ErrCoreDumped) {
b.errCoreDumpedCount += 1
}
if b.errCoreDumpedCount > 1 {
return nil
}
return err
return b.pdfMock(ctx, logger, inputPath, outputPath, options)
}
// Interface guards.

View File

@@ -42,53 +42,14 @@ func TestProviderMock(t *testing.T) {
}
func TestLibreOfficeMock(t *testing.T) {
for _, tc := range []struct {
scenario string
mock *libreOfficeMock
expectError bool
}{
{
scenario: "success",
mock: &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return nil
},
},
expectError: false,
mock := &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
return nil
},
{
scenario: "ErrCoreDumped (first call)",
mock: &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
},
},
expectError: true,
},
{
scenario: "ErrCoreDumped (second call)",
mock: func() *libreOfficeMock {
m := &libreOfficeMock{
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
return ErrCoreDumped
},
}
m.pdf(context.Background(), zap.NewNop(), "", "", Options{})
return m
}(),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error from libreOfficeMock.pdf but got none")
}
})
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
if err != nil {
t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err)
}
}

View File

@@ -5,9 +5,9 @@ import (
flag "github.com/spf13/pflag"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libeofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
libeofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
)
func init() {

View File

@@ -5,8 +5,8 @@ import (
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
)
func TestLibreOffice_Descriptor(t *testing.T) {

View File

@@ -1,6 +1,4 @@
// Package pdfengine provides a module which interacts with LibreOffice via the
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
// interface. This package allows for:
//
// 1. The conversion to specific PDF formats.
// interface.
package pdfengine

View File

@@ -7,8 +7,8 @@ import (
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
)
func init() {
@@ -51,41 +51,26 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Split is not available in this implementation.
func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert converts the given PDF to a specific PDF format. Currently, only the
// 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]
// error.
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
opts := api.DefaultOptions()
opts.PdfFormats = formats
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, api.Options{
PdfFormats: formats,
})
if err == nil {
return nil
}
if errors.Is(err, api.ErrInvalidPdfFormats) {
if errors.Is(err, api.ErrInvalidPdfFormat) {
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported)
}
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

@@ -8,8 +8,8 @@ import (
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
)
func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
@@ -118,21 +118,11 @@ func TestLibreOfficePdfEngine_Merge(t *testing.T) {
}
}
func TestLibreOfficePdfEngine_Split(t *testing.T) {
engine := new(LibreOfficePdfEngine)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
api api.Uno
expectError bool
expectedError error
scenario string
api api.Uno
expectError bool
}{
{
scenario: "convert success",
@@ -144,14 +134,13 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
expectError: false,
},
{
scenario: "ErrInvalidPdfFormats",
scenario: "invalid PDF format",
api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return api.ErrInvalidPdfFormats
return api.ErrInvalidPdfFormat
},
},
expectError: true,
expectedError: gotenberg.ErrPdfFormatNotSupported,
expectError: true,
},
{
scenario: "convert fail",
@@ -174,28 +163,6 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
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)
}
})
}
}
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,19 +1,15 @@
package libreoffice
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
)
// convertRoute returns an [api.Route] which can convert LibreOffice documents
@@ -25,178 +21,96 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
defaultOptions := libreofficeapi.DefaultOptions()
form := ctx.FormData()
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
zeroValuedSplitMode := gotenberg.SplitMode{}
// Let's get the data from the form and validate them.
var (
inputPaths []string
password string
landscape bool
nativePageRanges string
exportFormFields bool
allowDuplicateFieldNames bool
exportBookmarks bool
exportBookmarksToPdfDestination bool
exportPlaceholders bool
exportNotes bool
exportNotesPages bool
exportOnlyNotesPages bool
exportNotesInMargin bool
convertOooTargetToPdfTarget bool
exportLinksRelativeFsys bool
exportHiddenSlides bool
skipEmptyPages bool
addOriginalDocumentAsStream bool
singlePageSheets bool
losslessImageCompression bool
quality int
reduceImageResolution bool
maxImageResolution int
nativePdfFormats bool
merge bool
inputPaths []string
landscape bool
nativePageRanges string
nativePdfA1aFormat bool
nativePdfFormat string
pdfFormat string
pdfa string
pdfua bool
nativePdfFormats bool
merge bool
)
err := form.
err := ctx.FormData().
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
String("password", &password, defaultOptions.Password).
Bool("landscape", &landscape, defaultOptions.Landscape).
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks).
Bool("exportBookmarksToPdfDestination", &exportBookmarksToPdfDestination, defaultOptions.ExportBookmarksToPdfDestination).
Bool("exportPlaceholders", &exportPlaceholders, defaultOptions.ExportPlaceholders).
Bool("exportNotes", &exportNotes, defaultOptions.ExportNotes).
Bool("exportNotesPages", &exportNotesPages, defaultOptions.ExportNotesPages).
Bool("exportOnlyNotesPages", &exportOnlyNotesPages, defaultOptions.ExportOnlyNotesPages).
Bool("exportNotesInMargin", &exportNotesInMargin, defaultOptions.ExportNotesInMargin).
Bool("convertOooTargetToPdfTarget", &convertOooTargetToPdfTarget, defaultOptions.ConvertOooTargetToPdfTarget).
Bool("exportLinksRelativeFsys", &exportLinksRelativeFsys, defaultOptions.ExportLinksRelativeFsys).
Bool("exportHiddenSlides", &exportHiddenSlides, defaultOptions.ExportHiddenSlides).
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
Bool("losslessImageCompression", &losslessImageCompression, defaultOptions.LosslessImageCompression).
Custom("quality", func(value string) error {
if value == "" {
quality = defaultOptions.Quality
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
if intValue > 100 {
return errors.New("value is superior to 100")
}
quality = intValue
return nil
}).
Bool("reduceImageResolution", &reduceImageResolution, defaultOptions.ReduceImageResolution).
Custom("maxImageResolution", func(value string) error {
if value == "" {
maxImageResolution = defaultOptions.MaxImageResolution
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if !slices.Contains([]int{75, 150, 300, 600, 1200}, intValue) {
return errors.New("value is not 75, 150, 300, 600 or 1200")
}
maxImageResolution = intValue
return nil
}).
Bool("landscape", &landscape, false).
String("nativePageRanges", &nativePageRanges, "").
Bool("nativePdfA1aFormat", &nativePdfA1aFormat, false).
String("nativePdfFormat", &nativePdfFormat, "").
String("pdfFormat", &pdfFormat, "").
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)
}
// FIXME: deprecated.
// pdfa > nativePdfFormat > pdfFormat > nativePdfA1aFormat.
var (
actualPdfArchive string
nativeFormats bool
)
if nativePdfA1aFormat {
ctx.Log().Warn("'nativePdfA1aFormat' is deprecated; prefer the 'pdfa' form field instead")
actualPdfArchive = gotenberg.PdfA1a
nativeFormats = true
}
if pdfFormat != "" {
ctx.Log().Warn("'pdfFormat' is deprecated; prefer the 'pdfa' form field instead")
actualPdfArchive = pdfFormat
nativeFormats = false
}
if nativePdfFormat != "" {
ctx.Log().Warn("'nativePdfFormat' is deprecated; prefer the 'pdfa' form field instead")
actualPdfArchive = nativePdfFormat
nativeFormats = true
}
if pdfa != "" {
actualPdfArchive = pdfa
nativeFormats = nativePdfFormats
}
if pdfua {
nativeFormats = nativePdfFormats
}
pdfFormats := gotenberg.PdfFormats{
PdfA: actualPdfArchive,
PdfUa: pdfua,
}
// Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
options := libreofficeapi.Options{
Password: password,
Landscape: landscape,
PageRanges: nativePageRanges,
ExportFormFields: exportFormFields,
AllowDuplicateFieldNames: allowDuplicateFieldNames,
ExportBookmarks: exportBookmarks,
ExportBookmarksToPdfDestination: exportBookmarksToPdfDestination,
ExportPlaceholders: exportPlaceholders,
ExportNotes: exportNotes,
ExportNotesPages: exportNotesPages,
ExportOnlyNotesPages: exportOnlyNotesPages,
ExportNotesInMargin: exportNotesInMargin,
ConvertOooTargetToPdfTarget: convertOooTargetToPdfTarget,
ExportLinksRelativeFsys: exportLinksRelativeFsys,
ExportHiddenSlides: exportHiddenSlides,
SkipEmptyPages: skipEmptyPages,
AddOriginalDocumentAsStream: addOriginalDocumentAsStream,
SinglePageSheets: singlePageSheets,
LosslessImageCompression: losslessImageCompression,
Quality: quality,
ReduceImageResolution: reduceImageResolution,
MaxImageResolution: maxImageResolution,
Landscape: landscape,
PageRanges: nativePageRanges,
}
if nativePdfFormats && splitMode == zeroValuedSplitMode {
// Only apply natively given PDF formats if we're not
// splitting the PDF later.
if nativeFormats {
options.PdfFormats = pdfFormats
}
err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options)
if err != nil {
if errors.Is(err, libreofficeapi.ErrInvalidPdfFormats) {
if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("A PDF format in '%+v' is not supported", pdfFormats),
),
)
}
if errors.Is(err, libreofficeapi.ErrUnoException) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
)
}
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
return api.WrapError(
fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
)
}
@@ -204,77 +118,88 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}
}
if merge {
outputPath, err := pdfengines.MergeStub(ctx, engine, outputPaths)
// 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.
if len(outputPaths) > 1 && merge {
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), outputPaths, outputPath)
if err != nil {
return fmt.Errorf("merge PDFs: %w", err)
}
// Only one output path.
outputPaths = []string{outputPath}
}
// Now, let's check if the client want to convert this result
// PDF to specific PDF formats.
zeroValued := gotenberg.PdfFormats{}
if !nativeFormats && pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
if splitMode != zeroValuedSplitMode {
if !merge {
// document.docx -> document.docx.pdf, so that split naming
// document.docx_0.pdf, etc.
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
}
}
outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths)
if err != nil {
return fmt.Errorf("split PDFs: %w", err)
}
}
if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) {
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
if splitMode != zeroValuedSplitMode {
// The PDF has been split and split parts have been converted to
// specific formats. We want to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
}
} else {
outputPaths = convertOutputPaths
}
}
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
// 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)
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
if err != nil {
return fmt.Errorf("rename output path: %w", err)
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)
}
outputPaths[i] = outputPath
// 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
}
// 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.
zeroValued := gotenberg.PdfFormats{}
if !nativeFormats && pdfFormats != zeroValued {
convertOutputPaths := make([]string, len(outputPaths))
for i, outputPath := range outputPaths {
convertInputPath := outputPath
convertOutputPaths[i] = ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
if err != nil {
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
),
)
}
return fmt.Errorf("convert PDF: %w", err)
}
}
// Important: the output paths are now the converted files.
outputPaths = convertOutputPaths
}
// Last but not least, add the output paths to the context so that
// the Uno is able to send them as a response to the client.
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)

View File

@@ -3,19 +3,15 @@ package libreoffice
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"slices"
"testing"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
)
func TestConvertRoute(t *testing.T) {
@@ -29,7 +25,6 @@ func TestConvertRoute(t *testing.T) {
expectHttpError bool
expectHttpStatus int
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "missing at least one mandatory file",
@@ -43,139 +38,7 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (not an integer)",
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{
"quality": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (< 1)",
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{
"quality": {
"0",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid quality form field (> 100)",
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{
"quality": {
"101",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid maxImageResolution form field (not an integer)",
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{
"maxImageResolution": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "invalid maxImageResolution form field (not in range)",
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{
"maxImageResolution": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
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
}(),
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
return []string{".docx"}
}},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfFormatNotSupported (nativePdfFormats)",
scenario: "ErrMalformedPageRanges",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -185,61 +48,7 @@ func TestConvertRoute(t *testing.T) {
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrInvalidPdfFormats
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrUnoException",
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{
"nativePageRanges": {
"foo",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrUnoException
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "ErrRuntimeException",
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{
"password": {
"invalid",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return libreofficeapi.ErrRuntimeException
return libreofficeapi.ErrMalformedPageRanges
},
ExtensionsMock: func() []string {
return []string{".docx"}
@@ -272,50 +81,18 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
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",
"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: "PDF engine split error",
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{
"splitMode": {
gotenberg.SplitModeIntervals,
"pdfa": {
"foo",
},
"splitSpan": {
"1",
"nativePdfFormats": {
"false",
},
})
return ctx
@@ -329,16 +106,17 @@ func TestConvertRoute(t *testing.T) {
},
},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return gotenberg.ErrPdfFormatNotSupported
},
},
expectError: true,
expectHttpError: false,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "PDF engine convert error",
scenario: "PDF engine convert error (single file)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -372,38 +150,7 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0,
},
{
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 add output paths",
scenario: "cannot add output paths (single file)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -429,31 +176,7 @@ 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",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"quality": {
"100",
},
"maxImageResolution": {
"1200",
},
"merge": {
"true",
},
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
"nativePdfFormats": {
"false",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
"document.docx": "/document.docx",
})
return ctx
}(),
@@ -465,17 +188,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
},
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,
@@ -487,25 +199,7 @@ func TestConvertRoute(t *testing.T) {
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"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{
@@ -513,27 +207,15 @@ func TestConvertRoute(t *testing.T) {
return nil
},
ExtensionsMock: func() []string {
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
return []string{".docx"}
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 3,
expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"},
expectOutputPathsCount: 2,
},
{
scenario: "success with native PDF/A & PDF/UA",
scenario: "success with non-native PDF/A & PDF/UA (single file)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -546,35 +228,8 @@ func TestConvertRoute(t *testing.T) {
"pdfua": {
"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"}
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success with split (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",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
"nativePdfFormats": {
"false",
},
})
return ctx
@@ -588,31 +243,62 @@ func TestConvertRoute(t *testing.T) {
},
},
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
expectOutputPathsCount: 1,
},
{
scenario: "success with merge and split",
scenario: "success with every non-native PDF/A & PDF/UA form fields (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{
"nativePdfA1aFormat": {
"true",
},
"pdfFormat": {
gotenberg.PdfA1b,
},
"nativePdfFormat": {
gotenberg.PdfA1b,
},
"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: 1,
},
{
scenario: "merge error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -623,11 +309,43 @@ func TestConvertRoute(t *testing.T) {
"merge": {
"true",
},
"splitMode": {
gotenberg.SplitModeIntervals,
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfFormatNotSupported (merge)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"merge": {
"true",
},
"splitSpan": {
"1",
"pdfa": {
"foo",
},
"nativePdfFormats": {
"false",
},
})
return ctx
@@ -644,27 +362,17 @@ func TestConvertRoute(t *testing.T) {
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return gotenberg.ErrPdfFormatNotSupported
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 2,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "success with split and native PDF/A & PDF/UA (many files)",
scenario: "PDF engine convert error (merge)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
@@ -672,16 +380,81 @@ func TestConvertRoute(t *testing.T) {
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
"merge": {
"true",
},
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"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",
},
})
@@ -696,19 +469,49 @@ func TestConvertRoute(t *testing.T) {
},
},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
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
@@ -716,18 +519,55 @@ func TestConvertRoute(t *testing.T) {
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
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,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context)
@@ -762,12 +602,6 @@ func TestConvertRoute(t *testing.T) {
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
for _, path := range tc.expectOutputPaths {
if !slices.Contains(tc.ctx.OutputPaths(), path) {
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
}
}
})
}
}

View File

@@ -11,7 +11,7 @@ import (
"go.uber.org/zap/zapcore"
"golang.org/x/term"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func init() {
@@ -174,7 +174,7 @@ func newLogEncoder(format string) (zapcore.Encoder, error) {
// If interactive terminal, make output more human-readable by default.
// Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671.
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000"))
encoder.AppendString(ts.UTC().Format("2006/01/02 15:04:05.000"))
}
if format == textLoggingFormat || format == autoLoggingFormat {

View File

@@ -9,7 +9,7 @@ import (
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestLogging_Descriptor(t *testing.T) {

View File

@@ -1,8 +1,6 @@
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine
// interface using the pdfcpu command-line tool. This package allows for:
//
// 1. The merging of PDF files.
// 2. The splitting of PDF files.
// interface using the pdfcpu library. This package allows for the merging of
// PDF files but does not support conversion to specific PDF formats.
//
// See: https://github.com/pdfcpu/pdfcpu.
package pdfcpu

View File

@@ -2,24 +2,24 @@ package pdfcpu
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
pdfcpuAPI "github.com/pdfcpu/pdfcpu/pkg/api"
pdfcpuLog "github.com/pdfcpu/pdfcpu/pkg/log"
pdfcpuConfig "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func init() {
gotenberg.MustRegisterModule(new(PdfCpu))
}
// PdfCpu abstracts the CLI tool pdfcpu and implements the
// [gotenberg.PdfEngine] interface.
// PdfCpu abstracts the pdfcpu library and implements the [gotenberg.PdfEngine]
// interface.
type PdfCpu struct {
binPath string
conf *pdfcpuConfig.Configuration
}
// Descriptor returns a [PdfCpu]'s module descriptor.
@@ -32,100 +32,31 @@ func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor {
// Provision sets the engine properties.
func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error {
binPath, ok := os.LookupEnv("PDFCPU_BIN_PATH")
if !ok {
return errors.New("PDFCPU_BIN_PATH environment variable is not set")
}
engine.binPath = binPath
return nil
}
// Validate validates the module properties.
func (engine *PdfCpu) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("pdfcpu binary path does not exist: %w", err)
}
pdfcpuConfig.ConfigPath = "disable"
pdfcpuLog.DisableLoggers()
engine.conf = pdfcpuConfig.NewDefaultConfiguration()
return nil
}
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args = append(args, "merge", outputPath)
args = append(args, inputPaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, false, engine.conf)
if err == nil {
return nil
}
return fmt.Errorf("merge PDFs with pdfcpu: %w", err)
}
// Split splits a given PDF file.
func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
switch mode.Mode {
case gotenberg.SplitModeIntervals:
args = append(args, "split", "-mode", "span", inputPath, outputDirPath, mode.Span)
case gotenberg.SplitModePages:
if mode.Unify {
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath)
break
}
args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err)
}
outputPaths, err := gotenberg.WalkDir(outputDirPath, ".pdf")
if err != nil {
return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err)
}
return outputPaths, nil
return fmt.Errorf("merge PDFs with PDFcpu: %w", err)
}
// Convert is not available in this implementation.
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
}
// 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)
return fmt.Errorf("convert PDF to '%+v' with PDFcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*PdfCpu)(nil)
_ gotenberg.Provisioner = (*PdfCpu)(nil)
_ gotenberg.Validator = (*PdfCpu)(nil)
_ gotenberg.PdfEngine = (*PdfCpu)(nil)
)

View File

@@ -9,7 +9,7 @@ import (
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestPdfCpu_Descriptor(t *testing.T) {
@@ -33,59 +33,14 @@ func TestPdfCpu_Provision(t *testing.T) {
}
}
func TestPdfCpu_Validate(t *testing.T) {
for _, tc := range []struct {
scenario string
binPath string
expectError bool
}{
{
scenario: "empty bin path",
binPath: "",
expectError: true,
},
{
scenario: "bin path does not exist",
binPath: "/foo",
expectError: true,
},
{
scenario: "validate success",
binPath: os.Getenv("PDFTK_BIN_PATH"),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
engine.binPath = tc.binPath
err := engine.Validate()
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestPdfCpu_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
inputPaths []string
expectError bool
}{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
inputPaths: []string{
"foo",
},
@@ -93,7 +48,6 @@ func TestPdfCpu_Merge(t *testing.T) {
},
{
scenario: "single file success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
},
@@ -101,12 +55,10 @@ func TestPdfCpu_Merge(t *testing.T) {
},
{
scenario: "many files success",
ctx: context.TODO(),
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
@@ -116,7 +68,7 @@ func TestPdfCpu_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
@@ -129,7 +81,7 @@ func TestPdfCpu_Merge(t *testing.T) {
}
}()
err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
err = engine.Merge(nil, nil, tc.inputPaths, outputDir+"/foo.pdf")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
@@ -142,103 +94,6 @@ func TestPdfCpu_Merge(t *testing.T) {
}
}
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (intervals)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 3,
},
{
scenario: "success (pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error 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)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfCpu_Convert(t *testing.T) {
mod := new(PdfCpu)
err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
@@ -247,21 +102,3 @@ 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,35 +3,20 @@ package pdfengines
import (
"context"
"fmt"
"sync"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMedataEngines []gotenberg.PdfEngine
writeMedataEngines []gotenberg.PdfEngine
engines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
mergeEngines,
splitEngines,
convertEngines,
readMetadataEngines,
writeMedataEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
func newMultiPdfEngines(engines ...gotenberg.PdfEngine) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
splitEngines: splitEngines,
convertEngines: convertEngines,
readMedataEngines: readMetadataEngines,
writeMedataEngines: writeMedataEngines,
engines: engines,
}
}
@@ -41,7 +26,7 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
var err error
errChan := make(chan error, 1)
for _, engine := range multi.mergeEngines {
for _, engine := range multi.engines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
}(engine)
@@ -60,51 +45,13 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
}
type splitResult struct {
outputPaths []string
err error
}
// Split tries to split at intervals a given PDF thanks to its children. If the
// context is done, it stops and returns an error.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var err error
var mu sync.Mutex // to safely append errors.
resultChan := make(chan splitResult, len(multi.splitEngines))
for _, engine := range multi.splitEngines {
go func(engine gotenberg.PdfEngine) {
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
resultChan <- splitResult{outputPaths: outputPaths, err: err}
}(engine)
}
for range multi.splitEngines {
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.outputPaths, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
}
// Convert converts the given PDF to a specific PDF format. thanks to its
// children. If the context is done, it stops and returns an error.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.convertEngines {
for _, engine := range multi.engines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
}(engine)
@@ -123,65 +70,6 @@ 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.readMedataEngines))
for _, engine := range multi.readMedataEngines {
go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine)
}
for range multi.readMedataEngines {
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.writeMedataEngines {
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

@@ -7,7 +7,7 @@ import (
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestMultiPdfEngines_Merge(t *testing.T) {
@@ -19,67 +19,59 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
),
ctx: context.Background(),
expectError: false,
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
),
ctx: context.Background(),
expectError: false,
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
),
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
),
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -103,97 +95,6 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}
}
func TestMultiPdfEngines_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, 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.Split(tc.ctx, zap.NewNop(), gotenberg.SplitMode{}, "", "")
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_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -203,65 +104,57 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
),
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
),
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
),
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
engine: newMultiPdfEngines(
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
),
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -284,185 +177,3 @@ 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: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&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: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&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: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&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: &multiPdfEngines{
readMedataEngines: []gotenberg.PdfEngine{
&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: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&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: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
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: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
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: &multiPdfEngines{
writeMedataEngines: []gotenberg.PdfEngine{
&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

@@ -7,8 +7,8 @@ import (
flag "github.com/spf13/pflag"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
func init() {
@@ -27,13 +27,9 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if
// enabled.
type PdfEngines struct {
mergeNames []string
splitNames []string
convertNames []string
readMetadataNames []string
writeMedataNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
names []string
engines []gotenberg.PdfEngine
disableRoutes bool
}
// Descriptor returns a PdfEngines' module descriptor.
@@ -42,19 +38,9 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
ID: "pdfengines",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines and their order - all by default")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
if err != nil {
panic(err)
}
return fs
}(),
New: func() gotenberg.Module { return new(PdfEngines) },
@@ -65,13 +51,21 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
// selected by the user thanks to the "engines" flag.
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
splitNames := flags.MustStringSlice("pdfengines-split-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
names := flags.MustStringSlice("pdfengines-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
logger = logger.Named("pdfengines")
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
if err != nil {
return fmt.Errorf("get PDF engines: %w", err)
@@ -83,42 +77,26 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.engines[i] = engine.(gotenberg.PdfEngine)
}
defaultNames := make([]string, len(mod.engines))
if len(names) > 0 {
// Selection from user.
mod.names = names
for i, name := range names {
// FIXME: deprecated.
if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
mod.names[i] = "libreoffice-pdfengine"
}
}
return nil
}
// No selection from user, use all PDF engines available.
mod.names = make([]string, len(mod.engines))
for i, engine := range mod.engines {
defaultNames[i] = engine.(gotenberg.Module).Descriptor().ID
}
// Example in case of deprecated module name.
//for i, name := range defaultNames {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// mod.defaultNames[i] = "libreoffice-pdfengine"
// }
//}
mod.mergeNames = defaultNames
if len(mergeNames) > 0 {
mod.mergeNames = mergeNames
}
mod.splitNames = defaultNames
if len(splitNames) > 0 {
mod.splitNames = splitNames
}
mod.convertNames = defaultNames
if len(convertNames) > 0 {
mod.convertNames = convertNames
}
mod.readMetadataNames = defaultNames
if len(readMetadataNames) > 0 {
mod.readMetadataNames = readMetadataNames
}
mod.writeMedataNames = defaultNames
if len(writeMetadataNames) > 0 {
mod.writeMedataNames = writeMetadataNames
mod.names[i] = engine.(gotenberg.Module).Descriptor().ID
}
return nil
@@ -139,40 +117,21 @@ func (mod *PdfEngines) Validate() error {
}
nonExistingEngines := make([]string, 0)
findNonExistingEngines := func(names []string) {
for _, name := range names {
engineExists := false
for _, engine := range mod.engines {
if name == engine.(gotenberg.Module).Descriptor().ID {
engineExists = true
break
}
}
for _, name := range mod.names {
engineExists := false
if engineExists {
continue
}
alreadyInSlice := false
for _, engine := range nonExistingEngines {
if engine == name {
alreadyInSlice = true
break
}
}
if !alreadyInSlice {
nonExistingEngines = append(nonExistingEngines, name)
for _, engine := range mod.engines {
if name == engine.(gotenberg.Module).Descriptor().ID {
engineExists = true
break
}
}
}
findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMedataNames)
if !engineExists {
nonExistingEngines = append(nonExistingEngines, name)
}
}
if len(nonExistingEngines) == 0 {
return nil
@@ -185,37 +144,24 @@ func (mod *PdfEngines) Validate() error {
// modules.
func (mod *PdfEngines) SystemMessages() []string {
return []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
strings.Join(mod.names[:], " "),
}
}
// PdfEngine returns a [gotenberg.PdfEngine].
func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines := func(names []string) []gotenberg.PdfEngine {
list := make([]gotenberg.PdfEngine, len(names))
for i, name := range names {
for _, engine := range mod.engines {
if name == engine.(gotenberg.Module).Descriptor().ID {
list[i] = engine
break
}
engines := make([]gotenberg.PdfEngine, len(mod.names))
for i, name := range mod.names {
for _, engine := range mod.engines {
if name == engine.(gotenberg.Module).Descriptor().ID {
engines[i] = engine
break
}
}
return list
}
return newMultiPdfEngines(
engines(mod.mergeNames),
engines(mod.splitNames),
engines(mod.convertNames),
engines(mod.readMetadataNames),
engines(mod.writeMedataNames),
), nil
return newMultiPdfEngines(engines...), nil
}
// Routes returns the HTTP routes.
@@ -233,10 +179,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{
mergeRoute(engine),
splitRoute(engine),
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),
}, nil
}

View File

@@ -2,12 +2,13 @@ package pdfengines
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestPdfEngines_Descriptor(t *testing.T) {
@@ -23,26 +24,26 @@ func TestPdfEngines_Descriptor(t *testing.T) {
func TestPdfEngines_Provision(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *gotenberg.Context
expectedMergePdfEngines []string
expectedSplitPdfEngines []string
expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string
expectError bool
scenario string
ctx *gotenberg.Context
expectedPdfEngines []string
expectError bool
}{
{
scenario: "no selection from user",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
engine := &struct {
gotenberg.ModuleMock
@@ -66,24 +67,25 @@ func TestPdfEngines_Provision(t *testing.T) {
},
)
}(),
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"},
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"},
expectError: false,
expectedPdfEngines: []string{"bar"},
expectError: false,
},
{
scenario: "selection from user",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
engine1 := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
@@ -109,7 +111,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}
fs := new(PdfEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
err := fs.Parse([]string{"--pdfengines-engines=b", "--pdfengines-engines=a"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
@@ -125,25 +127,111 @@ func TestPdfEngines_Provision(t *testing.T) {
},
)
}(),
expectedMergePdfEngines: []string{"b"},
expectedSplitPdfEngines: []string{"a"},
expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"},
expectError: false,
expectedPdfEngines: []string{"b", "a"},
expectError: false,
},
{
scenario: "no valid PDF engine",
scenario: "user select deprecated unoconv-pdfengine",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
engine := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
gotenberg.PdfEngineMock
}{}
engine.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "libreoffice-pdfengine", New: func() gotenberg.Module { return engine }}
}
engine.ValidateMock = func() error {
return nil
}
fs := new(PdfEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-engines=unoconv-pdfengine"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: fs,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
engine.Descriptor(),
},
)
}(),
expectedPdfEngines: []string{"libreoffice-pdfengine"},
expectError: false,
},
{
scenario: "no logger provider",
ctx: func() *gotenberg.Context {
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PdfEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{},
)
}(),
expectError: true,
},
{
scenario: "no logger from logger provider",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return nil, errors.New("foo")
}
return gotenberg.NewContext(
gotenberg.ParsedFlags{
FlagSet: new(PdfEngines).Descriptor().FlagSet,
},
[]gotenberg.ModuleDescriptor{
provider.Descriptor(),
},
)
}(),
expectError: true,
},
{
scenario: "no valid PDF engine",
ctx: func() *gotenberg.Context {
provider := &struct {
gotenberg.ModuleMock
gotenberg.LoggerProviderMock
}{}
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
return provider
}}
}
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
return zap.NewNop(), nil
}
engine := &struct {
gotenberg.ModuleMock
gotenberg.ValidatorMock
@@ -181,49 +269,13 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatal("expected error but got none")
}
if len(tc.expectedMergePdfEngines) != len(mod.mergeNames) {
t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
if len(tc.expectedPdfEngines) != len(mod.names) {
t.Fatalf("expected %d names but got %d", len(tc.expectedPdfEngines), len(mod.names))
}
if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
}
if len(tc.expectedReadMetadataPdfEngines) != len(mod.readMetadataNames) {
t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
}
if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) {
t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames))
}
for index, name := range mod.mergeNames {
if name != tc.expectedMergePdfEngines[index] {
t.Fatalf("expected merge name at index %d to be %s, but got: %s", index, name, tc.expectedMergePdfEngines[index])
}
}
for index, name := range mod.splitNames {
if name != tc.expectedSplitPdfEngines[index] {
t.Fatalf("expected split name at index %d to be %s, but got: %s", index, name, tc.expectedSplitPdfEngines[index])
}
}
for index, name := range mod.convertNames {
if name != tc.expectedConvertPdfEngines[index] {
t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
}
}
for index, name := range mod.readMetadataNames {
if name != tc.expectedReadMetadataPdfEngines[index] {
t.Fatalf("expected read metadata name at index %d to be %s, but got: %s", index, name, tc.expectedReadMetadataPdfEngines[index])
}
}
for index, name := range mod.writeMedataNames {
if name != tc.expectedWriteMetadataPdfEngines[index] {
t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
for index, name := range mod.names {
if name != tc.expectedPdfEngines[index] {
t.Fatalf("expected scenario at index %d to be %s, but got: %s", index, name, tc.expectedPdfEngines[index])
}
}
})
@@ -289,11 +341,8 @@ func TestPdfEngines_Validate(t *testing.T) {
} {
t.Run(tc.scenario, func(t *testing.T) {
mod := PdfEngines{
mergeNames: tc.names,
convertNames: tc.names,
readMetadataNames: tc.names,
writeMedataNames: tc.names,
engines: tc.engines,
names: tc.names,
engines: tc.engines,
}
err := mod.Validate()
@@ -311,39 +360,22 @@ func TestPdfEngines_Validate(t *testing.T) {
func TestPdfEngines_SystemMessages(t *testing.T) {
mod := new(PdfEngines)
mod.mergeNames = []string{"foo", "bar"}
mod.splitNames = []string{"foo", "bar"}
mod.convertNames = []string{"foo", "bar"}
mod.readMetadataNames = []string{"foo", "bar"}
mod.writeMedataNames = []string{"foo", "bar"}
mod.names = []string{"foo", "bar"}
messages := mod.SystemMessages()
if len(messages) != 5 {
if len(messages) != 1 {
t.Errorf("expected one and only one message, but got %d", len(messages))
}
expect := []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
}
for i, message := range messages {
if message != expect[i] {
t.Errorf("expected message at index %d to be %s, but got %s", i, message, expect[i])
}
expect := strings.Join(mod.names[:], " ")
if messages[0] != expect {
t.Errorf("expected message '%s', but got '%s'", expect, messages[0])
}
}
func TestPdfEngines_PdfEngine(t *testing.T) {
mod := PdfEngines{
mergeNames: []string{"foo", "bar"},
splitNames: []string{"foo", "bar"},
convertNames: []string{"foo", "bar"},
readMetadataNames: []string{"foo", "bar"},
writeMedataNames: []string{"foo", "bar"},
names: []string{"foo", "bar"},
engines: func() []gotenberg.PdfEngine {
engine1 := &struct {
gotenberg.ModuleMock
@@ -382,7 +414,7 @@ func TestPdfEngines_Routes(t *testing.T) {
}{
{
scenario: "routes not disabled",
expectRoutes: 5,
expectRoutes: 2,
disableRoutes: false,
},
{

View File

@@ -1,246 +1,16 @@
package pdfengines
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
// FormDataPdfSplitMode creates a [gotenberg.SplitMode] from the form data.
func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMode {
var (
mode string
span string
unify bool
)
splitModeFunc := func(value string) error {
if value != "" && value != gotenberg.SplitModeIntervals && value != gotenberg.SplitModePages {
return fmt.Errorf("wrong value, expected either '%s' or '%s'", gotenberg.SplitModeIntervals, gotenberg.SplitModePages)
}
mode = value
return nil
}
splitSpanFunc := func(value string) error {
value = strings.Join(strings.Fields(value), "")
if mode == gotenberg.SplitModeIntervals {
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
}
span = value
return nil
}
if mandatory {
form.
MandatoryCustom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
MandatoryCustom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
} else {
form.
Custom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
Custom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
}
form.
Bool("splitUnify", &unify, false).
Custom("splitUnify", func(value string) error {
if value != "" && unify && mode != gotenberg.SplitModePages {
return fmt.Errorf("unify is not available for split mode '%s'", mode)
}
return nil
})
return gotenberg.SplitMode{
Mode: mode,
Span: span,
Unify: unify,
}
}
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to default value if the considered key is not present.
func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var (
pdfa string
pdfua bool
)
form.
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false)
return gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
}
// FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} {
var metadata map[string]interface{}
metadataFunc := 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
}
if mandatory {
form.MandatoryCustom("metadata", func(value string) error {
return metadataFunc(value)
})
} else {
form.Custom("metadata", func(value string) error {
return metadataFunc(value)
})
}
return metadata
}
// MergeStub merges given PDFs. If only one input PDF, it does nothing and
// returns the corresponding input path.
func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) {
if len(inputPaths) == 0 {
return "", errors.New("no input paths")
}
if len(inputPaths) == 1 {
return inputPaths[0], nil
}
outputPath := ctx.GeneratePath(".pdf")
err := engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil {
return "", fmt.Errorf("merge %d PDFs: %w", len(inputPaths), err)
}
return outputPath, nil
}
// SplitPdfStub splits a list of PDF files based on [gotenberg.SplitMode].
// It returns a list of output paths or the list of provided input paths if no
// split requested.
func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.SplitMode, inputPaths []string) ([]string, error) {
zeroValued := gotenberg.SplitMode{}
if mode == zeroValued {
return inputPaths, nil
}
var outputPaths []string
for _, inputPath := range inputPaths {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_"))
if err != nil {
return nil, fmt.Errorf("create subdirectory from input path: %w", err)
}
paths, err := engine.Split(ctx, ctx.Log(), mode, inputPath, outputDirPath)
if err != nil {
return nil, fmt.Errorf("split PDF '%s': %w", inputPath, err)
}
// Keep the original filename.
for i, path := range paths {
var newPath string
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
newPath = fmt.Sprintf(
"%s/%s.pdf",
outputDirPath, filenameNoExt,
)
} else {
newPath = fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, i,
)
}
err = ctx.Rename(path, newPath)
if err != nil {
return nil, fmt.Errorf("rename path: %w", err)
}
outputPaths = append(outputPaths, newPath)
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
break
}
}
}
return outputPaths, nil
}
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
func ConvertStub(ctx *api.Context, engine gotenberg.PdfEngine, formats gotenberg.PdfFormats, inputPaths []string) ([]string, error) {
zeroValued := gotenberg.PdfFormats{}
if formats == zeroValued {
return inputPaths, nil
}
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
err := engine.Convert(ctx, ctx.Log(), formats, inputPath, outputPaths[i])
if err != nil {
return nil, fmt.Errorf("convert '%s': %w", inputPath, err)
}
}
return outputPaths, nil
}
// WriteMetadataStub writes the metadata into PDF files. If no metadata, it
// does nothing.
func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]interface{}, inputPaths []string) error {
if len(metadata) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath)
if err != nil {
return fmt.Errorf("write metadata into '%s': %w", inputPath, err)
}
}
return nil
}
// mergeRoute returns an [api.Route] which can merge PDFs.
func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -250,96 +20,84 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
// Let's get the data from the form and validate them.
var (
inputPaths []string
pdfFormat string
pdfa string
pdfua bool
)
var inputPaths []string
err := form.
err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths).
String("pdfFormat", &pdfFormat, "").
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
var actualPdfArchive string
if pdfFormat != "" {
// FIXME: deprecated
ctx.Log().Warn("'pdfFormat' is deprecated; prefer the 'pdfa' form field instead")
actualPdfArchive = pdfFormat
}
if pdfa != "" {
actualPdfArchive = pdfa
}
pdfFormats := gotenberg.PdfFormats{
PdfA: actualPdfArchive,
PdfUa: pdfua,
}
// Alright, let's merge the PDFs.
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil {
return fmt.Errorf("merge PDFs: %w", err)
}
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, []string{outputPath})
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
}
// So far so good, the PDFs are merged into one unique PDF.
// Now, let's check if the client want to convert this result PDF
// to specific PDF formats.
zeroValued := gotenberg.PdfFormats{}
if pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// splitRoute returns an [api.Route] which can extract pages from a PDF.
func splitRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/split",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths)
if err != nil {
return fmt.Errorf("split PDFs: %w", err)
}
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
// Rename the files to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
if err != nil {
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
),
)
}
return fmt.Errorf("convert PDF: %w", err)
}
// Important: the output path is now the converted file.
outputPath = convertOutputPath
}
err = ctx.AddOutputPaths(outputPaths...)
// 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 paths: %w", err)
return fmt.Errorf("add output path: %w", err)
}
return nil
@@ -347,8 +105,8 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
}
}
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
// format.
// convertRoute returns an [api.Route] which can convert a PDF to a specific
// PDF format.
func convertRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
@@ -357,17 +115,41 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
// Let's get the data from the form and validate them.
var (
inputPaths []string
pdfFormat string
pdfa string
pdfua bool
)
var inputPaths []string
err := form.
err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths).
String("pdfFormat", &pdfFormat, "").
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
var actualPdfArchive string
if pdfFormat != "" {
// FIXME: deprecated.
ctx.Log().Warn("'pdfFormat' is deprecated; prefer the 'pdfa' form field instead")
actualPdfArchive = pdfFormat
}
if pdfa != "" {
actualPdfArchive = pdfa
}
pdfFormats := gotenberg.PdfFormats{
PdfA: actualPdfArchive,
PdfUa: pdfua,
}
zeroValued := gotenberg.PdfFormats{}
if pdfFormats == zeroValued {
return api.WrapError(
@@ -379,23 +161,32 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
)
}
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
// Alright, let's convert the PDFs.
outputPaths := make([]string, len(inputPaths))
if len(outputPaths) > 1 {
// If .zip archive, keep the original filename.
for i, inputPath := range inputPaths {
err = ctx.Rename(outputPaths[i], inputPath)
if err != nil {
return fmt.Errorf("rename output path: %w", err)
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, inputPath, outputPaths[i])
if err != nil {
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return api.WrapError(
fmt.Errorf("convert PDF: %w", err),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
),
)
}
outputPaths[i] = inputPath
return fmt.Errorf("convert PDF: %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(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
@@ -405,76 +196,3 @@ 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)
var inputPaths []string
err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
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)
form := ctx.FormData()
metadata := FormDataPdfMetadata(form, true)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, inputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,8 @@
// Package pdftk provides an implementation of the gotenberg.PdfEngine
// interface using the PDFtk command-line tool. This package allows for:
//
// 1. The merging of PDF files.
// 2. The splitting of PDF files.
//
// 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 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.
//
// See: https://gitlab.com/pdftk-java/pdftk.
package pdftk

View File

@@ -5,11 +5,11 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func init() {
@@ -46,38 +46,27 @@ func (engine *PdfTk) Provision(ctx *gotenberg.Context) error {
func (engine *PdfTk) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("PDFtk binary path does not exist: %w", err)
return fmt.Errorf("PdfTk binary path does not exist: %w", err)
}
return nil
}
// Split splits a given PDF file.
func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
// Metrics returns the metrics.
func (engine *PdfTk) Metrics() ([]gotenberg.Metric, error) {
// TODO: remove deprecated.
return []gotenberg.Metric{
{
Name: "pdftk_active_instances_count",
Description: "Current number of active PDFtk instances - deprecated.",
Read: func() float64 {
activeInstancesCountMu.RLock()
defer activeInstancesCountMu.RUnlock()
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
args = append(args, inputPath, "cat", mode.Span, "output", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with PDFtk: %w", err)
}
return []string{outputPath}, nil
return activeInstancesCount
},
},
}, nil
}
// Merge combines multiple PDFs into a single PDF.
@@ -91,7 +80,16 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [
return fmt.Errorf("create command: %w", err)
}
activeInstancesCountMu.Lock()
activeInstancesCount += 1
activeInstancesCountMu.Unlock()
_, err = cmd.Exec()
activeInstancesCountMu.Lock()
activeInstancesCount -= 1
activeInstancesCountMu.Unlock()
if err == nil {
return nil
}
@@ -104,20 +102,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)
}
var (
activeInstancesCount float64
activeInstancesCountMu sync.RWMutex
)
// Interface guards.
var (
_ gotenberg.Module = (*PdfTk)(nil)
_ gotenberg.Provisioner = (*PdfTk)(nil)
_ gotenberg.Validator = (*PdfTk)(nil)
_ gotenberg.PdfEngine = (*PdfTk)(nil)
_ gotenberg.Module = (*PdfTk)(nil)
_ gotenberg.Provisioner = (*PdfTk)(nil)
_ gotenberg.Validator = (*PdfTk)(nil)
_ gotenberg.MetricsProvider = (*PdfTk)(nil)
_ gotenberg.PdfEngine = (*PdfTk)(nil)
)

View File

@@ -9,7 +9,7 @@ import (
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestPdfTk_Descriptor(t *testing.T) {
@@ -71,6 +71,22 @@ func TestPdfTk_Validate(t *testing.T) {
}
}
func TestPdfTk_Metrics(t *testing.T) {
metrics, err := new(PdfTk).Metrics()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if len(metrics) != 1 {
t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
}
actual := metrics[0].Read()
if actual != 0 {
t.Errorf("expected %d PDFtk instances, but got %f", 0, actual)
}
}
func TestPdfTk_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -97,7 +113,6 @@ func TestPdfTk_Merge(t *testing.T) {
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
},
expectError: false,
},
{
scenario: "many files success",
@@ -106,7 +121,6 @@ func TestPdfTk_Merge(t *testing.T) {
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
@@ -116,7 +130,7 @@ func TestPdfTk_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
@@ -142,96 +156,6 @@ func TestPdfTk_Merge(t *testing.T) {
}
}
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfTk)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error 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)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfTk_Convert(t *testing.T) {
engine := new(PdfTk)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
@@ -240,21 +164,3 @@ 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

@@ -12,8 +12,8 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
flag "github.com/spf13/pflag"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
func init() {

View File

@@ -8,7 +8,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestPrometheus_Descriptor(t *testing.T) {

View File

@@ -1,11 +1,8 @@
// Package qpdf provides an implementation of the gotenberg.PdfEngine
// interface using the QPDF command-line tool. This package allows for:
//
// 1. The merging of PDF files.
// 2. The splitting of PDF files.
//
// 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 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.
//
// See: https://github.com/qpdf/qpdf.
package qpdf

View File

@@ -5,11 +5,11 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func init() {
@@ -46,38 +46,27 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
func (engine *QPdf) Validate() error {
_, err := os.Stat(engine.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("QPDF binary path does not exist: %w", err)
return fmt.Errorf("QPdf binary path does not exist: %w", err)
}
return nil
}
// Split splits a given PDF file.
func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
// Metrics returns the metrics.
func (engine *QPdf) Metrics() ([]gotenberg.Metric, error) {
// TODO: remove deprecated.
return []gotenberg.Metric{
{
Name: "qpdf_active_instances_count",
Description: "Current number of active QPDF instances - deprecated.",
Read: func() float64 {
activeInstancesCountMu.RLock()
defer activeInstancesCountMu.RUnlock()
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
args = append(args, inputPath, "--pages", ".", mode.Span, "--", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with QPDF: %w", err)
}
return []string{outputPath}, nil
return activeInstancesCount
},
},
}, nil
}
// Merge combines multiple PDFs into a single PDF.
@@ -93,7 +82,16 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
return fmt.Errorf("create command: %w", err)
}
activeInstancesCountMu.Lock()
activeInstancesCount += 1
activeInstancesCountMu.Unlock()
_, err = cmd.Exec()
activeInstancesCountMu.Lock()
activeInstancesCount -= 1
activeInstancesCountMu.Unlock()
if err == nil {
return nil
}
@@ -106,19 +104,15 @@ 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 (
activeInstancesCount float64
activeInstancesCountMu sync.RWMutex
)
var (
_ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil)
_ gotenberg.Validator = (*QPdf)(nil)
_ gotenberg.PdfEngine = (*QPdf)(nil)
_ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil)
_ gotenberg.Validator = (*QPdf)(nil)
_ gotenberg.MetricsProvider = (*QPdf)(nil)
_ gotenberg.PdfEngine = (*QPdf)(nil)
)

View File

@@ -9,7 +9,7 @@ import (
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestQPdf_Descriptor(t *testing.T) {
@@ -71,6 +71,22 @@ func TestQPdf_Validate(t *testing.T) {
}
}
func TestQPdf_Metrics(t *testing.T) {
metrics, err := new(QPdf).Metrics()
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
if len(metrics) != 1 {
t.Errorf("expected %d metrics, but got %d", 1, len(metrics))
}
actual := metrics[0].Read()
if actual != 0 {
t.Errorf("expected %d QPdf instances, but got %f", 0, actual)
}
}
func TestQPdf_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -97,7 +113,6 @@ func TestQPdf_Merge(t *testing.T) {
inputPaths: []string{
"/tests/test/testdata/pdfengines/sample1.pdf",
},
expectError: false,
},
{
scenario: "many files success",
@@ -106,7 +121,6 @@ func TestQPdf_Merge(t *testing.T) {
"/tests/test/testdata/pdfengines/sample1.pdf",
"/tests/test/testdata/pdfengines/sample2.pdf",
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
@@ -116,7 +130,7 @@ func TestQPdf_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
fs := gotenberg.NewFileSystem()
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
@@ -142,96 +156,6 @@ func TestQPdf_Merge(t *testing.T) {
}
}
func TestQPdf_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error 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)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestQPdf_Convert(t *testing.T) {
engine := new(QPdf)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
@@ -240,21 +164,3 @@ 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

@@ -27,9 +27,9 @@ type client struct {
// send call the webhook either to send the success response or the error response.
func (c client) send(body io.Reader, headers map[string]string, erroed bool) error {
url := c.url
URL := c.url
if erroed {
url = c.errorUrl
URL = c.errorUrl
}
method := c.method
@@ -37,9 +37,9 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
method = c.errorMethod
}
req, err := retryablehttp.NewRequest(method, url, body)
req, err := retryablehttp.NewRequest(method, URL, body)
if err != nil {
return fmt.Errorf("create '%s' request to '%s': %w", method, url, err)
return fmt.Errorf("create '%s' request to '%s': %w", method, URL, err)
}
req.Header.Set("User-Agent", "Gotenberg")
@@ -75,17 +75,17 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("send '%s' request to '%s': %w", method, url, err)
return fmt.Errorf("send '%s' request to '%s': %w", method, URL, err)
}
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, URL, resp.Status)
}
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", url, err))
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", URL, err))
}
}()
@@ -94,7 +94,7 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
// Now let's log!
fields := make([]zap.Field, 5)
fields[0] = zap.String("webhook_url", url)
fields[0] = zap.String("webhook_url", URL)
fields[1] = zap.String("method", method)
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
@@ -110,3 +110,34 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
return nil
}
// leveledLogger is wrapper around a [zap.Logger] which is used by the
// [retryablehttp.Client].
type leveledLogger struct {
logger *zap.Logger
}
// Error logs a message at error level using the wrapped zap.Logger.
func (leveled leveledLogger) Error(msg string, keysAndValues ...interface{}) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at warning level using the wrapped zap.Logger.
func (leveled leveledLogger) Warn(msg string, keysAndValues ...interface{}) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at info level using the wrapped zap.Logger.
func (leveled leveledLogger) Info(msg string, keysAndValues ...interface{}) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at debug level using the wrapped zap.Logger.
func (leveled leveledLogger) Debug(msg string, keysAndValues ...interface{}) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*leveledLogger)(nil)
)

View File

@@ -1,4 +1,4 @@
package gotenberg
package webhook
import (
"testing"
@@ -7,17 +7,17 @@ import (
)
func TestLeveledLogger_Error(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Error("foo")
leveledLogger{logger: zap.NewNop()}.Error("foo")
}
func TestLeveledLogger_Warn(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Warn("foo")
leveledLogger{logger: zap.NewNop()}.Warn("foo")
}
func TestLeveledLogger_Info(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Info("foo")
leveledLogger{logger: zap.NewNop()}.Info("foo")
}
func TestLeveledLogger_Debug(t *testing.T) {
NewLeveledLogger(zap.NewNop()).Debug("foo")
leveledLogger{logger: zap.NewNop()}.Debug("foo")
}

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -16,8 +17,7 @@ import (
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
func webhookMiddleware(w *Webhook) api.Middleware {
@@ -44,19 +44,38 @@ func webhookMiddleware(w *Webhook) api.Middleware {
)
}
deadline, ok := ctx.Deadline()
if !ok {
return errors.New("context has no deadline")
}
// Let's check if the webhook URLs are acceptable according to our
// allowed/denied lists.
err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline)
filter := func(URL, header string, allowList, denyList *regexp.Regexp) error {
if !allowList.MatchString(URL) {
return api.WrapError(
fmt.Errorf("'%s' does not match the expression from the allowed list", URL),
api.NewSentinelHttpError(
http.StatusForbidden,
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
),
)
}
if denyList.String() != "" && denyList.MatchString(URL) {
return api.WrapError(
fmt.Errorf("'%s' matches the expression from the denied list", URL),
api.NewSentinelHttpError(
http.StatusForbidden,
fmt.Sprintf("Invalid '%s' header value: '%s' does not match the authorized URLs", header, URL),
),
)
}
return nil
}
err := filter(webhookUrl, "Gotenberg-Webhook-Url", w.allowList, w.denyList)
if err != nil {
return fmt.Errorf("filter webhook URL: %w", err)
}
err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline)
err = filter(webhookErrorUrl, "Gotenberg-Webhook-Error-Url", w.errorAllowList, w.errorDenyList)
if err != nil {
return fmt.Errorf("filter webhook error URL: %w", err)
}
@@ -100,11 +119,11 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
// What about extra HTTP headers?
var extraHttpHeaders map[string]string
var extraHTTPHeaders map[string]string
extraHttpHeadersJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHttpHeadersJson != "" {
err = json.Unmarshal([]byte(extraHttpHeadersJson), &extraHttpHeaders)
extraHTTPHeadersJSON := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHTTPHeadersJSON != "" {
err = json.Unmarshal([]byte(extraHTTPHeadersJSON), &extraHTTPHeaders)
if err != nil {
return api.WrapError(
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
@@ -113,19 +132,13 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
}
// Retrieve values from echo.Context before it get recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
client := &client{
url: webhookUrl,
method: webhookMethod,
errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod,
extraHttpHeaders: extraHttpHeaders,
startTime: startTime,
extraHttpHeaders: extraHTTPHeaders,
startTime: c.Get("startTime").(time.Time),
client: &retryablehttp.Client{
HTTPClient: &http.Client{
@@ -134,9 +147,11 @@ func webhookMiddleware(w *Webhook) api.Middleware {
RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait,
Logger: gotenberg.NewLeveledLogger(ctx.Log()),
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
Logger: leveledLogger{
logger: ctx.Log(),
},
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
},
logger: ctx.Log(),
}
@@ -163,8 +178,8 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSON,
traceHeader: trace,
echo.HeaderContentType: echo.MIMEApplicationJSONCharsetUTF8,
c.Get("traceHeader").(string): c.Get("trace").(string),
}
err = client.send(bytes.NewReader(b), headers, true)
@@ -242,7 +257,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
traceHeader: trace,
c.Get("traceHeader").(string): c.Get("trace").(string),
}
// Send the output file to the webhook.

View File

@@ -11,15 +11,15 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
func TestWebhookMiddlewareGuards(t *testing.T) {
@@ -47,10 +47,10 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
buildWebhookModule := func() *Webhook {
return &Webhook{
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
errorAllowList: regexp2.MustCompile("", 0),
errorDenyList: regexp2.MustCompile("", 0),
allowList: regexp.MustCompile(""),
denyList: regexp.MustCompile(""),
errorAllowList: regexp.MustCompile(""),
errorDenyList: regexp.MustCompile(""),
maxRetry: 0,
retryMinWait: 0,
retryMaxWait: 0,
@@ -63,7 +63,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
request *http.Request
mod *Webhook
next echo.HandlerFunc
noDeadline bool
expectError bool
expectHttpError bool
expectHttpStatus int
@@ -77,7 +76,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return nil
}
}(),
noDeadline: false,
expectError: false,
expectHttpError: false,
},
@@ -89,23 +87,10 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
},
{
scenario: "context has no deadline",
request: func() *http.Request {
req := buildMultipartFormDataRequest()
req.Header.Set("Gotenberg-Webhook-Url", "foo")
req.Header.Set("Gotenberg-Webhook-Error-Url", "bar")
return req
}(),
mod: buildWebhookModule(),
noDeadline: true,
expectError: true,
},
{
scenario: "webhook URL is not allowed",
request: func() *http.Request {
@@ -116,11 +101,12 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.allowList = regexp2.MustCompile("bar", 0)
mod.allowList = regexp.MustCompile("bar")
return mod
}(),
noDeadline: false,
expectError: true,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
},
{
scenario: "webhook URL is denied",
@@ -132,11 +118,12 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.denyList = regexp2.MustCompile("foo", 0)
mod.denyList = regexp.MustCompile("foo")
return mod
}(),
noDeadline: false,
expectError: true,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
},
{
scenario: "webhook error URL is not allowed",
@@ -148,11 +135,12 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.errorAllowList = regexp2.MustCompile("foo", 0)
mod.errorAllowList = regexp.MustCompile("foo")
return mod
}(),
noDeadline: false,
expectError: true,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
},
{
scenario: "webhook error URL is denied",
@@ -164,11 +152,12 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
}(),
mod: func() *Webhook {
mod := buildWebhookModule()
mod.errorDenyList = regexp2.MustCompile("bar", 0)
mod.errorDenyList = regexp.MustCompile("bar")
return mod
}(),
noDeadline: false,
expectError: true,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusForbidden,
},
{
scenario: "invalid webhook method (GET)",
@@ -180,7 +169,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -195,7 +183,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -211,7 +198,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -227,7 +213,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -243,7 +228,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -258,7 +242,6 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
return req
}(),
mod: buildWebhookModule(),
noDeadline: false,
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
@@ -271,20 +254,15 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
c := srv.NewContext(tc.request, httptest.NewRecorder())
if tc.noDeadline {
ctx := &api.ContextMock{Context: &api.Context{Context: context.Background()}}
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", func() context.CancelFunc {
return nil
}())
} else {
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", cancel)
}
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", func() context.CancelFunc {
return func() {
return
}
}())
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
@@ -342,10 +320,10 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
buildWebhookModule := func() *Webhook {
return &Webhook{
allowList: regexp2.MustCompile("", 0),
denyList: regexp2.MustCompile("", 0),
errorAllowList: regexp2.MustCompile("", 0),
errorDenyList: regexp2.MustCompile("", 0),
allowList: regexp.MustCompile(""),
denyList: regexp.MustCompile(""),
errorAllowList: regexp.MustCompile(""),
errorDenyList: regexp.MustCompile(""),
maxRetry: 0,
retryMinWait: 0,
retryMaxWait: 0,
@@ -376,7 +354,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
return errors.New("foo")
}
}(),
expectWebhookContentType: echo.MIMEApplicationJSON,
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusInternalServerError,
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
@@ -390,7 +368,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
return api.NewSentinelHttpError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
}
}(),
expectWebhookContentType: echo.MIMEApplicationJSON,
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusBadRequest,
expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest),
@@ -430,7 +408,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
}
}(),
returnedError: echo.ErrInternalServerError,
expectWebhookContentType: echo.MIMEApplicationJSON,
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
expectWebhookMethod: http.MethodPost,
expectWebhookErrorStatus: http.StatusInternalServerError,
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
@@ -448,17 +426,22 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
c.Set("trace", "foo")
c.Set("startTime", time.Now())
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
ctx := &api.ContextMock{Context: &api.Context{}}
ctx.SetLogger(zap.NewNop())
ctx.SetEchoContext(c)
c.Set("context", ctx.Context)
c.Set("cancel", cancel)
c.Set("cancel", func() context.CancelFunc {
return func() {
return
}
}())
webhook := echo.New()
webhook.HideBanner = true
webhook.HidePort = true
rand.Seed(time.Now().UnixNano())
webhookPort := rand.Intn(65535-1025+1) + 1025
c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
@@ -493,7 +476,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
}
}
if contentType == echo.MIMEApplicationJSON {
if contentType == echo.MIMEApplicationJSONCharsetUTF8 {
body, err := io.ReadAll(c.Request().Body)
if err != nil {
errChan <- err

View File

@@ -1,13 +1,15 @@
package webhook
import (
"fmt"
"regexp"
"time"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
)
func init() {
@@ -17,10 +19,10 @@ func init() {
// Webhook is a module which provides a middleware for uploading output files
// to any destinations in an asynchronous fashion.
type Webhook struct {
allowList *regexp2.Regexp
denyList *regexp2.Regexp
errorAllowList *regexp2.Regexp
errorDenyList *regexp2.Regexp
allowList *regexp.Regexp
denyList *regexp.Regexp
errorAllowList *regexp.Regexp
errorDenyList *regexp.Regexp
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
@@ -34,6 +36,31 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
ID: "webhook",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("webhook", flag.ExitOnError)
// Deprecated flags.
fs.String("api-webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("api-webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("api-webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
fs.String("api-webhook-error-deny-list", "", "Set the denied URLs in case of an error for the webhook feature using a regular expression")
fs.Int("api-webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
fs.Duration("api-webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
fs.Duration("api-webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
fs.Bool("api-disable-webhook", false, "Disable the webhook feature")
var err error
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-allow-list", "use webhook-allow-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-deny-list", "use webhook-deny-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-error-allow-list", "use webhook-error-allow-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-error-deny-list", "use webhook-error-deny-list instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-max-retry", "use webhook-max-retry instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-retry-min-wait", "use webhook-retry-min-wait instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-webhook-retry-max-wait", "use webhook-retry-max-wait instead"))
err = multierr.Append(err, fs.MarkDeprecated("api-disable-webhook", "use webhook-disable instead"))
if err != nil {
panic(fmt.Errorf("create deprecated flags for the webhook module: %v", err))
}
// New flags.
fs.String("webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
@@ -53,15 +80,15 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
// Provision sets the module properties.
func (w *Webhook) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
w.allowList = flags.MustRegexp("webhook-allow-list")
w.denyList = flags.MustRegexp("webhook-deny-list")
w.errorAllowList = flags.MustRegexp("webhook-error-allow-list")
w.errorDenyList = flags.MustRegexp("webhook-error-deny-list")
w.maxRetry = flags.MustInt("webhook-max-retry")
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
w.allowList = flags.MustDeprecatedRegexp("api-webhook-allow-list", "webhook-allow-list")
w.denyList = flags.MustDeprecatedRegexp("api-webhook-deny-list", "webhook-deny-list")
w.errorAllowList = flags.MustDeprecatedRegexp("api-webhook-error-allow-list", "webhook-error-allow-list")
w.errorDenyList = flags.MustDeprecatedRegexp("api-webhook-error-deny-list", "webhook-error-deny-list")
w.maxRetry = flags.MustDeprecatedInt("api-webhook-max-retry", "webhook-max-retry")
w.retryMinWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-min-wait")
w.retryMaxWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-max-wait")
w.clientTimeout = flags.MustDuration("webhook-client-timeout")
w.disable = flags.MustBool("webhook-disable")
w.disable = flags.MustDeprecatedBool("api-disable-webhook", "webhook-disable")
return nil
}

View File

@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
)
func TestWebhook_Descriptor(t *testing.T) {

View File

@@ -2,17 +2,16 @@ package standard
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"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/logging"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfcpu"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdftk"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/prometheus"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/qpdf"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/webhook"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/api"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/chromium"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/logging"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfcpu"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfengines"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdftk"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/prometheus"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/qpdf"
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/webhook"
)

Some files were not shown because too many files have changed in this diff Show More