mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 19:32:15 +01:00
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ba4877ac | ||
|
|
e65a5e6d22 | ||
|
|
711b43070d | ||
|
|
b2f957d1d8 | ||
|
|
249745f256 | ||
|
|
8ff9d3bcf1 | ||
|
|
99c328c302 | ||
|
|
8a9f0a245e | ||
|
|
9daecc127a | ||
|
|
119bba4f04 | ||
|
|
bf205c579d | ||
|
|
aa57b17254 | ||
|
|
7df786c5c6 | ||
|
|
b315464e98 | ||
|
|
1252ea076b | ||
|
|
d970e446a8 | ||
|
|
fe40f3727b | ||
|
|
10a290b065 | ||
|
|
0abb11b42a | ||
|
|
1eb6eb729a | ||
|
|
e371a4a3c3 | ||
|
|
f2b6bd3d4b | ||
|
|
d21f87b543 | ||
|
|
b8cdee4f45 | ||
|
|
b06d7445d9 | ||
|
|
2aa9cceb32 | ||
|
|
ae2a2a8a07 | ||
|
|
286555411b | ||
|
|
3c42aec180 | ||
|
|
a203ae00c6 | ||
|
|
68784616a4 | ||
|
|
5cd8c6a5da | ||
|
|
a3647fea8a | ||
|
|
300e73f07d | ||
|
|
03ced23f05 | ||
|
|
da8eeb5d8f | ||
|
|
16cf02896f | ||
|
|
c9f10d248e | ||
|
|
3297e41289 | ||
|
|
4e1b347797 | ||
|
|
4602332dbb | ||
|
|
e1f69ee533 | ||
|
|
d90e431b5b | ||
|
|
45a9eca0ce | ||
|
|
af202f4871 | ||
|
|
7b32fa35f3 | ||
|
|
0c40b230dc | ||
|
|
2baba67bfc | ||
|
|
b1e69a9059 | ||
|
|
f87362ec1b | ||
|
|
84dc57373a | ||
|
|
b3f4a02658 | ||
|
|
50304dbefe | ||
|
|
bf01af0bf3 | ||
|
|
0e3fe3463b | ||
|
|
3956763825 | ||
|
|
d6fe895f6e | ||
|
|
697fa08d8b | ||
|
|
669a35afef | ||
|
|
b1d94a3845 | ||
|
|
06869cf491 | ||
|
|
ed67d8514c | ||
|
|
07ec2f3d7b | ||
|
|
73d02e85c4 | ||
|
|
c1cf1eb37a | ||
|
|
7aecc94049 | ||
|
|
b55fbfeb8e | ||
|
|
20b8991fa4 | ||
|
|
e50de4201d | ||
|
|
ba2b876ee4 | ||
|
|
9b1399c9d5 | ||
|
|
020a2f00c2 | ||
|
|
8713946f35 | ||
|
|
bed8c25e40 | ||
|
|
7c51a09068 | ||
|
|
0f0a6ce042 | ||
|
|
a890968ee4 | ||
|
|
2267c5be00 | ||
|
|
e853cfb98a | ||
|
|
f89a0db471 | ||
|
|
1d66b43da1 | ||
|
|
75c4a4f253 | ||
|
|
6a9c95b3b1 | ||
|
|
c11a2d0081 | ||
|
|
43a13ff2c5 | ||
|
|
8454d163eb | ||
|
|
4a11f6a39f | ||
|
|
c63235cd3d | ||
|
|
f4ce196a34 | ||
|
|
e644b27201 | ||
|
|
d2b14582ee | ||
|
|
ca9194fd96 | ||
|
|
773d3ab13c | ||
|
|
a02a4a07a5 | ||
|
|
0a8625227f | ||
|
|
dc613aa2bf | ||
|
|
2008ac5663 | ||
|
|
da326f1925 | ||
|
|
71911cb1a7 | ||
|
|
31e7582216 | ||
|
|
91f63047e0 | ||
|
|
c9aa4938f4 | ||
|
|
b9e6a38037 | ||
|
|
eb054146d2 | ||
|
|
a780373cfc | ||
|
|
5ad90ccdb8 | ||
|
|
ef97d81ca8 | ||
|
|
c9f7b642c5 | ||
|
|
770962be3f | ||
|
|
d6c857f4a8 | ||
|
|
7b7ffb427d | ||
|
|
b80318d1b6 | ||
|
|
3997d69e49 | ||
|
|
4af41b33c0 | ||
|
|
0f8bbead66 | ||
|
|
804fcff5b6 | ||
|
|
d18e6920fc | ||
|
|
c95174ff11 | ||
|
|
c70329c22b | ||
|
|
34f69263a2 | ||
|
|
bf5056f8fe | ||
|
|
c47f5b06ba | ||
|
|
63055e3df2 | ||
|
|
8ee555140b | ||
|
|
859f78e986 | ||
|
|
366eebb68c | ||
|
|
25ce0b9aee | ||
|
|
376a7db545 | ||
|
|
f906ed741d | ||
|
|
ad152e62e5 | ||
|
|
7f3a61ef43 | ||
|
|
a7ba8c8213 | ||
|
|
b1f3d6d4c0 | ||
|
|
5707ae027f | ||
|
|
770208024d | ||
|
|
975a9f5344 | ||
|
|
a45008411e | ||
|
|
9091ffd1b3 | ||
|
|
089f161d1e | ||
|
|
e6d7131701 | ||
|
|
ed82170c2c | ||
|
|
3472d42cdd | ||
|
|
b8926350b9 | ||
|
|
e5194b313d | ||
|
|
94228375ad | ||
|
|
0e8385a5a7 | ||
|
|
afd3763d96 | ||
|
|
1122f09093 | ||
|
|
1123af15c9 | ||
|
|
b2f6e865a7 | ||
|
|
93b9ac2d13 | ||
|
|
64af65b0e7 | ||
|
|
390c8437c5 | ||
|
|
cc65ca9b6a | ||
|
|
4bd7cba247 | ||
|
|
92e2f7f8b7 | ||
|
|
98054ddff6 | ||
|
|
4e5c1aba2e | ||
|
|
5eb44320ae | ||
|
|
f751269289 | ||
|
|
0e836622ec | ||
|
|
a6ac0a813d | ||
|
|
21c47f60f2 | ||
|
|
66142b1dd8 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
github: [gulien]
|
||||||
4
.github/stale.yml
vendored
4
.github/stale.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
# Number of days of inactivity before an issue becomes stale
|
# Number of days of inactivity before an issue becomes stale
|
||||||
daysUntilStale: 15
|
daysUntilStale: 7
|
||||||
# Number of days of inactivity before a stale issue is closed
|
# Number of days of inactivity before a stale issue is closed
|
||||||
daysUntilClose: 7
|
daysUntilClose: 3
|
||||||
# Issues with these labels will never be considered stale
|
# Issues with these labels will never be considered stale
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
- bug
|
- bug
|
||||||
|
|||||||
2
.github/workflows/continuous_delivery.yml
vendored
2
.github/workflows/continuous_delivery.yml
vendored
@@ -23,4 +23,4 @@ jobs:
|
|||||||
- name: Build and push Docker image for release
|
- name: Build and push Docker image for release
|
||||||
run: |
|
run: |
|
||||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
|
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
|
||||||
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REPOSITORY=thecodingmachine
|
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REGISTRY=thecodingmachine
|
||||||
|
|||||||
48
.github/workflows/continuous_integration.yml
vendored
48
.github/workflows/continuous_integration.yml
vendored
@@ -15,20 +15,20 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.21'
|
go-version: '1.23'
|
||||||
cache: false
|
cache: false
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: golangci/golangci-lint-action@v3
|
uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
version: v1.54.2
|
version: v1.61.0
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
needs:
|
needs:
|
||||||
- Lint
|
- lint
|
||||||
name: Tests
|
name: Tests
|
||||||
# TODO: once arm64 actions are available, also run the tests on this architecture.
|
# TODO: once arm64 actions are available, also run the tests on this architecture.
|
||||||
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
|
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
|
||||||
@@ -43,15 +43,39 @@ jobs:
|
|||||||
- name: Build testing environment
|
- name: Build testing environment
|
||||||
run: make build build-tests
|
run: make build build-tests
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: make tests-once
|
||||||
make tests-once
|
- name: Upload to Codecov
|
||||||
bash <(curl -s https://codecov.io/bash)
|
uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
verbose: true
|
||||||
|
|
||||||
multiarch_build:
|
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:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
needs:
|
needs:
|
||||||
- Tests
|
- tests
|
||||||
name: Multi-arch build
|
name: Edge release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
@@ -68,4 +92,4 @@ jobs:
|
|||||||
- name: Build and push Docker image for main branch
|
- name: Build and push Docker image for main branch
|
||||||
run: |
|
run: |
|
||||||
make release GOTENBERG_VERSION=edge
|
make release GOTENBERG_VERSION=edge
|
||||||
make release GOTENBERG_VERSION=edge DOCKER_REPOSITORY=thecodingmachine
|
make release GOTENBERG_VERSION=edge DOCKER_REGISTRY=thecodingmachine
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
/coverage.html
|
/coverage.html
|
||||||
/coverage.txt
|
/coverage.txt
|
||||||
/TODO.txt
|
.idea
|
||||||
|
.vscode
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -3,35 +3,58 @@ linters-settings:
|
|||||||
sections:
|
sections:
|
||||||
- standard
|
- standard
|
||||||
- default
|
- default
|
||||||
- prefix(github.com/gotenberg/gotenberg/v7)
|
- prefix(github.com/gotenberg/gotenberg/v8)
|
||||||
skip-generated: true
|
skip-generated: true
|
||||||
skip-vendor: true
|
|
||||||
custom-order: true
|
custom-order: true
|
||||||
|
# Until https://github.com/securego/gosec/issues/1187 is resolved.
|
||||||
|
gosec:
|
||||||
|
excludes:
|
||||||
|
- G115
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
disable-all: true
|
disable-all: true
|
||||||
enable:
|
enable:
|
||||||
|
- asasalint
|
||||||
|
- asciicheck
|
||||||
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
|
- decorder
|
||||||
|
- dogsled
|
||||||
|
- dupl
|
||||||
|
- dupword
|
||||||
|
- durationcheck
|
||||||
|
- copyloopvar
|
||||||
- errcheck
|
- errcheck
|
||||||
|
- errname
|
||||||
|
- exhaustive
|
||||||
- gci
|
- gci
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
- gofumpt
|
- gofumpt
|
||||||
- gosec
|
- gosec
|
||||||
- gosimple
|
- gosimple
|
||||||
- govet
|
- govet
|
||||||
- ineffassign
|
- ineffassign
|
||||||
|
- importas
|
||||||
- misspell
|
- misspell
|
||||||
- prealloc
|
- prealloc
|
||||||
|
- promlinter
|
||||||
|
#- sloglint
|
||||||
- staticcheck
|
- staticcheck
|
||||||
|
- tenv
|
||||||
|
- testableexamples
|
||||||
|
- tparallel
|
||||||
- typecheck
|
- typecheck
|
||||||
- unconvert
|
- unconvert
|
||||||
- unused
|
- unused
|
||||||
|
- wastedassign
|
||||||
|
- whitespace
|
||||||
|
|
||||||
run:
|
run:
|
||||||
deadline: 5m
|
timeout: 5m
|
||||||
issues-exit-code: 1
|
issues-exit-code: 1
|
||||||
tests: false
|
tests: false
|
||||||
|
|
||||||
output:
|
output:
|
||||||
format: 'colored-line-number'
|
|
||||||
print-issued-lines: true
|
print-issued-lines: true
|
||||||
print-linter-name: true
|
print-linter-name: true
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 Julien Neuhart
|
Copyright (c) 2024 Julien Neuhart
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
84
Makefile
84
Makefile
@@ -5,14 +5,16 @@ help: ## Show the help
|
|||||||
.PHONY: it
|
.PHONY: it
|
||||||
it: build build-tests ## Initialize the development environment
|
it: build build-tests ## Initialize the development environment
|
||||||
|
|
||||||
GOLANG_VERSION=1.21
|
GOLANG_VERSION=1.23
|
||||||
|
DOCKER_REGISTRY=gotenberg
|
||||||
DOCKER_REPOSITORY=gotenberg
|
DOCKER_REPOSITORY=gotenberg
|
||||||
GOTENBERG_VERSION=snapshot
|
GOTENBERG_VERSION=snapshot
|
||||||
GOTENBERG_USER_GID=1001
|
GOTENBERG_USER_GID=1001
|
||||||
GOTENBERG_USER_UID=1001
|
GOTENBERG_USER_UID=1001
|
||||||
NOTO_COLOR_EMOJI_VERSION=v2.040 # See https://github.com/googlefonts/noto-emoji/releases.
|
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||||
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
|
||||||
GOLANGCI_LINT_VERSION=v1.54.2 # See https://github.com/golangci/golangci-lint/releases.
|
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
|
||||||
|
GOLANGCI_LINT_VERSION=v1.61.0 # See https://github.com/golangci/golangci-lint/releases.
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: ## Build the Gotenberg's Docker image
|
build: ## Build the Gotenberg's Docker image
|
||||||
@@ -23,19 +25,31 @@ build: ## Build the Gotenberg's Docker image
|
|||||||
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
|
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
|
||||||
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
|
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
|
||||||
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
|
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
|
||||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
|
||||||
|
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||||
-f build/Dockerfile .
|
-f build/Dockerfile .
|
||||||
|
|
||||||
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
|
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
|
||||||
API_PORT=3000
|
API_PORT=3000
|
||||||
API_PORT_FROM_ENV=
|
API_PORT_FROM_ENV=
|
||||||
|
API_BIND_IP=
|
||||||
|
API_START_TIMEOUT=30s
|
||||||
API_TIMEOUT=30s
|
API_TIMEOUT=30s
|
||||||
|
API_BODY_LIMIT=
|
||||||
API_ROOT_PATH=/
|
API_ROOT_PATH=/
|
||||||
API_TRACE_HEADER=Gotenberg-Trace
|
API_TRACE_HEADER=Gotenberg-Trace
|
||||||
|
API_ENABLE_BASIC_AUTH=false
|
||||||
|
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
|
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||||
CHROMIUM_RESTART_AFTER=0
|
CHROMIUM_RESTART_AFTER=0
|
||||||
|
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||||
CHROMIUM_AUTO_START=false
|
CHROMIUM_AUTO_START=false
|
||||||
CHROMIUM_START_TIMEOUT=10s
|
CHROMIUM_START_TIMEOUT=20s
|
||||||
CHROMIUM_INCOGNITO=false
|
CHROMIUM_INCOGNITO=false
|
||||||
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
|
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
|
||||||
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
|
CHROMIUM_IGNORE_CERTIFICATE_ERRORS=false
|
||||||
@@ -44,17 +58,24 @@ CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
|
|||||||
CHROMIUM_HOST_RESOLVER_RULES=
|
CHROMIUM_HOST_RESOLVER_RULES=
|
||||||
CHROMIUM_PROXY_SERVER=
|
CHROMIUM_PROXY_SERVER=
|
||||||
CHROMIUM_ALLOW_LIST=
|
CHROMIUM_ALLOW_LIST=
|
||||||
CHROMIUM_DENY_LIST="^file:///[^tmp].*"
|
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
|
||||||
|
CHROMIUM_CLEAR_CACHE=false
|
||||||
|
CHROMIUM_CLEAR_COOKIES=false
|
||||||
CHROMIUM_DISABLE_JAVASCRIPT=false
|
CHROMIUM_DISABLE_JAVASCRIPT=false
|
||||||
CHROMIUM_DISABLE_ROUTES=false
|
CHROMIUM_DISABLE_ROUTES=false
|
||||||
LIBREOFFICE_RESTART_AFTER=10
|
LIBREOFFICE_RESTART_AFTER=10
|
||||||
|
LIBREOFFICE_MAX_QUEUE_SIZE=0
|
||||||
LIBREOFFICE_AUTO_START=false
|
LIBREOFFICE_AUTO_START=false
|
||||||
LIBREOFFICE_START_TIMEOUT=10s
|
LIBREOFFICE_START_TIMEOUT=20s
|
||||||
LIBREOFFICE_DISABLE_ROUTES=false
|
LIBREOFFICE_DISABLE_ROUTES=false
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FORMAT=auto
|
LOG_FORMAT=auto
|
||||||
LOG_FIELDS_PREFIX=
|
LOG_FIELDS_PREFIX=
|
||||||
PDFENGINES_ENGINES=
|
PDFENGINES_ENGINES=
|
||||||
|
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
|
||||||
|
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
|
||||||
|
PDFENGINES_READ_METADATA_ENGINES=exiftool
|
||||||
|
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
|
||||||
PDFENGINES_DISABLE_ROUTES=false
|
PDFENGINES_DISABLE_ROUTES=false
|
||||||
PROMETHEUS_NAMESPACE=gotenberg
|
PROMETHEUS_NAMESPACE=gotenberg
|
||||||
PROMETHEUS_COLLECT_INTERVAL=1s
|
PROMETHEUS_COLLECT_INTERVAL=1s
|
||||||
@@ -74,17 +95,28 @@ WEBHOOK_DISABLE=false
|
|||||||
run: ## Start a Gotenberg container
|
run: ## Start a Gotenberg container
|
||||||
docker run --rm -it \
|
docker run --rm -it \
|
||||||
-p $(API_PORT):$(API_PORT) \
|
-p $(API_PORT):$(API_PORT) \
|
||||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
-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) \
|
||||||
gotenberg \
|
gotenberg \
|
||||||
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
||||||
--api-port=$(API_PORT) \
|
--api-port=$(API_PORT) \
|
||||||
--api-port-from-env=$(API_PORT_FROM_ENV) \
|
--api-port-from-env=$(API_PORT_FROM_ENV) \
|
||||||
|
--api-bind-ip=$(API_BIND_IP) \
|
||||||
|
--api-start-timeout=$(API_START_TIMEOUT) \
|
||||||
--api-timeout=$(API_TIMEOUT) \
|
--api-timeout=$(API_TIMEOUT) \
|
||||||
|
--api-body-limit="$(API_BODY_LIMIT)" \
|
||||||
--api-root-path=$(API_ROOT_PATH) \
|
--api-root-path=$(API_ROOT_PATH) \
|
||||||
--api-trace-header=$(API_TRACE_HEADER) \
|
--api-trace-header=$(API_TRACE_HEADER) \
|
||||||
|
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
|
||||||
|
--api-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) \
|
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
|
||||||
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
||||||
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
||||||
|
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
|
||||||
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
|
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
|
||||||
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
|
--chromium-incognito=$(CHROMIUM_INCOGNITO) \
|
||||||
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
|
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
|
||||||
@@ -93,11 +125,14 @@ run: ## Start a Gotenberg container
|
|||||||
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
|
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
|
||||||
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
|
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
|
||||||
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
|
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
|
||||||
--chromium-allow-list=$(CHROMIUM_ALLOW_LIST) \
|
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
|
||||||
--chromium-deny-list=$(CHROMIUM_DENY_LIST) \
|
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
|
||||||
|
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
|
||||||
|
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
|
||||||
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
|
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
|
||||||
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
|
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
|
||||||
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
|
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
|
||||||
|
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
|
||||||
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
|
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
|
||||||
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
|
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
|
||||||
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
|
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
|
||||||
@@ -105,13 +140,17 @@ run: ## Start a Gotenberg container
|
|||||||
--log-format=$(LOG_FORMAT) \
|
--log-format=$(LOG_FORMAT) \
|
||||||
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
|
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
|
||||||
--pdfengines-engines=$(PDFENGINES_ENGINES) \
|
--pdfengines-engines=$(PDFENGINES_ENGINES) \
|
||||||
|
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
|
||||||
|
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
|
||||||
|
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
|
||||||
|
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
|
||||||
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
|
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
|
||||||
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
|
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
|
||||||
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
|
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
|
||||||
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
|
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
|
||||||
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
|
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
|
||||||
--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
|
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
|
||||||
--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
|
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
|
||||||
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
|
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
|
||||||
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
|
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
|
||||||
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
|
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
|
||||||
@@ -124,24 +163,25 @@ run: ## Start a Gotenberg container
|
|||||||
build-tests: ## Build the tests' Docker image
|
build-tests: ## Build the tests' Docker image
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
||||||
|
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
|
||||||
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
|
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
|
||||||
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
||||||
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
|
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
|
||||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||||
-f test/Dockerfile .
|
-f test/Dockerfile .
|
||||||
|
|
||||||
.PHONY: tests
|
.PHONY: tests
|
||||||
tests: ## Start the testing environment
|
tests: ## Start the testing environment
|
||||||
docker run --rm -it \
|
docker run --rm -it \
|
||||||
-v $(PWD):/tests \
|
-v $(PWD):/tests \
|
||||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||||
bash
|
bash
|
||||||
|
|
||||||
.PHONY: tests-once
|
.PHONY: tests-once
|
||||||
tests-once: ## Run the tests once (prefer the "tests" command while developing)
|
tests-once: ## Run the tests once (prefer the "tests" command while developing)
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v $(PWD):/tests \
|
-v $(PWD):/tests \
|
||||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||||
gotest
|
gotest
|
||||||
|
|
||||||
# go install mvdan.cc/gofumpt@latest
|
# go install mvdan.cc/gofumpt@latest
|
||||||
@@ -149,17 +189,19 @@ tests-once: ## Run the tests once (prefer the "tests" command while developing)
|
|||||||
.PHONY: fmt
|
.PHONY: fmt
|
||||||
fmt: ## Format the code and "optimize" the dependencies
|
fmt: ## Format the code and "optimize" the dependencies
|
||||||
gofumpt -l -w .
|
gofumpt -l -w .
|
||||||
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v7)" --skip-generated --skip-vendor --custom-order .
|
gci write -s standard -s default -s "prefix(github.com/gotenberg/gotenberg/v8)" --skip-generated --skip-vendor --custom-order .
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
# go install golang.org/x/tools/cmd/godoc@latest
|
# go install golang.org/x/tools/cmd/godoc@latest
|
||||||
.PHONY: godoc
|
.PHONY: godoc
|
||||||
godoc: ## Run a webserver with Gotenberg godoc
|
godoc: ## Run a webserver with Gotenberg godoc
|
||||||
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v7)
|
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
|
||||||
godoc -http=:6060
|
godoc -http=:6060
|
||||||
|
|
||||||
|
LINUX_AMD64_RELEASE=false
|
||||||
|
|
||||||
.PHONY: release
|
.PHONY: release
|
||||||
release: ## Build the Gotenberg's Docker image for many platforms, then push it to a Docker repository
|
release: ## Build the Gotenberg's Docker image and push it to a Docker repository
|
||||||
./scripts/release.sh \
|
./scripts/release.sh \
|
||||||
$(GOLANG_VERSION) \
|
$(GOLANG_VERSION) \
|
||||||
$(GOTENBERG_VERSION) \
|
$(GOTENBERG_VERSION) \
|
||||||
@@ -167,4 +209,8 @@ release: ## Build the Gotenberg's Docker image for many platforms, then push it
|
|||||||
$(GOTENBERG_USER_UID) \
|
$(GOTENBERG_USER_UID) \
|
||||||
$(NOTO_COLOR_EMOJI_VERSION) \
|
$(NOTO_COLOR_EMOJI_VERSION) \
|
||||||
$(PDFTK_VERSION) \
|
$(PDFTK_VERSION) \
|
||||||
$(DOCKER_REPOSITORY)
|
$(PDFCPU_VERSION) \
|
||||||
|
$(DOCKER_REGISTRY) \
|
||||||
|
$(DOCKER_REPOSITORY) \
|
||||||
|
$(LINUX_AMD64_RELEASE)
|
||||||
|
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -2,12 +2,20 @@
|
|||||||
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
|
<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>
|
<h3 align="center">Gotenberg</h3>
|
||||||
<p align="center">A Docker-powered stateless API for PDF files</p>
|
<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> · <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
|
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> · <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
|
||||||
</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!
|
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -15,13 +23,13 @@ numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, an
|
|||||||
Open a terminal and run the following command:
|
Open a terminal and run the following command:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker run --rm -p 3000:3000 gotenberg/gotenberg:7
|
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
|
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
|
||||||
|
|
||||||
```
|
```
|
||||||
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:7
|
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8
|
||||||
```
|
```
|
||||||
|
|
||||||
The API is now available on your host at http://localhost:3000.
|
The API is now available on your host at http://localhost:3000.
|
||||||
@@ -32,14 +40,11 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://thecodingmachine.com">
|
<a href="https://thecodingmachine.com">
|
||||||
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="429" height="210" />
|
<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" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Badges
|
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️
|
||||||
|
|
||||||
[](https://hub.docker.com/r/gotenberg/gotenberg)
|
|
||||||
[](https://hub.docker.com/r/thecodingmachine/gotenberg)
|
|
||||||
[](https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml)
|
|
||||||
[](https://pkg.go.dev/github.com/gotenberg/gotenberg/v7)
|
|
||||||
[](https://codecov.io/gh/gotenberg/gotenberg)
|
|
||||||
|
|||||||
@@ -2,15 +2,39 @@
|
|||||||
# concatenate them. Also, we have to repeat ARG instructions in each build
|
# concatenate them. Also, we have to repeat ARG instructions in each build
|
||||||
# stage that uses them.
|
# stage that uses them.
|
||||||
ARG GOLANG_VERSION
|
ARG GOLANG_VERSION
|
||||||
ARG GOTENBERG_VERSION
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# pdfcpu binary build stage
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Note: this stage is required as pdfcpu does not release an armhf variant by
|
||||||
|
# default.
|
||||||
|
|
||||||
|
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
|
||||||
|
|
||||||
|
ARG PDFCPU_VERSION
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
|
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||||
|
WORKDIR /home
|
||||||
|
|
||||||
|
RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION.tar.gz" -o pdfcpu.tar.gz &&\
|
||||||
|
tar --strip-components=1 -xvzf pdfcpu.tar.gz
|
||||||
|
|
||||||
|
# Install module dependencies.
|
||||||
|
RUN go mod download &&\
|
||||||
|
go mod verify
|
||||||
|
|
||||||
|
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu &&\
|
||||||
|
# Verify installation.
|
||||||
|
./pdfcpu version
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# Gotenberg binary build stage
|
# Gotenberg binary build stage
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
FROM golang:$GOLANG_VERSION AS binary-stage
|
FROM golang:$GOLANG_VERSION AS gotenberg-binary-stage
|
||||||
|
|
||||||
ARG GOTENBERG_VERSION
|
ARG GOTENBERG_VERSION
|
||||||
ENV CGO_ENABLED 0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||||
WORKDIR /home
|
WORKDIR /home
|
||||||
@@ -25,7 +49,7 @@ RUN go mod download &&\
|
|||||||
COPY cmd ./cmd
|
COPY cmd ./cmd
|
||||||
COPY pkg ./pkg
|
COPY pkg ./pkg
|
||||||
|
|
||||||
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v7/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
RUN go build -o gotenberg -ldflags "-X 'github.com/gotenberg/gotenberg/v8/cmd.Version=$GOTENBERG_VERSION'" cmd/gotenberg/main.go
|
||||||
|
|
||||||
# ----------------------------------------------
|
# ----------------------------------------------
|
||||||
# Final stage
|
# Final stage
|
||||||
@@ -134,7 +158,12 @@ RUN \
|
|||||||
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
|
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
|
||||||
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
|
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
|
||||||
apt-get update -qq &&\
|
apt-get update -qq &&\
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium-common="$TMP_CHOMIUM_VERSION_ARMHF" chromium="$TMP_CHOMIUM_VERSION_ARMHF"; \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
|
||||||
|
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
|
||||||
|
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" "./binary-chromium/chromium_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" &&\
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
|
||||||
|
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
|
||||||
else \
|
else \
|
||||||
apt-get update -qq &&\
|
apt-get update -qq &&\
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
|
||||||
@@ -160,19 +189,20 @@ RUN \
|
|||||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
# Install PDFtk & QPDF (PDF engines).
|
# Install PDFtk, QPDF & ExifTool (PDF engines).
|
||||||
# See https://github.com/gotenberg/gotenberg/pull/273.
|
# 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" &&\
|
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 &&\
|
chmod a+x /usr/bin/pdftk-all.jar &&\
|
||||||
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
|
||||||
chmod +x /usr/bin/pdftk &&\
|
chmod +x /usr/bin/pdftk &&\
|
||||||
apt-get update -qq &&\
|
apt-get update -qq &&\
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
|
||||||
# See https://github.com/nextcloud/docker/issues/380.
|
# See https://github.com/nextcloud/docker/issues/380.
|
||||||
mkdir -p /usr/share/man/man1 &&\
|
mkdir -p /usr/share/man/man1 &&\
|
||||||
# Verify installations.
|
# Verify installations.
|
||||||
pdftk --version &&\
|
pdftk --version &&\
|
||||||
qpdf --version &&\
|
qpdf --version &&\
|
||||||
|
exiftool --version &&\
|
||||||
# Cleanup.
|
# Cleanup.
|
||||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
@@ -182,15 +212,20 @@ RUN \
|
|||||||
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
|
||||||
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||||
|
|
||||||
# Copy the Gotenberg binary from the binary stage.
|
# Copy the pdfcpu binary from the pdfcpu-binary-stage.
|
||||||
COPY --from=binary-stage /home/gotenberg /usr/bin/
|
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
|
||||||
|
|
||||||
|
# Copy the Gotenberg binary from the gotenberg-binary-stage.
|
||||||
|
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
|
||||||
|
|
||||||
# Environment variables required by modules or else.
|
# Environment variables required by modules or else.
|
||||||
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
|
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||||
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
|
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||||
ENV UNOCONVERTER_BIN_PATH /usr/bin/unoconverter
|
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||||
ENV PDFTK_BIN_PATH /usr/bin/pdftk
|
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||||
ENV QPDF_BIN_PATH /usr/bin/qpdf
|
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||||
|
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||||
|
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
|
||||||
|
|
||||||
USER gotenberg
|
USER gotenberg
|
||||||
WORKDIR /home/gotenberg
|
WORKDIR /home/gotenberg
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
ARG DOCKER_REGISTRY
|
||||||
ARG DOCKER_REPOSITORY
|
ARG DOCKER_REPOSITORY
|
||||||
ARG GOTENBERG_VERSION
|
ARG GOTENBERG_VERSION
|
||||||
|
|
||||||
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
|
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
|
// See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
|
||||||
@@ -35,6 +35,7 @@ var Version = "snapshot"
|
|||||||
// Run starts the Gotenberg application. Call this in the main of your program.
|
// Run starts the Gotenberg application. Call this in the main of your program.
|
||||||
func Run() {
|
func Run() {
|
||||||
fmt.Printf(banner, Version)
|
fmt.Printf(banner, Version)
|
||||||
|
gotenberg.Version = Version
|
||||||
|
|
||||||
// Create the root FlagSet and adds the modules flags to it.
|
// Create the root FlagSet and adds the modules flags to it.
|
||||||
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
|
||||||
@@ -75,7 +76,6 @@ func Run() {
|
|||||||
go func(app gotenberg.App) {
|
go func(app gotenberg.App) {
|
||||||
id := app.(gotenberg.Module).Descriptor().ID
|
id := app.(gotenberg.Module).Descriptor().ID
|
||||||
err = app.Start()
|
err = app.Start()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
|
fmt.Printf("[FATAL] starting %s: %s\n", id, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -84,7 +84,6 @@ func Run() {
|
|||||||
startupMessage := app.StartupMessage()
|
startupMessage := app.StartupMessage()
|
||||||
if startupMessage == "" {
|
if startupMessage == "" {
|
||||||
fmt.Printf("[SYSTEM] %s: application started\n", id)
|
fmt.Printf("[SYSTEM] %s: application started\n", id)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +143,6 @@ func Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
|
fmt.Printf("[SYSTEM] %s: application stopped\n", id)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}(a.(gotenberg.App)))
|
}(a.(gotenberg.App)))
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
gotenbergcmd "github.com/gotenberg/gotenberg/v7/cmd"
|
gotenbergcmd "github.com/gotenberg/gotenberg/v8/cmd"
|
||||||
// Gotenberg modules.
|
// Gotenberg modules.
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/standard"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/standard"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@@ -1,744 +0,0 @@
|
|||||||
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
|
|
||||||
71
go.mod
71
go.mod
@@ -1,66 +1,63 @@
|
|||||||
module github.com/gotenberg/gotenberg/v7
|
module github.com/gotenberg/gotenberg/v8
|
||||||
|
|
||||||
go 1.21
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexliesenfeld/health v0.8.0
|
github.com/alexliesenfeld/health v0.8.0
|
||||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||||
github.com/chromedp/cdproto v0.0.0-20231114014204-3e458d5176f9
|
github.com/barasher/go-exiftool v1.10.0
|
||||||
github.com/chromedp/chromedp v0.9.3
|
github.com/chromedp/cdproto v0.0.0-20241030022559-23c28aebe8cb
|
||||||
|
github.com/chromedp/chromedp v0.11.1
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/uuid v1.4.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.5
|
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||||
github.com/klauspost/compress v1.17.3 // indirect
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
github.com/labstack/echo/v4 v4.11.3
|
github.com/labstack/echo/v4 v4.12.0
|
||||||
github.com/labstack/gommon v0.4.1
|
github.com/labstack/gommon v0.4.2
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mholt/archiver/v3 v3.5.1
|
github.com/mholt/archiver/v3 v3.5.1
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||||
github.com/pdfcpu/pdfcpu v0.5.0
|
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.18 // indirect
|
github.com/prometheus/client_golang v1.20.5
|
||||||
github.com/prometheus/client_golang v1.17.0
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0
|
github.com/russross/blackfriday/v2 v2.1.0
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/spf13/pflag v1.0.5
|
||||||
github.com/ulikunitz/xz v0.5.11 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
go.uber.org/multierr v1.11.0
|
go.uber.org/multierr v1.11.0
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.15.0 // indirect
|
golang.org/x/crypto v0.28.0 // indirect
|
||||||
golang.org/x/image v0.14.0 // indirect
|
golang.org/x/net v0.30.0
|
||||||
golang.org/x/net v0.18.0
|
golang.org/x/sync v0.8.0
|
||||||
golang.org/x/sync v0.5.0
|
golang.org/x/sys v0.26.0 // indirect
|
||||||
golang.org/x/sys v0.14.0 // indirect
|
golang.org/x/term v0.25.0
|
||||||
golang.org/x/term v0.14.0
|
golang.org/x/text v0.19.0
|
||||||
golang.org/x/text v0.14.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/dlclark/regexp2 v1.11.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/gobwas/ws v1.3.1 // indirect
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
|
||||||
github.com/hhrutter/tiff v1.0.1 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/prometheus/common v0.60.1 // indirect
|
||||||
github.com/prometheus/client_model v0.5.0 // indirect
|
github.com/prometheus/procfs v0.15.1 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
golang.org/x/time v0.7.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
google.golang.org/protobuf v1.35.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
172
go.sum
172
go.sum
@@ -1,72 +1,72 @@
|
|||||||
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
|
github.com/alexliesenfeld/health v0.8.0 h1:lCV0i+ZJPTbqP7LfKG7p3qZBl5VhelwUFCIVWl77fgk=
|
||||||
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
github.com/alexliesenfeld/health v0.8.0/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
|
||||||
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
|
||||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||||
|
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
github.com/chromedp/cdproto v0.0.0-20241030022559-23c28aebe8cb h1:yBPpAakATGLWZsVgYRcU9FopbOqzoazzbFaStQ9DCMc=
|
||||||
github.com/chromedp/cdproto v0.0.0-20231114014204-3e458d5176f9 h1:e3tMnG8i9SfKOilykpprojNk3a49O4dn+wqZsam1qYQ=
|
github.com/chromedp/cdproto v0.0.0-20241030022559-23c28aebe8cb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
|
||||||
github.com/chromedp/cdproto v0.0.0-20231114014204-3e458d5176f9/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
github.com/chromedp/chromedp v0.11.1 h1:Spca8egFqUlv+JDW+yIs+ijlHlJDPufgrfXPwtq6NMs=
|
||||||
github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg=
|
github.com/chromedp/chromedp v0.11.1/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
|
||||||
github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
|
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
|
||||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
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/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
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 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU=
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
|
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
|
||||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
|
||||||
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
|
|
||||||
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||||
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
|
||||||
github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws=
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
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 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
@@ -76,90 +76,76 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.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 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
|
||||||
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
|
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
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/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
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 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pdfcpu/pdfcpu v0.5.0 h1:F3wC4bwPbaJM+RPgm1D0Q4SAUwxElw7BhwNvL3iPgDo=
|
|
||||||
github.com/pdfcpu/pdfcpu v0.5.0/go.mod h1:UPcHdWcMw1V6Bo5tcWHd3jZfkG8cwUwrJkQOlB6o+7g=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
|
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
|
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
|
||||||
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
|
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
|
||||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
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.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
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 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||||
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8=
|
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||||
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
|
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
|
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||||
|
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ func (cmd *Cmd) pipeOutput() error {
|
|||||||
r := bufio.NewReader(reader)
|
r := bufio.NewReader(reader)
|
||||||
defer func(reader io.ReadCloser) {
|
defer func(reader io.ReadCloser) {
|
||||||
err := reader.Close()
|
err := reader.Close()
|
||||||
if err != nil {
|
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
||||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||||
}
|
}
|
||||||
}(reader)
|
}(reader)
|
||||||
|
|||||||
@@ -104,19 +104,8 @@ func TestCmd_Wait(t *testing.T) {
|
|||||||
expectWaitError: false,
|
expectWaitError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "wait error",
|
scenario: "wait error",
|
||||||
cmd: func() *Cmd {
|
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||||
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,
|
expectWaitError: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
35
pkg/gotenberg/env.go
Normal file
35
pkg/gotenberg/env.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StringEnv retrieves the value of the environment variable named by the key.
|
||||||
|
// If the variable is present in the environment and not empty, the value is
|
||||||
|
// returned.
|
||||||
|
func StringEnv(key string) (string, error) {
|
||||||
|
val, ok := os.LookupEnv(key)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("environment variable '%s' does not exist", key)
|
||||||
|
}
|
||||||
|
if val == "" {
|
||||||
|
return "", fmt.Errorf("environment variable '%s' is empty", key)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntEnv relies on [StringEnv] and converts the values if it exists and is not
|
||||||
|
// empty.
|
||||||
|
func IntEnv(key string) (int, error) {
|
||||||
|
val, err := StringEnv(key)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
intVal, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err)
|
||||||
|
}
|
||||||
|
return intVal, nil
|
||||||
|
}
|
||||||
134
pkg/gotenberg/env_test.go
Normal file
134
pkg/gotenberg/env_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStringEnv(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
key string
|
||||||
|
setEnv func()
|
||||||
|
expectVal string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "non-existing environment variable",
|
||||||
|
key: "NON_EXISTING",
|
||||||
|
expectVal: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "empty environment variable",
|
||||||
|
key: "EMPTY_STRING",
|
||||||
|
setEnv: func() {
|
||||||
|
err := os.Setenv("EMPTY_STRING", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectVal: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
key: "EXISTING_STRING_VALUE",
|
||||||
|
setEnv: func() {
|
||||||
|
err := os.Setenv("EXISTING_STRING_VALUE", "foo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectVal: "foo",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
if tc.setEnv != nil {
|
||||||
|
tc.setEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := StringEnv(tc.key)
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectVal != val {
|
||||||
|
t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntEnv(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
key string
|
||||||
|
setEnv func()
|
||||||
|
expectVal int
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "empty environment variable",
|
||||||
|
key: "EMPTY_INT",
|
||||||
|
setEnv: func() {
|
||||||
|
err := os.Setenv("EMPTY_INT", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectVal: 0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "non-integer value",
|
||||||
|
key: "NON_INTEGER",
|
||||||
|
setEnv: func() {
|
||||||
|
err := os.Setenv("NON_INTEGER", "foo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectVal: 0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
key: "EXISTING_INT_VALUE",
|
||||||
|
setEnv: func() {
|
||||||
|
err := os.Setenv("EXISTING_INT_VALUE", "123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectVal: 123,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
if tc.setEnv != nil {
|
||||||
|
tc.setEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := IntEnv(tc.key)
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectVal != val {
|
||||||
|
t.Errorf("expected value %d but got %d", tc.expectVal, val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
53
pkg/gotenberg/filter.go
Normal file
53
pkg/gotenberg/filter.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
|
||||||
|
var ErrFiltered = errors.New("value filtered")
|
||||||
|
|
||||||
|
// FilterDeadline checks if given value is allowed and not denied according to
|
||||||
|
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
|
||||||
|
// to process.
|
||||||
|
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
|
||||||
|
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
|
||||||
|
if allowed.String() != "" {
|
||||||
|
allow := regexp2.MustCompile(allowed.String(), 0)
|
||||||
|
allow.MatchTimeout = time.Until(deadline)
|
||||||
|
|
||||||
|
ok, err := allow.MatchString(s)
|
||||||
|
if err != nil {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if denied.String() != "" {
|
||||||
|
deny := regexp2.MustCompile(denied.String(), 0)
|
||||||
|
deny.MatchTimeout = time.Until(deadline)
|
||||||
|
|
||||||
|
ok, err := deny.MatchString(s)
|
||||||
|
if err != nil {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
83
pkg/gotenberg/filter_test.go
Normal file
83
pkg/gotenberg/filter_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilterDeadline(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
allowed *regexp2.Regexp
|
||||||
|
denied *regexp2.Regexp
|
||||||
|
s string
|
||||||
|
deadline time.Time
|
||||||
|
expectError bool
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "DeadlineExceeded (allowed)",
|
||||||
|
allowed: regexp2.MustCompile("foo", 0),
|
||||||
|
denied: regexp2.MustCompile("", 0),
|
||||||
|
s: "foo",
|
||||||
|
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||||
|
expectError: true,
|
||||||
|
expectedError: context.DeadlineExceeded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrFiltered (allowed)",
|
||||||
|
allowed: regexp2.MustCompile("foo", 0),
|
||||||
|
denied: regexp2.MustCompile("", 0),
|
||||||
|
s: "bar",
|
||||||
|
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||||
|
expectError: true,
|
||||||
|
expectedError: ErrFiltered,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "DeadlineExceeded (denied)",
|
||||||
|
allowed: regexp2.MustCompile("", 0),
|
||||||
|
denied: regexp2.MustCompile("foo", 0),
|
||||||
|
s: "foo",
|
||||||
|
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
|
||||||
|
expectError: true,
|
||||||
|
expectedError: context.DeadlineExceeded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrFiltered (denied)",
|
||||||
|
allowed: regexp2.MustCompile("", 0),
|
||||||
|
denied: regexp2.MustCompile("foo", 0),
|
||||||
|
s: "foo",
|
||||||
|
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||||
|
expectError: true,
|
||||||
|
expectedError: ErrFiltered,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
allowed: regexp2.MustCompile("", 0),
|
||||||
|
denied: regexp2.MustCompile("", 0),
|
||||||
|
s: "foo",
|
||||||
|
deadline: time.Now().Add(time.Duration(5) * time.Second),
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected an error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||||
|
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package gotenberg
|
package gotenberg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/labstack/gommon/bytes"
|
"github.com/labstack/gommon/bytes"
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
@@ -168,50 +168,54 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
|
|||||||
return f.MustDuration(newName)
|
return f.MustDuration(newName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustHumanReadableBytesString returns the human-readable bytes string of a
|
// MustHumanReadableBytes returns the human-readable bytes string of a flag
|
||||||
// flag given by name.
|
// given by name.
|
||||||
// It panics if an error occurs.
|
// It panics if an error occurs.
|
||||||
func (f *ParsedFlags) MustHumanReadableBytesString(name string) string {
|
func (f *ParsedFlags) MustHumanReadableBytes(name string) int64 {
|
||||||
val, err := f.GetString(name)
|
val, err := f.GetString(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = bytes.Parse(val)
|
if val == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := bytes.Parse(val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return val
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
|
// MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
|
||||||
// string of a deprecated flag if it was explicitly set or the human-readable
|
// deprecated flag if it was explicitly set or the human-readable bytes string
|
||||||
// bytes string of the new flag.
|
// of the new flag.
|
||||||
// It panics if an error occurs.
|
// It panics if an error occurs.
|
||||||
func (f *ParsedFlags) MustDeprecatedHumanReadableBytesString(deprecated string, newName string) string {
|
func (f *ParsedFlags) MustDeprecatedHumanReadableBytes(deprecated string, newName string) int64 {
|
||||||
if f.Changed(deprecated) {
|
if f.Changed(deprecated) {
|
||||||
return f.MustHumanReadableBytesString(deprecated)
|
return f.MustHumanReadableBytes(deprecated)
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.MustHumanReadableBytesString(newName)
|
return f.MustHumanReadableBytes(newName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustRegexp returns the regular expression of a flag given by name.
|
// MustRegexp returns the regular expression of a flag given by name.
|
||||||
// It panics if an error occurs.
|
// It panics if an error occurs.
|
||||||
func (f *ParsedFlags) MustRegexp(name string) *regexp.Regexp {
|
func (f *ParsedFlags) MustRegexp(name string) *regexp2.Regexp {
|
||||||
val, err := f.GetString(name)
|
val, err := f.GetString(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return regexp.MustCompile(val)
|
return regexp2.MustCompile(val, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
// MustDeprecatedRegexp returns the regular expression of a deprecated flag if
|
||||||
// it was explicitly set or the regular expression of the new flag.
|
// it was explicitly set or the regular expression of the new flag.
|
||||||
// It panics if an error occurs.
|
// It panics if an error occurs.
|
||||||
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp.Regexp {
|
func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *regexp2.Regexp {
|
||||||
if f.Changed(deprecated) {
|
if f.Changed(deprecated) {
|
||||||
return f.MustRegexp(deprecated)
|
return f.MustRegexp(deprecated)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,10 +644,11 @@ func TestParsedFlags_MustDeprecatedDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
func TestParsedFlags_MustHumanReadableBytes(t *testing.T) {
|
||||||
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
|
||||||
fs.String("foo", "1MB", "")
|
fs.String("foo", "1MB", "")
|
||||||
fs.String("bar", "1MB", "")
|
fs.String("bar", "1MB", "")
|
||||||
|
fs.String("qux", "", "")
|
||||||
|
|
||||||
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
|
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -671,6 +672,11 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
|||||||
name: "bar",
|
name: "bar",
|
||||||
expectPanic: true,
|
expectPanic: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "success: empty value",
|
||||||
|
name: "qux",
|
||||||
|
expectPanic: false,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
if tc.expectPanic {
|
if tc.expectPanic {
|
||||||
@@ -689,31 +695,31 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedFlags.MustHumanReadableBytesString(tc.name)
|
parsedFlags.MustHumanReadableBytes(tc.name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
|
func TestParsedFlags_MustDeprecatedHumanReadableBytes(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
rawFlags []string
|
rawFlags []string
|
||||||
expectValue string
|
expectValue int64
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "deprecated flag value",
|
scenario: "deprecated flag value",
|
||||||
rawFlags: []string{"--foo=1MB"},
|
rawFlags: []string{"--foo=1MB"},
|
||||||
expectValue: "1MB",
|
expectValue: 1000000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "non-deprecated flag value",
|
scenario: "non-deprecated flag value",
|
||||||
rawFlags: []string{"--bar=2MB"},
|
rawFlags: []string{"--bar=2MB"},
|
||||||
expectValue: "2MB",
|
expectValue: 2000000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "deprecated flag value > non-deprecated flag value",
|
scenario: "deprecated flag value > non-deprecated flag value",
|
||||||
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
|
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
|
||||||
expectValue: "1MB",
|
expectValue: 1000000,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
@@ -728,9 +734,9 @@ func TestParsedFlags_MustDeprecatedHumanReadableBytesString(t *testing.T) {
|
|||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
|
actual := parsedFlags.MustDeprecatedHumanReadableBytes("foo", "bar")
|
||||||
if actual != tc.expectValue {
|
if actual != tc.expectValue {
|
||||||
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
|
t.Errorf("expected %d but got %d", tc.expectValue, actual)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,3 +51,11 @@ func (fs *FileSystem) MkdirAll() (string, error) {
|
|||||||
|
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PathRename defines the method signature for renaming files. Implement this
|
||||||
|
// interface if you don't want to rely on [os.Rename], notably for testing
|
||||||
|
// purpose.
|
||||||
|
type PathRename interface {
|
||||||
|
// Rename uses the same signature as [os.Rename].
|
||||||
|
Rename(oldpath, newpath string) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package gotenberg
|
package gotenberg
|
||||||
|
|
||||||
import "go.uber.org/zap"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/go-retryablehttp"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
// LoggerProvider is an interface for a module that supplies a method for
|
// LoggerProvider is an interface for a module that supplies a method for
|
||||||
// creating a [zap.Logger] instance for use by other modules.
|
// creating a [zap.Logger] instance for use by other modules.
|
||||||
@@ -12,3 +17,41 @@ import "go.uber.org/zap"
|
|||||||
type LoggerProvider interface {
|
type LoggerProvider interface {
|
||||||
Logger(mod Module) (*zap.Logger, error)
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package webhook
|
package gotenberg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -7,17 +7,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestLeveledLogger_Error(t *testing.T) {
|
func TestLeveledLogger_Error(t *testing.T) {
|
||||||
leveledLogger{logger: zap.NewNop()}.Error("foo")
|
NewLeveledLogger(zap.NewNop()).Error("foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLeveledLogger_Warn(t *testing.T) {
|
func TestLeveledLogger_Warn(t *testing.T) {
|
||||||
leveledLogger{logger: zap.NewNop()}.Warn("foo")
|
NewLeveledLogger(zap.NewNop()).Warn("foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLeveledLogger_Info(t *testing.T) {
|
func TestLeveledLogger_Info(t *testing.T) {
|
||||||
leveledLogger{logger: zap.NewNop()}.Info("foo")
|
NewLeveledLogger(zap.NewNop()).Info("foo")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLeveledLogger_Debug(t *testing.T) {
|
func TestLeveledLogger_Debug(t *testing.T) {
|
||||||
leveledLogger{logger: zap.NewNop()}.Debug("foo")
|
NewLeveledLogger(zap.NewNop()).Debug("foo")
|
||||||
}
|
}
|
||||||
@@ -35,8 +35,10 @@ func (mod *ValidatorMock) Validate() error {
|
|||||||
|
|
||||||
// PdfEngineMock is a mock for the [PdfEngine] interface.
|
// PdfEngineMock is a mock for the [PdfEngine] interface.
|
||||||
type PdfEngineMock struct {
|
type PdfEngineMock struct {
|
||||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||||
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||||
|
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
|
||||||
|
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
@@ -47,6 +49,14 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, fo
|
|||||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
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.
|
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
|
||||||
type PdfEngineProviderMock struct {
|
type PdfEngineProviderMock struct {
|
||||||
PdfEngineMock func() (PdfEngine, error)
|
PdfEngineMock func() (PdfEngine, error)
|
||||||
@@ -127,6 +137,15 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
|
|||||||
return provider.MetricsMock()
|
return provider.MetricsMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PathRenameMock is a mock for the [PathRename] interface.
|
||||||
|
type PathRenameMock struct {
|
||||||
|
RenameMock func(oldpath, newpath string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rename *PathRenameMock) Rename(oldpath, newpath string) error {
|
||||||
|
return rename.RenameMock(oldpath, newpath)
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ Module = (*ModuleMock)(nil)
|
_ Module = (*ModuleMock)(nil)
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ func TestPDFEngineMock(t *testing.T) {
|
|||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||||
return nil
|
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, "")
|
err := mock.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||||
@@ -66,6 +72,16 @@ func TestPDFEngineMock(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
|
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) {
|
func TestPDFEngineProviderMock(t *testing.T) {
|
||||||
@@ -188,3 +204,16 @@ func TestMetricsProviderMock(t *testing.T) {
|
|||||||
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
|
t.Errorf("expected no error from MetricsProviderMock.Metrics, but got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPathRenameMock(t *testing.T) {
|
||||||
|
mock := &PathRenameMock{
|
||||||
|
RenameMock: func(oldpath, newpath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mock.Rename("", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error from PathRenameMock.Rename, but got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ type SystemLogger interface {
|
|||||||
// gotenberg.MustRegisterModule(YourModule{})
|
// gotenberg.MustRegisterModule(YourModule{})
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// Then, in the main command (github.com/gotenberg/gotenberg/v7/cmd/gotenberg),
|
// Then, in the main command (github.com/gotenberg/gotenberg/v8/cmd/gotenberg),
|
||||||
// import the module:
|
// import the module:
|
||||||
//
|
//
|
||||||
// imports (
|
// imports (
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ var (
|
|||||||
// ErrPdfFormatNotSupported is returned when the Convert method of the
|
// ErrPdfFormatNotSupported is returned when the Convert method of the
|
||||||
// PdfEngine interface does not support a requested PDF format conversion.
|
// PdfEngine interface does not support a requested PDF format conversion.
|
||||||
ErrPdfFormatNotSupported = errors.New("PDF format not supported")
|
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 (
|
const (
|
||||||
@@ -64,6 +68,12 @@ type PdfEngine interface {
|
|||||||
// Convert transforms a given PDF to the specified formats defined in
|
// Convert transforms a given PDF to the specified formats defined in
|
||||||
// PdfFormats. If no format, it does nothing.
|
// PdfFormats. If no format, it does nothing.
|
||||||
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
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].
|
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||||
|
|||||||
69
pkg/gotenberg/sort.go
Normal file
69
pkg/gotenberg/sort.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlphanumericSort implements sort.Interface and helps to sort strings
|
||||||
|
// alphanumerically.
|
||||||
|
//
|
||||||
|
// See: https://github.com/gotenberg/gotenberg/issues/805.
|
||||||
|
type AlphanumericSort []string
|
||||||
|
|
||||||
|
func (s AlphanumericSort) Len() int {
|
||||||
|
return len(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s AlphanumericSort) Swap(i, j int) {
|
||||||
|
s[i], s[j] = s[j], s[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s AlphanumericSort) Less(i, j int) bool {
|
||||||
|
numI, restI := extractPrefix(s[i])
|
||||||
|
numJ, restJ := extractPrefix(s[j])
|
||||||
|
|
||||||
|
// Compares numerical prefixes if they exist.
|
||||||
|
if numI != -1 && numJ != -1 {
|
||||||
|
if numI != numJ {
|
||||||
|
return numI < numJ
|
||||||
|
}
|
||||||
|
// If numbers are equal, falls back to string comparison of the rest.
|
||||||
|
return restI < restJ
|
||||||
|
}
|
||||||
|
|
||||||
|
// If one has a numerical prefix and the other doesn't, the one with the
|
||||||
|
// number comes first.
|
||||||
|
if numI != -1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if numJ != -1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither has a numerical prefix, compare as strings
|
||||||
|
return s[i] < s[j]
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPrefix attempts to extract a numerical prefix and the rest of the filename
|
||||||
|
func extractPrefix(filename string) (int, string) {
|
||||||
|
matches := numPrefixRegexp.FindStringSubmatch(filename)
|
||||||
|
if len(matches) > 2 {
|
||||||
|
prefix, err := strconv.Atoi(matches[1])
|
||||||
|
if err == nil {
|
||||||
|
return prefix, matches[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns -1 if no numerical prefix is found, indicating to just compare
|
||||||
|
// as strings.
|
||||||
|
return -1, filename
|
||||||
|
}
|
||||||
|
|
||||||
|
var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
|
||||||
|
|
||||||
|
// Interface guard.
|
||||||
|
var (
|
||||||
|
_ sort.Interface = (*AlphanumericSort)(nil)
|
||||||
|
)
|
||||||
34
pkg/gotenberg/sort_test.go
Normal file
34
pkg/gotenberg/sort_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlphanumericSort(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
values []string
|
||||||
|
expectedSort []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "numeric and letters",
|
||||||
|
values: []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
|
||||||
|
expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "hrtime (PHP library)",
|
||||||
|
values: []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},
|
||||||
|
expectedSort: []string{"245654773394369", "245654773394919", "245654773395039", "245654773395149", "245654773395259"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
sort.Sort(AlphanumericSort(tc.values))
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(tc.values, tc.expectedSort) {
|
||||||
|
t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,21 @@ package gotenberg
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||||
|
// to restart an already restarting [Process].
|
||||||
|
var ErrProcessAlreadyRestarting = errors.New("process already restarting")
|
||||||
|
|
||||||
|
// 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
|
// Process is an interface that represents an abstract process
|
||||||
// and provides methods for starting, stopping, and checking the health of the
|
// and provides methods for starting, stopping, and checking the health of the
|
||||||
// process.
|
// process.
|
||||||
@@ -69,6 +78,7 @@ type processSupervisor struct {
|
|||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
process Process
|
process Process
|
||||||
maxReqLimit int64
|
maxReqLimit int64
|
||||||
|
maxQueueSize int64
|
||||||
mutexChan chan struct{}
|
mutexChan chan struct{}
|
||||||
firstStart atomic.Bool
|
firstStart atomic.Bool
|
||||||
reqCounter atomic.Int64
|
reqCounter atomic.Int64
|
||||||
@@ -78,12 +88,13 @@ type processSupervisor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit int64) ProcessSupervisor {
|
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
|
||||||
b := &processSupervisor{
|
b := &processSupervisor{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
process: process,
|
process: process,
|
||||||
mutexChan: make(chan struct{}, 1),
|
mutexChan: make(chan struct{}, 1),
|
||||||
maxReqLimit: maxReqLimit,
|
maxReqLimit: maxReqLimit,
|
||||||
|
maxQueueSize: maxQueueSize,
|
||||||
}
|
}
|
||||||
b.reqCounter.Store(0)
|
b.reqCounter.Store(0)
|
||||||
b.reqQueueSize.Store(0)
|
b.reqQueueSize.Store(0)
|
||||||
@@ -122,7 +133,7 @@ func (s *processSupervisor) restart() error {
|
|||||||
if s.isRestarting.Load() {
|
if s.isRestarting.Load() {
|
||||||
s.logger.Debug("process already restarting, skip restart")
|
s.logger.Debug("process already restarting, skip restart")
|
||||||
|
|
||||||
return nil
|
return ErrProcessAlreadyRestarting
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Debug("restart process")
|
s.logger.Debug("restart process")
|
||||||
@@ -162,55 +173,98 @@ func (s *processSupervisor) Healthy() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||||
|
// 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)
|
s.reqQueueSize.Add(1)
|
||||||
|
|
||||||
select {
|
for {
|
||||||
case s.mutexChan <- struct{}{}:
|
err := func() error {
|
||||||
logger.Debug("process lock acquired")
|
select {
|
||||||
s.reqQueueSize.Add(-1)
|
case s.mutexChan <- struct{}{}:
|
||||||
s.reqCounter.Add(1)
|
logger.Debug("process lock acquired")
|
||||||
|
s.reqQueueSize.Add(-1)
|
||||||
|
s.reqCounter.Add(1)
|
||||||
|
releaseMutexChan := true
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
logger.Debug("process lock released")
|
if releaseMutexChan {
|
||||||
<-s.mutexChan
|
logger.Debug("process lock released")
|
||||||
|
<-s.mutexChan
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !s.firstStart.Load() {
|
||||||
|
err := s.runWithDeadline(ctx, func() error {
|
||||||
|
return s.Launch()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("process first start: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.Healthy() {
|
||||||
|
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
|
||||||
|
err := s.runWithDeadline(ctx, func() error {
|
||||||
|
return s.restart()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("process restart before task: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.Debug("failed to acquire process lock before deadline")
|
||||||
|
s.reqQueueSize.Add(-1)
|
||||||
|
|
||||||
|
return fmt.Errorf("acquire process lock: %w", ctx.Err())
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if !s.firstStart.Load() {
|
if errors.Is(err, ErrProcessAlreadyRestarting) {
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
logger.Debug("process is already restarting, trying to acquire process lock again...")
|
||||||
return s.Launch()
|
s.reqQueueSize.Add(1)
|
||||||
})
|
continue
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("process first start: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.Healthy() {
|
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||||
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
|
return err
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
|
||||||
return s.restart()
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("process restart before task: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
|
|
||||||
s.logger.Debug("max request limit reached, restarting...")
|
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
|
||||||
return s.restart()
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("process restart before task: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: no error wrapping because it leaks on Chromium console exceptions output.
|
|
||||||
return s.runWithDeadline(ctx, task)
|
|
||||||
case <-ctx.Done():
|
|
||||||
logger.Debug("failed to acquire process lock before deadline")
|
|
||||||
s.reqQueueSize.Add(-1)
|
|
||||||
|
|
||||||
return fmt.Errorf("acquire process lock: %w", ctx.Err())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||||
if tc.firstStartSet {
|
if tc.firstStartSet {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5)
|
ps := NewProcessSupervisor(logger, process, 5, 0)
|
||||||
err := ps.Shutdown()
|
err := ps.Shutdown()
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
@@ -115,11 +115,13 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
startError error
|
startError error
|
||||||
stopError error
|
stopError error
|
||||||
expectError bool
|
expectError bool
|
||||||
|
expectedError error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "already restarting",
|
scenario: "already restarting",
|
||||||
initiallyRestarting: true,
|
initiallyRestarting: true,
|
||||||
expectError: false,
|
expectError: true,
|
||||||
|
expectedError: ErrProcessAlreadyRestarting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "successful restart",
|
scenario: "successful restart",
|
||||||
@@ -152,7 +154,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||||
if tc.initiallyRestarting {
|
if tc.initiallyRestarting {
|
||||||
ps.isRestarting.Store(true)
|
ps.isRestarting.Store(true)
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,10 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
if tc.expectError && err == nil {
|
if tc.expectError && err == nil {
|
||||||
t.Fatal("expected error but got none")
|
t.Fatal("expected error but got none")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||||
|
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +217,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -232,19 +238,24 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
initiallyStarted bool
|
initiallyStarted bool
|
||||||
|
isRestarting bool
|
||||||
startError error
|
startError error
|
||||||
processHealthy bool
|
processHealthy bool
|
||||||
maxReqLimit int64
|
maxReqLimit int64
|
||||||
tasksToRun int
|
tasksToRun int
|
||||||
taskError error
|
taskError error
|
||||||
expectError bool
|
expectError bool
|
||||||
|
skipCallsCheck bool
|
||||||
expectedStartCalls int64
|
expectedStartCalls int64
|
||||||
expectedHealthyCalls int64
|
expectedHealthyCalls int64
|
||||||
expectedStopCalls int64
|
expectedStopCalls int64
|
||||||
|
currentQueueSize int64
|
||||||
|
maxQueueSize int64
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "successfully run task on non-started process",
|
scenario: "successfully run task on non-started process",
|
||||||
initiallyStarted: false,
|
initiallyStarted: false,
|
||||||
|
isRestarting: false,
|
||||||
processHealthy: true,
|
processHealthy: true,
|
||||||
maxReqLimit: 2,
|
maxReqLimit: 2,
|
||||||
tasksToRun: 1,
|
tasksToRun: 1,
|
||||||
@@ -256,6 +267,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "cannot launch non-started process",
|
scenario: "cannot launch non-started process",
|
||||||
initiallyStarted: false,
|
initiallyStarted: false,
|
||||||
|
isRestarting: false,
|
||||||
startError: errors.New("launch error"),
|
startError: errors.New("launch error"),
|
||||||
processHealthy: true,
|
processHealthy: true,
|
||||||
maxReqLimit: 2,
|
maxReqLimit: 2,
|
||||||
@@ -268,6 +280,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "run task with unhealthy process causing restart",
|
scenario: "run task with unhealthy process causing restart",
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
|
isRestarting: false,
|
||||||
processHealthy: false,
|
processHealthy: false,
|
||||||
maxReqLimit: 2,
|
maxReqLimit: 2,
|
||||||
tasksToRun: 1,
|
tasksToRun: 1,
|
||||||
@@ -280,6 +293,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
scenario: "cannot restart unhealthy process",
|
scenario: "cannot restart unhealthy process",
|
||||||
startError: errors.New("start error"),
|
startError: errors.New("start error"),
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
|
isRestarting: false,
|
||||||
processHealthy: false,
|
processHealthy: false,
|
||||||
maxReqLimit: 2,
|
maxReqLimit: 2,
|
||||||
tasksToRun: 1,
|
tasksToRun: 1,
|
||||||
@@ -288,9 +302,20 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
expectedHealthyCalls: 1,
|
expectedHealthyCalls: 1,
|
||||||
expectedStopCalls: 1,
|
expectedStopCalls: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrProcessAlreadyRestarting",
|
||||||
|
initiallyStarted: true,
|
||||||
|
isRestarting: true,
|
||||||
|
processHealthy: false,
|
||||||
|
maxReqLimit: 1,
|
||||||
|
tasksToRun: 1,
|
||||||
|
expectError: true,
|
||||||
|
skipCallsCheck: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "run tasks reaching max request limit causing restart",
|
scenario: "run tasks reaching max request limit causing restart",
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
|
isRestarting: false,
|
||||||
processHealthy: true,
|
processHealthy: true,
|
||||||
maxReqLimit: 2,
|
maxReqLimit: 2,
|
||||||
tasksToRun: 3,
|
tasksToRun: 3,
|
||||||
@@ -300,9 +325,10 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
expectedStopCalls: 1,
|
expectedStopCalls: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "cannot restart after reaching max request limit",
|
scenario: "auto-restart after reaching max request limit",
|
||||||
startError: errors.New("start error"),
|
startError: errors.New("start error"),
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
|
isRestarting: false,
|
||||||
processHealthy: true,
|
processHealthy: true,
|
||||||
maxReqLimit: 2,
|
maxReqLimit: 2,
|
||||||
tasksToRun: 2,
|
tasksToRun: 2,
|
||||||
@@ -314,6 +340,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "task error",
|
scenario: "task error",
|
||||||
initiallyStarted: true,
|
initiallyStarted: true,
|
||||||
|
isRestarting: false,
|
||||||
processHealthy: true,
|
processHealthy: true,
|
||||||
maxReqLimit: 0,
|
maxReqLimit: 0,
|
||||||
tasksToRun: 1,
|
tasksToRun: 1,
|
||||||
@@ -323,6 +350,34 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
expectedHealthyCalls: 1,
|
expectedHealthyCalls: 1,
|
||||||
expectedStopCalls: 0,
|
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) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
logger := zap.NewNop()
|
logger := zap.NewNop()
|
||||||
@@ -347,10 +402,16 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
|
if tc.isRestarting {
|
||||||
|
ps.isRestarting.Store(true)
|
||||||
|
}
|
||||||
|
if tc.currentQueueSize > 0 {
|
||||||
|
ps.reqQueueSize.Store(tc.currentQueueSize)
|
||||||
|
}
|
||||||
|
|
||||||
task := func() error {
|
task := func() error {
|
||||||
return tc.taskError
|
return tc.taskError
|
||||||
@@ -386,6 +447,14 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tc.skipCallsCheck {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Making sure restarts are finished.
|
||||||
|
ps.mutexChan <- struct{}{}
|
||||||
|
<-ps.mutexChan
|
||||||
|
|
||||||
if startCalls.Load() != tc.expectedStartCalls {
|
if startCalls.Load() != tc.expectedStartCalls {
|
||||||
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
|
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
|
||||||
}
|
}
|
||||||
@@ -419,13 +488,13 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0).(*processSupervisor)
|
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if tc.ctxDone {
|
if tc.ctxDone {
|
||||||
cancel()
|
cancel()
|
||||||
|
} else {
|
||||||
|
defer cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
err := ps.runWithDeadline(ctx, func() error {
|
err := ps.runWithDeadline(ctx, func() error {
|
||||||
@@ -453,7 +522,7 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock.
|
// Simulating a lock.
|
||||||
ps.mutexChan <- struct{}{}
|
ps.mutexChan <- struct{}{}
|
||||||
@@ -554,7 +623,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
||||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||||
|
|
||||||
for i := 0; i < tc.restartAttempts; i++ {
|
for i := 0; i < tc.restartAttempts; i++ {
|
||||||
|
|||||||
4
pkg/gotenberg/version.go
Normal file
4
pkg/gotenberg/version.go
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
// Version is the... version of the Gotenberg application.
|
||||||
|
var Version = "snapshot"
|
||||||
@@ -4,21 +4,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexliesenfeld/health"
|
"github.com/alexliesenfeld/health"
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
"go.uber.org/multierr"
|
"go.uber.org/multierr"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -29,21 +30,35 @@ func init() {
|
|||||||
// middlewares or health checks.
|
// middlewares or health checks.
|
||||||
type Api struct {
|
type Api struct {
|
||||||
port int
|
port int
|
||||||
readTimeout time.Duration
|
bindIp string
|
||||||
writeTimeout time.Duration
|
tlsCertFile string
|
||||||
|
tlsKeyFile string
|
||||||
|
startTimeout time.Duration
|
||||||
|
bodyLimit int64
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
rootPath string
|
rootPath string
|
||||||
traceHeader string
|
traceHeader string
|
||||||
|
basicAuthUsername string
|
||||||
|
basicAuthPassword string
|
||||||
|
downloadFromCfg downloadFromConfig
|
||||||
disableHealthCheckLogging bool
|
disableHealthCheckLogging bool
|
||||||
|
|
||||||
routes []Route
|
routes []Route
|
||||||
externalMiddlewares []Middleware
|
externalMiddlewares []Middleware
|
||||||
healthChecks []health.CheckerOption
|
healthChecks []health.CheckerOption
|
||||||
|
readyFn []func() error
|
||||||
fs *gotenberg.FileSystem
|
fs *gotenberg.FileSystem
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
srv *echo.Echo
|
srv *echo.Echo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type downloadFromConfig struct {
|
||||||
|
allowList *regexp2.Regexp
|
||||||
|
denyList *regexp2.Regexp
|
||||||
|
maxRetry int
|
||||||
|
disable bool
|
||||||
|
}
|
||||||
|
|
||||||
// Router is a module interface which adds routes to the [Api].
|
// Router is a module interface which adds routes to the [Api].
|
||||||
type Router interface {
|
type Router interface {
|
||||||
Routes() ([]Route, error)
|
Routes() ([]Route, error)
|
||||||
@@ -147,6 +162,7 @@ type Middleware struct {
|
|||||||
// See https://github.com/alexliesenfeld/health for more details.
|
// See https://github.com/alexliesenfeld/health for more details.
|
||||||
type HealthChecker interface {
|
type HealthChecker interface {
|
||||||
Checks() ([]health.CheckerOption, error)
|
Checks() ([]health.CheckerOption, error)
|
||||||
|
Ready() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns an [Api]'s module descriptor.
|
// Descriptor returns an [Api]'s module descriptor.
|
||||||
@@ -157,23 +173,20 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||||
fs.Int("api-port", 3000, "Set the port on which the API should listen")
|
fs.Int("api-port", 3000, "Set the port on which the API should listen")
|
||||||
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
|
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
|
||||||
fs.Duration("api-read-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to read a complete request, including the body")
|
fs.String("api-bind-ip", "", "Set the IP address the API should bind to for incoming connections")
|
||||||
fs.Duration("api-process-timeout", time.Duration(30)*time.Second, "Set the maximum duration allowed to process a request")
|
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
|
||||||
fs.Duration("api-write-timeout", time.Duration(30)*time.Second, "Set the maximum duration before timing out writes of the response")
|
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
|
||||||
|
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
|
||||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||||
|
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
|
||||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||||
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||||
|
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||||
|
fs.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")
|
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
|
return fs
|
||||||
}(),
|
}(),
|
||||||
New: func() gotenberg.Module { return new(Api) },
|
New: func() gotenberg.Module { return new(Api) },
|
||||||
@@ -184,34 +197,47 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||||
flags := ctx.ParsedFlags()
|
flags := ctx.ParsedFlags()
|
||||||
a.port = flags.MustInt("api-port")
|
a.port = flags.MustInt("api-port")
|
||||||
a.readTimeout = flags.MustDeprecatedDuration("api-read-timeout", "api-timeout")
|
a.bindIp = flags.MustString("api-bind-ip")
|
||||||
a.writeTimeout = flags.MustDeprecatedDuration("api-write-timeout", "api-timeout")
|
a.tlsCertFile = flags.MustString("api-tls-cert-file")
|
||||||
a.timeout = flags.MustDeprecatedDuration("api-process-timeout", "api-timeout")
|
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.rootPath = flags.MustString("api-root-path")
|
a.rootPath = flags.MustString("api-root-path")
|
||||||
a.traceHeader = flags.MustString("api-trace-header")
|
a.traceHeader = flags.MustString("api-trace-header")
|
||||||
|
a.downloadFromCfg = downloadFromConfig{
|
||||||
|
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")
|
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
|
||||||
|
|
||||||
// Port from env?
|
// Port from env?
|
||||||
portEnvVar := flags.MustString("api-port-from-env")
|
portEnvVar := flags.MustString("api-port-from-env")
|
||||||
if portEnvVar != "" {
|
if portEnvVar != "" {
|
||||||
val, ok := os.LookupEnv(portEnvVar)
|
port, err := gotenberg.IntEnv(portEnvVar)
|
||||||
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
|
|
||||||
}
|
|
||||||
|
|
||||||
if val == "" {
|
|
||||||
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
|
|
||||||
}
|
|
||||||
|
|
||||||
port, err := strconv.Atoi(val)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
|
return fmt.Errorf("get API port from env: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.port = port
|
a.port = port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable basic auth?
|
||||||
|
enableBasicAuth := flags.MustBool("api-enable-basic-auth")
|
||||||
|
if enableBasicAuth {
|
||||||
|
basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get basic auth username from env: %w", err)
|
||||||
|
}
|
||||||
|
basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get basic auth password from env: %w", err)
|
||||||
|
}
|
||||||
|
a.basicAuthUsername = basicAuthUsername
|
||||||
|
a.basicAuthPassword = basicAuthPassword
|
||||||
|
}
|
||||||
|
|
||||||
// Get routes from modules.
|
// Get routes from modules.
|
||||||
mods, err := ctx.Modules(new(Router))
|
mods, err := ctx.Modules(new(Router))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -275,6 +301,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.healthChecks = append(a.healthChecks, checks...)
|
a.healthChecks = append(a.healthChecks, checks...)
|
||||||
|
a.readyFn = append(a.readyFn, healthChecker.Ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger.
|
// Logger.
|
||||||
@@ -306,6 +333,16 @@ 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, "/") {
|
if !strings.HasPrefix(a.rootPath, "/") {
|
||||||
err = multierr.Append(err,
|
err = multierr.Append(err,
|
||||||
errors.New("root path must start with /"),
|
errors.New("root path must start with /"),
|
||||||
@@ -328,8 +365,9 @@ func (a *Api) Validate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
routesMap := make(map[string]string, len(a.routes)+1)
|
routesMap := make(map[string]string, len(a.routes)+2)
|
||||||
routesMap["/health"] = "/health"
|
routesMap["/health"] = "/health"
|
||||||
|
routesMap["/version"] = "/version"
|
||||||
|
|
||||||
for _, route := range a.routes {
|
for _, route := range a.routes {
|
||||||
if route.Path == "" {
|
if route.Path == "" {
|
||||||
@@ -373,10 +411,10 @@ func (a *Api) Start() error {
|
|||||||
a.srv = echo.New()
|
a.srv = echo.New()
|
||||||
a.srv.HideBanner = true
|
a.srv.HideBanner = true
|
||||||
a.srv.HidePort = true
|
a.srv.HidePort = true
|
||||||
a.srv.Server.ReadTimeout = a.readTimeout
|
a.srv.Server.ReadTimeout = a.timeout
|
||||||
a.srv.Server.IdleTimeout = a.timeout
|
a.srv.Server.IdleTimeout = a.timeout
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/396.
|
// See https://github.com/gotenberg/gotenberg/issues/396.
|
||||||
a.srv.Server.WriteTimeout = a.writeTimeout + a.writeTimeout
|
a.srv.Server.WriteTimeout = a.timeout + a.timeout
|
||||||
a.srv.HTTPErrorHandler = httpErrorHandler()
|
a.srv.HTTPErrorHandler = httpErrorHandler()
|
||||||
|
|
||||||
// Let's prepare the modules' routes.
|
// Let's prepare the modules' routes.
|
||||||
@@ -411,7 +449,7 @@ func (a *Api) Start() error {
|
|||||||
a.srv.Pre(externalMiddleware.Handler)
|
a.srv.Pre(externalMiddleware.Handler)
|
||||||
case MultipartStack:
|
case MultipartStack:
|
||||||
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
|
externalMultipartMiddlewares = append(externalMultipartMiddlewares, externalMiddleware)
|
||||||
default:
|
case DefaultStack:
|
||||||
a.srv.Use(externalMiddleware.Handler)
|
a.srv.Use(externalMiddleware.Handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,8 +460,13 @@ func (a *Api) Start() error {
|
|||||||
for _, route := range a.routes {
|
for _, route := range a.routes {
|
||||||
var middlewares []echo.MiddlewareFunc
|
var middlewares []echo.MiddlewareFunc
|
||||||
|
|
||||||
|
// Basic auth?
|
||||||
|
if a.basicAuthUsername != "" {
|
||||||
|
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
|
||||||
|
}
|
||||||
|
|
||||||
if route.IsMultipart {
|
if route.IsMultipart {
|
||||||
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout))
|
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
|
||||||
|
|
||||||
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
|
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
|
||||||
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
|
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
|
||||||
@@ -440,22 +483,59 @@ func (a *Api) Start() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let's not forget the health check route.
|
// Let's not forget the health check routes...
|
||||||
|
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||||
|
checker := health.NewChecker(checks...)
|
||||||
|
healthCheckHandler := health.NewHandler(checker)
|
||||||
|
|
||||||
a.srv.GET(
|
a.srv.GET(
|
||||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||||
func() echo.HandlerFunc {
|
func() echo.HandlerFunc {
|
||||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
return echo.WrapHandler(healthCheckHandler)
|
||||||
checker := health.NewChecker(checks...)
|
}(),
|
||||||
|
hardTimeoutMiddleware(hardTimeout),
|
||||||
return echo.WrapHandler(health.NewHandler(checker))
|
)
|
||||||
|
a.srv.HEAD(
|
||||||
|
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||||
|
func() echo.HandlerFunc {
|
||||||
|
return echo.WrapHandler(healthCheckHandler)
|
||||||
}(),
|
}(),
|
||||||
hardTimeoutMiddleware(hardTimeout),
|
hardTimeoutMiddleware(hardTimeout),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ...and the version route.
|
||||||
|
a.srv.GET(
|
||||||
|
fmt.Sprintf("%s%s", a.rootPath, "version"),
|
||||||
|
func(c echo.Context) error {
|
||||||
|
return c.String(http.StatusOK, gotenberg.Version)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for all modules to be ready.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), a.startTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
eg, _ := errgroup.WithContext(ctx)
|
||||||
|
for _, f := range a.readyFn {
|
||||||
|
eg.Go(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := eg.Wait()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("waiting for modules readiness: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// As the following code is blocking, run it in a goroutine.
|
// As the following code is blocking, run it in a goroutine.
|
||||||
go func() {
|
go func() {
|
||||||
server := &http2.Server{}
|
var err error
|
||||||
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
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)
|
||||||
|
}
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
a.logger.Fatal(err.Error())
|
a.logger.Fatal(err.Error())
|
||||||
}
|
}
|
||||||
@@ -466,7 +546,11 @@ func (a *Api) Start() error {
|
|||||||
|
|
||||||
// StartupMessage returns a custom startup message.
|
// StartupMessage returns a custom startup message.
|
||||||
func (a *Api) StartupMessage() string {
|
func (a *Api) StartupMessage() string {
|
||||||
return fmt.Sprintf("server listening on port %d", a.port)
|
ip := a.bindIp
|
||||||
|
if a.bindIp == "" {
|
||||||
|
ip = "[::]"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("server started on %s:%d", ip, a.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops the HTTP server.
|
// Stop stops the HTTP server.
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/alexliesenfeld/health"
|
"github.com/alexliesenfeld/health"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApi_Descriptor(t *testing.T) {
|
func TestApi_Descriptor(t *testing.T) {
|
||||||
@@ -56,30 +57,6 @@ func TestApi_Provision(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
expectError: true,
|
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",
|
scenario: "port from env: invalid environment variable value",
|
||||||
ctx: func() *gotenberg.Context {
|
ctx: func() *gotenberg.Context {
|
||||||
@@ -104,6 +81,48 @@ func TestApi_Provision(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expectError: true,
|
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",
|
scenario: "no valid routers",
|
||||||
ctx: func() *gotenberg.Context {
|
ctx: func() *gotenberg.Context {
|
||||||
@@ -222,9 +241,6 @@ func TestApi_Provision(t *testing.T) {
|
|||||||
mod.ValidateMock = func() error {
|
mod.ValidateMock = func() error {
|
||||||
return errors.New("foo")
|
return errors.New("foo")
|
||||||
}
|
}
|
||||||
mod.ChecksMock = func() ([]health.CheckerOption, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return gotenberg.NewContext(
|
return gotenberg.NewContext(
|
||||||
gotenberg.ParsedFlags{
|
gotenberg.ParsedFlags{
|
||||||
FlagSet: new(Api).Descriptor().FlagSet,
|
FlagSet: new(Api).Descriptor().FlagSet,
|
||||||
@@ -347,6 +363,9 @@ func TestApi_Provision(t *testing.T) {
|
|||||||
mod3.ChecksMock = func() ([]health.CheckerOption, error) {
|
mod3.ChecksMock = func() ([]health.CheckerOption, error) {
|
||||||
return []health.CheckerOption{health.WithDisabledAutostart()}, nil
|
return []health.CheckerOption{health.WithDisabledAutostart()}, nil
|
||||||
}
|
}
|
||||||
|
mod3.ReadyMock = func() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
mod4 := &struct {
|
mod4 := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
@@ -360,7 +379,7 @@ func TestApi_Provision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs := new(Api).Descriptor().FlagSet
|
fs := new(Api).Descriptor().FlagSet
|
||||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
err := fs.Parse([]string{"--api-port-from-env=PORT", "--api-enable-basic-auth=true"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
@@ -382,6 +401,14 @@ func TestApi_Provision(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
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,
|
expectPort: 1337,
|
||||||
expectMiddlewares: []Middleware{
|
expectMiddlewares: []Middleware{
|
||||||
@@ -435,6 +462,9 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
port int
|
port int
|
||||||
|
bindIp string
|
||||||
|
tlsCertFile string
|
||||||
|
tlsKeyFile string
|
||||||
rootPath string
|
rootPath string
|
||||||
traceHeader string
|
traceHeader string
|
||||||
routes []Route
|
routes []Route
|
||||||
@@ -444,6 +474,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid port (< 1)",
|
scenario: "invalid port (< 1)",
|
||||||
port: 0,
|
port: 0,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: nil,
|
routes: nil,
|
||||||
@@ -453,6 +484,39 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid port (> 65535)",
|
scenario: "invalid port (> 65535)",
|
||||||
port: 65536,
|
port: 65536,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
|
rootPath: "/foo/",
|
||||||
|
traceHeader: "foo",
|
||||||
|
routes: nil,
|
||||||
|
middlewares: nil,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid IP",
|
||||||
|
port: 10,
|
||||||
|
bindIp: "foo",
|
||||||
|
rootPath: "/foo/",
|
||||||
|
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/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: nil,
|
routes: nil,
|
||||||
@@ -462,6 +526,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid root path: missing / prefix",
|
scenario: "invalid root path: missing / prefix",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "foo/",
|
rootPath: "foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: nil,
|
routes: nil,
|
||||||
@@ -471,6 +536,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid root path: missing / suffix",
|
scenario: "invalid root path: missing / suffix",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo",
|
rootPath: "/foo",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: nil,
|
routes: nil,
|
||||||
@@ -480,6 +546,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid trace header",
|
scenario: "invalid trace header",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "",
|
traceHeader: "",
|
||||||
routes: nil,
|
routes: nil,
|
||||||
@@ -489,6 +556,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid route: empty path",
|
scenario: "invalid route: empty path",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: []Route{
|
routes: []Route{
|
||||||
@@ -502,6 +570,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid route: missing / prefix in path",
|
scenario: "invalid route: missing / prefix in path",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: []Route{
|
routes: []Route{
|
||||||
@@ -515,6 +584,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid multipart route: no /forms prefix in path",
|
scenario: "invalid multipart route: no /forms prefix in path",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: []Route{
|
routes: []Route{
|
||||||
@@ -529,6 +599,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid route: no method",
|
scenario: "invalid route: no method",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: []Route{
|
routes: []Route{
|
||||||
@@ -543,6 +614,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid route: nil handler",
|
scenario: "invalid route: nil handler",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: []Route{
|
routes: []Route{
|
||||||
@@ -558,6 +630,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid route: path already existing",
|
scenario: "invalid route: path already existing",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: []Route{
|
routes: []Route{
|
||||||
@@ -578,6 +651,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "invalid middleware: nil handler",
|
scenario: "invalid middleware: nil handler",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: nil,
|
routes: nil,
|
||||||
@@ -592,6 +666,7 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "success",
|
scenario: "success",
|
||||||
port: 10,
|
port: 10,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
rootPath: "/foo/",
|
rootPath: "/foo/",
|
||||||
traceHeader: "foo",
|
traceHeader: "foo",
|
||||||
routes: []Route{
|
routes: []Route{
|
||||||
@@ -620,10 +695,34 @@ 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) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
mod := Api{
|
mod := Api{
|
||||||
port: tc.port,
|
port: tc.port,
|
||||||
|
bindIp: tc.bindIp,
|
||||||
|
tlsCertFile: tc.tlsCertFile,
|
||||||
|
tlsKeyFile: tc.tlsKeyFile,
|
||||||
rootPath: tc.rootPath,
|
rootPath: tc.rootPath,
|
||||||
traceHeader: tc.traceHeader,
|
traceHeader: tc.traceHeader,
|
||||||
routes: tc.routes,
|
routes: tc.routes,
|
||||||
@@ -643,154 +742,239 @@ func TestApi_Validate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApi_Start(t *testing.T) {
|
func TestApi_Start(t *testing.T) {
|
||||||
mod := new(Api)
|
for _, tc := range []struct {
|
||||||
mod.port = 3000
|
scenario string
|
||||||
mod.rootPath = "/"
|
readyFn []func() error
|
||||||
mod.disableHealthCheckLogging = true
|
tlsCertFile string
|
||||||
mod.routes = []Route{
|
tlsKeyFile string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
scenario: "at least one module not ready",
|
||||||
Path: "/forms/foo",
|
readyFn: []func() error{
|
||||||
IsMultipart: true,
|
func() error { return nil },
|
||||||
DisableLogging: true,
|
func() error { return errors.New("not ready") },
|
||||||
Handler: func(c echo.Context) error {
|
|
||||||
ctx := c.Get("context").(*Context)
|
|
||||||
ctx.outputPaths = []string{
|
|
||||||
"/tests/test/testdata/api/sample1.txt",
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
|
expectError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodPost,
|
scenario: "success",
|
||||||
Path: "/forms/bar",
|
readyFn: []func() error{
|
||||||
IsMultipart: true,
|
func() error { return nil },
|
||||||
Handler: func(_ echo.Context) error { return errors.New("foo") },
|
func() error { return nil },
|
||||||
},
|
},
|
||||||
}
|
expectError: false,
|
||||||
mod.externalMiddlewares = []Middleware{
|
|
||||||
{
|
|
||||||
Stack: PreRouterStack,
|
|
||||||
Handler: func() echo.MiddlewareFunc {
|
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
|
||||||
return next(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Stack: MultipartStack,
|
scenario: "success with TLS",
|
||||||
Handler: func() echo.MiddlewareFunc {
|
readyFn: []func() error{
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
func() error { return nil },
|
||||||
return func(c echo.Context) error {
|
func() error { return nil },
|
||||||
return next(c)
|
},
|
||||||
}
|
tlsCertFile: "/tests/test/testdata/api/cert.pem",
|
||||||
}
|
tlsKeyFile: "/tests/test/testdata/api/key.pem",
|
||||||
}(),
|
expectError: false,
|
||||||
},
|
},
|
||||||
{
|
} {
|
||||||
Stack: DefaultStack,
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
Handler: func() echo.MiddlewareFunc {
|
mod := new(Api)
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
mod.port = 3000
|
||||||
return func(c echo.Context) error {
|
mod.tlsCertFile = tc.tlsCertFile
|
||||||
return next(c)
|
mod.tlsKeyFile = tc.tlsKeyFile
|
||||||
}
|
mod.startTimeout = time.Duration(30) * time.Second
|
||||||
}
|
mod.rootPath = "/"
|
||||||
}(),
|
mod.basicAuthUsername = "foo"
|
||||||
},
|
mod.basicAuthPassword = "bar"
|
||||||
{
|
mod.disableHealthCheckLogging = true
|
||||||
Handler: func() echo.MiddlewareFunc {
|
mod.routes = []Route{
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
{
|
||||||
return func(c echo.Context) error {
|
Method: http.MethodPost,
|
||||||
return next(c)
|
Path: "/forms/foo",
|
||||||
}
|
IsMultipart: true,
|
||||||
}
|
DisableLogging: true,
|
||||||
}(),
|
Handler: func(c echo.Context) error {
|
||||||
},
|
ctx := c.Get("context").(*Context)
|
||||||
}
|
ctx.outputPaths = []string{
|
||||||
mod.fs = gotenberg.NewFileSystem()
|
"/tests/test/testdata/api/sample1.txt",
|
||||||
mod.logger = zap.NewNop()
|
}
|
||||||
|
|
||||||
err := mod.Start()
|
return nil
|
||||||
if err != nil {
|
},
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
},
|
||||||
}
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/forms/bar",
|
||||||
|
IsMultipart: true,
|
||||||
|
Handler: func(_ echo.Context) error { return errors.New("foo") },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mod.externalMiddlewares = []Middleware{
|
||||||
|
{
|
||||||
|
Stack: PreRouterStack,
|
||||||
|
Handler: func() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Stack: MultipartStack,
|
||||||
|
Handler: func() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Stack: DefaultStack,
|
||||||
|
Handler: func() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Handler: func() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mod.readyFn = tc.readyFn
|
||||||
|
mod.fs = gotenberg.NewFileSystem()
|
||||||
|
mod.logger = zap.NewNop()
|
||||||
|
|
||||||
// health request.
|
err := mod.Start()
|
||||||
recorder := httptest.NewRecorder()
|
if !tc.expectError && err != nil {
|
||||||
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
|
||||||
|
|
||||||
mod.srv.ServeHTTP(recorder, healthRequest)
|
|
||||||
if recorder.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// "multipart/form-data" request.
|
|
||||||
multipartRequest := func(url string) *http.Request {
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
|
|
||||||
writer := multipart.NewWriter(body)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
err := writer.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
|
||||||
err := writer.WriteField("foo", "foo")
|
if tc.expectError && err == nil {
|
||||||
if err != nil {
|
t.Fatal("expected error but got none")
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
if tc.expectError {
|
||||||
if err != nil {
|
return
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_, err = part.Write([]byte("foo"))
|
// health requests.
|
||||||
if err != nil {
|
recorder := httptest.NewRecorder()
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, url, body)
|
healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
mod.srv.ServeHTTP(recorder, healthGetRequest)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
return req
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
recorder = httptest.NewRecorder()
|
// version request.
|
||||||
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
|
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
|
||||||
|
mod.srv.ServeHTTP(recorder, versionRequest)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
if recorder.Code != http.StatusOK {
|
// "multipart/form-data" request.
|
||||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
multipartRequest := func(url string) *http.Request {
|
||||||
}
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
recorder = httptest.NewRecorder()
|
defer func() {
|
||||||
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar"))
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if recorder.Code != http.StatusInternalServerError {
|
err := writer.WriteField("foo", "foo")
|
||||||
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code)
|
if err != nil {
|
||||||
}
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = mod.srv.Shutdown(context.TODO())
|
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("expected no error but got: %v", err)
|
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, url, body)
|
||||||
|
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||||
|
req.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/foo"))
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
mod.srv.ServeHTTP(recorder, multipartRequest("/forms/bar"))
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected %d status code but got %d", http.StatusInternalServerError, recorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mod.srv.Shutdown(context.TODO())
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApi_StartupMessage(t *testing.T) {
|
func TestApi_StartupMessage(t *testing.T) {
|
||||||
mod := Api{
|
for _, tc := range []struct {
|
||||||
port: 3000,
|
scenario string
|
||||||
}
|
port int
|
||||||
|
bindIp string
|
||||||
|
expectMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "no custom IP",
|
||||||
|
port: 3000,
|
||||||
|
bindIp: "",
|
||||||
|
expectMessage: "server started on [::]:3000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "custom IP",
|
||||||
|
port: 3000,
|
||||||
|
bindIp: "127.0.0.1",
|
||||||
|
expectMessage: "server started on 127.0.0.1:3000",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
mod := Api{
|
||||||
|
port: tc.port,
|
||||||
|
bindIp: tc.bindIp,
|
||||||
|
}
|
||||||
|
|
||||||
actual := mod.StartupMessage()
|
actual := mod.StartupMessage()
|
||||||
expect := "server listening on port 3000"
|
if actual != tc.expectMessage {
|
||||||
|
t.Errorf("expected '%s' but got '%s'", tc.expectMessage, actual)
|
||||||
if actual != expect {
|
}
|
||||||
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,26 +3,28 @@ package api
|
|||||||
import (
|
import (
|
||||||
"compress/flate"
|
"compress/flate"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/hashicorp/go-retryablehttp"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mholt/archiver/v3"
|
"github.com/mholt/archiver/v3"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/text/runes"
|
"golang.org/x/sync/errgroup"
|
||||||
"golang.org/x/text/transform"
|
|
||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -37,27 +39,80 @@ var (
|
|||||||
|
|
||||||
// Context is the request context for a "multipart/form-data" requests.
|
// Context is the request context for a "multipart/form-data" requests.
|
||||||
type Context struct {
|
type Context struct {
|
||||||
dirPath string
|
dirPath string
|
||||||
values map[string][]string
|
values map[string][]string
|
||||||
files map[string]string
|
files map[string]string
|
||||||
|
|
||||||
outputPaths []string
|
outputPaths []string
|
||||||
|
cancelled bool
|
||||||
|
|
||||||
cancelled bool
|
logger *zap.Logger
|
||||||
logger *zap.Logger
|
echoCtx echo.Context
|
||||||
echoCtx echo.Context
|
pathRename gotenberg.PathRename
|
||||||
context.Context
|
context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type trackingReader struct {
|
||||||
|
R io.Reader
|
||||||
|
AddReadBytes func(n int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trackingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := t.R.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
errAddRead := t.AddReadBytes(int64(n))
|
||||||
|
if errAddRead != nil {
|
||||||
|
return n, fmt.Errorf("add read bytes: %w", errAddRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// It's a common practice in Go to return io.EOF unwrapped to signal
|
||||||
|
// the end of a data stream. Wrapping it can lead to unexpected
|
||||||
|
// behavior in standard library functions.
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type downloadFrom struct {
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type osPathRename struct{}
|
||||||
|
|
||||||
|
func (o *osPathRename) Rename(oldpath, newpath string) error {
|
||||||
|
return os.Rename(oldpath, newpath)
|
||||||
|
}
|
||||||
|
|
||||||
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
// newContext returns a [Context] by parsing a "multipart/form-data" request.
|
||||||
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration) (*Context, context.CancelFunc, error) {
|
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
|
||||||
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
|
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
|
||||||
|
// We want to make sure the multipart/form-data does not exceed a given
|
||||||
|
// limit. We consider: form fields (keys, values, files) and files
|
||||||
|
// downloaded remotely ("download from" feature).
|
||||||
|
var totalBytesRead atomic.Int64
|
||||||
|
|
||||||
|
addReadBytes := func(n int64) error {
|
||||||
|
newTotal := totalBytesRead.Add(n)
|
||||||
|
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||||
|
return WrapError(
|
||||||
|
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||||
|
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
outputPaths: make([]string, 0),
|
outputPaths: make([]string, 0),
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
echoCtx: echoCtx,
|
echoCtx: echoCtx,
|
||||||
|
pathRename: new(osPathRename),
|
||||||
Context: processCtx,
|
Context: processCtx,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +144,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
|||||||
|
|
||||||
form, err := echoCtx.MultipartForm()
|
form, err := echoCtx.MultipartForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
if errors.Is(err, http.ErrNotMultipart) {
|
if errors.Is(err, http.ErrNotMultipart) {
|
||||||
return nil, cancel, WrapError(
|
return nil, cancel, WrapError(
|
||||||
fmt.Errorf("get multipart form: %w", err),
|
fmt.Errorf("get multipart form: %w", err),
|
||||||
@@ -114,6 +168,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
|||||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This will ensure we do not exceed the body limit.
|
||||||
|
var formValuesSize int64
|
||||||
|
for key, valArray := range form.Value {
|
||||||
|
formValuesSize += int64(len(key))
|
||||||
|
for _, val := range valArray {
|
||||||
|
formValuesSize += int64(len(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = addReadBytes(formValuesSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
dirPath, err := fs.MkdirAll()
|
dirPath, err := fs.MkdirAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cancel, fmt.Errorf("create working directory: %w", err)
|
return nil, cancel, fmt.Errorf("create working directory: %w", err)
|
||||||
@@ -123,16 +190,151 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
|||||||
ctx.values = form.Value
|
ctx.values = form.Value
|
||||||
ctx.files = make(map[string]string)
|
ctx.files = make(map[string]string)
|
||||||
|
|
||||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
// First, try to download files listed in the "downloadFrom" form field, if
|
||||||
// Avoid directory traversal and normalize filename.
|
// any.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/104.
|
raw, ok := ctx.values["downloadFrom"]
|
||||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
if !downloadFromCfg.disable && ok {
|
||||||
|
var dls []downloadFrom
|
||||||
filename, _, err := transform.String(t, filepath.Base(fh.Filename))
|
err = json.Unmarshal([]byte(raw[0]), &dls)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("transform filename: %w", err)
|
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()
|
in, err := fh.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open multipart file: %w", err)
|
return fmt.Errorf("open multipart file: %w", err)
|
||||||
@@ -145,13 +347,19 @@ 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.
|
||||||
|
filename := norm.NFC.String(filepath.Base(fh.Filename))
|
||||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||||
|
|
||||||
out, err := os.Create(path)
|
out, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create local file: %w", err)
|
return fmt.Errorf("create local file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
err := out.Close()
|
err := out.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,7 +367,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err = io.Copy(out, in)
|
_, err = io.Copy(out, reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("copy multipart file to local file: %w", err)
|
return fmt.Errorf("copy multipart file to local file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -169,10 +377,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Then, copy the form files, if any.
|
||||||
for _, files := range form.File {
|
for _, files := range form.File {
|
||||||
for _, fh := range files {
|
for _, fh := range files {
|
||||||
err = copyToDisk(fh)
|
err = copyToDisk(fh)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
|
||||||
}
|
}
|
||||||
@@ -181,6 +389,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
|||||||
|
|
||||||
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
|
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
|
||||||
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
|
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
|
||||||
|
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
|
||||||
|
|
||||||
return ctx, cancel, err
|
return ctx, cancel, err
|
||||||
}
|
}
|
||||||
@@ -199,10 +408,20 @@ func (ctx *Context) FormData() *FormData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeneratePath generates a path within the context's working directory. It
|
// GeneratePath generates a path within the context's working directory.
|
||||||
// does not create a file.
|
// It generates a new UUID-based filename. It does not create a file.
|
||||||
func (ctx *Context) GeneratePath(extension string) string {
|
func (ctx *Context) GeneratePath(extension string) string {
|
||||||
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New(), extension)
|
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename is just a wrapper around [os.Rename], as we need to mock this
|
||||||
|
// behavior in our tests.
|
||||||
|
func (ctx *Context) Rename(oldpath, newpath string) error {
|
||||||
|
err := ctx.pathRename.Rename(oldpath, newpath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rename path: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddOutputPaths adds the given paths. Those paths will be used later to build
|
// AddOutputPaths adds the given paths. Those paths will be used later to build
|
||||||
@@ -277,3 +496,8 @@ func (ctx *Context) OutputFilename(outputPath string) string {
|
|||||||
|
|
||||||
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interface guard.
|
||||||
|
var (
|
||||||
|
_ gotenberg.PathRename = (*osPathRename)(nil)
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,26 +2,103 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOsPathRename_Rename(t *testing.T) {
|
||||||
|
dirPath, err := gotenberg.NewFileSystem().MkdirAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create working directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/tests/test/testdata/api/sample1.txt"
|
||||||
|
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
|
||||||
|
|
||||||
|
in, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := in.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close file: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
out, err := os.Create(copyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create new file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := out.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close new file: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy file to new file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rename := new(osPathRename)
|
||||||
|
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
|
||||||
|
|
||||||
|
err = rename.Rename(copyPath, newPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.RemoveAll(dirPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remove working directory: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewContext(t *testing.T) {
|
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 {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
request *http.Request
|
request *http.Request
|
||||||
|
bodyLimit int64
|
||||||
|
downloadFromCfg downloadFromConfig
|
||||||
|
downloadFromSrv *echo.Echo
|
||||||
|
expectContext *Context
|
||||||
expectError bool
|
expectError bool
|
||||||
expectHttpError bool
|
expectHttpError bool
|
||||||
expectHttpStatus int
|
expectHttpStatus int
|
||||||
@@ -67,6 +144,325 @@ func TestNewContext(t *testing.T) {
|
|||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "request entity too large: form values",
|
||||||
|
request: func() *http.Request {
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
defer func() {
|
||||||
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
err := writer.WriteField("key", "value")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||||
|
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||||
|
return req
|
||||||
|
}(),
|
||||||
|
bodyLimit: 1,
|
||||||
|
downloadFromCfg: defaultDownloadFromCfg,
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "request entity too large: downloadFrom",
|
||||||
|
request: func() *http.Request {
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
defer func() {
|
||||||
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||||
|
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||||
|
return req
|
||||||
|
}(),
|
||||||
|
bodyLimit: 45, // form values = 44 bytes.
|
||||||
|
downloadFromSrv: func() *echo.Echo {
|
||||||
|
srv := echo.New()
|
||||||
|
srv.HideBanner = true
|
||||||
|
srv.GET("/", func(c echo.Context) error {
|
||||||
|
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
|
||||||
|
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
|
||||||
|
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||||
|
})
|
||||||
|
return srv
|
||||||
|
}(),
|
||||||
|
downloadFromCfg: defaultDownloadFromCfg,
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "request entity too large: form files",
|
||||||
|
request: func() *http.Request {
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
defer func() {
|
||||||
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write([]byte("foo"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||||
|
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||||
|
return req
|
||||||
|
}(),
|
||||||
|
bodyLimit: 1,
|
||||||
|
downloadFromCfg: defaultDownloadFromCfg,
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid downloadFrom form field: cannot unmarshal",
|
||||||
|
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",
|
scenario: "success",
|
||||||
request: func() *http.Request {
|
request: func() *http.Request {
|
||||||
@@ -90,17 +486,69 @@ func TestNewContext(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
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 := httptest.NewRequest(http.MethodPost, "/", body)
|
||||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||||
return req
|
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,
|
expectError: false,
|
||||||
expectHttpError: false,
|
expectHttpError: false,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
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 {
|
handler := func(c echo.Context) error {
|
||||||
_, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second)
|
ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
// Context already cancelled.
|
// Context already cancelled.
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -109,6 +557,20 @@ func TestNewContext(t *testing.T) {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,12 +653,46 @@ func TestContext_GeneratePath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
path := ctx.GeneratePath(".pdf")
|
path := ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
if !strings.HasPrefix(path, ctx.dirPath) {
|
if !strings.HasPrefix(path, ctx.dirPath) {
|
||||||
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
|
t.Errorf("expected '%s' to start with '%s'", path, ctx.dirPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContext_Rename(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
ctx *Context
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "failure",
|
||||||
|
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||||
|
return errors.New("cannot rename")
|
||||||
|
}}},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
ctx: &Context{pathRename: &gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||||
|
return nil
|
||||||
|
}}},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
err := tc.ctx.Rename("", "")
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContext_AddOutputPaths(t *testing.T) {
|
func TestContext_AddOutputPaths(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,6 +12,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/multierr"
|
"go.uber.org/multierr"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormData is a helper for validating and hydrating values from a
|
// FormData is a helper for validating and hydrating values from a
|
||||||
@@ -143,6 +146,91 @@ func (form *FormData) MandatoryDuration(key string, target *time.Duration) *Form
|
|||||||
return form.mustMandatoryField(key, target)
|
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.
|
// Custom helps to define a custom binding function for a form field.
|
||||||
//
|
//
|
||||||
// var foo map[string]string
|
// var foo map[string]string
|
||||||
@@ -304,7 +392,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/139.
|
// See https://github.com/gotenberg/gotenberg/issues/139.
|
||||||
sort.Strings(*target)
|
sort.Sort(gotenberg.AlphanumericSort(*target))
|
||||||
|
|
||||||
return form
|
return form
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -768,6 +769,333 @@ func TestFormData_MandatoryDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormData_Inches(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
form *FormData
|
||||||
|
defaultValue float64
|
||||||
|
expect float64
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "key does not exist, fallback to default zero value",
|
||||||
|
form: &FormData{},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does not exist, fallback to default value",
|
||||||
|
form: &FormData{},
|
||||||
|
defaultValue: 2.5,
|
||||||
|
expect: 2.5,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist, but empty value, fallback to default value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist, value has a unit, but the rest is not float64 compatible",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"foomm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist, but value has no unit and is invalid",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist with a pt value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"72pt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist with a px value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"96px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist with an in value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"1in",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist with a mm value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"25.4mm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist with a cm value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"2.54cm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist with a pc value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"6pc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "key does exist with no unit in the value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultValue: 0.0,
|
||||||
|
expect: 100,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
var actual float64
|
||||||
|
|
||||||
|
tc.form.Inches("foo", &actual, tc.defaultValue)
|
||||||
|
|
||||||
|
if fmt.Sprintf("%.1f", actual) != fmt.Sprintf("%.1f", tc.expect) {
|
||||||
|
t.Errorf("expected %.1f but got %.1f", tc.expect, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && tc.form.errors == nil {
|
||||||
|
t.Fatal("expected error but got none", tc.form.errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectError && tc.form.errors != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", tc.form.errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormData_MandatoryInches(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
form *FormData
|
||||||
|
expect float64
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "missing mandatory key",
|
||||||
|
form: &FormData{},
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "mandatory value is empty",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "mandatory value has a unit, but the rest is not float64 compatible",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"foomm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "mandatory value has no unit and is invalid",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 0.0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "a pt mandatory value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"72pt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "a px mandatory value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"96px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "an in mandatory value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"1in",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "a mm mandatory value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"25.4mm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "a cm mandatory value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"2.54cm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "a pc mandatory value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"6pc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 1.0,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no unit in the mandatory value",
|
||||||
|
form: &FormData{
|
||||||
|
values: map[string][]string{
|
||||||
|
"foo": {
|
||||||
|
"100",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expect: 100,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
var actual float64
|
||||||
|
|
||||||
|
tc.form.MandatoryInches("foo", &actual)
|
||||||
|
|
||||||
|
if fmt.Sprintf("%.1f", actual) != fmt.Sprintf("%.1f", tc.expect) {
|
||||||
|
t.Errorf("expected %.1f but got %.1f", tc.expect, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && tc.form.errors == nil {
|
||||||
|
t.Fatal("expected error but got none", tc.form.errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectError && tc.form.errors != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", tc.form.errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormData_Custom(t *testing.T) {
|
func TestFormData_Custom(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,14 +11,21 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrAsyncProcess happens when a handler or middleware handles a request in an
|
var (
|
||||||
// asynchronous fashion.
|
// ErrAsyncProcess happens when a handler or middleware handles a request
|
||||||
var ErrAsyncProcess = errors.New("async process")
|
// in an asynchronous fashion.
|
||||||
|
ErrAsyncProcess = errors.New("async process")
|
||||||
|
|
||||||
|
// ErrNoOutputFile happens when a handler or middleware handles a request
|
||||||
|
// without sending any output file.
|
||||||
|
ErrNoOutputFile = errors.New("no output file")
|
||||||
|
)
|
||||||
|
|
||||||
// ParseError parses an error and returns the corresponding HTTP status and
|
// ParseError parses an error and returns the corresponding HTTP status and
|
||||||
// HTTP message.
|
// HTTP message.
|
||||||
@@ -32,6 +40,22 @@ func ParseError(err error) (int, string) {
|
|||||||
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
return http.StatusServiceUnavailable, http.StatusText(http.StatusServiceUnavailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, gotenberg.ErrFiltered) {
|
||||||
|
return http.StatusForbidden, http.StatusText(http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
|
||||||
|
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
||||||
|
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
|
||||||
|
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
|
||||||
|
}
|
||||||
|
|
||||||
var httpErr HttpError
|
var httpErr HttpError
|
||||||
if errors.As(err, &httpErr) {
|
if errors.As(err, &httpErr) {
|
||||||
return httpErr.HttpError()
|
return httpErr.HttpError()
|
||||||
@@ -90,7 +114,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
|||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
c.Set("rootPath", rootPath)
|
c.Set("rootPath", rootPath)
|
||||||
|
|
||||||
// Call the next middleware in the chain.
|
// Call the next middleware in the chain.
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
@@ -195,6 +218,17 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// basicAuthMiddleware manages basic authentication.
|
||||||
|
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||||
|
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
|
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
|
||||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||||
@@ -202,14 +236,16 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
|||||||
//
|
//
|
||||||
// ctx := c.Get("context").(*api.Context)
|
// ctx := c.Get("context").(*api.Context)
|
||||||
// cancel := c.Get("cancel").(context.CancelFunc)
|
// cancel := c.Get("cancel").(context.CancelFunc)
|
||||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.MiddlewareFunc {
|
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
logger := c.Get("logger").(*zap.Logger)
|
logger := c.Get("logger").(*zap.Logger)
|
||||||
|
traceHeader := c.Get("traceHeader").(string)
|
||||||
|
trace := c.Get("trace").(string)
|
||||||
|
|
||||||
// We create a context with a timeout so that underlying processes are
|
// We create a context with a timeout so that underlying processes are
|
||||||
// able to stop early and handle correctly a timeout scenario.
|
// able to stop early and handle correctly a timeout scenario.
|
||||||
ctx, cancel, err := newContext(c, logger, fs, timeout)
|
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
@@ -230,6 +266,13 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) echo.Mid
|
|||||||
|
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseError(t *testing.T) {
|
func TestParseError(t *testing.T) {
|
||||||
@@ -29,9 +29,24 @@ func TestParseError(t *testing.T) {
|
|||||||
expectMessage: http.StatusText(http.StatusInternalServerError),
|
expectMessage: http.StatusText(http.StatusInternalServerError),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
err: context.DeadlineExceeded,
|
err: gotenberg.ErrFiltered,
|
||||||
expectStatus: http.StatusServiceUnavailable,
|
expectStatus: http.StatusForbidden,
|
||||||
expectMessage: http.StatusText(http.StatusServiceUnavailable),
|
expectMessage: http.StatusText(http.StatusForbidden),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
err: gotenberg.ErrMaximumQueueSizeExceeded,
|
||||||
|
expectStatus: http.StatusTooManyRequests,
|
||||||
|
expectMessage: http.StatusText(http.StatusTooManyRequests),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
err: gotenberg.ErrPdfFormatNotSupported,
|
||||||
|
expectStatus: http.StatusBadRequest,
|
||||||
|
expectMessage: "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
err: gotenberg.ErrPdfEngineMetadataValueNotSupported,
|
||||||
|
expectStatus: http.StatusBadRequest,
|
||||||
|
expectMessage: "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
err: WrapError(
|
err: WrapError(
|
||||||
@@ -221,6 +236,56 @@ func TestTraceMiddleware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBasicAuthMiddleware(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
request *http.Request
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "invalid basic auth",
|
||||||
|
request: func() *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.SetBasicAuth("invalid", "invalid")
|
||||||
|
return req
|
||||||
|
}(),
|
||||||
|
username: "foo",
|
||||||
|
password: "bar",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "valid basic auth",
|
||||||
|
request: func() *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.SetBasicAuth("foo", "bar")
|
||||||
|
return req
|
||||||
|
}(),
|
||||||
|
username: "foo",
|
||||||
|
password: "bar",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
srv := echo.New()
|
||||||
|
srv.HideBanner = true
|
||||||
|
srv.HidePort = true
|
||||||
|
c := srv.NewContext(tc.request, recorder)
|
||||||
|
err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error {
|
||||||
|
return nil
|
||||||
|
})(c)
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoggerMiddleware(t *testing.T) {
|
func TestLoggerMiddleware(t *testing.T) {
|
||||||
for i, tc := range []struct {
|
for i, tc := range []struct {
|
||||||
request *http.Request
|
request *http.Request
|
||||||
@@ -320,6 +385,15 @@ func TestContextMiddleware(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
expectStatus: http.StatusNoContent,
|
expectStatus: http.StatusNoContent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
request: buildMultipartFormDataRequest(),
|
||||||
|
next: func() echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
return ErrNoOutputFile
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
expectStatus: http.StatusOK,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
request: buildMultipartFormDataRequest(),
|
request: buildMultipartFormDataRequest(),
|
||||||
next: func() echo.HandlerFunc {
|
next: func() echo.HandlerFunc {
|
||||||
@@ -384,10 +458,11 @@ func TestContextMiddleware(t *testing.T) {
|
|||||||
|
|
||||||
c := srv.NewContext(tc.request, recorder)
|
c := srv.NewContext(tc.request, recorder)
|
||||||
c.Set("logger", zap.NewNop())
|
c.Set("logger", zap.NewNop())
|
||||||
|
c.Set("traceHeader", "Gotenberg-Trace")
|
||||||
c.Set("trace", "foo")
|
c.Set("trace", "foo")
|
||||||
c.Set("startTime", time.Now())
|
c.Set("startTime", time.Now())
|
||||||
|
|
||||||
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second)(tc.next)(c)
|
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
|
||||||
|
|
||||||
if tc.expectErr && err == nil {
|
if tc.expectErr && err == nil {
|
||||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"github.com/alexliesenfeld/health"
|
"github.com/alexliesenfeld/health"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContextMock is a helper for tests.
|
// ContextMock is a helper for tests.
|
||||||
@@ -76,7 +78,7 @@ func (ctx *ContextMock) SetLogger(logger *zap.Logger) {
|
|||||||
ctx.logger = logger
|
ctx.logger = logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetEchoContext sets the echo.Context.
|
// SetEchoContext sets the [echo.Context].
|
||||||
//
|
//
|
||||||
// ctx := &api.ContextMock{Context: &api.Context{}}
|
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||||
// ctx.setEchoContext(c)
|
// ctx.setEchoContext(c)
|
||||||
@@ -84,6 +86,14 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) {
|
|||||||
ctx.Context.echoCtx = c
|
ctx.Context.echoCtx = c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPathRename sets the [gotenberg.PathRename].
|
||||||
|
//
|
||||||
|
// ctx := &api.ContextMock{Context: &api.Context{}}
|
||||||
|
// ctx.setPathRename(rename)
|
||||||
|
func (ctx *ContextMock) SetPathRename(rename gotenberg.PathRename) {
|
||||||
|
ctx.Context.pathRename = rename
|
||||||
|
}
|
||||||
|
|
||||||
// RouterMock is a mock for the [Router] interface.
|
// RouterMock is a mock for the [Router] interface.
|
||||||
type RouterMock struct {
|
type RouterMock struct {
|
||||||
RoutesMock func() ([]Route, error)
|
RoutesMock func() ([]Route, error)
|
||||||
@@ -105,12 +115,17 @@ func (provider *MiddlewareProviderMock) Middlewares() ([]Middleware, error) {
|
|||||||
// HealthCheckerMock is mock for the [HealthChecker] interface.
|
// HealthCheckerMock is mock for the [HealthChecker] interface.
|
||||||
type HealthCheckerMock struct {
|
type HealthCheckerMock struct {
|
||||||
ChecksMock func() ([]health.CheckerOption, error)
|
ChecksMock func() ([]health.CheckerOption, error)
|
||||||
|
ReadyMock func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) {
|
func (mod *HealthCheckerMock) Checks() ([]health.CheckerOption, error) {
|
||||||
return mod.ChecksMock()
|
return mod.ChecksMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mod *HealthCheckerMock) Ready() error {
|
||||||
|
return mod.ReadyMock()
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ Router = (*RouterMock)(nil)
|
_ Router = (*RouterMock)(nil)
|
||||||
|
|||||||
@@ -117,6 +117,19 @@ func TestContextMock_SetEchoContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContextMock_SetPathRename(t *testing.T) {
|
||||||
|
mock := ContextMock{&Context{}}
|
||||||
|
|
||||||
|
expect := new(osPathRename)
|
||||||
|
mock.SetPathRename(expect)
|
||||||
|
|
||||||
|
actual := mock.pathRename
|
||||||
|
|
||||||
|
if actual != expect {
|
||||||
|
t.Errorf("expected %v but got %v", expect, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRouterMock(t *testing.T) {
|
func TestRouterMock(t *testing.T) {
|
||||||
mock := &RouterMock{
|
mock := &RouterMock{
|
||||||
RoutesMock: func() ([]Route, error) {
|
RoutesMock: func() ([]Route, error) {
|
||||||
@@ -148,10 +161,18 @@ func TestHealthCheckerMock(t *testing.T) {
|
|||||||
ChecksMock: func() ([]health.CheckerOption, error) {
|
ChecksMock: func() ([]health.CheckerOption, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
},
|
},
|
||||||
|
ReadyMock: func() error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := mock.Checks()
|
_, err := mock.Checks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
|
t.Errorf("expected no error from HealthCheckerMock.Checks, but got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = mock.Ready()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error from HealthCheckerMock.Ready, but got: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -15,14 +14,16 @@ import (
|
|||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/runtime"
|
"github.com/chromedp/cdproto/runtime"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type browser interface {
|
type browser interface {
|
||||||
gotenberg.Process
|
gotenberg.Process
|
||||||
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
type browserArguments struct {
|
type browserArguments struct {
|
||||||
@@ -38,8 +39,10 @@ type browserArguments struct {
|
|||||||
wsUrlReadTimeout time.Duration
|
wsUrlReadTimeout time.Duration
|
||||||
|
|
||||||
// Tasks specific.
|
// Tasks specific.
|
||||||
allowList *regexp.Regexp
|
allowList *regexp2.Regexp
|
||||||
denyList *regexp.Regexp
|
denyList *regexp2.Regexp
|
||||||
|
clearCache bool
|
||||||
|
clearCookies bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +90,8 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
|
|||||||
// https://github.com/puppeteer/puppeteer/issues/2410
|
// https://github.com/puppeteer/puppeteer/issues/2410
|
||||||
chromedp.Flag("font-render-hinting", "none"),
|
chromedp.Flag("font-render-hinting", "none"),
|
||||||
chromedp.UserDataDir(b.userProfileDirPath),
|
chromedp.UserDataDir(b.userProfileDirPath),
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/831.
|
||||||
|
chromedp.Flag("disable-pdf-tagging", true),
|
||||||
)
|
)
|
||||||
|
|
||||||
if b.arguments.incognito {
|
if b.arguments.incognito {
|
||||||
@@ -137,7 +142,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
|
|||||||
b.ctxMu.Lock()
|
b.ctxMu.Lock()
|
||||||
defer b.ctxMu.Unlock()
|
defer b.ctxMu.Unlock()
|
||||||
|
|
||||||
// We have to keep the context around, as we need it to create a new tabs
|
// We have to keep the context around, as we need it to create new tabs
|
||||||
// later.
|
// later.
|
||||||
b.ctx = ctx
|
b.ctx = ctx
|
||||||
b.cancelFunc = func() {
|
b.cancelFunc = func() {
|
||||||
@@ -161,7 +166,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
|
|||||||
go func() {
|
go func() {
|
||||||
// FIXME: Chromium seems to recreate the user profile directory
|
// FIXME: Chromium seems to recreate the user profile directory
|
||||||
// right after its deletion if we do not wait a certain amount
|
// right after its deletion if we do not wait a certain amount
|
||||||
// of time before re-deleting it.
|
// of time before deleting it.
|
||||||
<-time.After(10 * time.Second)
|
<-time.After(10 * time.Second)
|
||||||
|
|
||||||
err := os.RemoveAll(userProfileDirPath)
|
err := os.RemoveAll(userProfileDirPath)
|
||||||
@@ -211,18 +216,56 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
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 {
|
||||||
if !b.isStarted.Load() {
|
if !b.isStarted.Load() {
|
||||||
return errors.New("browser not started, cannot handle PDF conversion")
|
return errors.New("browser not started, cannot handle tasks")
|
||||||
}
|
|
||||||
|
|
||||||
// We validate the "main" URL against our allow / deny lists.
|
|
||||||
if !b.arguments.allowList.MatchString(url) {
|
|
||||||
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", url, ErrUrlNotAuthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
if b.arguments.denyList.String() != "" && b.arguments.denyList.MatchString(url) {
|
|
||||||
return fmt.Errorf("'%s' matches the expression from the denied list: %w", url, ErrUrlNotAuthorized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline, ok := ctx.Deadline()
|
deadline, ok := ctx.Deadline()
|
||||||
@@ -230,6 +273,12 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
|||||||
return errors.New("context has no deadline")
|
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()
|
b.ctxMu.RLock()
|
||||||
defer b.ctxMu.RUnlock()
|
defer b.ctxMu.RUnlock()
|
||||||
|
|
||||||
@@ -240,8 +289,36 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
|||||||
defer taskCancel()
|
defer taskCancel()
|
||||||
|
|
||||||
// We validate all others requests against our allow / deny lists.
|
// We validate all others requests against our allow / deny lists.
|
||||||
// If a request does not pass the validation, we make it fail.
|
// If a request does not pass the validation, we make it fail. It also set
|
||||||
listenForEventRequestPaused(taskCtx, logger, b.arguments.allowList, b.arguments.denyList)
|
// the extra HTTP headers, if any.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
|
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
||||||
|
allowList: b.arguments.allowList,
|
||||||
|
denyList: b.arguments.denyList,
|
||||||
|
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
consoleExceptions error
|
consoleExceptions error
|
||||||
@@ -253,22 +330,25 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
|||||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks := chromedp.Tasks{
|
var (
|
||||||
network.Enable(),
|
loadingFailed error
|
||||||
fetch.Enable(),
|
loadingFailedMu sync.RWMutex
|
||||||
runtime.Enable(),
|
resourceLoadingFailed error
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
resourceLoadingFailedMu sync.RWMutex
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := chromedp.Run(taskCtx, tasks...)
|
// 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...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMessage := err.Error()
|
errMessage := err.Error()
|
||||||
|
|
||||||
@@ -284,7 +364,23 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
|||||||
return ErrRpccMessageTooLarge
|
return ErrRpccMessageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("print to PDF: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||||
@@ -295,6 +391,23 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
|||||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alexliesenfeld/health"
|
"github.com/alexliesenfeld/health"
|
||||||
|
"github.com/chromedp/cdproto/network"
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
"go.uber.org/multierr"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -21,14 +22,6 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
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
|
// ErrInvalidEmulatedMediaType happens if the emulated media type is not
|
||||||
// "screen" nor "print". Empty value are allowed though.
|
// "screen" nor "print". Empty value are allowed though.
|
||||||
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
|
ErrInvalidEmulatedMediaType = errors.New("invalid emulated media type")
|
||||||
@@ -37,22 +30,43 @@ var (
|
|||||||
// returns an exception or undefined.
|
// returns an exception or undefined.
|
||||||
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
|
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
|
// ErrRpccMessageTooLarge happens when the messages received by
|
||||||
// ChromeDevTools are larger than 100 MB.
|
// ChromeDevTools are larger than 100 MB.
|
||||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
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
|
// ErrConsoleExceptions happens when there are exceptions in the Chromium
|
||||||
// console. It also happens only if the [Options.FailOnConsoleExceptions]
|
// console. It also happens only if the [Options.FailOnConsoleExceptions]
|
||||||
// is set to true.
|
// is set to true.
|
||||||
ErrConsoleExceptions = errors.New("console exceptions")
|
ErrConsoleExceptions = errors.New("console exceptions")
|
||||||
|
|
||||||
|
// ErrLoadingFailed happens when 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
|
// Chromium is a module which provides both an [Api] and routes for converting
|
||||||
@@ -68,81 +82,116 @@ type Chromium struct {
|
|||||||
engine gotenberg.PdfEngine
|
engine gotenberg.PdfEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options are the available expectedOptions for converting HTML document to PDF.
|
// Options are the common options for all conversions.
|
||||||
type Options struct {
|
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
|
// FailOnConsoleExceptions sets if the conversion should fail if there are
|
||||||
// exceptions in the Chromium console.
|
// exceptions in the Chromium console.
|
||||||
// Optional.
|
|
||||||
FailOnConsoleExceptions bool
|
FailOnConsoleExceptions bool
|
||||||
|
|
||||||
// WaitDelay is the duration to wait when loading an HTML document before
|
// WaitDelay is the duration to wait when loading an HTML document before
|
||||||
// converting it to PDF.
|
// converting it.
|
||||||
// Optional.
|
|
||||||
WaitDelay time.Duration
|
WaitDelay time.Duration
|
||||||
|
|
||||||
// WaitWindowStatus is the window.status value to wait for before
|
// WaitWindowStatus is the window.status value to wait for before
|
||||||
// converting an HTML document to PDF.
|
// converting an HTML document.
|
||||||
// Optional.
|
|
||||||
WaitWindowStatus string
|
WaitWindowStatus string
|
||||||
|
|
||||||
// WaitForExpression is the custom JavaScript expression to wait before
|
// WaitForExpression is the custom JavaScript expression to wait before
|
||||||
// converting an HTML document to PDF until it returns true
|
// converting an HTML document until it returns true
|
||||||
// Optional.
|
|
||||||
WaitForExpression string
|
WaitForExpression string
|
||||||
|
|
||||||
// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
|
// Cookies are the cookies to put in the Chromium cookies' jar.
|
||||||
// the HTML document.
|
Cookies []Cookie
|
||||||
// Optional.
|
|
||||||
ExtraHttpHeaders map[string]string
|
// 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
|
||||||
|
|
||||||
// EmulatedMediaType is the media type to emulate, either "screen" or
|
// EmulatedMediaType is the media type to emulate, either "screen" or
|
||||||
// "print".
|
// "print".
|
||||||
// Optional.
|
|
||||||
EmulatedMediaType string
|
EmulatedMediaType string
|
||||||
|
|
||||||
// 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
|
// OmitBackground hides default white background and allows generating PDFs
|
||||||
// with transparency.
|
// with transparency.
|
||||||
// Optional.
|
|
||||||
OmitBackground bool
|
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.
|
||||||
|
Landscape bool
|
||||||
|
|
||||||
|
// PrintBackground prints the background graphics.
|
||||||
|
PrintBackground bool
|
||||||
|
|
||||||
// Scale is the scale of the page rendering.
|
// Scale is the scale of the page rendering.
|
||||||
// Optional.
|
|
||||||
Scale float64
|
Scale float64
|
||||||
|
|
||||||
|
// SinglePage defines whether to print the entire content in one single
|
||||||
|
// page.
|
||||||
|
SinglePage bool
|
||||||
|
|
||||||
// PaperWidth is the paper width, in inches.
|
// PaperWidth is the paper width, in inches.
|
||||||
// Optional.
|
|
||||||
PaperWidth float64
|
PaperWidth float64
|
||||||
|
|
||||||
// PaperHeight is the paper height, in inches.
|
// PaperHeight is the paper height, in inches.
|
||||||
// Optional.
|
|
||||||
PaperHeight float64
|
PaperHeight float64
|
||||||
|
|
||||||
// MarginTop is the top margin, in inches.
|
// MarginTop is the top margin, in inches.
|
||||||
// Optional.
|
|
||||||
MarginTop float64
|
MarginTop float64
|
||||||
|
|
||||||
// MarginBottom is the bottom margin, in inches.
|
// MarginBottom is the bottom margin, in inches.
|
||||||
// Optional.
|
|
||||||
MarginBottom float64
|
MarginBottom float64
|
||||||
|
|
||||||
// MarginLeft is the left margin, in inches.
|
// MarginLeft is the left margin, in inches.
|
||||||
// Optional.
|
|
||||||
MarginLeft float64
|
MarginLeft float64
|
||||||
|
|
||||||
// MarginRight is the right margin, in inches.
|
// MarginRight is the right margin, in inches.
|
||||||
// Optional.
|
|
||||||
MarginRight float64
|
MarginRight float64
|
||||||
|
|
||||||
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
|
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
|
||||||
// Optional.
|
|
||||||
PageRanges string
|
PageRanges string
|
||||||
|
|
||||||
// HeaderTemplate is the HTML template of the header. It should be valid
|
// HeaderTemplate is the HTML template of the header. It should be valid
|
||||||
@@ -155,49 +204,130 @@ type Options struct {
|
|||||||
// - totalPages: total pages in the document
|
// - totalPages: total pages in the document
|
||||||
// For example, <span class=title></span> would generate span containing
|
// For example, <span class=title></span> would generate span containing
|
||||||
// the title.
|
// the title.
|
||||||
// Optional.
|
|
||||||
HeaderTemplate string
|
HeaderTemplate string
|
||||||
|
|
||||||
// FooterTemplate is the HTML template of the footer. It should use the
|
// FooterTemplate is the HTML template of the footer. It should use the
|
||||||
// same format as the HeaderTemplate.
|
// same format as the HeaderTemplate.
|
||||||
// Optional.
|
|
||||||
FooterTemplate string
|
FooterTemplate string
|
||||||
|
|
||||||
// PreferCssPageSize defines whether to prefer page size as defined by CSS.
|
// PreferCssPageSize defines whether to prefer page size as defined by CSS.
|
||||||
// If false, the content will be scaled to fit the paper size.
|
// If false, the content will be scaled to fit the paper size.
|
||||||
// Optional.
|
|
||||||
PreferCssPageSize bool
|
PreferCssPageSize bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultOptions returns the default values for Options.
|
// DefaultPdfOptions returns the default values for PdfOptions.
|
||||||
func DefaultOptions() Options {
|
func DefaultPdfOptions() PdfOptions {
|
||||||
return Options{
|
return PdfOptions{
|
||||||
FailOnConsoleExceptions: false,
|
Options: DefaultOptions(),
|
||||||
WaitDelay: 0,
|
Landscape: false,
|
||||||
WaitWindowStatus: "",
|
PrintBackground: false,
|
||||||
WaitForExpression: "",
|
Scale: 1.0,
|
||||||
ExtraHttpHeaders: nil,
|
SinglePage: false,
|
||||||
EmulatedMediaType: "",
|
PaperWidth: 8.5,
|
||||||
Landscape: false,
|
PaperHeight: 11,
|
||||||
PrintBackground: false,
|
MarginTop: 0.39,
|
||||||
OmitBackground: false,
|
MarginBottom: 0.39,
|
||||||
Scale: 1.0,
|
MarginLeft: 0.39,
|
||||||
PaperWidth: 8.5,
|
MarginRight: 0.39,
|
||||||
PaperHeight: 11,
|
PageRanges: "",
|
||||||
MarginTop: 0.39,
|
HeaderTemplate: "<html><head></head><body></body></html>",
|
||||||
MarginBottom: 0.39,
|
FooterTemplate: "<html><head></head><body></body></html>",
|
||||||
MarginLeft: 0.39,
|
PreferCssPageSize: false,
|
||||||
MarginRight: 0.39,
|
|
||||||
PageRanges: "",
|
|
||||||
HeaderTemplate: "<html><head></head><body></body></html>",
|
|
||||||
FooterTemplate: "<html><head></head><body></body></html>",
|
|
||||||
PreferCssPageSize: 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.
|
// Api helps to interact with Chromium for converting HTML documents to PDF.
|
||||||
type Api interface {
|
type Api interface {
|
||||||
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider is a module interface which exposes a method for creating an [Api]
|
// Provider is a module interface which exposes a method for creating an [Api]
|
||||||
@@ -217,21 +347,10 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
ID: "chromium",
|
ID: "chromium",
|
||||||
FlagSet: func() *flag.FlagSet {
|
FlagSet: func() *flag.FlagSet {
|
||||||
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
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-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.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
|
||||||
fs.Duration("chromium-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
||||||
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
|
||||||
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
|
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
|
||||||
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
|
fs.Bool("chromium-ignore-certificate-errors", false, "Ignore the certificate errors")
|
||||||
@@ -240,7 +359,9 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
|
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-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-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
|
||||||
fs.String("chromium-deny-list", "^file:///[^tmp].*", "Set the denied URLs for Chromium using a regular expression")
|
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
|
||||||
|
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||||
|
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
@@ -274,6 +395,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
allowList: flags.MustRegexp("chromium-allow-list"),
|
allowList: flags.MustRegexp("chromium-allow-list"),
|
||||||
denyList: flags.MustRegexp("chromium-deny-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"),
|
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +413,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
mod.browser = newChromiumBrowser(mod.args)
|
mod.browser = newChromiumBrowser(mod.args)
|
||||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"))
|
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"))
|
||||||
|
|
||||||
// PDF Engine.
|
// PDF Engine.
|
||||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||||
@@ -359,22 +482,6 @@ func (mod *Chromium) Stop(ctx context.Context) error {
|
|||||||
// Metrics returns the metrics.
|
// Metrics returns the metrics.
|
||||||
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
|
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
|
||||||
return []gotenberg.Metric{
|
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",
|
Name: "chromium_requests_queue_size",
|
||||||
Description: "Current number of Chromium conversion requests waiting to be treated.",
|
Description: "Current number of Chromium conversion requests waiting to be treated.",
|
||||||
@@ -408,6 +515,34 @@ func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ready returns no error if the module is ready.
|
||||||
|
func (mod *Chromium) Ready() error {
|
||||||
|
if !mod.autoStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), mod.args.wsUrlReadTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
ticker.Stop()
|
||||||
|
return fmt.Errorf("context done while waiting for Chromium to be ready: %w", ctx.Err())
|
||||||
|
case <-ticker.C:
|
||||||
|
ok := mod.browser.Healthy(mod.logger)
|
||||||
|
if ok {
|
||||||
|
ticker.Stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chromium returns an [Api] for interacting with Chromium for converting HTML
|
// Chromium returns an [Api] for interacting with Chromium for converting HTML
|
||||||
// documents to PDF.
|
// documents to PDF.
|
||||||
func (mod *Chromium) Chromium() (Api, error) {
|
func (mod *Chromium) Chromium() (Api, error) {
|
||||||
@@ -422,19 +557,31 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
|
|||||||
|
|
||||||
return []api.Route{
|
return []api.Route{
|
||||||
convertUrlRoute(mod, mod.engine),
|
convertUrlRoute(mod, mod.engine),
|
||||||
|
screenshotUrlRoute(mod),
|
||||||
convertHtmlRoute(mod, mod.engine),
|
convertHtmlRoute(mod, mod.engine),
|
||||||
|
screenshotHtmlRoute(mod),
|
||||||
convertMarkdownRoute(mod, mod.engine),
|
convertMarkdownRoute(mod, mod.engine),
|
||||||
|
screenshotMarkdownRoute(mod),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pdf converts a URL to PDF.
|
// Pdf converts a URL to PDF.
|
||||||
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
// FIXME: no error wrapping because it leaks on console exceptions output.
|
// 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.supervisor.Run(ctx, logger, func() error {
|
||||||
return mod.browser.pdf(ctx, logger, url, outputPath, options)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*Chromium)(nil)
|
_ gotenberg.Module = (*Chromium)(nil)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/alexliesenfeld/health"
|
"github.com/alexliesenfeld/health"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultOptions(t *testing.T) {
|
func TestDefaultOptions(t *testing.T) {
|
||||||
@@ -23,6 +23,24 @@ 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) {
|
func TestChromium_Descriptor(t *testing.T) {
|
||||||
descriptor := new(Chromium).Descriptor()
|
descriptor := new(Chromium).Descriptor()
|
||||||
|
|
||||||
@@ -334,26 +352,16 @@ func TestChromium_Metrics(t *testing.T) {
|
|||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(metrics) != 4 {
|
if len(metrics) != 2 {
|
||||||
t.Fatalf("expected %d metrics, but got %d", 4, len(metrics))
|
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
|
||||||
}
|
}
|
||||||
|
|
||||||
actual := metrics[0].Read()
|
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) {
|
if actual != float64(10) {
|
||||||
t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual)
|
t.Errorf("expected %f for chromium_requests_queue_size, but got %f", float64(10), actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual = metrics[3].Read()
|
actual = metrics[1].Read()
|
||||||
if actual != float64(0) {
|
if actual != float64(0) {
|
||||||
t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual)
|
t.Errorf("expected %f for chromium_restarts_count, but got %f", float64(0), actual)
|
||||||
}
|
}
|
||||||
@@ -399,6 +407,61 @@ func TestChromium_Checks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChromium_Ready(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
autoStart bool
|
||||||
|
startTimeout time.Duration
|
||||||
|
browser browser
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "no auto-start",
|
||||||
|
autoStart: false,
|
||||||
|
startTimeout: time.Duration(30) * time.Second,
|
||||||
|
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return false
|
||||||
|
}}},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "auto-start: context done",
|
||||||
|
autoStart: true,
|
||||||
|
startTimeout: time.Duration(200) * time.Millisecond,
|
||||||
|
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return false
|
||||||
|
}}},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "auto-start success",
|
||||||
|
autoStart: true,
|
||||||
|
startTimeout: time.Duration(30) * time.Second,
|
||||||
|
browser: &browserMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return true
|
||||||
|
}}},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
mod := new(Chromium)
|
||||||
|
mod.autoStart = tc.autoStart
|
||||||
|
mod.args = browserArguments{wsUrlReadTimeout: tc.startTimeout}
|
||||||
|
mod.browser = tc.browser
|
||||||
|
|
||||||
|
err := mod.Ready()
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChromium_Chromium(t *testing.T) {
|
func TestChromium_Chromium(t *testing.T) {
|
||||||
mod := new(Chromium)
|
mod := new(Chromium)
|
||||||
|
|
||||||
@@ -416,7 +479,7 @@ func TestChromium_Routes(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "routes not disabled",
|
scenario: "routes not disabled",
|
||||||
expectRoutes: 3,
|
expectRoutes: 6,
|
||||||
disableRoutes: false,
|
disableRoutes: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -450,14 +513,14 @@ func TestChromium_Pdf(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "PDF task success",
|
scenario: "PDF task success",
|
||||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
expectError: false,
|
expectError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "PDF task error",
|
scenario: "PDF task error",
|
||||||
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
browser: &browserMock{pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
return errors.New("PDF task error")
|
return errors.New("PDF task error")
|
||||||
}},
|
}},
|
||||||
expectError: true,
|
expectError: true,
|
||||||
@@ -470,7 +533,49 @@ func TestChromium_Pdf(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
mod.browser = tc.browser
|
mod.browser = tc.browser
|
||||||
|
|
||||||
err := mod.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
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{})
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package chromium
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
@@ -12,14 +13,30 @@ import (
|
|||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
"github.com/chromedp/cdproto/runtime"
|
"github.com/chromedp/cdproto/runtime"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
"go.uber.org/multierr"
|
"go.uber.org/multierr"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/sync/errgroup"
|
"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
|
// listenForEventRequestPaused listens for requests to check if they are
|
||||||
// allowed or not.
|
// allowed or not. It also set the extra HTTP headers, if any.
|
||||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp.Regexp, denyList *regexp.Regexp) {
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||||
switch e := ev.(type) {
|
switch e := ev.(type) {
|
||||||
case *fetch.EventRequestPaused:
|
case *fetch.EventRequestPaused:
|
||||||
@@ -27,39 +44,229 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
|
|||||||
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
|
||||||
allow := true
|
allow := true
|
||||||
|
|
||||||
if !allowList.MatchString(e.Request.URL) {
|
deadline, ok := ctx.Deadline()
|
||||||
logger.Warn(fmt.Sprintf("'%s' does not match the expression from the allowed list", e.Request.URL))
|
if !ok {
|
||||||
allow = false
|
logger.Error("context has no deadline, cannot filter URL")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if denyList.String() != "" && denyList.MatchString(e.Request.URL) {
|
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
|
||||||
logger.Warn(fmt.Sprintf("'%s' matches the expression from the denied list", e.Request.URL))
|
if err != nil {
|
||||||
|
logger.Warn(err.Error())
|
||||||
allow = false
|
allow = false
|
||||||
}
|
}
|
||||||
|
|
||||||
cctx := chromedp.FromContext(ctx)
|
cctx := chromedp.FromContext(ctx)
|
||||||
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
|
||||||
|
|
||||||
if allow {
|
if !allow {
|
||||||
req := fetch.ContinueRequest(e.RequestID)
|
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
||||||
err := req.Do(executorCtx)
|
err = req.Do(executorCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(fmt.Sprintf("continue request: %s", err))
|
logger.Error(fmt.Sprintf("fail request: %s", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
|
req := fetch.ContinueRequest(e.RequestID)
|
||||||
err := req.Do(executorCtx)
|
|
||||||
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(fmt.Sprintf("fail request: %s", err))
|
logger.Error(fmt.Sprintf("continue request: %s", err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventResponseReceivedOptions struct {
|
||||||
|
mainPageUrl string
|
||||||
|
failOnHttpStatusCodes []int64
|
||||||
|
invalidHttpStatusCode *error
|
||||||
|
invalidHttpStatusCodeMu *sync.RWMutex
|
||||||
|
failOnResourceOnHttpStatusCode []int64
|
||||||
|
invalidResourceHttpStatusCode *error
|
||||||
|
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenForEventResponseReceived listens for an invalid HTTP status code that
|
||||||
|
// 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
|
// listenForEventExceptionThrown listens for exceptions in the console and
|
||||||
// appends those exceptions to the given error pointer.
|
// appends those exceptions to the given error pointer.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||||
@@ -179,7 +386,6 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
|
|||||||
// completed or an error is encountered.
|
// completed or an error is encountered.
|
||||||
func runBatch(ctx context.Context, fn ...func() error) error {
|
func runBatch(ctx context.Context, fn ...func() error) error {
|
||||||
eg, _ := errgroup.WithContext(ctx)
|
eg, _ := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
for _, f := range fn {
|
for _, f := range fn {
|
||||||
eg.Go(f)
|
eg.Go(f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,28 +5,38 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApiMock is a mock for the [Api] interface.
|
// ApiMock is a mock for the [Api] interface.
|
||||||
type ApiMock struct {
|
type ApiMock struct {
|
||||||
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *ApiMock) Pdf(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 {
|
||||||
return api.PdfMock(ctx, logger, url, outputPath, options)
|
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.
|
// browserMock is a mock for the [browser] interface.
|
||||||
type browserMock struct {
|
type browserMock struct {
|
||||||
gotenberg.ProcessMock
|
gotenberg.ProcessMock
|
||||||
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *browserMock) pdf(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 {
|
||||||
return b.pdfMock(ctx, logger, url, outputPath, options)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ Api = (*ApiMock)(nil)
|
_ Api = (*ApiMock)(nil)
|
||||||
|
|||||||
@@ -9,26 +9,42 @@ import (
|
|||||||
|
|
||||||
func TestApiMock(t *testing.T) {
|
func TestApiMock(t *testing.T) {
|
||||||
mock := &ApiMock{
|
mock := &ApiMock{
|
||||||
PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
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 {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
err := mock.Pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("expected no error from ApiMock.Pdf, but got: %v", err)
|
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) {
|
func TestBrowserMock(t *testing.T) {
|
||||||
mock := &browserMock{
|
mock := &browserMock{
|
||||||
pdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options Options) error {
|
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 {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
err := mock.pdf(context.Background(), zap.NewNop(), "", "", PdfOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("expected no error from browserMock.pdf, but got: %v", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,32 +9,197 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/russross/blackfriday/v2"
|
"github.com/russross/blackfriday/v2"
|
||||||
"go.uber.org/multierr"
|
"go.uber.org/multierr"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormDataChromiumPdfOptions creates [Options] from the form data. Fallback to
|
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
|
||||||
// default value if the considered key is not present.
|
// default value if the considered key is not present.
|
||||||
func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
|
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||||
defaultOptions := DefaultOptions()
|
defaultOptions := DefaultOptions()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
failOnConsoleExceptions bool
|
skipNetworkIdleEvent bool
|
||||||
waitDelay time.Duration
|
failOnHttpStatusCodes []int64
|
||||||
waitWindowStatus string
|
failOnResourceHttpStatusCodes []int64
|
||||||
waitForExpression string
|
failOnResourceLoadingFailed bool
|
||||||
userAgent string
|
failOnConsoleExceptions bool
|
||||||
extraHttpHeaders map[string]string
|
waitDelay time.Duration
|
||||||
emulatedMediaType string
|
waitWindowStatus string
|
||||||
landscape, printBackground, omitBackground bool
|
waitForExpression string
|
||||||
|
cookies []Cookie
|
||||||
|
userAgent string
|
||||||
|
extraHttpHeaders []ExtraHttpHeader
|
||||||
|
emulatedMediaType string
|
||||||
|
omitBackground 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).
|
||||||
|
Custom("extraHttpHeaders", func(value string) error {
|
||||||
|
if value == "" {
|
||||||
|
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers map[string]string
|
||||||
|
err := json.Unmarshal([]byte(value), &headers)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unmarshal extraHttpHeaders: %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
|
||||||
|
}).
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
emulatedMediaType = value
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}).
|
||||||
|
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
scale, paperWidth, paperHeight float64
|
||||||
marginTop, marginBottom, marginLeft, marginRight float64
|
marginTop, marginBottom, marginLeft, marginRight float64
|
||||||
pageRanges string
|
pageRanges string
|
||||||
@@ -42,126 +207,145 @@ func FormDataChromiumPdfOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
preferCssPageSize bool
|
preferCssPageSize bool
|
||||||
)
|
)
|
||||||
|
|
||||||
form := ctx.FormData().
|
form.
|
||||||
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
Bool("landscape", &landscape, defaultPdfOptions.Landscape).
|
||||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
Bool("printBackground", &printBackground, defaultPdfOptions.PrintBackground).
|
||||||
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
|
Float64("scale", &scale, defaultPdfOptions.Scale).
|
||||||
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
|
Bool("singlePage", &singlePage, defaultPdfOptions.SinglePage).
|
||||||
String("userAgent", &userAgent, ""). // FIXME: deprecated.
|
Inches("paperWidth", &paperWidth, defaultPdfOptions.PaperWidth).
|
||||||
Custom("extraHttpHeaders", func(value string) error {
|
Inches("paperHeight", &paperHeight, defaultPdfOptions.PaperHeight).
|
||||||
if value == "" {
|
Inches("marginTop", &marginTop, defaultPdfOptions.MarginTop).
|
||||||
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
|
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)
|
||||||
|
|
||||||
|
pdfOptions := PdfOptions{
|
||||||
|
Options: options,
|
||||||
|
Landscape: landscape,
|
||||||
|
PrintBackground: printBackground,
|
||||||
|
Scale: scale,
|
||||||
|
SinglePage: singlePage,
|
||||||
|
PaperWidth: paperWidth,
|
||||||
|
PaperHeight: paperHeight,
|
||||||
|
MarginTop: marginTop,
|
||||||
|
MarginBottom: marginBottom,
|
||||||
|
MarginLeft: marginLeft,
|
||||||
|
MarginRight: marginRight,
|
||||||
|
PageRanges: pageRanges,
|
||||||
|
HeaderTemplate: headerTemplate,
|
||||||
|
FooterTemplate: footerTemplate,
|
||||||
|
PreferCssPageSize: preferCssPageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
return form, pdfOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormDataChromiumScreenshotOptions creates [ScreenshotOptions] 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()
|
||||||
|
|
||||||
|
var (
|
||||||
|
width, height int
|
||||||
|
clip bool
|
||||||
|
format string
|
||||||
|
quality int
|
||||||
|
optimizeForSpeed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
form.
|
||||||
|
Int("width", &width, defaultScreenshotOptions.Width).
|
||||||
|
Int("height", &height, defaultScreenshotOptions.Height).
|
||||||
|
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||||
|
Custom("format", func(value string) error {
|
||||||
|
if value == "" {
|
||||||
|
format = defaultScreenshotOptions.Format
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(value), &extraHttpHeaders)
|
if value != "png" && value != "jpeg" && value != "webp" {
|
||||||
|
return fmt.Errorf("wrong value, expected either 'png', 'jpeg' or 'webp'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("unmarshal extra HTTP headers: %w", err)
|
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
|
return nil
|
||||||
}).
|
}).
|
||||||
Custom("emulatedMediaType", func(value string) error {
|
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
|
||||||
if value == "" {
|
|
||||||
emulatedMediaType = defaultOptions.EmulatedMediaType
|
|
||||||
|
|
||||||
return nil
|
screenshotOptions := ScreenshotOptions{
|
||||||
}
|
Options: options,
|
||||||
|
Width: width,
|
||||||
if value != "screen" && value != "print" {
|
Height: height,
|
||||||
return fmt.Errorf("wrong value, expected either 'screen', 'print' or empty")
|
Clip: clip,
|
||||||
}
|
Format: format,
|
||||||
|
Quality: quality,
|
||||||
emulatedMediaType = value
|
OptimizeForSpeed: optimizeForSpeed,
|
||||||
|
|
||||||
return nil
|
|
||||||
}).
|
|
||||||
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)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
options := Options{
|
return form, screenshotOptions
|
||||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
|
||||||
WaitDelay: waitDelay,
|
|
||||||
WaitWindowStatus: waitWindowStatus,
|
|
||||||
WaitForExpression: waitForExpression,
|
|
||||||
ExtraHttpHeaders: extraHttpHeaders,
|
|
||||||
EmulatedMediaType: emulatedMediaType,
|
|
||||||
Landscape: landscape,
|
|
||||||
PrintBackground: printBackground,
|
|
||||||
OmitBackground: omitBackground,
|
|
||||||
Scale: scale,
|
|
||||||
PaperWidth: paperWidth,
|
|
||||||
PaperHeight: paperHeight,
|
|
||||||
MarginTop: marginTop,
|
|
||||||
MarginBottom: marginBottom,
|
|
||||||
MarginLeft: marginLeft,
|
|
||||||
MarginRight: marginRight,
|
|
||||||
PageRanges: pageRanges,
|
|
||||||
HeaderTemplate: headerTemplate,
|
|
||||||
FooterTemplate: footerTemplate,
|
|
||||||
PreferCssPageSize: preferCssPageSize,
|
|
||||||
}
|
|
||||||
|
|
||||||
return form, options
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
|
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
|
||||||
// data. Fallback to default value if the considered key is not present.
|
// data. Fallback to default value if the considered key is not present.
|
||||||
func FormDataChromiumPdfFormats(ctx *api.Context) gotenberg.PdfFormats {
|
func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats {
|
||||||
var (
|
var (
|
||||||
pdfFormat string
|
pdfa string
|
||||||
pdfa string
|
pdfua bool
|
||||||
pdfua bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx.FormData().
|
form.
|
||||||
String("pdfFormat", &pdfFormat, "").
|
|
||||||
String("pdfa", &pdfa, "").
|
String("pdfa", &pdfa, "").
|
||||||
Bool("pdfua", &pdfua, false)
|
Bool("pdfua", &pdfua, false)
|
||||||
|
|
||||||
// FIXME: deprecated.
|
|
||||||
// pdfa > pdfFormat.
|
|
||||||
var actualPdfArchive string
|
|
||||||
|
|
||||||
if pdfFormat != "" {
|
|
||||||
ctx.Log().Warn("'pdfFormat' is deprecated; prefer the 'pdfa' form field instead")
|
|
||||||
actualPdfArchive = pdfFormat
|
|
||||||
}
|
|
||||||
|
|
||||||
if pdfa != "" {
|
|
||||||
actualPdfArchive = pdfa
|
|
||||||
}
|
|
||||||
|
|
||||||
return gotenberg.PdfFormats{
|
return gotenberg.PdfFormats{
|
||||||
PdfA: actualPdfArchive,
|
PdfA: pdfa,
|
||||||
PdfUa: pdfua,
|
PdfUa: pdfua,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormDataPdfMetadata creates metadata object from the form data.
|
||||||
|
func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
|
||||||
|
var metadata map[string]interface{}
|
||||||
|
form.Custom("metadata", func(value string) error {
|
||||||
|
if len(value) > 0 {
|
||||||
|
err := json.Unmarshal([]byte(value), &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
||||||
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||||
return api.Route{
|
return api.Route{
|
||||||
@@ -171,7 +355,8 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
Handler: func(c echo.Context) error {
|
Handler: func(c echo.Context) error {
|
||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
form, options := FormDataChromiumPdfOptions(ctx)
|
form, options := FormDataChromiumPdfOptions(ctx)
|
||||||
pdfFormats := FormDataChromiumPdfFormats(ctx)
|
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||||
|
metadata := FormDataPdfMetadata(form)
|
||||||
|
|
||||||
var url string
|
var url string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -181,7 +366,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert URL to PDF: %w", err)
|
return fmt.Errorf("convert URL to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -191,6 +376,35 @@ 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
|
// convertHtmlRoute returns an [api.Route] which can convert an HTML file to
|
||||||
// PDF.
|
// PDF.
|
||||||
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||||
@@ -201,7 +415,8 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
Handler: func(c echo.Context) error {
|
Handler: func(c echo.Context) error {
|
||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
form, options := FormDataChromiumPdfOptions(ctx)
|
form, options := FormDataChromiumPdfOptions(ctx)
|
||||||
pdfFormats := FormDataChromiumPdfFormats(ctx)
|
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||||
|
metadata := FormDataPdfMetadata(form)
|
||||||
|
|
||||||
var inputPath string
|
var inputPath string
|
||||||
err := form.
|
err := form.
|
||||||
@@ -212,7 +427,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("file://%s", inputPath)
|
url := fmt.Sprintf("file://%s", inputPath)
|
||||||
err = convertUrl(ctx, chromium, engine, url, pdfFormats, options)
|
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert HTML to PDF: %w", err)
|
return fmt.Errorf("convert HTML to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -222,6 +437,36 @@ 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
|
// convertMarkdownRoute returns an [api.Route] which can convert markdown files
|
||||||
// to PDF.
|
// to PDF.
|
||||||
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||||
@@ -232,7 +477,8 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
Handler: func(c echo.Context) error {
|
Handler: func(c echo.Context) error {
|
||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
form, options := FormDataChromiumPdfOptions(ctx)
|
form, options := FormDataChromiumPdfOptions(ctx)
|
||||||
pdfFormats := FormDataChromiumPdfFormats(ctx)
|
pdfFormats := FormDataChromiumPdfFormats(form)
|
||||||
|
metadata := FormDataPdfMetadata(form)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
inputPath string
|
inputPath string
|
||||||
@@ -247,80 +493,12 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We have to convert each markdown file referenced in the HTML
|
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
|
||||||
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("parse template file: %w", err)
|
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata)
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("convert markdown to PDF: %w", err)
|
return fmt.Errorf("convert markdown to PDF: %w", err)
|
||||||
}
|
}
|
||||||
@@ -330,23 +508,126 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertUrl is a stub which is called by the other methods of this file.
|
// screenshotMarkdownRoute returns an [api.Route] which can take a screenshot
|
||||||
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options Options) error {
|
// 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, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
|
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
|
||||||
|
err = handleChromiumError(err, options.Options)
|
||||||
if err != nil {
|
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) {
|
if errors.Is(err, ErrOmitBackgroundWithoutPrintBackground) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
@@ -357,23 +638,6 @@ 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) {
|
if errors.Is(err, ErrInvalidPrinterSettings) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
@@ -394,16 +658,6 @@ 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)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,18 +670,7 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("convert PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +678,14 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
outputPath = convertOutputPath
|
outputPath = convertOutputPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes and potentially overrides metadata entries, if any.
|
||||||
|
if len(metadata) > 0 {
|
||||||
|
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = ctx.AddOutputPaths(outputPath)
|
err = ctx.AddOutputPaths(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add output path: %w", err)
|
return fmt.Errorf("add output path: %w", err)
|
||||||
@@ -442,3 +693,96 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
|
|||||||
|
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ctx.AddOutputPaths(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add output path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -3,6 +3,7 @@ package chromium
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,24 +16,44 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options) chromedp.ActionFunc {
|
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
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().
|
printToPdf := page.PrintToPDF().
|
||||||
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
WithTransferMode(page.PrintToPDFTransferModeReturnAsStream).
|
||||||
WithLandscape(options.Landscape).
|
WithLandscape(options.Landscape).
|
||||||
WithPrintBackground(options.PrintBackground).
|
WithPrintBackground(options.PrintBackground).
|
||||||
WithScale(options.Scale).
|
WithScale(options.Scale).
|
||||||
WithPaperWidth(options.PaperWidth).
|
WithPaperWidth(options.PaperWidth).
|
||||||
WithPaperHeight(options.PaperHeight).
|
WithPaperHeight(paperHeight).
|
||||||
WithMarginTop(options.MarginTop).
|
WithMarginTop(options.MarginTop).
|
||||||
WithMarginBottom(options.MarginBottom).
|
WithMarginBottom(options.MarginBottom).
|
||||||
WithMarginLeft(options.MarginLeft).
|
WithMarginLeft(options.MarginLeft).
|
||||||
WithMarginRight(options.MarginRight).
|
WithMarginRight(options.MarginRight).
|
||||||
WithPageRanges(options.PageRanges).
|
WithPageRanges(pageRanges).
|
||||||
WithPreferCSSPageSize(options.PreferCssPageSize)
|
WithPreferCSSPageSize(options.PreferCssPageSize).
|
||||||
|
// Does not seem to work.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/831.
|
||||||
|
WithGenerateTaggedPDF(false)
|
||||||
|
|
||||||
hasCustomHeaderFooter := options.HeaderTemplate != DefaultOptions().HeaderTemplate ||
|
hasCustomHeaderFooter := options.HeaderTemplate != DefaultPdfOptions().HeaderTemplate ||
|
||||||
options.FooterTemplate != DefaultOptions().FooterTemplate
|
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
|
||||||
|
|
||||||
if !hasCustomHeaderFooter {
|
if !hasCustomHeaderFooter {
|
||||||
logger.Debug("no custom header nor footer")
|
logger.Debug("no custom header nor footer")
|
||||||
@@ -47,11 +68,11 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
|||||||
WithFooterTemplate(options.FooterTemplate)
|
WithFooterTemplate(options.FooterTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug(fmt.Sprintf("print to Pdf with: %+v", printToPdf))
|
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf))
|
||||||
|
|
||||||
_, stream, err := printToPdf.Do(ctx)
|
_, stream, err := printToPdf.Do(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("print to Pdf: %w", err)
|
return fmt.Errorf("print to PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := &streamReader{
|
reader := &streamReader{
|
||||||
@@ -63,7 +84,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
|||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
err := reader.Close()
|
err = reader.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||||
}
|
}
|
||||||
@@ -75,7 +96,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
|||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
err := file.Close()
|
err = file.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(fmt.Sprintf("close output path: %s", err))
|
logger.Error(fmt.Sprintf("close output path: %s", err))
|
||||||
}
|
}
|
||||||
@@ -92,12 +113,111 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options Options
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||||
if !disable {
|
if !disable {
|
||||||
logger.Debug("JavaScript not disabled")
|
logger.Debug("JavaScript not disabled")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,31 +232,101 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if len(extraHttpHeaders) == 0 {
|
if len(cookies) == 0 {
|
||||||
logger.Debug("no extra HTTP headers")
|
logger.Debug("no cookies to set")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
return errors.New("context has no deadline, cannot set cookies")
|
||||||
|
}
|
||||||
|
epochTime := cdp.TimeSinceEpoch(deadline)
|
||||||
|
|
||||||
headers := make(network.Headers, len(extraHttpHeaders))
|
cookiePretty := func(c *network.SetCookieParams) string {
|
||||||
for key, value := range extraHttpHeaders {
|
return fmt.Sprintf(
|
||||||
headers[key] = value
|
"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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := network.SetExtraHTTPHeaders(headers).Do(ctx)
|
for _, cookie := range cookies {
|
||||||
|
cookieParams := network.
|
||||||
|
SetCookie(cookie.Name, cookie.Value).
|
||||||
|
WithDomain(cookie.Domain).
|
||||||
|
WithPath(cookie.Path).
|
||||||
|
WithSecure(cookie.Secure).
|
||||||
|
WithHTTPOnly(cookie.HttpOnly).
|
||||||
|
WithSameSite(cookie.SameSite).
|
||||||
|
WithExpires(&epochTime)
|
||||||
|
|
||||||
|
err := cookieParams.Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if len(userAgent) == 0 {
|
||||||
|
logger.Debug("no user agent override")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
|
||||||
|
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("set extra HTTP headers: %w", err)
|
return fmt.Errorf("set user agent override: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func navigateActionFunc(logger *zap.Logger, url string) chromedp.ActionFunc {
|
// 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 {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
|
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
|
||||||
|
|
||||||
@@ -145,12 +335,21 @@ func navigateActionFunc(logger *zap.Logger, url string) chromedp.ActionFunc {
|
|||||||
return fmt.Errorf("navigate to '%s': %w", url, err)
|
return fmt.Errorf("navigate to '%s': %w", url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = runBatch(
|
waitFunc := []func() error{
|
||||||
ctx,
|
|
||||||
waitForEventDomContentEventFired(ctx, logger),
|
waitForEventDomContentEventFired(ctx, logger),
|
||||||
waitForEventLoadEventFired(ctx, logger),
|
waitForEventLoadEventFired(ctx, logger),
|
||||||
waitForEventNetworkIdle(ctx, logger),
|
|
||||||
waitForEventLoadingFinished(ctx, logger),
|
waitForEventLoadingFinished(ctx, logger),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !skipNetworkIdleEvent {
|
||||||
|
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
|
||||||
|
} else {
|
||||||
|
logger.Debug("skipping network idle event")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = runBatch(
|
||||||
|
ctx,
|
||||||
|
waitFunc...,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -166,7 +365,6 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
|
|||||||
// See https://github.com/gotenberg/gotenberg/issues/226.
|
// See https://github.com/gotenberg/gotenberg/issues/226.
|
||||||
if !omitBackground {
|
if !omitBackground {
|
||||||
logger.Debug("default white background not hidden")
|
logger.Debug("default white background not hidden")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +423,6 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.A
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if mediaType == "" {
|
if mediaType == "" {
|
||||||
logger.Debug("no emulated media type")
|
logger.Debug("no emulated media type")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +446,11 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool,
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if disableJavaScript {
|
if disableJavaScript {
|
||||||
logger.Debug("JavaScript disabled, skipping wait delay")
|
logger.Debug("JavaScript disabled, skipping wait delay")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if delay <= 0 {
|
if delay <= 0 {
|
||||||
logger.Debug("no wait delay")
|
logger.Debug("no wait delay")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,13 +471,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
|
|||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if disableJavaScript {
|
if disableJavaScript {
|
||||||
logger.Debug("JavaScript disabled, skipping wait expression")
|
logger.Debug("JavaScript disabled, skipping wait expression")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if expression == "" {
|
if expression == "" {
|
||||||
logger.Debug("no wait expression")
|
logger.Debug("no wait expression")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,12 +488,11 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
ticker.Stop()
|
ticker.Stop()
|
||||||
|
|
||||||
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
return fmt.Errorf("context done while evaluating '%s': %w", expression, ctx.Err())
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|
||||||
evaluate := chromedp.Evaluate(expression, &ok)
|
evaluate := chromedp.Evaluate(expression, &ok)
|
||||||
|
|
||||||
err := evaluate.Do(ctx)
|
err := evaluate.Do(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
|
return fmt.Errorf("evaluate: %v: %w", err, ErrInvalidEvaluationExpression)
|
||||||
@@ -308,7 +500,6 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
|
|||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
ticker.Stop()
|
ticker.Stop()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
pkg/modules/exiftool/doc.go
Normal file
11
pkg/modules/exiftool/doc.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Package exiftool provides an implementation of the gotenberg.PdfEngine
|
||||||
|
// interface using the ExifTool command-line tool. This package allows for:
|
||||||
|
//
|
||||||
|
// 1. The reading of metadata.
|
||||||
|
// 2. The writing of metadata.
|
||||||
|
//
|
||||||
|
// The path to the exiftool binary must be specified using the
|
||||||
|
// EXIFTOOL_BIN_PATH environment variable.
|
||||||
|
//
|
||||||
|
// See: https://exiftool.org.
|
||||||
|
package exiftool
|
||||||
143
pkg/modules/exiftool/exiftool.go
Normal file
143
pkg/modules/exiftool/exiftool.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package exiftool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/barasher/go-exiftool"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
gotenberg.MustRegisterModule(new(ExifTool))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExifTool abstracts the CLI tool ExifTool and implements the
|
||||||
|
// [gotenberg.PdfEngine] interface.
|
||||||
|
type ExifTool struct {
|
||||||
|
binPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descriptor returns [ExifTool]'s module descriptor.
|
||||||
|
func (engine *ExifTool) Descriptor() gotenberg.ModuleDescriptor {
|
||||||
|
return gotenberg.ModuleDescriptor{
|
||||||
|
ID: "exiftool",
|
||||||
|
New: func() gotenberg.Module { return new(ExifTool) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision sets the module properties.
|
||||||
|
func (engine *ExifTool) Provision(ctx *gotenberg.Context) error {
|
||||||
|
binPath, ok := os.LookupEnv("EXIFTOOL_BIN_PATH")
|
||||||
|
if !ok {
|
||||||
|
return errors.New("EXIFTOOL_BIN_PATH environment variable is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.binPath = binPath
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the module properties.
|
||||||
|
func (engine *ExifTool) Validate() error {
|
||||||
|
_, err := os.Stat(engine.binPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("ExifTool binary path does not exist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge is not available in this implementation.
|
||||||
|
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
|
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert is not available in this implementation.
|
||||||
|
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMetadata extracts the metadata of a given PDF file.
|
||||||
|
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
|
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("new ExifTool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(exifTool *exiftool.Exiftool) {
|
||||||
|
err := exifTool.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||||
|
}
|
||||||
|
}(exifTool)
|
||||||
|
|
||||||
|
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||||
|
if fileMetadata[0].Err != nil {
|
||||||
|
return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileMetadata[0].Fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteMetadata writes the metadata into a given PDF file.
|
||||||
|
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new ExifTool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(exifTool *exiftool.Exiftool) {
|
||||||
|
err := exifTool.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
|
||||||
|
}
|
||||||
|
}(exifTool)
|
||||||
|
|
||||||
|
fileMetadata := exifTool.ExtractMetadata(inputPath)
|
||||||
|
if fileMetadata[0].Err != nil {
|
||||||
|
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range metadata {
|
||||||
|
switch val := value.(type) {
|
||||||
|
case string:
|
||||||
|
fileMetadata[0].SetString(key, val)
|
||||||
|
case []string:
|
||||||
|
fileMetadata[0].SetStrings(key, val)
|
||||||
|
case bool:
|
||||||
|
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
|
||||||
|
case int:
|
||||||
|
fileMetadata[0].SetInt(key, int64(val))
|
||||||
|
case int64:
|
||||||
|
fileMetadata[0].SetInt(key, val)
|
||||||
|
case float32:
|
||||||
|
fileMetadata[0].SetFloat(key, float64(val))
|
||||||
|
case float64:
|
||||||
|
fileMetadata[0].SetFloat(key, val)
|
||||||
|
// TODO: support more complex cases, e.g., arrays and nested objects
|
||||||
|
// (limitations in underlying library).
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("write PDF metadata with ExifTool: %w", gotenberg.ErrPdfEngineMetadataValueNotSupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exifTool.WriteMetadata(fileMetadata)
|
||||||
|
if fileMetadata[0].Err != nil {
|
||||||
|
return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface guards.
|
||||||
|
var (
|
||||||
|
_ gotenberg.Module = (*ExifTool)(nil)
|
||||||
|
_ gotenberg.Provisioner = (*ExifTool)(nil)
|
||||||
|
_ gotenberg.Validator = (*ExifTool)(nil)
|
||||||
|
_ gotenberg.PdfEngine = (*ExifTool)(nil)
|
||||||
|
)
|
||||||
322
pkg/modules/exiftool/exiftool_test.go
Normal file
322
pkg/modules/exiftool/exiftool_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package exiftool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExifTool_Descriptor(t *testing.T) {
|
||||||
|
descriptor := new(ExifTool).Descriptor()
|
||||||
|
|
||||||
|
actual := reflect.TypeOf(descriptor.New())
|
||||||
|
expect := reflect.TypeOf(new(ExifTool))
|
||||||
|
|
||||||
|
if actual != expect {
|
||||||
|
t.Errorf("expected '%s' but got '%s'", expect, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExifTool_Provision(t *testing.T) {
|
||||||
|
engine := new(ExifTool)
|
||||||
|
ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
|
||||||
|
|
||||||
|
err := engine.Provision(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExifTool_Validate(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
binPath string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "empty bin path",
|
||||||
|
binPath: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "bin path does not exist",
|
||||||
|
binPath: "/foo",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "validate success",
|
||||||
|
binPath: os.Getenv("EXIFTOOL_BIN_PATH"),
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
engine := new(ExifTool)
|
||||||
|
engine.binPath = tc.binPath
|
||||||
|
err := engine.Validate()
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExiftool_Merge(t *testing.T) {
|
||||||
|
engine := new(ExifTool)
|
||||||
|
err := engine.Merge(context.Background(), zap.NewNop(), nil, "")
|
||||||
|
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||||
|
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExiftool_Convert(t *testing.T) {
|
||||||
|
engine := new(ExifTool)
|
||||||
|
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
|
||||||
|
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||||
|
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExiftool_ReadMetadata(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
inputPath string
|
||||||
|
expectMetadata map[string]interface{}
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "invalid input path",
|
||||||
|
inputPath: "foo",
|
||||||
|
expectMetadata: nil,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
|
expectMetadata: map[string]interface{}{
|
||||||
|
"FileName": "sample1.pdf",
|
||||||
|
"FileTypeExtension": "pdf",
|
||||||
|
"MIMEType": "application/pdf",
|
||||||
|
"PDFVersion": 1.4,
|
||||||
|
"PageCount": float64(3),
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
engine := new(ExifTool)
|
||||||
|
err := engine.Provision(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), tc.inputPath)
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectMetadata != nil && err == nil {
|
||||||
|
for k, v := range tc.expectMetadata {
|
||||||
|
if v2, ok := metadata[k]; !ok || v != v2 {
|
||||||
|
t.Errorf("expected entry %s with value %v to exists", k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExiftool_WriteMetadata(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
createCopy bool
|
||||||
|
inputPath string
|
||||||
|
metadata map[string]interface{}
|
||||||
|
expectMetadata map[string]interface{}
|
||||||
|
expectError bool
|
||||||
|
expectedError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "invalid input path",
|
||||||
|
createCopy: false,
|
||||||
|
inputPath: "foo",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported",
|
||||||
|
createCopy: true,
|
||||||
|
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"Unsupported": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
createCopy: true,
|
||||||
|
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
|
metadata: map[string]interface{}{
|
||||||
|
"Author": "Julien Neuhart",
|
||||||
|
"Copyright": "Julien Neuhart",
|
||||||
|
"CreationDate": "2006-09-18T16:27:50-04:00",
|
||||||
|
"Creator": "Gotenberg",
|
||||||
|
"Keywords": []string{
|
||||||
|
"first",
|
||||||
|
"second",
|
||||||
|
},
|
||||||
|
"Marked": true,
|
||||||
|
"ModDate": "2006-09-18T16:27:50-04:00",
|
||||||
|
"PDFVersion": 1.7,
|
||||||
|
"Producer": "Gotenberg",
|
||||||
|
"Subject": "Sample",
|
||||||
|
"Title": "Sample",
|
||||||
|
"Trapped": "Unknown",
|
||||||
|
// Those are not valid PDF metadata.
|
||||||
|
"int": 1,
|
||||||
|
"int64": int64(2),
|
||||||
|
"float32": float32(2.2),
|
||||||
|
"float64": 3.3,
|
||||||
|
},
|
||||||
|
expectMetadata: map[string]interface{}{
|
||||||
|
"Author": "Julien Neuhart",
|
||||||
|
"Copyright": "Julien Neuhart",
|
||||||
|
"CreationDate": "2006:09:18 16:27:50-04:00",
|
||||||
|
"Creator": "Gotenberg",
|
||||||
|
"Keywords": []interface{}{
|
||||||
|
"first",
|
||||||
|
"second",
|
||||||
|
},
|
||||||
|
"Marked": true,
|
||||||
|
"ModDate": "2006:09:18 16:27:50-04:00",
|
||||||
|
"PDFVersion": 1.7,
|
||||||
|
"Producer": "Gotenberg",
|
||||||
|
"Subject": "Sample",
|
||||||
|
"Title": "Sample",
|
||||||
|
"Trapped": "Unknown",
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
engine := new(ExifTool)
|
||||||
|
err := engine.Provision(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinationPath string
|
||||||
|
if tc.createCopy {
|
||||||
|
fs := gotenberg.NewFileSystem()
|
||||||
|
outputDir, err := fs.MkdirAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected error no but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err = os.RemoveAll(fs.WorkingDirPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error while cleaning up but got: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
|
||||||
|
source, err := os.Open(tc.inputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open source file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(source *os.File) {
|
||||||
|
err := source.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close file: %v", err)
|
||||||
|
}
|
||||||
|
}(source)
|
||||||
|
|
||||||
|
destination, err := os.Create(destinationPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create destination file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func(destination *os.File) {
|
||||||
|
err := destination.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close file: %v", err)
|
||||||
|
}
|
||||||
|
}(destination)
|
||||||
|
|
||||||
|
_, err = io.Copy(destination, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy source into destination: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
destinationPath = tc.inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
err = engine.WriteMetadata(context.Background(), zap.NewNop(), tc.metadata, destinationPath)
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||||
|
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := engine.ReadMetadata(context.Background(), zap.NewNop(), destinationPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectMetadata != nil && err == nil {
|
||||||
|
for k, v := range tc.expectMetadata {
|
||||||
|
v2, ok := metadata[k]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("expected entry %s with value %v to exists, but got none", k, v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v2.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for i, entry := range v.([]interface{}) {
|
||||||
|
if entry != v2.([]interface{})[i] {
|
||||||
|
t.Errorf("expected entry %s to contain value %v, but got %v", k, entry, v2.([]interface{})[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if v != v2 {
|
||||||
|
t.Errorf("expected entry %s with value %v to exists, but got %v", k, v, v2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"go.uber.org/multierr"
|
"go.uber.org/multierr"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -21,13 +21,20 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrInvalidPdfFormat happens if the PDF format option cannot be handled
|
// ErrInvalidPdfFormats happens if the PDF formats option cannot be handled
|
||||||
// by LibreOffice.
|
// by LibreOffice.
|
||||||
ErrInvalidPdfFormat = errors.New("invalid PDF format")
|
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||||
|
|
||||||
// ErrMalformedPageRanges happens if the page ranges option cannot be
|
// ErrUnoException happens when unoconverter returns an exit code 5.
|
||||||
// interpreted by LibreOffice.
|
ErrUnoException = errors.New("uno exception")
|
||||||
ErrMalformedPageRanges = errors.New("page ranges are malformed")
|
|
||||||
|
// 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")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Api is a module which provides a [Uno] to interact with LibreOffice.
|
// Api is a module which provides a [Uno] to interact with LibreOffice.
|
||||||
@@ -41,22 +48,133 @@ type Api struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Options gathers available options when converting a document to PDF.
|
// 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 {
|
type Options struct {
|
||||||
|
// Password specifies the password for opening the source file.
|
||||||
|
Password string
|
||||||
|
|
||||||
// Landscape allows to change the orientation of the resulting PDF.
|
// Landscape allows to change the orientation of the resulting PDF.
|
||||||
// Optional.
|
|
||||||
Landscape bool
|
Landscape bool
|
||||||
|
|
||||||
// PageRanges allows to select the pages to convert.
|
// PageRanges allows to select the pages to convert.
|
||||||
// TODO: should prefer a method form PdfEngine.
|
|
||||||
// Optional.
|
|
||||||
PageRanges string
|
PageRanges string
|
||||||
|
|
||||||
// PdfFormats allows to convert the resulting PDF to PDF/A-1a, PDF/A-2b,
|
// 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 sheet’s 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.
|
// PDF/A-3b and PDF/UA.
|
||||||
// Optional.
|
|
||||||
PdfFormats gotenberg.PdfFormats
|
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.
|
// Uno is an abstraction on top of the Universal Network Objects API.
|
||||||
type Uno interface {
|
type Uno interface {
|
||||||
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||||
@@ -80,24 +198,10 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
ID: "libreoffice-api",
|
ID: "libreoffice-api",
|
||||||
FlagSet: func() *flag.FlagSet {
|
FlagSet: func() *flag.FlagSet {
|
||||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
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.Int("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-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.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
|
||||||
fs.Duration("libreoffice-start-timeout", time.Duration(10)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
|
||||||
|
|
||||||
return fs
|
return fs
|
||||||
}(),
|
}(),
|
||||||
@@ -123,7 +227,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
a.args = libreOfficeArguments{
|
a.args = libreOfficeArguments{
|
||||||
binPath: libreOfficeBinPath,
|
binPath: libreOfficeBinPath,
|
||||||
unoBinPath: unoBinPath,
|
unoBinPath: unoBinPath,
|
||||||
startTimeout: flags.MustDeprecatedDuration("uno-listener-start-timeout", "libreoffice-start-timeout"),
|
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger.
|
// Logger.
|
||||||
@@ -139,7 +243,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustDeprecatedInt64("uno-listener-restart-threshold", "libreoffice-restart-after"))
|
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -204,46 +308,6 @@ func (a *Api) Stop(ctx context.Context) error {
|
|||||||
// Metrics returns the metrics.
|
// Metrics returns the metrics.
|
||||||
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
|
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
|
||||||
return []gotenberg.Metric{
|
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",
|
Name: "libreoffice_requests_queue_size",
|
||||||
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
|
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
|
||||||
@@ -277,6 +341,34 @@ func (a *Api) Checks() ([]health.CheckerOption, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ready returns no error if the module is ready.
|
||||||
|
func (a *Api) Ready() error {
|
||||||
|
if !a.autoStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), a.args.startTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
ticker.Stop()
|
||||||
|
return fmt.Errorf("context done while waiting for LibreOffice to be ready: %w", ctx.Err())
|
||||||
|
case <-ticker.C:
|
||||||
|
ok := a.libreOffice.Healthy(a.logger)
|
||||||
|
if ok {
|
||||||
|
ticker.Stop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LibreOffice returns a [Uno] for interacting with LibreOffice.
|
// LibreOffice returns a [Uno] for interacting with LibreOffice.
|
||||||
func (a *Api) LibreOffice() (Uno, error) {
|
func (a *Api) LibreOffice() (Uno, error) {
|
||||||
return a, nil
|
return a, nil
|
||||||
@@ -284,94 +376,157 @@ func (a *Api) LibreOffice() (Uno, error) {
|
|||||||
|
|
||||||
// Pdf converts a document to PDF.
|
// Pdf converts a document to PDF.
|
||||||
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||||
return a.supervisor.Run(ctx, logger, func() error {
|
err := a.supervisor.Run(ctx, logger, func() error {
|
||||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
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.
|
// Extensions returns the file extensions available for conversions.
|
||||||
// FIXME: don't care, take all on the route level?
|
// FIXME: don't care, take all on the route level?
|
||||||
func (a *Api) Extensions() []string {
|
func (a *Api) Extensions() []string {
|
||||||
return []string{
|
return []string{
|
||||||
|
".123",
|
||||||
|
".602",
|
||||||
|
".abw",
|
||||||
".bib",
|
".bib",
|
||||||
".doc",
|
|
||||||
".xml",
|
|
||||||
".docx",
|
|
||||||
".fodt",
|
|
||||||
".html",
|
|
||||||
".ltx",
|
|
||||||
".txt",
|
|
||||||
".odt",
|
|
||||||
".ott",
|
|
||||||
".pdb",
|
|
||||||
".pdf",
|
|
||||||
".psw",
|
|
||||||
".rtf",
|
|
||||||
".sdw",
|
|
||||||
".stw",
|
|
||||||
".sxw",
|
|
||||||
".uot",
|
|
||||||
".vor",
|
|
||||||
".wps",
|
|
||||||
".epub",
|
|
||||||
".png",
|
|
||||||
".bmp",
|
".bmp",
|
||||||
|
".cdr",
|
||||||
|
".cgm",
|
||||||
|
".cmx",
|
||||||
|
".csv",
|
||||||
|
".cwk",
|
||||||
|
".dbf",
|
||||||
|
".dif",
|
||||||
|
".doc",
|
||||||
|
".docm",
|
||||||
|
".docx",
|
||||||
|
".dot",
|
||||||
|
".dotm",
|
||||||
|
".dotx",
|
||||||
|
".dxf",
|
||||||
".emf",
|
".emf",
|
||||||
".eps",
|
".eps",
|
||||||
|
".epub",
|
||||||
".fodg",
|
".fodg",
|
||||||
|
".fodp",
|
||||||
|
".fods",
|
||||||
|
".fodt",
|
||||||
|
".fopd",
|
||||||
".gif",
|
".gif",
|
||||||
".jpg",
|
".htm",
|
||||||
|
".html",
|
||||||
|
".hwp",
|
||||||
".jpeg",
|
".jpeg",
|
||||||
|
".jpg",
|
||||||
|
".key",
|
||||||
|
".ltx",
|
||||||
|
".lwp",
|
||||||
|
".mcw",
|
||||||
".met",
|
".met",
|
||||||
|
".mml",
|
||||||
|
".mw",
|
||||||
|
".numbers",
|
||||||
".odd",
|
".odd",
|
||||||
|
".odg",
|
||||||
|
".odm",
|
||||||
|
".odp",
|
||||||
|
".ods",
|
||||||
|
".odt",
|
||||||
".otg",
|
".otg",
|
||||||
|
".oth",
|
||||||
|
".otp",
|
||||||
|
".ots",
|
||||||
|
".ott",
|
||||||
|
".pages",
|
||||||
".pbm",
|
".pbm",
|
||||||
|
".pcd",
|
||||||
".pct",
|
".pct",
|
||||||
|
".pcx",
|
||||||
|
".pdb",
|
||||||
|
".pdf",
|
||||||
".pgm",
|
".pgm",
|
||||||
|
".png",
|
||||||
|
".pot",
|
||||||
|
".potm",
|
||||||
|
".potx",
|
||||||
".ppm",
|
".ppm",
|
||||||
|
".pps",
|
||||||
|
".ppt",
|
||||||
|
".pptm",
|
||||||
|
".pptx",
|
||||||
|
".psd",
|
||||||
|
".psw",
|
||||||
|
".pub",
|
||||||
|
".pwp",
|
||||||
|
".pxl",
|
||||||
".ras",
|
".ras",
|
||||||
|
".rtf",
|
||||||
|
".sda",
|
||||||
|
".sdc",
|
||||||
|
".sdd",
|
||||||
|
".sdp",
|
||||||
|
".sdw",
|
||||||
|
".sgl",
|
||||||
|
".slk",
|
||||||
|
".smf",
|
||||||
|
".stc",
|
||||||
".std",
|
".std",
|
||||||
|
".sti",
|
||||||
|
".stw",
|
||||||
".svg",
|
".svg",
|
||||||
".svm",
|
".svm",
|
||||||
".swf",
|
".swf",
|
||||||
|
".sxc",
|
||||||
".sxd",
|
".sxd",
|
||||||
|
".sxg",
|
||||||
|
".sxi",
|
||||||
|
".sxm",
|
||||||
".sxw",
|
".sxw",
|
||||||
|
".tga",
|
||||||
".tif",
|
".tif",
|
||||||
".tiff",
|
".tiff",
|
||||||
".xhtml",
|
".txt",
|
||||||
".xpm",
|
".uof",
|
||||||
".odp",
|
|
||||||
".fodp",
|
|
||||||
".potm",
|
|
||||||
".pot",
|
|
||||||
".pptx",
|
|
||||||
".pps",
|
|
||||||
".ppt",
|
|
||||||
".pwp",
|
|
||||||
".sda",
|
|
||||||
".sdd",
|
|
||||||
".sti",
|
|
||||||
".sxi",
|
|
||||||
".uop",
|
".uop",
|
||||||
".wmf",
|
|
||||||
".csv",
|
|
||||||
".dbf",
|
|
||||||
".dif",
|
|
||||||
".fods",
|
|
||||||
".ods",
|
|
||||||
".ots",
|
|
||||||
".pxl",
|
|
||||||
".sdc",
|
|
||||||
".slk",
|
|
||||||
".stc",
|
|
||||||
".sxc",
|
|
||||||
".uos",
|
".uos",
|
||||||
|
".uot",
|
||||||
|
".vdx",
|
||||||
|
".vor",
|
||||||
|
".vsd",
|
||||||
|
".vsdm",
|
||||||
|
".vsdx",
|
||||||
|
".wb2",
|
||||||
|
".wk1",
|
||||||
|
".wks",
|
||||||
|
".wmf",
|
||||||
|
".wpd",
|
||||||
|
".wpg",
|
||||||
|
".wps",
|
||||||
|
".xbm",
|
||||||
|
".xhtml",
|
||||||
".xls",
|
".xls",
|
||||||
".xlt",
|
".xlsb",
|
||||||
|
".xlsm",
|
||||||
".xlsx",
|
".xlsx",
|
||||||
".odg",
|
".xlt",
|
||||||
".dotx",
|
".xltm",
|
||||||
".xltx",
|
".xltx",
|
||||||
|
".xlw",
|
||||||
|
".xml",
|
||||||
|
".xpm",
|
||||||
|
".zabw",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,18 @@ import (
|
|||||||
"github.com/alexliesenfeld/health"
|
"github.com/alexliesenfeld/health"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/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) {
|
func TestApi_Descriptor(t *testing.T) {
|
||||||
descriptor := new(Api).Descriptor()
|
descriptor := new(Api).Descriptor()
|
||||||
|
|
||||||
@@ -284,41 +293,16 @@ func TestApi_Metrics(t *testing.T) {
|
|||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(metrics) != 7 {
|
if len(metrics) != 2 {
|
||||||
t.Fatalf("expected %d metrics, but got %d", 7, len(metrics))
|
t.Fatalf("expected %d metrics, but got %d", 2, len(metrics))
|
||||||
}
|
}
|
||||||
|
|
||||||
actual := metrics[0].Read()
|
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) {
|
if actual != float64(10) {
|
||||||
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
|
t.Errorf("expected %f for libreoffice_requests_queue_size, but got %f", float64(10), actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual = metrics[6].Read()
|
actual = metrics[1].Read()
|
||||||
if actual != float64(0) {
|
if actual != float64(0) {
|
||||||
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
|
t.Errorf("expected %f for libreoffice_restarts_count, but got %f", float64(0), actual)
|
||||||
}
|
}
|
||||||
@@ -364,6 +348,61 @@ func TestApi_Checks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChromium_Ready(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
autoStart bool
|
||||||
|
startTimeout time.Duration
|
||||||
|
libreOffice libreOffice
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "no auto-start",
|
||||||
|
autoStart: false,
|
||||||
|
startTimeout: time.Duration(30) * time.Second,
|
||||||
|
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return false
|
||||||
|
}}},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "auto-start: context done",
|
||||||
|
autoStart: true,
|
||||||
|
startTimeout: time.Duration(200) * time.Millisecond,
|
||||||
|
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return false
|
||||||
|
}}},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "auto-start success",
|
||||||
|
autoStart: true,
|
||||||
|
startTimeout: time.Duration(30) * time.Second,
|
||||||
|
libreOffice: &libreOfficeMock{ProcessMock: gotenberg.ProcessMock{HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return true
|
||||||
|
}}},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
a := new(Api)
|
||||||
|
a.autoStart = tc.autoStart
|
||||||
|
a.args = libreOfficeArguments{startTimeout: tc.startTimeout}
|
||||||
|
a.libreOffice = tc.libreOffice
|
||||||
|
|
||||||
|
err := a.Ready()
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApi_LibreOffice(t *testing.T) {
|
func TestApi_LibreOffice(t *testing.T) {
|
||||||
a := new(Api)
|
a := new(Api)
|
||||||
|
|
||||||
@@ -394,6 +433,13 @@ func TestApi_Pdf(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
expectError: true,
|
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) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
a := new(Api)
|
a := new(Api)
|
||||||
@@ -420,7 +466,7 @@ func TestApi_Extensions(t *testing.T) {
|
|||||||
extensions := a.Extensions()
|
extensions := a.Extensions()
|
||||||
|
|
||||||
actual := len(extensions)
|
actual := len(extensions)
|
||||||
expect := 79
|
expect := 130
|
||||||
|
|
||||||
if actual != expect {
|
if actual != expect {
|
||||||
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
t.Errorf("expected %d extensions, but got %d", expect, actual)
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type libreOffice interface {
|
type libreOffice interface {
|
||||||
@@ -262,6 +266,10 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
|||||||
args = append(args, "-vvv")
|
args = append(args, "-vvv")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if options.Password != "" {
|
||||||
|
args = append(args, "--password", options.Password)
|
||||||
|
}
|
||||||
|
|
||||||
if options.Landscape {
|
if options.Landscape {
|
||||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||||
}
|
}
|
||||||
@@ -270,24 +278,58 @@ 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("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 {
|
switch options.PdfFormats.PdfA {
|
||||||
case "":
|
case "":
|
||||||
case gotenberg.PdfA1a:
|
case gotenberg.PdfA1b:
|
||||||
args = append(args, "--export", "SelectPdfVersion=1")
|
args = append(args, "--export", "SelectPdfVersion=1")
|
||||||
case gotenberg.PdfA2b:
|
case gotenberg.PdfA2b:
|
||||||
args = append(args, "--export", "SelectPdfVersion=2")
|
args = append(args, "--export", "SelectPdfVersion=2")
|
||||||
case gotenberg.PdfA3b:
|
case gotenberg.PdfA3b:
|
||||||
args = append(args, "--export", "SelectPdfVersion=3")
|
args = append(args, "--export", "SelectPdfVersion=3")
|
||||||
default:
|
default:
|
||||||
return ErrInvalidPdfFormat
|
return ErrInvalidPdfFormats
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.PdfFormats.PdfUa {
|
if options.PdfFormats.PdfUa {
|
||||||
args = append(
|
args = append(
|
||||||
args,
|
args,
|
||||||
"--export", "EnableTextAccessForAccessibilityTools=true",
|
"--export", "PDFUACompliance=true",
|
||||||
"--export", "UseTaggedPDF=true",
|
"--export", "UseTaggedPDF=true",
|
||||||
|
"--export", "EnableTextAccessForAccessibilityTools=true",
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
args = append(
|
||||||
|
args,
|
||||||
|
"--export", "PDFUACompliance=false",
|
||||||
|
"--export", "UseTaggedPDF=false",
|
||||||
|
"--export", "EnableTextAccessForAccessibilityTools=false",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inputPath, err := nonBasicLatinCharactersGuard(logger, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("non-basic latin characters guard: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, "--output", outputPath, inputPath)
|
args = append(args, "--output", outputPath, inputPath)
|
||||||
@@ -305,10 +347,22 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LibreOffice's errors are not explicit.
|
// LibreOffice's errors are not explicit.
|
||||||
// That's why we have to make an educated guess according to the exit code
|
// For instance, an exit code 5 may be explained by a malformed page
|
||||||
// and given inputs.
|
// ranges, but also by a not required password.
|
||||||
if exitCode == 5 && options.PageRanges != "" {
|
|
||||||
return ErrMalformedPageRanges
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Possible errors:
|
// Possible errors:
|
||||||
@@ -321,6 +375,65 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
|||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LibreOffice cannot convert a file with a name containing non-basic Latin
|
||||||
|
// characters.
|
||||||
|
// See:
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/104
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/730
|
||||||
|
func nonBasicLatinCharactersGuard(logger *zap.Logger, inputPath string) (string, error) {
|
||||||
|
hasNonBasicLatinChars := func(str string) bool {
|
||||||
|
for _, r := range str {
|
||||||
|
// Check if the character is outside basic Latin.
|
||||||
|
if r != '.' && (r < ' ' || r > '~') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Base(inputPath)
|
||||||
|
if !hasNonBasicLatinChars(filename) {
|
||||||
|
logger.Debug("no non-basic latin characters in filename, skip copy")
|
||||||
|
return inputPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warn("non-basic latin characters in filename, copy to a file with a valid filename")
|
||||||
|
basePath := filepath.Dir(inputPath)
|
||||||
|
ext := filepath.Ext(inputPath)
|
||||||
|
newInputPath := filepath.Join(basePath, fmt.Sprintf("%s%s", uuid.NewString(), ext))
|
||||||
|
|
||||||
|
in, err := os.Open(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := in.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("close file: %s", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
out, err := os.Create(newInputPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create new file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := out.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(fmt.Sprintf("close new file: %s", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("copy file to new file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newInputPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Process = (*libreOfficeProcess)(nil)
|
_ gotenberg.Process = (*libreOfficeProcess)(nil)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,7 +12,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLibreOfficeProcess_Start(t *testing.T) {
|
func TestLibreOfficeProcess_Start(t *testing.T) {
|
||||||
@@ -235,7 +236,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrInvalidPdfFormat",
|
scenario: "ErrInvalidPdfFormats",
|
||||||
libreOffice: func() libreOffice {
|
libreOffice: func() libreOffice {
|
||||||
p := new(libreOfficeProcess)
|
p := new(libreOfficeProcess)
|
||||||
p.socketPort = 12345
|
p.socketPort = 12345
|
||||||
@@ -247,10 +248,10 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
cancelledCtx: false,
|
cancelledCtx: false,
|
||||||
start: false,
|
start: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: ErrInvalidPdfFormat,
|
expectedError: ErrInvalidPdfFormats,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrMalformedPageRanges",
|
scenario: "ErrUnoException",
|
||||||
libreOffice: newLibreOfficeProcess(
|
libreOffice: newLibreOfficeProcess(
|
||||||
libreOfficeArguments{
|
libreOfficeArguments{
|
||||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||||
@@ -267,7 +268,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("ErrMalformedPageRanges"), 0o755)
|
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Context done"), 0o755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
@@ -277,7 +278,61 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
cancelledCtx: false,
|
cancelledCtx: false,
|
||||||
start: true,
|
start: true,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: ErrMalformedPageRanges,
|
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()
|
||||||
|
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "context done",
|
scenario: "context done",
|
||||||
@@ -336,7 +391,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
expectError: false,
|
expectError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "success (landscape)",
|
scenario: "success (not default options)",
|
||||||
libreOffice: newLibreOfficeProcess(
|
libreOffice: newLibreOfficeProcess(
|
||||||
libreOfficeArguments{
|
libreOfficeArguments{
|
||||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||||
@@ -352,20 +407,43 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Success"), 0o755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fs
|
return fs
|
||||||
}(),
|
}(),
|
||||||
options: Options{Landscape: true},
|
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,
|
||||||
|
},
|
||||||
cancelledCtx: false,
|
cancelledCtx: false,
|
||||||
start: true,
|
start: true,
|
||||||
expectError: false,
|
expectError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "success (page ranges)",
|
scenario: "success (PDF/A-1b)",
|
||||||
libreOffice: newLibreOfficeProcess(
|
libreOffice: newLibreOfficeProcess(
|
||||||
libreOfficeArguments{
|
libreOfficeArguments{
|
||||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||||
@@ -388,36 +466,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
|
|
||||||
return fs
|
return fs
|
||||||
}(),
|
}(),
|
||||||
options: Options{PageRanges: "1-1"},
|
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b}},
|
||||||
cancelledCtx: false,
|
|
||||||
start: true,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "success (PDF/A-1a)",
|
|
||||||
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{PdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1a}},
|
|
||||||
cancelledCtx: false,
|
cancelledCtx: false,
|
||||||
start: true,
|
start: true,
|
||||||
expectError: false,
|
expectError: false,
|
||||||
@@ -545,7 +594,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
err := tc.libreOffice.pdf(
|
err := tc.libreOffice.pdf(
|
||||||
ctx,
|
ctx,
|
||||||
logger,
|
logger,
|
||||||
fmt.Sprintf("file://%s/document.txt", tc.fs.WorkingDirPath()),
|
fmt.Sprintf("%s/document.txt", tc.fs.WorkingDirPath()),
|
||||||
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
|
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
|
||||||
tc.options,
|
tc.options,
|
||||||
)
|
)
|
||||||
@@ -564,3 +613,87 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNonBasicLatinCharactersGuard(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
fs *gotenberg.FileSystem
|
||||||
|
filename string
|
||||||
|
expectSameInputPath bool
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "basic latin characters",
|
||||||
|
fs: func() *gotenberg.FileSystem {
|
||||||
|
fs := gotenberg.NewFileSystem()
|
||||||
|
|
||||||
|
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Basic latin characters"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}(),
|
||||||
|
filename: "document.txt",
|
||||||
|
expectSameInputPath: true,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "non-basic latin characters",
|
||||||
|
fs: func() *gotenberg.FileSystem {
|
||||||
|
fs := gotenberg.NewFileSystem()
|
||||||
|
|
||||||
|
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/éèßàùä.txt", fs.WorkingDirPath()), []byte("Non-basic latin characters"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}(),
|
||||||
|
filename: "éèßàùä.txt",
|
||||||
|
expectSameInputPath: false,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
err := os.RemoveAll(tc.fs.WorkingDirPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error while cleaning up, but got: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
inputPath := fmt.Sprintf("%s/%s", tc.fs.WorkingDirPath(), tc.filename)
|
||||||
|
newInputPath, err := nonBasicLatinCharactersGuard(
|
||||||
|
zap.NewNop(),
|
||||||
|
inputPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectSameInputPath && newInputPath != inputPath {
|
||||||
|
t.Fatalf("expected same input path, but got '%s'", newInputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectSameInputPath && newInputPath == inputPath {
|
||||||
|
t.Fatalf("expected different input path, but got same '%s'", newInputPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ApiMock is a mock for the [Uno] interface.
|
// ApiMock is a mock for the [Uno] interface.
|
||||||
@@ -33,12 +34,21 @@ func (provider *ProviderMock) LibreOffice() (Uno, error) {
|
|||||||
|
|
||||||
// libreOfficeMock is a mock for the [libreOffice] interface.
|
// libreOfficeMock is a mock for the [libreOffice] interface.
|
||||||
type libreOfficeMock struct {
|
type libreOfficeMock struct {
|
||||||
|
errCoreDumpedCount int
|
||||||
|
|
||||||
gotenberg.ProcessMock
|
gotenberg.ProcessMock
|
||||||
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
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 {
|
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||||
return b.pdfMock(ctx, logger, inputPath, outputPath, options)
|
err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
|
||||||
|
if errors.Is(err, ErrCoreDumped) {
|
||||||
|
b.errCoreDumpedCount += 1
|
||||||
|
}
|
||||||
|
if b.errCoreDumpedCount > 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
|
|||||||
@@ -42,14 +42,53 @@ func TestProviderMock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLibreOfficeMock(t *testing.T) {
|
func TestLibreOfficeMock(t *testing.T) {
|
||||||
mock := &libreOfficeMock{
|
for _, tc := range []struct {
|
||||||
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
scenario string
|
||||||
return nil
|
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,
|
||||||
},
|
},
|
||||||
}
|
{
|
||||||
|
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{})
|
||||||
|
|
||||||
err := mock.pdf(context.Background(), zap.NewNop(), "", "", Options{})
|
if !tc.expectError && err != nil {
|
||||||
if err != nil {
|
t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err)
|
||||||
t.Errorf("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")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
|
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
libeofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
|
libeofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
|
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLibreOffice_Descriptor(t *testing.T) {
|
func TestLibreOffice_Descriptor(t *testing.T) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// Package pdfengine provides a module which interacts with LibreOffice via the
|
// Package pdfengine provides a module which interacts with LibreOffice via the
|
||||||
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
|
// UNO (Universal Network Objects) API and implements the gotenberg.PdfEngine
|
||||||
// interface.
|
// interface. This package allows for:
|
||||||
|
//
|
||||||
|
// 1. The conversion to specific PDF formats.
|
||||||
package pdfengine
|
package pdfengine
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -18,7 +18,7 @@ func init() {
|
|||||||
// LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API
|
// LibreOfficePdfEngine interacts with the LibreOffice (Universal Network Objects) API
|
||||||
// and implements the [gotenberg.PdfEngine] interface.
|
// and implements the [gotenberg.PdfEngine] interface.
|
||||||
type LibreOfficePdfEngine struct {
|
type LibreOfficePdfEngine struct {
|
||||||
unoAPI api.Uno
|
unoApi api.Uno
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns a [LibreOfficePdfEngine]'s module descriptor.
|
// Descriptor returns a [LibreOfficePdfEngine]'s module descriptor.
|
||||||
@@ -36,12 +36,12 @@ func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error {
|
|||||||
return fmt.Errorf("get LibreOffice Uno provider: %w", err)
|
return fmt.Errorf("get LibreOffice Uno provider: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unoAPI, err := provider.(api.Provider).LibreOffice()
|
unoApi, err := provider.(api.Provider).LibreOffice()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get LibreOffice Uno: %w", err)
|
return fmt.Errorf("get LibreOffice Uno: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
engine.unoAPI = unoAPI
|
engine.unoApi = unoApi
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -52,25 +52,35 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
// Convert converts the given PDF to a specific PDF format. Currently, only the
|
||||||
// PDF/A-1a, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
|
||||||
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||||
// error.
|
// error.
|
||||||
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
err := engine.unoAPI.Pdf(ctx, logger, inputPath, outputPath, api.Options{
|
opts := api.DefaultOptions()
|
||||||
PdfFormats: formats,
|
opts.PdfFormats = formats
|
||||||
})
|
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, api.ErrInvalidPdfFormat) {
|
if errors.Is(err, api.ErrInvalidPdfFormats) {
|
||||||
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported)
|
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)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
|
func TestLibreOfficePdfEngine_Descriptor(t *testing.T) {
|
||||||
@@ -137,7 +137,7 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
|
|||||||
scenario: "invalid PDF format",
|
scenario: "invalid PDF format",
|
||||||
api: &api.ApiMock{
|
api: &api.ApiMock{
|
||||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
|
||||||
return api.ErrInvalidPdfFormat
|
return api.ErrInvalidPdfFormats
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectError: true,
|
expectError: true,
|
||||||
@@ -153,7 +153,7 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
engine := &LibreOfficePdfEngine{unoAPI: tc.api}
|
engine := &LibreOfficePdfEngine{unoApi: tc.api}
|
||||||
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
|
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
@@ -166,3 +166,21 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
|
||||||
|
engine := new(LibreOfficePdfEngine)
|
||||||
|
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||||
|
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||||
|
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
|
||||||
|
engine := new(LibreOfficePdfEngine)
|
||||||
|
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
|
||||||
|
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||||
|
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package libreoffice
|
package libreoffice
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
|
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
||||||
@@ -21,73 +24,122 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
IsMultipart: true,
|
IsMultipart: true,
|
||||||
Handler: func(c echo.Context) error {
|
Handler: func(c echo.Context) error {
|
||||||
ctx := c.Get("context").(*api.Context)
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
defaultOptions := libreofficeapi.DefaultOptions()
|
||||||
|
|
||||||
// Let's get the data from the form and validate them.
|
// Let's get the data from the form and validate them.
|
||||||
var (
|
var (
|
||||||
inputPaths []string
|
inputPaths []string
|
||||||
landscape bool
|
password string
|
||||||
nativePageRanges string
|
landscape bool
|
||||||
nativePdfA1aFormat bool
|
nativePageRanges string
|
||||||
nativePdfFormat string
|
exportFormFields bool
|
||||||
pdfFormat string
|
allowDuplicateFieldNames bool
|
||||||
pdfa string
|
exportBookmarks bool
|
||||||
pdfua bool
|
exportBookmarksToPdfDestination bool
|
||||||
nativePdfFormats bool
|
exportPlaceholders bool
|
||||||
merge 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
|
||||||
|
pdfa string
|
||||||
|
pdfua bool
|
||||||
|
nativePdfFormats bool
|
||||||
|
merge bool
|
||||||
|
metadata map[string]interface{}
|
||||||
)
|
)
|
||||||
|
|
||||||
err := ctx.FormData().
|
err := ctx.FormData().
|
||||||
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
|
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
|
||||||
Bool("landscape", &landscape, false).
|
String("password", &password, defaultOptions.Password).
|
||||||
String("nativePageRanges", &nativePageRanges, "").
|
Bool("landscape", &landscape, defaultOptions.Landscape).
|
||||||
Bool("nativePdfA1aFormat", &nativePdfA1aFormat, false).
|
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
|
||||||
String("nativePdfFormat", &nativePdfFormat, "").
|
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
|
||||||
String("pdfFormat", &pdfFormat, "").
|
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
|
||||||
|
}).
|
||||||
String("pdfa", &pdfa, "").
|
String("pdfa", &pdfa, "").
|
||||||
Bool("pdfua", &pdfua, false).
|
Bool("pdfua", &pdfua, false).
|
||||||
Bool("nativePdfFormats", &nativePdfFormats, true).
|
Bool("nativePdfFormats", &nativePdfFormats, true).
|
||||||
Bool("merge", &merge, false).
|
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()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
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{
|
pdfFormats := gotenberg.PdfFormats{
|
||||||
PdfA: actualPdfArchive,
|
PdfA: pdfa,
|
||||||
PdfUa: pdfua,
|
PdfUa: pdfua,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,22 +147,58 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
outputPaths := make([]string, len(inputPaths))
|
outputPaths := make([]string, len(inputPaths))
|
||||||
for i, inputPath := range inputPaths {
|
for i, inputPath := range inputPaths {
|
||||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
options := libreofficeapi.Options{
|
options := libreofficeapi.Options{
|
||||||
Landscape: landscape,
|
Password: password,
|
||||||
PageRanges: nativePageRanges,
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
if nativeFormats {
|
if nativePdfFormats {
|
||||||
options.PdfFormats = pdfFormats
|
options.PdfFormats = pdfFormats
|
||||||
}
|
}
|
||||||
|
|
||||||
err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options)
|
err = libreOffice.Pdf(ctx, ctx.Log(), inputPath, outputPaths[i], options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) {
|
if errors.Is(err, libreofficeapi.ErrInvalidPdfFormats) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Malformed page ranges '%s' (nativePageRanges)", options.PageRanges)),
|
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."),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +206,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// So far so good, let's check if we have to merge the PDFs. Quick
|
// So far so good, let's check if we have to merge the PDFs.
|
||||||
// win: if there is only one PDF, skip this step.
|
|
||||||
|
|
||||||
if len(outputPaths) > 1 && merge {
|
if len(outputPaths) > 1 && merge {
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
@@ -129,47 +215,14 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
return fmt.Errorf("merge PDFs: %w", err)
|
return fmt.Errorf("merge PDFs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now, let's check if the client want to convert this result
|
// Only one output path.
|
||||||
// PDF to specific PDF formats.
|
outputPaths = []string{outputPath}
|
||||||
zeroValued := gotenberg.PdfFormats{}
|
|
||||||
if !nativeFormats && pdfFormats != zeroValued {
|
|
||||||
convertInputPath := outputPath
|
|
||||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
|
||||||
|
|
||||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
|
||||||
return api.WrapError(
|
|
||||||
fmt.Errorf("convert PDF: %w", err),
|
|
||||||
api.NewSentinelHttpError(
|
|
||||||
http.StatusBadRequest,
|
|
||||||
fmt.Sprintf("At least one PDF engine does not handle one of the PDF format in '%+v', while other have failed to convert for other reasons", pdfFormats),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("convert PDF: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Important: the output path is now the converted file.
|
|
||||||
outputPath = convertOutputPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last but not least, add the output path to the context so that
|
|
||||||
// the Uno is able to send it as a response to the client.
|
|
||||||
|
|
||||||
err = ctx.AddOutputPaths(outputPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("add output path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ok, we don't have to merge the PDFs. Let's check if the client
|
// Let's check if the client want to convert each PDF to a specific
|
||||||
// want to convert each PDF to a specific PDF format.
|
// PDF format.
|
||||||
zeroValued := gotenberg.PdfFormats{}
|
zeroValued := gotenberg.PdfFormats{}
|
||||||
if !nativeFormats && pdfFormats != zeroValued {
|
if !nativePdfFormats && pdfFormats != zeroValued {
|
||||||
convertOutputPaths := make([]string, len(outputPaths))
|
convertOutputPaths := make([]string, len(outputPaths))
|
||||||
|
|
||||||
for i, outputPath := range outputPaths {
|
for i, outputPath := range outputPaths {
|
||||||
@@ -178,28 +231,40 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
|
|
||||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
|
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i])
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("convert PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Important: the output paths are now the converted files.
|
// Important: the output paths are now the converted files.
|
||||||
outputPaths = convertOutputPaths
|
outputPaths = convertOutputPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last but not least, add the output paths to the context so that
|
// Writes and potentially overrides metadata entries, if any.
|
||||||
// the Uno is able to send them as a response to the client.
|
if len(metadata) > 0 {
|
||||||
|
for _, outputPath := range outputPaths {
|
||||||
|
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(outputPaths) > 1 {
|
||||||
|
// If .zip archive, document.docx -> document.docx.pdf.
|
||||||
|
for i, inputPath := range inputPaths {
|
||||||
|
outputPath := fmt.Sprintf("%s.pdf", inputPath)
|
||||||
|
|
||||||
|
err = ctx.Rename(outputPaths[i], outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rename output path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPaths[i] = outputPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last but not least, add the output paths to the context so that
|
||||||
|
// the API is able to send them as a response to the client.
|
||||||
err = ctx.AddOutputPaths(outputPaths...)
|
err = ctx.AddOutputPaths(outputPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add output paths: %w", err)
|
return fmt.Errorf("add output paths: %w", err)
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
libreofficeapi "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
|
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConvertRoute(t *testing.T) {
|
func TestConvertRoute(t *testing.T) {
|
||||||
@@ -25,6 +26,7 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
expectHttpError bool
|
expectHttpError bool
|
||||||
expectHttpStatus int
|
expectHttpStatus int
|
||||||
expectOutputPathsCount int
|
expectOutputPathsCount int
|
||||||
|
expectOutputPaths []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "missing at least one mandatory file",
|
scenario: "missing at least one mandatory file",
|
||||||
@@ -38,7 +40,139 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrMalformedPageRanges",
|
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)",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
@@ -48,7 +182,61 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
libreOffice: &libreofficeapi.ApiMock{
|
libreOffice: &libreofficeapi.ApiMock{
|
||||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||||
return libreofficeapi.ErrMalformedPageRanges
|
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
|
||||||
},
|
},
|
||||||
ExtensionsMock: func() []string {
|
ExtensionsMock: func() []string {
|
||||||
return []string{".docx"}
|
return []string{".docx"}
|
||||||
@@ -81,15 +269,16 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrPdfFormatNotSupported (single file)",
|
scenario: "PDF engine merge error",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
"document.docx": "/document.docx",
|
"document.docx": "/document.docx",
|
||||||
|
"document2.docx": "/document2.docx",
|
||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfFormat": {
|
"merge": {
|
||||||
"foo",
|
"true",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return ctx
|
return ctx
|
||||||
@@ -103,25 +292,27 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
engine: &gotenberg.PdfEngineMock{
|
engine: &gotenberg.PdfEngineMock{
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
return gotenberg.ErrPdfFormatNotSupported
|
return errors.New("foo")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: false,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
|
||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "PDF engine convert error (single file)",
|
scenario: "PDF engine convert error",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
"document.docx": "/document.docx",
|
"document.docx": "/document.docx",
|
||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfFormat": {
|
"pdfa": {
|
||||||
gotenberg.PdfA1a,
|
gotenberg.PdfA1b,
|
||||||
|
},
|
||||||
|
"nativePdfFormats": {
|
||||||
|
"false",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return ctx
|
return ctx
|
||||||
@@ -144,7 +335,64 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "cannot add output paths (single file)",
|
scenario: "PDF engine write metadata error",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"document.docx": "/document.docx",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
libreOffice: &libreofficeapi.ApiMock{
|
||||||
|
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ExtensionsMock: func() []string {
|
||||||
|
return []string{".docx"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "cannot rename many files",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"document.docx": "/document.docx",
|
||||||
|
"document2.docx": "/document2.docx",
|
||||||
|
"document2.doc": "/document2.doc",
|
||||||
|
})
|
||||||
|
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||||
|
return errors.New("cannot rename")
|
||||||
|
}})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
libreOffice: &libreofficeapi.ApiMock{
|
||||||
|
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ExtensionsMock: func() []string {
|
||||||
|
return []string{".docx", ".doc"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "cannot add output paths",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
@@ -170,7 +418,31 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
"document.docx": "/document.docx",
|
"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\" }",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return ctx
|
return ctx
|
||||||
}(),
|
}(),
|
||||||
@@ -182,6 +454,17 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
return []string{".docx"}
|
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,
|
expectError: false,
|
||||||
expectHttpError: false,
|
expectHttpError: false,
|
||||||
expectOutputPathsCount: 1,
|
expectOutputPathsCount: 1,
|
||||||
@@ -193,74 +476,11 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
"document.docx": "/document.docx",
|
"document.docx": "/document.docx",
|
||||||
"document2.docx": "/document2.docx",
|
"document2.docx": "/document2.docx",
|
||||||
})
|
"document2.doc": "/document2.doc",
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
libreOffice: &libreofficeapi.ApiMock{
|
|
||||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
ExtensionsMock: func() []string {
|
|
||||||
return []string{".docx"}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
expectHttpError: false,
|
|
||||||
expectOutputPathsCount: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "success with non-native PDF/A (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{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfa": {
|
"pdfa": {
|
||||||
gotenberg.PdfA1a,
|
gotenberg.PdfA1b,
|
||||||
},
|
|
||||||
"nativePdfFormats": {
|
|
||||||
"false",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
libreOffice: &libreofficeapi.ApiMock{
|
|
||||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
ExtensionsMock: func() []string {
|
|
||||||
return []string{".docx"}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
expectHttpError: false,
|
|
||||||
expectOutputPathsCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "success with 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.PdfA1a,
|
|
||||||
},
|
|
||||||
"nativePdfFormat": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
"pdfa": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
},
|
||||||
"pdfua": {
|
"pdfua": {
|
||||||
"true",
|
"true",
|
||||||
@@ -268,7 +488,13 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
"nativePdfFormats": {
|
"nativePdfFormats": {
|
||||||
"false",
|
"false",
|
||||||
},
|
},
|
||||||
|
"metadata": {
|
||||||
|
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||||
|
return nil
|
||||||
|
}})
|
||||||
return ctx
|
return ctx
|
||||||
}(),
|
}(),
|
||||||
libreOffice: &libreofficeapi.ApiMock{
|
libreOffice: &libreofficeapi.ApiMock{
|
||||||
@@ -276,176 +502,37 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
ExtensionsMock: func() []string {
|
ExtensionsMock: func() []string {
|
||||||
return []string{".docx"}
|
return []string{".docx", ".doc"}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
engine: &gotenberg.PdfEngineMock{
|
engine: &gotenberg.PdfEngineMock{
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
return nil
|
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,
|
expectError: false,
|
||||||
expectHttpError: false,
|
expectHttpError: false,
|
||||||
expectOutputPathsCount: 1,
|
expectOutputPathsCount: 3,
|
||||||
|
expectOutputPaths: []string{"/document.docx.pdf", "/document2.docx.pdf", "/document2.doc.pdf"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "merge error",
|
scenario: "success with native PDF/A & PDF/UA",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
"document.docx": "/document.docx",
|
"document.docx": "/document.docx",
|
||||||
"document2.docx": "/document2.docx",
|
|
||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"merge": {
|
|
||||||
"true",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
libreOffice: &libreofficeapi.ApiMock{
|
|
||||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
ExtensionsMock: func() []string {
|
|
||||||
return []string{".docx"}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
|
||||||
return errors.New("foo")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: true,
|
|
||||||
expectHttpError: false,
|
|
||||||
expectOutputPathsCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "ErrPdfFormatNotSupported (merge)",
|
|
||||||
ctx: func() *api.ContextMock {
|
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
|
||||||
ctx.SetFiles(map[string]string{
|
|
||||||
"document.docx": "/document.docx",
|
|
||||||
"document2.docx": "/document2.docx",
|
|
||||||
})
|
|
||||||
ctx.SetValues(map[string][]string{
|
|
||||||
"merge": {
|
|
||||||
"true",
|
|
||||||
},
|
|
||||||
"pdfa": {
|
"pdfa": {
|
||||||
"foo",
|
gotenberg.PdfA1b,
|
||||||
},
|
},
|
||||||
"nativePdfFormats": {
|
"pdfua": {
|
||||||
"false",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
libreOffice: &libreofficeapi.ApiMock{
|
|
||||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
ExtensionsMock: func() []string {
|
|
||||||
return []string{".docx"}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
|
||||||
return gotenberg.ErrPdfFormatNotSupported
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: true,
|
|
||||||
expectHttpError: true,
|
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
|
||||||
expectOutputPathsCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "PDF engine convert error (merge)",
|
|
||||||
ctx: func() *api.ContextMock {
|
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
|
||||||
ctx.SetFiles(map[string]string{
|
|
||||||
"document.docx": "/document.docx",
|
|
||||||
"document2.docx": "/document2.docx",
|
|
||||||
})
|
|
||||||
ctx.SetValues(map[string][]string{
|
|
||||||
"merge": {
|
|
||||||
"true",
|
|
||||||
},
|
|
||||||
"pdfa": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
"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",
|
"true",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -459,52 +546,6 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
return []string{".docx"}
|
return []string{".docx"}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
expectHttpError: false,
|
|
||||||
expectOutputPathsCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "success with non-native PDF/A (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.PdfA1a,
|
|
||||||
},
|
|
||||||
"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,
|
expectError: false,
|
||||||
expectHttpError: false,
|
expectHttpError: false,
|
||||||
expectOutputPathsCount: 1,
|
expectOutputPathsCount: 1,
|
||||||
@@ -546,6 +587,12 @@ func TestConvertRoute(t *testing.T) {
|
|||||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||||
t.Errorf("expected %d output paths but got %d", 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -174,7 +174,7 @@ func newLogEncoder(format string) (zapcore.Encoder, error) {
|
|||||||
// If interactive terminal, make output more human-readable by default.
|
// If interactive terminal, make output more human-readable by default.
|
||||||
// Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671.
|
// Credits: https://github.com/caddyserver/caddy/blob/v2.1.1/logging.go#L671.
|
||||||
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
|
encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) {
|
||||||
encoder.AppendString(ts.UTC().Format("2006/01/02 15:04:05.000"))
|
encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if format == textLoggingFormat || format == autoLoggingFormat {
|
if format == textLoggingFormat || format == autoLoggingFormat {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
"go.uber.org/zap/zaptest/observer"
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLogging_Descriptor(t *testing.T) {
|
func TestLogging_Descriptor(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine
|
// Package pdfcpu provides an implementation of the gotenberg.PdfEngine
|
||||||
// interface using the pdfcpu library. This package allows for the merging of
|
// interface using the pdfcpu command-line tool. This package allows for:
|
||||||
// PDF files but does not support conversion to specific PDF formats.
|
//
|
||||||
|
// 1. The merging of PDF files.
|
||||||
//
|
//
|
||||||
// See: https://github.com/pdfcpu/pdfcpu.
|
// See: https://github.com/pdfcpu/pdfcpu.
|
||||||
package pdfcpu
|
package pdfcpu
|
||||||
|
|||||||
@@ -2,24 +2,23 @@ package pdfcpu
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
pdfcpuAPI "github.com/pdfcpu/pdfcpu/pkg/api"
|
|
||||||
pdfcpuLog "github.com/pdfcpu/pdfcpu/pkg/log"
|
|
||||||
pdfcpuConfig "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
gotenberg.MustRegisterModule(new(PdfCpu))
|
gotenberg.MustRegisterModule(new(PdfCpu))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PdfCpu abstracts the pdfcpu library and implements the [gotenberg.PdfEngine]
|
// PdfCpu abstracts the CLI tool pdfcpu and implements the
|
||||||
// interface.
|
// [gotenberg.PdfEngine] interface.
|
||||||
type PdfCpu struct {
|
type PdfCpu struct {
|
||||||
conf *pdfcpuConfig.Configuration
|
binPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns a [PdfCpu]'s module descriptor.
|
// Descriptor returns a [PdfCpu]'s module descriptor.
|
||||||
@@ -32,31 +31,64 @@ func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
|
|
||||||
// Provision sets the engine properties.
|
// Provision sets the engine properties.
|
||||||
func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error {
|
func (engine *PdfCpu) Provision(ctx *gotenberg.Context) error {
|
||||||
pdfcpuConfig.ConfigPath = "disable"
|
binPath, ok := os.LookupEnv("PDFCPU_BIN_PATH")
|
||||||
pdfcpuLog.DisableLoggers()
|
if !ok {
|
||||||
engine.conf = pdfcpuConfig.NewDefaultConfiguration()
|
return errors.New("PDFCPU_BIN_PATH environment variable is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.binPath = binPath
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the module properties.
|
||||||
|
func (engine *PdfCpu) Validate() error {
|
||||||
|
_, err := os.Stat(engine.binPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("pdfcpu binary path does not exist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge combines multiple PDFs into a single PDF.
|
// Merge combines multiple PDFs into a single PDF.
|
||||||
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
err := pdfcpuAPI.MergeCreateFile(inputPaths, outputPath, engine.conf)
|
var args []string
|
||||||
|
args = append(args, "merge", outputPath)
|
||||||
|
args = append(args, inputPaths...)
|
||||||
|
|
||||||
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cmd.Exec()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("merge PDFs with PDFcpu: %w", err)
|
return fmt.Errorf("merge PDFs with pdfcpu: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert is not available in this implementation.
|
// Convert is not available in this implementation.
|
||||||
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
return fmt.Errorf("convert PDF to '%+v' with PDFcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMetadata is not available in this implementation.
|
||||||
|
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
|
return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteMetadata is not available in this implementation.
|
||||||
|
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*PdfCpu)(nil)
|
_ gotenberg.Module = (*PdfCpu)(nil)
|
||||||
_ gotenberg.Provisioner = (*PdfCpu)(nil)
|
_ gotenberg.Provisioner = (*PdfCpu)(nil)
|
||||||
|
_ gotenberg.Validator = (*PdfCpu)(nil)
|
||||||
_ gotenberg.PdfEngine = (*PdfCpu)(nil)
|
_ gotenberg.PdfEngine = (*PdfCpu)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPdfCpu_Descriptor(t *testing.T) {
|
func TestPdfCpu_Descriptor(t *testing.T) {
|
||||||
@@ -33,14 +33,59 @@ func TestPdfCpu_Provision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPdfCpu_Validate(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
binPath string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "empty bin path",
|
||||||
|
binPath: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "bin path does not exist",
|
||||||
|
binPath: "/foo",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "validate success",
|
||||||
|
binPath: os.Getenv("PDFTK_BIN_PATH"),
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
engine := new(PdfCpu)
|
||||||
|
engine.binPath = tc.binPath
|
||||||
|
err := engine.Validate()
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPdfCpu_Merge(t *testing.T) {
|
func TestPdfCpu_Merge(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
|
ctx context.Context
|
||||||
inputPaths []string
|
inputPaths []string
|
||||||
expectError bool
|
expectError bool
|
||||||
}{
|
}{
|
||||||
|
{
|
||||||
|
scenario: "invalid context",
|
||||||
|
ctx: nil,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "invalid input path",
|
scenario: "invalid input path",
|
||||||
|
ctx: context.TODO(),
|
||||||
inputPaths: []string{
|
inputPaths: []string{
|
||||||
"foo",
|
"foo",
|
||||||
},
|
},
|
||||||
@@ -48,6 +93,7 @@ func TestPdfCpu_Merge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "single file success",
|
scenario: "single file success",
|
||||||
|
ctx: context.TODO(),
|
||||||
inputPaths: []string{
|
inputPaths: []string{
|
||||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
},
|
},
|
||||||
@@ -55,10 +101,12 @@ func TestPdfCpu_Merge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "many files success",
|
scenario: "many files success",
|
||||||
|
ctx: context.TODO(),
|
||||||
inputPaths: []string{
|
inputPaths: []string{
|
||||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
"/tests/test/testdata/pdfengines/sample2.pdf",
|
"/tests/test/testdata/pdfengines/sample2.pdf",
|
||||||
},
|
},
|
||||||
|
expectError: false,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
@@ -81,7 +129,7 @@ func TestPdfCpu_Merge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
err = engine.Merge(nil, nil, tc.inputPaths, outputDir+"/foo.pdf")
|
err = engine.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf")
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
@@ -102,3 +150,21 @@ func TestPdfCpu_Convert(t *testing.T) {
|
|||||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) {
|
||||||
|
engine := new(PdfCpu)
|
||||||
|
_, err := engine.ReadMetadata(context.Background(), zap.NewNop(), "")
|
||||||
|
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||||
|
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) {
|
||||||
|
engine := new(PdfCpu)
|
||||||
|
err := engine.WriteMetadata(context.Background(), zap.NewNop(), nil, "")
|
||||||
|
|
||||||
|
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
|
||||||
|
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,20 +3,32 @@ package pdfengines
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"go.uber.org/multierr"
|
"go.uber.org/multierr"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type multiPdfEngines struct {
|
type multiPdfEngines struct {
|
||||||
engines []gotenberg.PdfEngine
|
mergeEngines []gotenberg.PdfEngine
|
||||||
|
convertEngines []gotenberg.PdfEngine
|
||||||
|
readMedataEngines []gotenberg.PdfEngine
|
||||||
|
writeMedataEngines []gotenberg.PdfEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMultiPdfEngines(engines ...gotenberg.PdfEngine) *multiPdfEngines {
|
func newMultiPdfEngines(
|
||||||
|
mergeEngines,
|
||||||
|
convertEngines,
|
||||||
|
readMetadataEngines,
|
||||||
|
writeMedataEngines []gotenberg.PdfEngine,
|
||||||
|
) *multiPdfEngines {
|
||||||
return &multiPdfEngines{
|
return &multiPdfEngines{
|
||||||
engines: engines,
|
mergeEngines: mergeEngines,
|
||||||
|
convertEngines: convertEngines,
|
||||||
|
readMedataEngines: readMetadataEngines,
|
||||||
|
writeMedataEngines: writeMedataEngines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +38,7 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
|
|||||||
var err error
|
var err error
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
for _, engine := range multi.engines {
|
for _, engine := range multi.mergeEngines {
|
||||||
go func(engine gotenberg.PdfEngine) {
|
go func(engine gotenberg.PdfEngine) {
|
||||||
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
|
errChan <- engine.Merge(ctx, logger, inputPaths, outputPath)
|
||||||
}(engine)
|
}(engine)
|
||||||
@@ -51,7 +63,7 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
|
|||||||
var err error
|
var err error
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
for _, engine := range multi.engines {
|
for _, engine := range multi.convertEngines {
|
||||||
go func(engine gotenberg.PdfEngine) {
|
go func(engine gotenberg.PdfEngine) {
|
||||||
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
|
errChan <- engine.Convert(ctx, logger, formats, inputPath, outputPath)
|
||||||
}(engine)
|
}(engine)
|
||||||
@@ -70,6 +82,65 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
|
|||||||
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
|
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.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
|
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMultiPdfEngines_Merge(t *testing.T) {
|
func TestMultiPdfEngines_Merge(t *testing.T) {
|
||||||
@@ -20,11 +20,16 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "nominal behavior",
|
scenario: "nominal behavior",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
[]gotenberg.PdfEngine{
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
&gotenberg.PdfEngineMock{
|
||||||
return nil
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
expectError: false,
|
expectError: false,
|
||||||
@@ -32,16 +37,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "at least one engine does not return an error",
|
scenario: "at least one engine does not return an error",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
[]gotenberg.PdfEngine{
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
&gotenberg.PdfEngineMock{
|
||||||
return errors.New("foo")
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
},
|
return errors.New("foo")
|
||||||
},
|
},
|
||||||
&gotenberg.PdfEngineMock{
|
},
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
&gotenberg.PdfEngineMock{
|
||||||
return nil
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
expectError: false,
|
expectError: false,
|
||||||
@@ -49,16 +59,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "all engines return an error",
|
scenario: "all engines return an error",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
[]gotenberg.PdfEngine{
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
&gotenberg.PdfEngineMock{
|
||||||
return errors.New("foo")
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
},
|
return errors.New("foo")
|
||||||
},
|
},
|
||||||
&gotenberg.PdfEngineMock{
|
},
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
&gotenberg.PdfEngineMock{
|
||||||
return errors.New("foo")
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
expectError: true,
|
expectError: true,
|
||||||
@@ -66,11 +81,16 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "context expired",
|
scenario: "context expired",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
[]gotenberg.PdfEngine{
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
&gotenberg.PdfEngineMock{
|
||||||
return nil
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: func() context.Context {
|
ctx: func() context.Context {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -105,43 +125,58 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "nominal behavior",
|
scenario: "nominal behavior",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
nil,
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
[]gotenberg.PdfEngine{
|
||||||
return nil
|
&gotenberg.PdfEngineMock{
|
||||||
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "at least one engine does not return an error",
|
scenario: "at least one engine does not return an error",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
nil,
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
[]gotenberg.PdfEngine{
|
||||||
return errors.New("foo")
|
&gotenberg.PdfEngineMock{
|
||||||
},
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
},
|
return errors.New("foo")
|
||||||
&gotenberg.PdfEngineMock{
|
},
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
},
|
||||||
return nil
|
&gotenberg.PdfEngineMock{
|
||||||
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "all engines return an error",
|
scenario: "all engines return an error",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
nil,
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
[]gotenberg.PdfEngine{
|
||||||
return errors.New("foo")
|
&gotenberg.PdfEngineMock{
|
||||||
},
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
},
|
return errors.New("foo")
|
||||||
&gotenberg.PdfEngineMock{
|
},
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
},
|
||||||
return errors.New("foo")
|
&gotenberg.PdfEngineMock{
|
||||||
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
expectError: true,
|
expectError: true,
|
||||||
@@ -149,11 +184,16 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
|
|||||||
{
|
{
|
||||||
scenario: "context expired",
|
scenario: "context expired",
|
||||||
engine: newMultiPdfEngines(
|
engine: newMultiPdfEngines(
|
||||||
&gotenberg.PdfEngineMock{
|
nil,
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
[]gotenberg.PdfEngine{
|
||||||
return nil
|
&gotenberg.PdfEngineMock{
|
||||||
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
),
|
),
|
||||||
ctx: func() context.Context {
|
ctx: func() context.Context {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
@@ -177,3 +217,209 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
engine *multiPdfEngines
|
||||||
|
ctx context.Context
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "nominal behavior",
|
||||||
|
engine: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]gotenberg.PdfEngine{
|
||||||
|
&gotenberg.PdfEngineMock{
|
||||||
|
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
ctx: context.Background(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "at least one engine does not return an error",
|
||||||
|
engine: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
ctx: context.Background(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "all engines return an error",
|
||||||
|
engine: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]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")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
ctx: context.Background(),
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "context expired",
|
||||||
|
engine: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]gotenberg.PdfEngine{
|
||||||
|
&gotenberg.PdfEngineMock{
|
||||||
|
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
ctx: func() context.Context {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
_, err := tc.engine.ReadMetadata(tc.ctx, zap.NewNop(), "")
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
engine *multiPdfEngines
|
||||||
|
ctx context.Context
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "nominal behavior",
|
||||||
|
engine: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]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: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]gotenberg.PdfEngine{
|
||||||
|
&gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ctx: context.Background(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "all engines return an error",
|
||||||
|
engine: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]gotenberg.PdfEngine{
|
||||||
|
&gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ctx: context.Background(),
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "context expired",
|
||||||
|
engine: newMultiPdfEngines(
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
|
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -27,9 +27,12 @@ func init() {
|
|||||||
// the [api.Router] interface to expose relevant PDF processing routes if
|
// the [api.Router] interface to expose relevant PDF processing routes if
|
||||||
// enabled.
|
// enabled.
|
||||||
type PdfEngines struct {
|
type PdfEngines struct {
|
||||||
names []string
|
mergeNames []string
|
||||||
engines []gotenberg.PdfEngine
|
convertNames []string
|
||||||
disableRoutes bool
|
readMetadataNames []string
|
||||||
|
writeMedataNames []string
|
||||||
|
engines []gotenberg.PdfEngine
|
||||||
|
disableRoutes bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Descriptor returns a PdfEngines' module descriptor.
|
// Descriptor returns a PdfEngines' module descriptor.
|
||||||
@@ -38,9 +41,18 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
ID: "pdfengines",
|
ID: "pdfengines",
|
||||||
FlagSet: func() *flag.FlagSet {
|
FlagSet: func() *flag.FlagSet {
|
||||||
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
|
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
|
||||||
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the PDF engines and their order - all by default")
|
fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
|
||||||
|
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
|
||||||
|
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
|
||||||
|
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
|
||||||
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
|
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
|
||||||
|
err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
return fs
|
return fs
|
||||||
}(),
|
}(),
|
||||||
New: func() gotenberg.Module { return new(PdfEngines) },
|
New: func() gotenberg.Module { return new(PdfEngines) },
|
||||||
@@ -51,21 +63,12 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
// selected by the user thanks to the "engines" flag.
|
// selected by the user thanks to the "engines" flag.
|
||||||
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
||||||
flags := ctx.ParsedFlags()
|
flags := ctx.ParsedFlags()
|
||||||
names := flags.MustStringSlice("pdfengines-engines")
|
mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
|
||||||
|
convertNames := flags.MustStringSlice("pdfengines-convert-engines")
|
||||||
|
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
|
||||||
|
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
|
||||||
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
|
||||||
|
|
||||||
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))
|
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get PDF engines: %w", err)
|
return fmt.Errorf("get PDF engines: %w", err)
|
||||||
@@ -77,26 +80,37 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
|
|||||||
mod.engines[i] = engine.(gotenberg.PdfEngine)
|
mod.engines[i] = engine.(gotenberg.PdfEngine)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(names) > 0 {
|
defaultNames := make([]string, len(mod.engines))
|
||||||
// Selection from user.
|
for i, engine := range mod.engines {
|
||||||
mod.names = names
|
defaultNames[i] = engine.(gotenberg.Module).Descriptor().ID
|
||||||
|
|
||||||
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.
|
// Example in case of deprecated module name.
|
||||||
mod.names = make([]string, len(mod.engines))
|
//for i, name := range defaultNames {
|
||||||
|
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
|
||||||
|
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
|
||||||
|
// mod.defaultNames[i] = "libreoffice-pdfengine"
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
for i, engine := range mod.engines {
|
mod.mergeNames = defaultNames
|
||||||
mod.names[i] = engine.(gotenberg.Module).Descriptor().ID
|
if len(mergeNames) > 0 {
|
||||||
|
mod.mergeNames = mergeNames
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.convertNames = defaultNames
|
||||||
|
if len(convertNames) > 0 {
|
||||||
|
mod.convertNames = convertNames
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.readMetadataNames = defaultNames
|
||||||
|
if len(readMetadataNames) > 0 {
|
||||||
|
mod.readMetadataNames = readMetadataNames
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.writeMedataNames = defaultNames
|
||||||
|
if len(writeMetadataNames) > 0 {
|
||||||
|
mod.writeMedataNames = writeMetadataNames
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -117,22 +131,40 @@ func (mod *PdfEngines) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonExistingEngines := make([]string, 0)
|
nonExistingEngines := make([]string, 0)
|
||||||
|
findNonExistingEngines := func(names []string) {
|
||||||
|
for _, name := range names {
|
||||||
|
engineExists := false
|
||||||
|
|
||||||
for _, name := range mod.names {
|
for _, engine := range mod.engines {
|
||||||
engineExists := false
|
if name == engine.(gotenberg.Module).Descriptor().ID {
|
||||||
|
engineExists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, engine := range mod.engines {
|
if engineExists {
|
||||||
if name == engine.(gotenberg.Module).Descriptor().ID {
|
continue
|
||||||
engineExists = true
|
}
|
||||||
break
|
|
||||||
|
alreadyInSlice := false
|
||||||
|
for _, engine := range nonExistingEngines {
|
||||||
|
if engine == name {
|
||||||
|
alreadyInSlice = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !alreadyInSlice {
|
||||||
|
nonExistingEngines = append(nonExistingEngines, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !engineExists {
|
|
||||||
nonExistingEngines = append(nonExistingEngines, name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findNonExistingEngines(mod.mergeNames)
|
||||||
|
findNonExistingEngines(mod.convertNames)
|
||||||
|
findNonExistingEngines(mod.readMetadataNames)
|
||||||
|
findNonExistingEngines(mod.writeMedataNames)
|
||||||
|
|
||||||
if len(nonExistingEngines) == 0 {
|
if len(nonExistingEngines) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -144,24 +176,35 @@ func (mod *PdfEngines) Validate() error {
|
|||||||
// modules.
|
// modules.
|
||||||
func (mod *PdfEngines) SystemMessages() []string {
|
func (mod *PdfEngines) SystemMessages() []string {
|
||||||
return []string{
|
return []string{
|
||||||
strings.Join(mod.names[:], " "),
|
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
|
||||||
|
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
|
||||||
|
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
|
||||||
|
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PdfEngine returns a [gotenberg.PdfEngine].
|
// PdfEngine returns a [gotenberg.PdfEngine].
|
||||||
func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
|
||||||
engines := make([]gotenberg.PdfEngine, len(mod.names))
|
engines := func(names []string) []gotenberg.PdfEngine {
|
||||||
|
list := make([]gotenberg.PdfEngine, len(names))
|
||||||
for i, name := range mod.names {
|
for i, name := range names {
|
||||||
for _, engine := range mod.engines {
|
for _, engine := range mod.engines {
|
||||||
if name == engine.(gotenberg.Module).Descriptor().ID {
|
if name == engine.(gotenberg.Module).Descriptor().ID {
|
||||||
engines[i] = engine
|
list[i] = engine
|
||||||
break
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
return newMultiPdfEngines(engines...), nil
|
return newMultiPdfEngines(
|
||||||
|
engines(mod.mergeNames),
|
||||||
|
engines(mod.convertNames),
|
||||||
|
engines(mod.readMetadataNames),
|
||||||
|
engines(mod.writeMedataNames),
|
||||||
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes returns the HTTP routes.
|
// Routes returns the HTTP routes.
|
||||||
@@ -180,6 +223,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
|
|||||||
return []api.Route{
|
return []api.Route{
|
||||||
mergeRoute(engine),
|
mergeRoute(engine),
|
||||||
convertRoute(engine),
|
convertRoute(engine),
|
||||||
|
readMetadataRoute(engine),
|
||||||
|
writeMetadataRoute(engine),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package pdfengines
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPdfEngines_Descriptor(t *testing.T) {
|
func TestPdfEngines_Descriptor(t *testing.T) {
|
||||||
@@ -24,26 +23,25 @@ func TestPdfEngines_Descriptor(t *testing.T) {
|
|||||||
|
|
||||||
func TestPdfEngines_Provision(t *testing.T) {
|
func TestPdfEngines_Provision(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
ctx *gotenberg.Context
|
ctx *gotenberg.Context
|
||||||
expectedPdfEngines []string
|
expectedMergePdfEngines []string
|
||||||
expectError bool
|
expectedConvertPdfEngines []string
|
||||||
|
expectedReadMetadataPdfEngines []string
|
||||||
|
expectedWriteMetadataPdfEngines []string
|
||||||
|
expectError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "no selection from user",
|
scenario: "no selection from user",
|
||||||
ctx: func() *gotenberg.Context {
|
ctx: func() *gotenberg.Context {
|
||||||
provider := &struct {
|
provider := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
gotenberg.LoggerProviderMock
|
|
||||||
}{}
|
}{}
|
||||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||||
return provider
|
return provider
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
|
||||||
return zap.NewNop(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := &struct {
|
engine := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
@@ -67,25 +65,23 @@ func TestPdfEngines_Provision(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}(),
|
}(),
|
||||||
expectedPdfEngines: []string{"bar"},
|
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
|
||||||
expectError: false,
|
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
|
||||||
|
expectedReadMetadataPdfEngines: []string{"exiftool"},
|
||||||
|
expectedWriteMetadataPdfEngines: []string{"exiftool"},
|
||||||
|
expectError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "selection from user",
|
scenario: "selection from user",
|
||||||
ctx: func() *gotenberg.Context {
|
ctx: func() *gotenberg.Context {
|
||||||
provider := &struct {
|
provider := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
gotenberg.LoggerProviderMock
|
|
||||||
}{}
|
}{}
|
||||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||||
return provider
|
return provider
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
|
||||||
return zap.NewNop(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
engine1 := &struct {
|
engine1 := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
gotenberg.ValidatorMock
|
gotenberg.ValidatorMock
|
||||||
@@ -111,7 +107,7 @@ func TestPdfEngines_Provision(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs := new(PdfEngines).Descriptor().FlagSet
|
fs := new(PdfEngines).Descriptor().FlagSet
|
||||||
err := fs.Parse([]string{"--pdfengines-engines=b", "--pdfengines-engines=a"})
|
err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error but got: %v", err)
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
}
|
}
|
||||||
@@ -127,111 +123,24 @@ func TestPdfEngines_Provision(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}(),
|
}(),
|
||||||
expectedPdfEngines: []string{"b", "a"},
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 {
|
expectedMergePdfEngines: []string{"b"},
|
||||||
gotenberg.ModuleMock
|
expectedConvertPdfEngines: []string{"b"},
|
||||||
gotenberg.ValidatorMock
|
expectedReadMetadataPdfEngines: []string{"a"},
|
||||||
gotenberg.PdfEngineMock
|
expectedWriteMetadataPdfEngines: []string{"a"},
|
||||||
}{}
|
expectError: false,
|
||||||
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",
|
scenario: "no valid PDF engine",
|
||||||
ctx: func() *gotenberg.Context {
|
ctx: func() *gotenberg.Context {
|
||||||
provider := &struct {
|
provider := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
gotenberg.LoggerProviderMock
|
|
||||||
}{}
|
}{}
|
||||||
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
provider.DescriptorMock = func() gotenberg.ModuleDescriptor {
|
||||||
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module {
|
||||||
return provider
|
return provider
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
provider.LoggerMock = func(mod gotenberg.Module) (*zap.Logger, error) {
|
|
||||||
return zap.NewNop(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := &struct {
|
engine := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
gotenberg.ValidatorMock
|
gotenberg.ValidatorMock
|
||||||
@@ -269,13 +178,43 @@ func TestPdfEngines_Provision(t *testing.T) {
|
|||||||
t.Fatal("expected error but got none")
|
t.Fatal("expected error but got none")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tc.expectedPdfEngines) != len(mod.names) {
|
if len(tc.expectedMergePdfEngines) != len(mod.mergeNames) {
|
||||||
t.Fatalf("expected %d names but got %d", len(tc.expectedPdfEngines), len(mod.names))
|
t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
|
||||||
}
|
}
|
||||||
|
|
||||||
for index, name := range mod.names {
|
if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
|
||||||
if name != tc.expectedPdfEngines[index] {
|
t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
|
||||||
t.Fatalf("expected scenario at index %d to be %s, but got: %s", index, name, tc.expectedPdfEngines[index])
|
}
|
||||||
|
|
||||||
|
if len(tc.expectedReadMetadataPdfEngines) != len(mod.readMetadataNames) {
|
||||||
|
t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) {
|
||||||
|
t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames))
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, name := range mod.mergeNames {
|
||||||
|
if name != tc.expectedMergePdfEngines[index] {
|
||||||
|
t.Fatalf("expected merge name at index %d to be %s, but got: %s", index, name, tc.expectedMergePdfEngines[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, name := range mod.convertNames {
|
||||||
|
if name != tc.expectedConvertPdfEngines[index] {
|
||||||
|
t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, name := range mod.readMetadataNames {
|
||||||
|
if name != tc.expectedReadMetadataPdfEngines[index] {
|
||||||
|
t.Fatalf("expected read metadata name at index %d to be %s, but got: %s", index, name, tc.expectedReadMetadataPdfEngines[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, name := range mod.writeMedataNames {
|
||||||
|
if name != tc.expectedWriteMetadataPdfEngines[index] {
|
||||||
|
t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -341,8 +280,11 @@ func TestPdfEngines_Validate(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
mod := PdfEngines{
|
mod := PdfEngines{
|
||||||
names: tc.names,
|
mergeNames: tc.names,
|
||||||
engines: tc.engines,
|
convertNames: tc.names,
|
||||||
|
readMetadataNames: tc.names,
|
||||||
|
writeMedataNames: tc.names,
|
||||||
|
engines: tc.engines,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := mod.Validate()
|
err := mod.Validate()
|
||||||
@@ -360,22 +302,36 @@ func TestPdfEngines_Validate(t *testing.T) {
|
|||||||
|
|
||||||
func TestPdfEngines_SystemMessages(t *testing.T) {
|
func TestPdfEngines_SystemMessages(t *testing.T) {
|
||||||
mod := new(PdfEngines)
|
mod := new(PdfEngines)
|
||||||
mod.names = []string{"foo", "bar"}
|
mod.mergeNames = []string{"foo", "bar"}
|
||||||
|
mod.convertNames = []string{"foo", "bar"}
|
||||||
|
mod.readMetadataNames = []string{"foo", "bar"}
|
||||||
|
mod.writeMedataNames = []string{"foo", "bar"}
|
||||||
|
|
||||||
messages := mod.SystemMessages()
|
messages := mod.SystemMessages()
|
||||||
if len(messages) != 1 {
|
if len(messages) != 4 {
|
||||||
t.Errorf("expected one and only one message, but got %d", len(messages))
|
t.Errorf("expected one and only one message, but got %d", len(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
expect := strings.Join(mod.names[:], " ")
|
expect := []string{
|
||||||
if messages[0] != expect {
|
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
|
||||||
t.Errorf("expected message '%s', but got '%s'", expect, messages[0])
|
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
|
||||||
|
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
|
||||||
|
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, message := range messages {
|
||||||
|
if message != expect[i] {
|
||||||
|
t.Errorf("expected message at index %d to be %s, but got %s", i, message, expect[i])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPdfEngines_PdfEngine(t *testing.T) {
|
func TestPdfEngines_PdfEngine(t *testing.T) {
|
||||||
mod := PdfEngines{
|
mod := PdfEngines{
|
||||||
names: []string{"foo", "bar"},
|
mergeNames: []string{"foo", "bar"},
|
||||||
|
convertNames: []string{"foo", "bar"},
|
||||||
|
readMetadataNames: []string{"foo", "bar"},
|
||||||
|
writeMedataNames: []string{"foo", "bar"},
|
||||||
engines: func() []gotenberg.PdfEngine {
|
engines: func() []gotenberg.PdfEngine {
|
||||||
engine1 := &struct {
|
engine1 := &struct {
|
||||||
gotenberg.ModuleMock
|
gotenberg.ModuleMock
|
||||||
@@ -414,7 +370,7 @@ func TestPdfEngines_Routes(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "routes not disabled",
|
scenario: "routes not disabled",
|
||||||
expectRoutes: 2,
|
expectRoutes: 4,
|
||||||
disableRoutes: false,
|
disableRoutes: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package pdfengines
|
package pdfengines
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mergeRoute returns an [api.Route] which can merge PDFs.
|
// mergeRoute returns an [api.Route] which can merge PDFs.
|
||||||
@@ -23,40 +25,35 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
// Let's get the data from the form and validate them.
|
// Let's get the data from the form and validate them.
|
||||||
var (
|
var (
|
||||||
inputPaths []string
|
inputPaths []string
|
||||||
pdfFormat string
|
|
||||||
pdfa string
|
pdfa string
|
||||||
pdfua bool
|
pdfua bool
|
||||||
|
metadata map[string]interface{}
|
||||||
)
|
)
|
||||||
|
|
||||||
err := ctx.FormData().
|
err := ctx.FormData().
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
String("pdfFormat", &pdfFormat, "").
|
|
||||||
String("pdfa", &pdfa, "").
|
String("pdfa", &pdfa, "").
|
||||||
Bool("pdfua", &pdfua, false).
|
Bool("pdfua", &pdfua, false).
|
||||||
|
Custom("metadata", func(value string) error {
|
||||||
|
if len(value) > 0 {
|
||||||
|
err := json.Unmarshal([]byte(value), &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}).
|
||||||
Validate()
|
Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("validate form data: %w", err)
|
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{
|
pdfFormats := gotenberg.PdfFormats{
|
||||||
PdfA: actualPdfArchive,
|
PdfA: pdfa,
|
||||||
PdfUa: pdfua,
|
PdfUa: pdfua,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alright, let's merge the PDFs.
|
// Alright, let's merge the PDFs.
|
||||||
|
|
||||||
outputPath := ctx.GeneratePath(".pdf")
|
outputPath := ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
|
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
|
||||||
@@ -73,18 +70,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
convertOutputPath := ctx.GeneratePath(".pdf")
|
convertOutputPath := ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
|
||||||
|
|
||||||
if err != nil {
|
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)
|
return fmt.Errorf("convert PDF: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +78,16 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
outputPath = convertOutputPath
|
outputPath = convertOutputPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes and potentially overrides metadata entries, if any.
|
||||||
|
if len(metadata) > 0 {
|
||||||
|
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Last but not least, add the output path to the context so that
|
// 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.
|
// the API is able to send it as a response to the client.
|
||||||
|
|
||||||
err = ctx.AddOutputPaths(outputPath)
|
err = ctx.AddOutputPaths(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add output path: %w", err)
|
return fmt.Errorf("add output path: %w", err)
|
||||||
@@ -105,8 +98,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertRoute returns an [api.Route] which can convert a PDF to a specific
|
// convertRoute returns an [api.Route] which can convert PDFs to a specific ODF
|
||||||
// PDF format.
|
// format.
|
||||||
func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
||||||
return api.Route{
|
return api.Route{
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -118,14 +111,12 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
// Let's get the data from the form and validate them.
|
// Let's get the data from the form and validate them.
|
||||||
var (
|
var (
|
||||||
inputPaths []string
|
inputPaths []string
|
||||||
pdfFormat string
|
|
||||||
pdfa string
|
pdfa string
|
||||||
pdfua bool
|
pdfua bool
|
||||||
)
|
)
|
||||||
|
|
||||||
err := ctx.FormData().
|
err := ctx.FormData().
|
||||||
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
String("pdfFormat", &pdfFormat, "").
|
|
||||||
String("pdfa", &pdfa, "").
|
String("pdfa", &pdfa, "").
|
||||||
Bool("pdfua", &pdfua, false).
|
Bool("pdfua", &pdfua, false).
|
||||||
Validate()
|
Validate()
|
||||||
@@ -133,20 +124,8 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
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{
|
pdfFormats := gotenberg.PdfFormats{
|
||||||
PdfA: actualPdfArchive,
|
PdfA: pdfa,
|
||||||
PdfUa: pdfua,
|
PdfUa: pdfua,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,30 +142,27 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
|
|
||||||
// Alright, let's convert the PDFs.
|
// Alright, let's convert the PDFs.
|
||||||
outputPaths := make([]string, len(inputPaths))
|
outputPaths := make([]string, len(inputPaths))
|
||||||
|
|
||||||
for i, inputPath := range inputPaths {
|
for i, inputPath := range inputPaths {
|
||||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||||
|
|
||||||
err = engine.Convert(ctx, ctx.Log(), pdfFormats, inputPath, outputPaths[i])
|
err = engine.Convert(ctx, ctx.Log(), pdfFormats, inputPath, outputPaths[i])
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
|
return fmt.Errorf("convert PDF: %w", err)
|
||||||
return api.WrapError(
|
}
|
||||||
fmt.Errorf("convert PDF: %w", err),
|
|
||||||
api.NewSentinelHttpError(
|
if len(outputPaths) > 1 {
|
||||||
http.StatusBadRequest,
|
// If .zip archive, keep the original filename.
|
||||||
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),
|
err = ctx.Rename(outputPaths[i], inputPath)
|
||||||
),
|
if err != nil {
|
||||||
)
|
return fmt.Errorf("rename output path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("convert PDF: %w", err)
|
outputPaths[i] = inputPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last but not least, add the output paths to the context so that
|
// 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.
|
// the API is able to send them as a response to the client.
|
||||||
|
|
||||||
err = ctx.AddOutputPaths(outputPaths...)
|
err = ctx.AddOutputPaths(outputPaths...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("add output paths: %w", err)
|
return fmt.Errorf("add output paths: %w", err)
|
||||||
@@ -196,3 +172,98 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readMetadataRoute returns an [api.Route] which returns the metadata of PDFs.
|
||||||
|
func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
|
||||||
|
return api.Route{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/forms/pdfengines/metadata/read",
|
||||||
|
IsMultipart: true,
|
||||||
|
Handler: func(c echo.Context) error {
|
||||||
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
|
// Let's get the data from the form and validate them.
|
||||||
|
var inputPaths []string
|
||||||
|
|
||||||
|
err := ctx.FormData().
|
||||||
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
Validate()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alright, let's read the metadata.
|
||||||
|
res := make(map[string]map[string]interface{}, len(inputPaths))
|
||||||
|
for _, inputPath := range inputPaths {
|
||||||
|
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res[filepath.Base(inputPath)] = metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.JSON(http.StatusOK, res)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("return JSON response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.ErrNoOutputFile
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMetadataRoute returns an [api.Route] which can write metadata into
|
||||||
|
// PDFs.
|
||||||
|
func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
|
||||||
|
return api.Route{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/forms/pdfengines/metadata/write",
|
||||||
|
IsMultipart: true,
|
||||||
|
Handler: func(c echo.Context) error {
|
||||||
|
ctx := c.Get("context").(*api.Context)
|
||||||
|
|
||||||
|
// Let's get the data from the form and validate them.
|
||||||
|
var (
|
||||||
|
inputPaths []string
|
||||||
|
metadata map[string]interface{}
|
||||||
|
)
|
||||||
|
|
||||||
|
err := ctx.FormData().
|
||||||
|
MandatoryPaths([]string{".pdf"}, &inputPaths).
|
||||||
|
MandatoryCustom("metadata", func(value string) error {
|
||||||
|
if len(value) > 0 {
|
||||||
|
err := json.Unmarshal([]byte(value), &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unmarshal metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return errors.New("no metadata")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}).
|
||||||
|
Validate()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alright, let's convert the PDFs.
|
||||||
|
for _, inputPath := range inputPaths {
|
||||||
|
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write metadata: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last but not least, add the output paths to the context so that
|
||||||
|
// the API is able to send them as a response to the client.
|
||||||
|
err = ctx.AddOutputPaths(inputPaths...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add output paths: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMergeHandler(t *testing.T) {
|
func TestMergeHandler(t *testing.T) {
|
||||||
@@ -32,7 +35,27 @@ func TestMergeHandler(t *testing.T) {
|
|||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "error from PDF engine",
|
scenario: "invalid metadata form field",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
"file2.pdf": "/file2.pdf",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"foo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "PDF engine merge error",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
@@ -50,6 +73,60 @@ func TestMergeHandler(t *testing.T) {
|
|||||||
expectHttpError: false,
|
expectHttpError: false,
|
||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "PDF engine convert error",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
"file2.pdf": "/file2.pdf",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"pdfa": {
|
||||||
|
gotenberg.PdfA1b,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "PDF engine write metadata error",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
"file2.pdf": "/file2.pdf",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "cannot add output paths",
|
scenario: "cannot add output paths",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
@@ -72,25 +149,6 @@ func TestMergeHandler(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "success",
|
scenario: "success",
|
||||||
ctx: func() *api.ContextMock {
|
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
|
||||||
ctx.SetFiles(map[string]string{
|
|
||||||
"file.pdf": "/file.pdf",
|
|
||||||
"file2.pdf": "/file2.pdf",
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: false,
|
|
||||||
expectHttpError: false,
|
|
||||||
expectOutputPathsCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "ErrPdfFormatNotSupported",
|
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
@@ -99,7 +157,10 @@ func TestMergeHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfa": {
|
"pdfa": {
|
||||||
gotenberg.PdfA1a,
|
gotenberg.PdfA1b,
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return ctx
|
return ctx
|
||||||
@@ -109,67 +170,9 @@ func TestMergeHandler(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
return gotenberg.ErrPdfFormatNotSupported
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: true,
|
|
||||||
expectHttpError: true,
|
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
|
||||||
expectOutputPathsCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "error from PDF engine (convert)",
|
|
||||||
ctx: func() *api.ContextMock {
|
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
|
||||||
ctx.SetFiles(map[string]string{
|
|
||||||
"file.pdf": "/file.pdf",
|
|
||||||
"file2.pdf": "/file2.pdf",
|
|
||||||
})
|
|
||||||
ctx.SetValues(map[string][]string{
|
|
||||||
"pdfa": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
return errors.New("foo")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: true,
|
|
||||||
expectHttpError: false,
|
|
||||||
expectOutputPathsCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scenario: "success with every PDF/A & PDF/UA form fields",
|
|
||||||
ctx: func() *api.ContextMock {
|
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
|
||||||
ctx.SetFiles(map[string]string{
|
|
||||||
"file.pdf": "/file.pdf",
|
|
||||||
"file2.pdf": "/file2.pdf",
|
|
||||||
})
|
|
||||||
ctx.SetValues(map[string][]string{
|
|
||||||
"pdfFormat": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
"pdfa": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
"pdfua": {
|
|
||||||
"true",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -227,6 +230,7 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
expectHttpError bool
|
expectHttpError bool
|
||||||
expectHttpStatus int
|
expectHttpStatus int
|
||||||
expectOutputPathsCount int
|
expectOutputPathsCount int
|
||||||
|
expectOutputPaths []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
scenario: "missing at least one mandatory file",
|
scenario: "missing at least one mandatory file",
|
||||||
@@ -250,30 +254,6 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
scenario: "ErrPdfFormatNotSupported",
|
|
||||||
ctx: func() *api.ContextMock {
|
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
|
||||||
ctx.SetFiles(map[string]string{
|
|
||||||
"file.pdf": "/file.pdf",
|
|
||||||
})
|
|
||||||
ctx.SetValues(map[string][]string{
|
|
||||||
"pdfa": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
}(),
|
|
||||||
engine: &gotenberg.PdfEngineMock{
|
|
||||||
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
|
||||||
return gotenberg.ErrPdfFormatNotSupported
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectError: true,
|
|
||||||
expectHttpError: true,
|
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
|
||||||
expectOutputPathsCount: 0,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
scenario: "error from PDF engine",
|
scenario: "error from PDF engine",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
@@ -283,7 +263,7 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfa": {
|
"pdfa": {
|
||||||
gotenberg.PdfA1a,
|
gotenberg.PdfA1b,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return ctx
|
return ctx
|
||||||
@@ -306,7 +286,7 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfa": {
|
"pdfa": {
|
||||||
gotenberg.PdfA1a,
|
gotenberg.PdfA1b,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
ctx.SetCancelled(true)
|
ctx.SetCancelled(true)
|
||||||
@@ -322,18 +302,15 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "success with every PDF/A & PDF/UA form fields (single file)",
|
scenario: "success with PDF/A & PDF/UA form fields (single file)",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
"file.pdf": "/file.pdf",
|
"file.pdf": "/file.pdf",
|
||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfFormat": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
"pdfa": {
|
"pdfa": {
|
||||||
gotenberg.PdfA1a,
|
gotenberg.PdfA1b,
|
||||||
},
|
},
|
||||||
"pdfua": {
|
"pdfua": {
|
||||||
"true",
|
"true",
|
||||||
@@ -351,7 +328,7 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
expectOutputPathsCount: 1,
|
expectOutputPathsCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "success with every PDF/A & PDF/UA form fields (many files)",
|
scenario: "cannot rename many files",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
ctx.SetFiles(map[string]string{
|
ctx.SetFiles(map[string]string{
|
||||||
@@ -359,16 +336,46 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
"file2.pdf": "/file2.pdf",
|
"file2.pdf": "/file2.pdf",
|
||||||
})
|
})
|
||||||
ctx.SetValues(map[string][]string{
|
ctx.SetValues(map[string][]string{
|
||||||
"pdfFormat": {
|
|
||||||
gotenberg.PdfA1a,
|
|
||||||
},
|
|
||||||
"pdfa": {
|
"pdfa": {
|
||||||
gotenberg.PdfA1a,
|
gotenberg.PdfA1b,
|
||||||
},
|
},
|
||||||
"pdfua": {
|
"pdfua": {
|
||||||
"true",
|
"true",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||||
|
return errors.New("cannot rename")
|
||||||
|
}})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success with PDF/A & PDF/UA form fields (many files)",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
"file2.pdf": "/file2.pdf",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"pdfa": {
|
||||||
|
gotenberg.PdfA1b,
|
||||||
|
},
|
||||||
|
"pdfua": {
|
||||||
|
"true",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
|
||||||
|
return nil
|
||||||
|
}})
|
||||||
return ctx
|
return ctx
|
||||||
}(),
|
}(),
|
||||||
engine: &gotenberg.PdfEngineMock{
|
engine: &gotenberg.PdfEngineMock{
|
||||||
@@ -379,6 +386,7 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
expectError: false,
|
expectError: false,
|
||||||
expectHttpError: false,
|
expectHttpError: false,
|
||||||
expectOutputPathsCount: 2,
|
expectOutputPathsCount: 2,
|
||||||
|
expectOutputPaths: []string{"/file.pdf", "/file2.pdf"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
@@ -417,6 +425,304 @@ func TestConvertHandler(t *testing.T) {
|
|||||||
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||||
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, path := range tc.expectOutputPaths {
|
||||||
|
if !slices.Contains(tc.ctx.OutputPaths(), path) {
|
||||||
|
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadMetadataHandler(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
ctx *api.ContextMock
|
||||||
|
engine gotenberg.PdfEngine
|
||||||
|
expectError bool
|
||||||
|
expectedError error
|
||||||
|
expectHttpError bool
|
||||||
|
expectHttpStatus int
|
||||||
|
expectedJson string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "missing at least one mandatory file",
|
||||||
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "error from PDF engine",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
|
return nil, errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"foo": "bar",
|
||||||
|
"bar": "foo",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectedError: api.ErrNoOutputFile,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectedJson: `{"file.pdf":{"bar":"foo","foo":"bar"}}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
tc.ctx.SetLogger(zap.NewNop())
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/metadata/read", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := echo.New().NewContext(req, rec)
|
||||||
|
c.Set("context", tc.ctx.Context)
|
||||||
|
|
||||||
|
err := readMetadataRoute(tc.engine).Handler(c)
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr api.HttpError
|
||||||
|
isHttpError := errors.As(err, &httpErr)
|
||||||
|
|
||||||
|
if tc.expectHttpError && !isHttpError {
|
||||||
|
t.Errorf("expected an HTTP error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectHttpError && isHttpError {
|
||||||
|
t.Errorf("expected no HTTP error but got one: %v", httpErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
||||||
|
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && tc.expectHttpError && isHttpError {
|
||||||
|
status, _ := httpErr.HttpError()
|
||||||
|
if status != tc.expectHttpStatus {
|
||||||
|
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectedJson != "" && tc.expectedJson != strings.TrimSpace(rec.Body.String()) {
|
||||||
|
t.Errorf("expected '%s' as HTTP response but got '%s'", tc.expectedJson, strings.TrimSpace(rec.Body.String()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteMetadataHandler(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
ctx *api.ContextMock
|
||||||
|
engine gotenberg.PdfEngine
|
||||||
|
expectError bool
|
||||||
|
expectHttpError bool
|
||||||
|
expectHttpStatus int
|
||||||
|
expectOutputPathsCount int
|
||||||
|
expectOutputPaths []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "missing at least one mandatory file",
|
||||||
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no metadata form field",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid metadata form field",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"document.docx": "/document.docx",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"foo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "no metadata",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"document.docx": "/document.docx",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"{}",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "error from PDF engine",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return errors.New("foo")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "cannot add output paths",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ctx.SetCancelled(true)
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "success",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetFiles(map[string]string{
|
||||||
|
"file.pdf": "/file.pdf",
|
||||||
|
})
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"metadata": {
|
||||||
|
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
engine: &gotenberg.PdfEngineMock{
|
||||||
|
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
expectHttpError: false,
|
||||||
|
expectOutputPathsCount: 1,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
tc.ctx.SetLogger(zap.NewNop())
|
||||||
|
c := echo.New().NewContext(nil, nil)
|
||||||
|
c.Set("context", tc.ctx.Context)
|
||||||
|
|
||||||
|
err := writeMetadataRoute(tc.engine).Handler(c)
|
||||||
|
|
||||||
|
if tc.expectError && err == nil {
|
||||||
|
t.Fatal("expected error but got none", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectError && err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr api.HttpError
|
||||||
|
isHttpError := errors.As(err, &httpErr)
|
||||||
|
|
||||||
|
if tc.expectHttpError && !isHttpError {
|
||||||
|
t.Errorf("expected an HTTP error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.expectHttpError && isHttpError {
|
||||||
|
t.Errorf("expected no HTTP error but got one: %v", httpErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && tc.expectHttpError && isHttpError {
|
||||||
|
status, _ := httpErr.HttpError()
|
||||||
|
if status != tc.expectHttpStatus {
|
||||||
|
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
|
||||||
|
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range tc.expectOutputPaths {
|
||||||
|
if !slices.Contains(tc.ctx.OutputPaths(), path) {
|
||||||
|
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Package pdftk provides an implementation of the gotenberg.PdfEngine
|
// Package pdftk provides an implementation of the gotenberg.PdfEngine
|
||||||
// interface using the PDFtk command-line tool. This package allows for the
|
// interface using the PDFtk command-line tool. This package allows for:
|
||||||
// merging of PDF files but does not support conversion to specific PDF
|
//
|
||||||
// formats. The path to the PDFtk binary must be specified using the
|
// 1. The merging of PDF files.
|
||||||
// PDFTK_BIN_PATH environment variable.
|
//
|
||||||
|
// The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH
|
||||||
|
// environment variable.
|
||||||
//
|
//
|
||||||
// See: https://gitlab.com/pdftk-java/pdftk.
|
// See: https://gitlab.com/pdftk-java/pdftk.
|
||||||
package pdftk
|
package pdftk
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -46,29 +45,12 @@ func (engine *PdfTk) Provision(ctx *gotenberg.Context) error {
|
|||||||
func (engine *PdfTk) Validate() error {
|
func (engine *PdfTk) Validate() error {
|
||||||
_, err := os.Stat(engine.binPath)
|
_, err := os.Stat(engine.binPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("PdfTk binary path does not exist: %w", err)
|
return fmt.Errorf("PDFtk binary path does not exist: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
return activeInstancesCount
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge combines multiple PDFs into a single PDF.
|
// Merge combines multiple PDFs into a single PDF.
|
||||||
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
var args []string
|
var args []string
|
||||||
@@ -80,16 +62,7 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [
|
|||||||
return fmt.Errorf("create command: %w", err)
|
return fmt.Errorf("create command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activeInstancesCountMu.Lock()
|
|
||||||
activeInstancesCount += 1
|
|
||||||
activeInstancesCountMu.Unlock()
|
|
||||||
|
|
||||||
_, err = cmd.Exec()
|
_, err = cmd.Exec()
|
||||||
|
|
||||||
activeInstancesCountMu.Lock()
|
|
||||||
activeInstancesCount -= 1
|
|
||||||
activeInstancesCountMu.Unlock()
|
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -102,16 +75,20 @@ 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)
|
return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// ReadMetadata is not available in this implementation.
|
||||||
activeInstancesCount float64
|
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
activeInstancesCountMu sync.RWMutex
|
return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
// WriteMetadata is not available in this implementation.
|
||||||
|
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
|
||||||
|
return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*PdfTk)(nil)
|
_ gotenberg.Module = (*PdfTk)(nil)
|
||||||
_ gotenberg.Provisioner = (*PdfTk)(nil)
|
_ gotenberg.Provisioner = (*PdfTk)(nil)
|
||||||
_ gotenberg.Validator = (*PdfTk)(nil)
|
_ gotenberg.Validator = (*PdfTk)(nil)
|
||||||
_ gotenberg.MetricsProvider = (*PdfTk)(nil)
|
_ gotenberg.PdfEngine = (*PdfTk)(nil)
|
||||||
_ gotenberg.PdfEngine = (*PdfTk)(nil)
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPdfTk_Descriptor(t *testing.T) {
|
func TestPdfTk_Descriptor(t *testing.T) {
|
||||||
@@ -71,22 +71,6 @@ 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) {
|
func TestPdfTk_Merge(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
@@ -113,6 +97,7 @@ func TestPdfTk_Merge(t *testing.T) {
|
|||||||
inputPaths: []string{
|
inputPaths: []string{
|
||||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
},
|
},
|
||||||
|
expectError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "many files success",
|
scenario: "many files success",
|
||||||
@@ -121,6 +106,7 @@ func TestPdfTk_Merge(t *testing.T) {
|
|||||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
"/tests/test/testdata/pdfengines/sample2.pdf",
|
"/tests/test/testdata/pdfengines/sample2.pdf",
|
||||||
},
|
},
|
||||||
|
expectError: false,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
@@ -164,3 +150,21 @@ func TestPdfTk_Convert(t *testing.T) {
|
|||||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPrometheus_Descriptor(t *testing.T) {
|
func TestPrometheus_Descriptor(t *testing.T) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Package qpdf provides an implementation of the gotenberg.PdfEngine
|
// Package qpdf provides an implementation of the gotenberg.PdfEngine
|
||||||
// interface using the QPDF command-line tool. This package allows for the
|
// interface using the QPDF command-line tool. This package allows for:
|
||||||
// merging of PDF files but does not support conversion to specific PDF
|
//
|
||||||
// formats. The path to the QPDF binary must be specified using the
|
// 1. The merging of PDF files.
|
||||||
// QPDK_BIN_PATH environment variable.
|
//
|
||||||
|
// The path to the QPDF binary must be specified using the QPDK_BIN_PATH
|
||||||
|
// environment variable.
|
||||||
//
|
//
|
||||||
// See: https://github.com/qpdf/qpdf.
|
// See: https://github.com/qpdf/qpdf.
|
||||||
package qpdf
|
package qpdf
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -52,23 +51,6 @@ func (engine *QPdf) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
|
||||||
|
|
||||||
return activeInstancesCount
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge combines multiple PDFs into a single PDF.
|
// Merge combines multiple PDFs into a single PDF.
|
||||||
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||||
var args []string
|
var args []string
|
||||||
@@ -82,16 +64,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
|
|||||||
return fmt.Errorf("create command: %w", err)
|
return fmt.Errorf("create command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
activeInstancesCountMu.Lock()
|
|
||||||
activeInstancesCount += 1
|
|
||||||
activeInstancesCountMu.Unlock()
|
|
||||||
|
|
||||||
_, err = cmd.Exec()
|
_, err = cmd.Exec()
|
||||||
|
|
||||||
activeInstancesCountMu.Lock()
|
|
||||||
activeInstancesCount -= 1
|
|
||||||
activeInstancesCountMu.Unlock()
|
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -104,15 +77,19 @@ 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)
|
return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
// ReadMetadata is not available in this implementation.
|
||||||
activeInstancesCount float64
|
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
|
||||||
activeInstancesCountMu sync.RWMutex
|
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 (
|
var (
|
||||||
_ gotenberg.Module = (*QPdf)(nil)
|
_ gotenberg.Module = (*QPdf)(nil)
|
||||||
_ gotenberg.Provisioner = (*QPdf)(nil)
|
_ gotenberg.Provisioner = (*QPdf)(nil)
|
||||||
_ gotenberg.Validator = (*QPdf)(nil)
|
_ gotenberg.Validator = (*QPdf)(nil)
|
||||||
_ gotenberg.MetricsProvider = (*QPdf)(nil)
|
_ gotenberg.PdfEngine = (*QPdf)(nil)
|
||||||
_ gotenberg.PdfEngine = (*QPdf)(nil)
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestQPdf_Descriptor(t *testing.T) {
|
func TestQPdf_Descriptor(t *testing.T) {
|
||||||
@@ -71,22 +71,6 @@ 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) {
|
func TestQPdf_Merge(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
@@ -113,6 +97,7 @@ func TestQPdf_Merge(t *testing.T) {
|
|||||||
inputPaths: []string{
|
inputPaths: []string{
|
||||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
},
|
},
|
||||||
|
expectError: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "many files success",
|
scenario: "many files success",
|
||||||
@@ -121,6 +106,7 @@ func TestQPdf_Merge(t *testing.T) {
|
|||||||
"/tests/test/testdata/pdfengines/sample1.pdf",
|
"/tests/test/testdata/pdfengines/sample1.pdf",
|
||||||
"/tests/test/testdata/pdfengines/sample2.pdf",
|
"/tests/test/testdata/pdfengines/sample2.pdf",
|
||||||
},
|
},
|
||||||
|
expectError: false,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
@@ -164,3 +150,21 @@ func TestQPdf_Convert(t *testing.T) {
|
|||||||
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ type client struct {
|
|||||||
|
|
||||||
// send call the webhook either to send the success response or the error response.
|
// 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 {
|
func (c client) send(body io.Reader, headers map[string]string, erroed bool) error {
|
||||||
URL := c.url
|
url := c.url
|
||||||
if erroed {
|
if erroed {
|
||||||
URL = c.errorUrl
|
url = c.errorUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
method := c.method
|
method := c.method
|
||||||
@@ -37,9 +37,9 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
|
|||||||
method = c.errorMethod
|
method = c.errorMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := retryablehttp.NewRequest(method, URL, body)
|
req, err := retryablehttp.NewRequest(method, url, body)
|
||||||
if err != nil {
|
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")
|
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)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
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 {
|
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() {
|
defer func() {
|
||||||
err := resp.Body.Close()
|
err := resp.Body.Close()
|
||||||
if err != nil {
|
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!
|
// Now let's log!
|
||||||
fields := make([]zap.Field, 5)
|
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[1] = zap.String("method", method)
|
||||||
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
|
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
|
||||||
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
|
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
|
||||||
@@ -110,34 +110,3 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
|
|||||||
|
|
||||||
return nil
|
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)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,7 +16,8 @@ import (
|
|||||||
"github.com/hashicorp/go-retryablehttp"
|
"github.com/hashicorp/go-retryablehttp"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func webhookMiddleware(w *Webhook) api.Middleware {
|
func webhookMiddleware(w *Webhook) api.Middleware {
|
||||||
@@ -44,38 +44,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let's check if the webhook URLs are acceptable according to our
|
deadline, ok := ctx.Deadline()
|
||||||
// allowed/denied lists.
|
if !ok {
|
||||||
filter := func(URL, header string, allowList, denyList *regexp.Regexp) error {
|
return errors.New("context has no deadline")
|
||||||
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)
|
// Let's check if the webhook URLs are acceptable according to our
|
||||||
|
// allowed/denied lists.
|
||||||
|
err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("filter webhook URL: %w", err)
|
return fmt.Errorf("filter webhook URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = filter(webhookErrorUrl, "Gotenberg-Webhook-Error-Url", w.errorAllowList, w.errorDenyList)
|
err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("filter webhook error URL: %w", err)
|
return fmt.Errorf("filter webhook error URL: %w", err)
|
||||||
}
|
}
|
||||||
@@ -119,11 +100,11 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// What about extra HTTP headers?
|
// 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")
|
extraHttpHeadersJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
|
||||||
if extraHTTPHeadersJSON != "" {
|
if extraHttpHeadersJson != "" {
|
||||||
err = json.Unmarshal([]byte(extraHTTPHeadersJSON), &extraHTTPHeaders)
|
err = json.Unmarshal([]byte(extraHttpHeadersJson), &extraHttpHeaders)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
|
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
|
||||||
@@ -132,13 +113,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retrieve values from echo.Context before it get recycled.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1000.
|
||||||
|
startTime := c.Get("startTime").(time.Time)
|
||||||
|
traceHeader := c.Get("traceHeader").(string)
|
||||||
|
trace := c.Get("trace").(string)
|
||||||
|
|
||||||
client := &client{
|
client := &client{
|
||||||
url: webhookUrl,
|
url: webhookUrl,
|
||||||
method: webhookMethod,
|
method: webhookMethod,
|
||||||
errorUrl: webhookErrorUrl,
|
errorUrl: webhookErrorUrl,
|
||||||
errorMethod: webhookErrorMethod,
|
errorMethod: webhookErrorMethod,
|
||||||
extraHttpHeaders: extraHTTPHeaders,
|
extraHttpHeaders: extraHttpHeaders,
|
||||||
startTime: c.Get("startTime").(time.Time),
|
startTime: startTime,
|
||||||
|
|
||||||
client: &retryablehttp.Client{
|
client: &retryablehttp.Client{
|
||||||
HTTPClient: &http.Client{
|
HTTPClient: &http.Client{
|
||||||
@@ -147,11 +134,9 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
RetryMax: w.maxRetry,
|
RetryMax: w.maxRetry,
|
||||||
RetryWaitMin: w.retryMinWait,
|
RetryWaitMin: w.retryMinWait,
|
||||||
RetryWaitMax: w.retryMaxWait,
|
RetryWaitMax: w.retryMaxWait,
|
||||||
Logger: leveledLogger{
|
Logger: gotenberg.NewLeveledLogger(ctx.Log()),
|
||||||
logger: ctx.Log(),
|
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||||
},
|
Backoff: retryablehttp.DefaultBackoff,
|
||||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
|
||||||
Backoff: retryablehttp.DefaultBackoff,
|
|
||||||
},
|
},
|
||||||
logger: ctx.Log(),
|
logger: ctx.Log(),
|
||||||
}
|
}
|
||||||
@@ -178,8 +163,8 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
}
|
}
|
||||||
|
|
||||||
headers := map[string]string{
|
headers := map[string]string{
|
||||||
echo.HeaderContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
echo.HeaderContentType: echo.MIMEApplicationJSON,
|
||||||
c.Get("traceHeader").(string): c.Get("trace").(string),
|
traceHeader: trace,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = client.send(bytes.NewReader(b), headers, true)
|
err = client.send(bytes.NewReader(b), headers, true)
|
||||||
@@ -257,7 +242,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
|||||||
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
|
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
|
||||||
echo.HeaderContentType: http.DetectContentType(fileHeader),
|
echo.HeaderContentType: http.DetectContentType(fileHeader),
|
||||||
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
|
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
|
||||||
c.Get("traceHeader").(string): c.Get("trace").(string),
|
traceHeader: trace,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the output file to the webhook.
|
// Send the output file to the webhook.
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebhookMiddlewareGuards(t *testing.T) {
|
func TestWebhookMiddlewareGuards(t *testing.T) {
|
||||||
@@ -47,10 +47,10 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
|
|
||||||
buildWebhookModule := func() *Webhook {
|
buildWebhookModule := func() *Webhook {
|
||||||
return &Webhook{
|
return &Webhook{
|
||||||
allowList: regexp.MustCompile(""),
|
allowList: regexp2.MustCompile("", 0),
|
||||||
denyList: regexp.MustCompile(""),
|
denyList: regexp2.MustCompile("", 0),
|
||||||
errorAllowList: regexp.MustCompile(""),
|
errorAllowList: regexp2.MustCompile("", 0),
|
||||||
errorDenyList: regexp.MustCompile(""),
|
errorDenyList: regexp2.MustCompile("", 0),
|
||||||
maxRetry: 0,
|
maxRetry: 0,
|
||||||
retryMinWait: 0,
|
retryMinWait: 0,
|
||||||
retryMaxWait: 0,
|
retryMaxWait: 0,
|
||||||
@@ -63,6 +63,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
request *http.Request
|
request *http.Request
|
||||||
mod *Webhook
|
mod *Webhook
|
||||||
next echo.HandlerFunc
|
next echo.HandlerFunc
|
||||||
|
noDeadline bool
|
||||||
expectError bool
|
expectError bool
|
||||||
expectHttpError bool
|
expectHttpError bool
|
||||||
expectHttpStatus int
|
expectHttpStatus int
|
||||||
@@ -76,6 +77,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: false,
|
expectError: false,
|
||||||
expectHttpError: false,
|
expectHttpError: false,
|
||||||
},
|
},
|
||||||
@@ -87,10 +89,23 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return req
|
return req
|
||||||
}(),
|
}(),
|
||||||
mod: buildWebhookModule(),
|
mod: buildWebhookModule(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
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",
|
scenario: "webhook URL is not allowed",
|
||||||
request: func() *http.Request {
|
request: func() *http.Request {
|
||||||
@@ -101,12 +116,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
mod: func() *Webhook {
|
mod: func() *Webhook {
|
||||||
mod := buildWebhookModule()
|
mod := buildWebhookModule()
|
||||||
mod.allowList = regexp.MustCompile("bar")
|
mod.allowList = regexp2.MustCompile("bar", 0)
|
||||||
return mod
|
return mod
|
||||||
}(),
|
}(),
|
||||||
expectError: true,
|
noDeadline: false,
|
||||||
expectHttpError: true,
|
expectError: true,
|
||||||
expectHttpStatus: http.StatusForbidden,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "webhook URL is denied",
|
scenario: "webhook URL is denied",
|
||||||
@@ -118,12 +132,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
mod: func() *Webhook {
|
mod: func() *Webhook {
|
||||||
mod := buildWebhookModule()
|
mod := buildWebhookModule()
|
||||||
mod.denyList = regexp.MustCompile("foo")
|
mod.denyList = regexp2.MustCompile("foo", 0)
|
||||||
return mod
|
return mod
|
||||||
}(),
|
}(),
|
||||||
expectError: true,
|
noDeadline: false,
|
||||||
expectHttpError: true,
|
expectError: true,
|
||||||
expectHttpStatus: http.StatusForbidden,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "webhook error URL is not allowed",
|
scenario: "webhook error URL is not allowed",
|
||||||
@@ -135,12 +148,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
mod: func() *Webhook {
|
mod: func() *Webhook {
|
||||||
mod := buildWebhookModule()
|
mod := buildWebhookModule()
|
||||||
mod.errorAllowList = regexp.MustCompile("foo")
|
mod.errorAllowList = regexp2.MustCompile("foo", 0)
|
||||||
return mod
|
return mod
|
||||||
}(),
|
}(),
|
||||||
expectError: true,
|
noDeadline: false,
|
||||||
expectHttpError: true,
|
expectError: true,
|
||||||
expectHttpStatus: http.StatusForbidden,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "webhook error URL is denied",
|
scenario: "webhook error URL is denied",
|
||||||
@@ -152,12 +164,11 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
mod: func() *Webhook {
|
mod: func() *Webhook {
|
||||||
mod := buildWebhookModule()
|
mod := buildWebhookModule()
|
||||||
mod.errorDenyList = regexp.MustCompile("bar")
|
mod.errorDenyList = regexp2.MustCompile("bar", 0)
|
||||||
return mod
|
return mod
|
||||||
}(),
|
}(),
|
||||||
expectError: true,
|
noDeadline: false,
|
||||||
expectHttpError: true,
|
expectError: true,
|
||||||
expectHttpStatus: http.StatusForbidden,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scenario: "invalid webhook method (GET)",
|
scenario: "invalid webhook method (GET)",
|
||||||
@@ -169,6 +180,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return req
|
return req
|
||||||
}(),
|
}(),
|
||||||
mod: buildWebhookModule(),
|
mod: buildWebhookModule(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
@@ -183,6 +195,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return req
|
return req
|
||||||
}(),
|
}(),
|
||||||
mod: buildWebhookModule(),
|
mod: buildWebhookModule(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
@@ -198,6 +211,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return req
|
return req
|
||||||
}(),
|
}(),
|
||||||
mod: buildWebhookModule(),
|
mod: buildWebhookModule(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
@@ -213,6 +227,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return req
|
return req
|
||||||
}(),
|
}(),
|
||||||
mod: buildWebhookModule(),
|
mod: buildWebhookModule(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
@@ -228,6 +243,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return req
|
return req
|
||||||
}(),
|
}(),
|
||||||
mod: buildWebhookModule(),
|
mod: buildWebhookModule(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
@@ -242,6 +258,7 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
return req
|
return req
|
||||||
}(),
|
}(),
|
||||||
mod: buildWebhookModule(),
|
mod: buildWebhookModule(),
|
||||||
|
noDeadline: false,
|
||||||
expectError: true,
|
expectError: true,
|
||||||
expectHttpError: true,
|
expectHttpError: true,
|
||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
@@ -254,15 +271,20 @@ func TestWebhookMiddlewareGuards(t *testing.T) {
|
|||||||
|
|
||||||
c := srv.NewContext(tc.request, httptest.NewRecorder())
|
c := srv.NewContext(tc.request, httptest.NewRecorder())
|
||||||
|
|
||||||
ctx := &api.ContextMock{Context: &api.Context{}}
|
if tc.noDeadline {
|
||||||
ctx.SetEchoContext(c)
|
ctx := &api.ContextMock{Context: &api.Context{Context: context.Background()}}
|
||||||
|
ctx.SetEchoContext(c)
|
||||||
c.Set("context", ctx.Context)
|
c.Set("context", ctx.Context)
|
||||||
c.Set("cancel", func() context.CancelFunc {
|
c.Set("cancel", func() context.CancelFunc {
|
||||||
return func() {
|
return nil
|
||||||
return
|
}())
|
||||||
}
|
} else {
|
||||||
}())
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||||
|
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
|
||||||
|
ctx.SetEchoContext(c)
|
||||||
|
c.Set("context", ctx.Context)
|
||||||
|
c.Set("cancel", cancel)
|
||||||
|
}
|
||||||
|
|
||||||
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
|
err := webhookMiddleware(tc.mod).Handler(tc.next)(c)
|
||||||
|
|
||||||
@@ -320,10 +342,10 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
|||||||
|
|
||||||
buildWebhookModule := func() *Webhook {
|
buildWebhookModule := func() *Webhook {
|
||||||
return &Webhook{
|
return &Webhook{
|
||||||
allowList: regexp.MustCompile(""),
|
allowList: regexp2.MustCompile("", 0),
|
||||||
denyList: regexp.MustCompile(""),
|
denyList: regexp2.MustCompile("", 0),
|
||||||
errorAllowList: regexp.MustCompile(""),
|
errorAllowList: regexp2.MustCompile("", 0),
|
||||||
errorDenyList: regexp.MustCompile(""),
|
errorDenyList: regexp2.MustCompile("", 0),
|
||||||
maxRetry: 0,
|
maxRetry: 0,
|
||||||
retryMinWait: 0,
|
retryMinWait: 0,
|
||||||
retryMaxWait: 0,
|
retryMaxWait: 0,
|
||||||
@@ -354,7 +376,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
|||||||
return errors.New("foo")
|
return errors.New("foo")
|
||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
expectWebhookContentType: echo.MIMEApplicationJSON,
|
||||||
expectWebhookMethod: http.MethodPost,
|
expectWebhookMethod: http.MethodPost,
|
||||||
expectWebhookErrorStatus: http.StatusInternalServerError,
|
expectWebhookErrorStatus: http.StatusInternalServerError,
|
||||||
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
|
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
|
||||||
@@ -368,7 +390,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
|||||||
return api.NewSentinelHttpError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
|
return api.NewSentinelHttpError(http.StatusBadRequest, http.StatusText(http.StatusBadRequest))
|
||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
expectWebhookContentType: echo.MIMEApplicationJSON,
|
||||||
expectWebhookMethod: http.MethodPost,
|
expectWebhookMethod: http.MethodPost,
|
||||||
expectWebhookErrorStatus: http.StatusBadRequest,
|
expectWebhookErrorStatus: http.StatusBadRequest,
|
||||||
expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest),
|
expectWebhookErrorMessage: http.StatusText(http.StatusBadRequest),
|
||||||
@@ -408,7 +430,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}(),
|
}(),
|
||||||
returnedError: echo.ErrInternalServerError,
|
returnedError: echo.ErrInternalServerError,
|
||||||
expectWebhookContentType: echo.MIMEApplicationJSONCharsetUTF8,
|
expectWebhookContentType: echo.MIMEApplicationJSON,
|
||||||
expectWebhookMethod: http.MethodPost,
|
expectWebhookMethod: http.MethodPost,
|
||||||
expectWebhookErrorStatus: http.StatusInternalServerError,
|
expectWebhookErrorStatus: http.StatusInternalServerError,
|
||||||
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
|
expectWebhookErrorMessage: http.StatusText(http.StatusInternalServerError),
|
||||||
@@ -426,22 +448,17 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
|||||||
c.Set("trace", "foo")
|
c.Set("trace", "foo")
|
||||||
c.Set("startTime", time.Now())
|
c.Set("startTime", time.Now())
|
||||||
|
|
||||||
ctx := &api.ContextMock{Context: &api.Context{}}
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Duration(10)*time.Second)
|
||||||
|
ctx := &api.ContextMock{Context: &api.Context{Context: timeoutCtx}}
|
||||||
ctx.SetLogger(zap.NewNop())
|
ctx.SetLogger(zap.NewNop())
|
||||||
ctx.SetEchoContext(c)
|
ctx.SetEchoContext(c)
|
||||||
|
|
||||||
c.Set("context", ctx.Context)
|
c.Set("context", ctx.Context)
|
||||||
c.Set("cancel", func() context.CancelFunc {
|
c.Set("cancel", cancel)
|
||||||
return func() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}())
|
|
||||||
|
|
||||||
webhook := echo.New()
|
webhook := echo.New()
|
||||||
webhook.HideBanner = true
|
webhook.HideBanner = true
|
||||||
webhook.HidePort = true
|
webhook.HidePort = true
|
||||||
|
|
||||||
rand.Seed(time.Now().UnixNano())
|
|
||||||
webhookPort := rand.Intn(65535-1025+1) + 1025
|
webhookPort := rand.Intn(65535-1025+1) + 1025
|
||||||
|
|
||||||
c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
|
c.Request().Header.Set("Gotenberg-Webhook-Url", fmt.Sprintf("http://localhost:%d/", webhookPort))
|
||||||
@@ -476,7 +493,7 @@ func TestWebhookMiddlewareAsynchronousProcess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if contentType == echo.MIMEApplicationJSONCharsetUTF8 {
|
if contentType == echo.MIMEApplicationJSON {
|
||||||
body, err := io.ReadAll(c.Request().Body)
|
body, err := io.ReadAll(c.Request().Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errChan <- err
|
errChan <- err
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
package webhook
|
package webhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
flag "github.com/spf13/pflag"
|
flag "github.com/spf13/pflag"
|
||||||
"go.uber.org/multierr"
|
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -19,10 +17,10 @@ func init() {
|
|||||||
// Webhook is a module which provides a middleware for uploading output files
|
// Webhook is a module which provides a middleware for uploading output files
|
||||||
// to any destinations in an asynchronous fashion.
|
// to any destinations in an asynchronous fashion.
|
||||||
type Webhook struct {
|
type Webhook struct {
|
||||||
allowList *regexp.Regexp
|
allowList *regexp2.Regexp
|
||||||
denyList *regexp.Regexp
|
denyList *regexp2.Regexp
|
||||||
errorAllowList *regexp.Regexp
|
errorAllowList *regexp2.Regexp
|
||||||
errorDenyList *regexp.Regexp
|
errorDenyList *regexp2.Regexp
|
||||||
maxRetry int
|
maxRetry int
|
||||||
retryMinWait time.Duration
|
retryMinWait time.Duration
|
||||||
retryMaxWait time.Duration
|
retryMaxWait time.Duration
|
||||||
@@ -36,31 +34,6 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
ID: "webhook",
|
ID: "webhook",
|
||||||
FlagSet: func() *flag.FlagSet {
|
FlagSet: func() *flag.FlagSet {
|
||||||
fs := flag.NewFlagSet("webhook", flag.ExitOnError)
|
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-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-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")
|
fs.String("webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
|
||||||
@@ -80,15 +53,15 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
// Provision sets the module properties.
|
// Provision sets the module properties.
|
||||||
func (w *Webhook) Provision(ctx *gotenberg.Context) error {
|
func (w *Webhook) Provision(ctx *gotenberg.Context) error {
|
||||||
flags := ctx.ParsedFlags()
|
flags := ctx.ParsedFlags()
|
||||||
w.allowList = flags.MustDeprecatedRegexp("api-webhook-allow-list", "webhook-allow-list")
|
w.allowList = flags.MustRegexp("webhook-allow-list")
|
||||||
w.denyList = flags.MustDeprecatedRegexp("api-webhook-deny-list", "webhook-deny-list")
|
w.denyList = flags.MustRegexp("webhook-deny-list")
|
||||||
w.errorAllowList = flags.MustDeprecatedRegexp("api-webhook-error-allow-list", "webhook-error-allow-list")
|
w.errorAllowList = flags.MustRegexp("webhook-error-allow-list")
|
||||||
w.errorDenyList = flags.MustDeprecatedRegexp("api-webhook-error-deny-list", "webhook-error-deny-list")
|
w.errorDenyList = flags.MustRegexp("webhook-error-deny-list")
|
||||||
w.maxRetry = flags.MustDeprecatedInt("api-webhook-max-retry", "webhook-max-retry")
|
w.maxRetry = flags.MustInt("webhook-max-retry")
|
||||||
w.retryMinWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-min-wait")
|
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
|
||||||
w.retryMaxWait = flags.MustDeprecatedDuration("api-webhook-retry-min-wait", "webhook-retry-max-wait")
|
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
|
||||||
w.clientTimeout = flags.MustDuration("webhook-client-timeout")
|
w.clientTimeout = flags.MustDuration("webhook-client-timeout")
|
||||||
w.disable = flags.MustDeprecatedBool("api-disable-webhook", "webhook-disable")
|
w.disable = flags.MustBool("webhook-disable")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebhook_Descriptor(t *testing.T) {
|
func TestWebhook_Descriptor(t *testing.T) {
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ package standard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
// Standard Gotenberg modules.
|
// Standard Gotenberg modules.
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/api"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/chromium"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/chromium"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/exiftool"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/api"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/libreoffice/pdfengine"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/logging"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/pdfengine"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfcpu"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/logging"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfengines"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfcpu"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdftk"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/prometheus"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdftk"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/qpdf"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/prometheus"
|
||||||
_ "github.com/gotenberg/gotenberg/v7/pkg/modules/webhook"
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/qpdf"
|
||||||
|
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/webhook"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,50 +2,61 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Args.
|
||||||
GOLANG_VERSION="$1"
|
GOLANG_VERSION="$1"
|
||||||
GOTENBERG_VERSION="$2"
|
GOTENBERG_VERSION="$2"
|
||||||
GOTENBERG_USER_GID="$3"
|
GOTENBERG_USER_GID="$3"
|
||||||
GOTENBERG_USER_UID="$4"
|
GOTENBERG_USER_UID="$4"
|
||||||
NOTO_COLOR_EMOJI_VERSION="$5"
|
NOTO_COLOR_EMOJI_VERSION="$5"
|
||||||
PDFTK_VERSION="$6"
|
PDFTK_VERSION="$6"
|
||||||
DOCKER_REPOSITORY="$7"
|
PDFCPU_VERSION="$7"
|
||||||
|
DOCKER_REGISTRY="$8"
|
||||||
if [ "$GOTENBERG_VERSION" == "edge" ]; then
|
DOCKER_REPOSITORY="$9"
|
||||||
docker buildx build \
|
LINUX_AMD64_RELEASE="${10}"
|
||||||
--build-arg GOLANG_VERSION="$GOLANG_VERSION" \
|
|
||||||
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
|
|
||||||
--build-arg GOTENBERG_USER_GID="$GOTENBERG_USER_GID" \
|
|
||||||
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
|
|
||||||
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
|
|
||||||
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \
|
|
||||||
--platform linux/amd64 \
|
|
||||||
--platform linux/arm64 \
|
|
||||||
--platform linux/386 \
|
|
||||||
--platform linux/arm/v7 \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:edge" \
|
|
||||||
--push \
|
|
||||||
-f build/Dockerfile .
|
|
||||||
|
|
||||||
# Cloud Run variant.
|
|
||||||
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
|
|
||||||
docker buildx build \
|
|
||||||
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
|
|
||||||
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
|
|
||||||
--platform linux/amd64 \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:edge-cloudrun" \
|
|
||||||
--push \
|
|
||||||
-f build/Dockerfile.cloudrun .
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
# Find out if given version is "semver".
|
||||||
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
|
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
|
||||||
IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
|
IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
|
||||||
VERSION_LENGTH=${#SEMVER[@]}
|
VERSION_LENGTH=${#SEMVER[@]}
|
||||||
|
TAGS=()
|
||||||
|
TAGS_CLOUD_RUN=()
|
||||||
|
|
||||||
if [ "$VERSION_LENGTH" -ne 3 ]; then
|
if [ "$VERSION_LENGTH" -eq 3 ]; then
|
||||||
echo "$VERSION is not semver."
|
MAJOR="${SEMVER[0]}"
|
||||||
exit 1
|
MINOR="${SEMVER[1]}"
|
||||||
|
PATCH="${SEMVER[2]}"
|
||||||
|
|
||||||
|
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest")
|
||||||
|
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR")
|
||||||
|
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR")
|
||||||
|
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH")
|
||||||
|
|
||||||
|
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
|
||||||
|
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR-cloudrun")
|
||||||
|
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR-cloudrun")
|
||||||
|
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH-cloudrun")
|
||||||
|
else
|
||||||
|
# Normalizes version.
|
||||||
|
GOTENBERG_VERSION="${GOTENBERG_VERSION// /-}"
|
||||||
|
GOTENBERG_VERSION="$(echo "$GOTENBERG_VERSION" | tr -cd '[:alnum:]._\-')"
|
||||||
|
|
||||||
|
if [[ "$GOTENBERG_VERSION" =~ ^[\.\-] ]]; then
|
||||||
|
GOTENBERG_VERSION="_${GOTENBERG_VERSION#?}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#GOTENBERG_VERSION}" -gt 128 ]; then
|
||||||
|
GOTENBERG_VERSION="${GOTENBERG_VERSION:0:128}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION")
|
||||||
|
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION-cloudrun")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Multi-arch build takes a lot of time.
|
||||||
|
if [ "$LINUX_AMD64_RELEASE" = true ]; then
|
||||||
|
PLATFORM_FLAG="--platform linux/amd64"
|
||||||
|
else
|
||||||
|
PLATFORM_FLAG="--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
@@ -55,26 +66,19 @@ docker buildx build \
|
|||||||
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
|
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
|
||||||
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
|
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
|
||||||
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \
|
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \
|
||||||
--platform linux/amd64 \
|
--build-arg PDFCPU_VERSION="$PDFCPU_VERSION" \
|
||||||
--platform linux/arm64 \
|
$PLATFORM_FLAG \
|
||||||
--platform linux/386 \
|
"${TAGS[@]}" \
|
||||||
--platform linux/arm/v7 \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:latest" \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}" \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}" \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}" \
|
|
||||||
--push \
|
--push \
|
||||||
-f build/Dockerfile .
|
-f build/Dockerfile .
|
||||||
|
|
||||||
# Cloud Run variant.
|
# Cloud Run variant.
|
||||||
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
|
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
|
--build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
|
||||||
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
|
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
|
||||||
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
|
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
|
||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:latest-cloudrun" \
|
"${TAGS_CLOUD_RUN[@]}" \
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}-cloudrun" \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}-cloudrun" \
|
|
||||||
-t "$DOCKER_REPOSITORY/gotenberg:${SEMVER[0]}.${SEMVER[1]}.${SEMVER[2]}-cloudrun" \
|
|
||||||
--push \
|
--push \
|
||||||
-f build/Dockerfile.cloudrun .
|
-f build/Dockerfile.cloudrun .
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
ARG GOLANG_VERSION
|
ARG GOLANG_VERSION
|
||||||
|
ARG DOCKER_REGISTRY
|
||||||
ARG DOCKER_REPOSITORY
|
ARG DOCKER_REPOSITORY
|
||||||
ARG GOTENBERG_VERSION
|
ARG GOTENBERG_VERSION
|
||||||
ARG GOLANGCI_LINT_VERSION
|
ARG GOLANGCI_LINT_VERSION
|
||||||
|
|
||||||
FROM golang:$GOLANG_VERSION-bookworm as golang
|
FROM golang:$GOLANG_VERSION-bookworm AS golang
|
||||||
|
|
||||||
# We're extending the Gotenberg's Docker image because our code relies on external
|
# We're extending the Gotenberg's Docker image because our code relies on external
|
||||||
# dependencies like Google Chrome, LibreOffice, etc.
|
# dependencies like Google Chrome, LibreOffice, etc.
|
||||||
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
|
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
ENV GOPATH /go
|
ENV GOPATH=/go
|
||||||
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
|
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||||
ENV CGO_ENABLED 1
|
ENV CGO_ENABLED=1
|
||||||
|
|
||||||
COPY --from=golang /usr/local/go /usr/local/go
|
COPY --from=golang /usr/local/go /usr/local/go
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
# Credits: https://github.com/thecodingmachine/docker-images-php.
|
# Credits: https://github.com/thecodingmachine/docker-images-php.
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
mkdir testing_file_system_rights.foo
|
mkdir -p testing_file_system_rights.foo
|
||||||
chmod 700 testing_file_system_rights.foo
|
chmod 700 testing_file_system_rights.foo
|
||||||
su gotenberg -c "touch testing_file_system_rights.foo/foo > /dev/null 2>&1"
|
su gotenberg -c "touch testing_file_system_rights.foo/foo > /dev/null 2>&1"
|
||||||
HAS_CONSISTENT_RIGHTS=$?
|
HAS_CONSISTENT_RIGHTS=$?
|
||||||
|
|
||||||
if [[ "$HAS_CONSISTENT_RIGHTS" != "0" ]]; then
|
if [[ "$HAS_CONSISTENT_RIGHTS" != "0" ]]; then
|
||||||
# If not specified, the DOCKER_USER is the owner of the current working directory (heuristic!).
|
# If not specified, the DOCKER_USER is the owner of the current working directory (heuristic!).
|
||||||
DOCKER_USER=$(stat -c '%U' "$(pwd)")
|
DOCKER_USER=$(stat -c '%u' "$(pwd)")
|
||||||
else
|
else
|
||||||
# macOs or Windows.
|
# macOs or Windows.
|
||||||
# Note: in most cases, we don't care about the rights (they are not respected).
|
# Note: in most cases, we don't care about the rights (they are not respected).
|
||||||
FILE_OWNER=$(stat -c '%U' "testing_file_system_rights.foo/foo")
|
FILE_OWNER=$(stat -c '%u' "testing_file_system_rights.foo/foo")
|
||||||
if [[ "$FILE_OWNER" == "root" ]]; then
|
if [[ "$FILE_OWNER" == "0" ]]; then
|
||||||
# If root, we are likely on a Windows host.
|
# If root, we are likely on a Windows host.
|
||||||
# All files will belong to root, but it does not matter as everybody can write/delete
|
# All files will belong to root, but it does not matter as everybody can write/delete
|
||||||
# those (0777 access rights).
|
# those (0777 access rights).
|
||||||
@@ -56,4 +56,4 @@ go mod tidy
|
|||||||
set +x
|
set +x
|
||||||
|
|
||||||
# Run the command with the correct user.
|
# Run the command with the correct user.
|
||||||
exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"
|
exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user