mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
daysUntilStale: 15
|
||||
daysUntilStale: 7
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
daysUntilClose: 3
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- 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
|
||||
run: |
|
||||
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
|
||||
|
||||
35
.github/workflows/continuous_integration.yml
vendored
35
.github/workflows/continuous_integration.yml
vendored
@@ -17,18 +17,18 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: '1.23'
|
||||
cache: false
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
- name: Run linters
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: v1.58.2
|
||||
version: v1.60.3
|
||||
|
||||
tests:
|
||||
needs:
|
||||
- Lint
|
||||
- lint
|
||||
name: Tests
|
||||
# TODO: once arm64 actions are available, also run the tests on this architecture.
|
||||
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
|
||||
@@ -50,11 +50,32 @@ jobs:
|
||||
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'
|
||||
needs:
|
||||
- Tests
|
||||
name: Multi-arch build
|
||||
- tests
|
||||
name: Edge release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
@@ -71,4 +92,4 @@ jobs:
|
||||
- name: Build and push Docker image for main branch
|
||||
run: |
|
||||
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.txt
|
||||
/TODO.txt
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
@@ -6,6 +6,10 @@ linters-settings:
|
||||
- prefix(github.com/gotenberg/gotenberg/v8)
|
||||
skip-generated: true
|
||||
custom-order: true
|
||||
# Until https://github.com/securego/gosec/issues/1187 is resolved.
|
||||
gosec:
|
||||
excludes:
|
||||
- G115
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
@@ -19,10 +23,10 @@ linters:
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- copyloopvar
|
||||
- errcheck
|
||||
- errname
|
||||
- exhaustive
|
||||
- exportloopref
|
||||
- gci
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
37
Makefile
37
Makefile
@@ -5,14 +5,15 @@ help: ## Show the help
|
||||
.PHONY: it
|
||||
it: build build-tests ## Initialize the development environment
|
||||
|
||||
GOLANG_VERSION=1.22
|
||||
GOLANG_VERSION=1.23
|
||||
DOCKER_REGISTRY=gotenberg
|
||||
DOCKER_REPOSITORY=gotenberg
|
||||
GOTENBERG_VERSION=snapshot
|
||||
GOTENBERG_USER_GID=1001
|
||||
GOTENBERG_USER_UID=1001
|
||||
NOTO_COLOR_EMOJI_VERSION=v2.042 # See https://github.com/googlefonts/noto-emoji/releases.
|
||||
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.
|
||||
GOLANGCI_LINT_VERSION=v1.58.2 # See https://github.com/golangci/golangci-lint/releases.
|
||||
GOLANGCI_LINT_VERSION=v1.60.3 # See https://github.com/golangci/golangci-lint/releases.
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the Gotenberg's Docker image
|
||||
@@ -23,7 +24,7 @@ build: ## Build the Gotenberg's Docker image
|
||||
--build-arg GOTENBERG_USER_UID=$(GOTENBERG_USER_UID) \
|
||||
--build-arg NOTO_COLOR_EMOJI_VERSION=$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
-f build/Dockerfile .
|
||||
|
||||
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
|
||||
@@ -31,11 +32,16 @@ API_PORT=3000
|
||||
API_PORT_FROM_ENV=
|
||||
API_START_TIMEOUT=30s
|
||||
API_TIMEOUT=30s
|
||||
API_BODY_LIMIT=
|
||||
API_ROOT_PATH=/
|
||||
API_TRACE_HEADER=Gotenberg-Trace
|
||||
API_ENABLE_BASIC_AUTH=false
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME=
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD=
|
||||
API-DOWNLOAD-FROM-ALLOW-LIST=
|
||||
API-DOWNLOAD-FROM-DENY-LIST=
|
||||
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
|
||||
API-DISABLE-DOWNLOAD-FROM=false
|
||||
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||
CHROMIUM_RESTART_AFTER=0
|
||||
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||
@@ -84,16 +90,21 @@ run: ## Start a Gotenberg container
|
||||
-p $(API_PORT):$(API_PORT) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
|
||||
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
|
||||
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
|
||||
gotenberg \
|
||||
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
|
||||
--api-port=$(API_PORT) \
|
||||
--api-port-from-env=$(API_PORT_FROM_ENV) \
|
||||
--api-start-timeout=$(API_START_TIMEOUT) \
|
||||
--api-timeout=$(API_TIMEOUT) \
|
||||
--api-body-limit="$(API_BODY_LIMIT)" \
|
||||
--api-root-path=$(API_ROOT_PATH) \
|
||||
--api-trace-header=$(API_TRACE_HEADER) \
|
||||
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
|
||||
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
|
||||
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
|
||||
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
|
||||
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
|
||||
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
|
||||
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
||||
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
||||
@@ -140,24 +151,25 @@ run: ## Start a Gotenberg container
|
||||
build-tests: ## Build the tests' Docker image
|
||||
docker build \
|
||||
--build-arg GOLANG_VERSION=$(GOLANG_VERSION) \
|
||||
--build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) \
|
||||
--build-arg DOCKER_REPOSITORY=$(DOCKER_REPOSITORY) \
|
||||
--build-arg GOTENBERG_VERSION=$(GOTENBERG_VERSION) \
|
||||
--build-arg GOLANGCI_LINT_VERSION=$(GOLANGCI_LINT_VERSION) \
|
||||
-t $(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||
-f test/Dockerfile .
|
||||
|
||||
.PHONY: tests
|
||||
tests: ## Start the testing environment
|
||||
docker run --rm -it \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||
bash
|
||||
|
||||
.PHONY: tests-once
|
||||
tests-once: ## Run the tests once (prefer the "tests" command while developing)
|
||||
docker run --rm \
|
||||
-v $(PWD):/tests \
|
||||
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION)-tests \
|
||||
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION)-tests \
|
||||
gotest
|
||||
|
||||
# go install mvdan.cc/gofumpt@latest
|
||||
@@ -174,8 +186,10 @@ godoc: ## Run a webserver with Gotenberg godoc
|
||||
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
|
||||
godoc -http=:6060
|
||||
|
||||
LINUX_AMD64_RELEASE=false
|
||||
|
||||
.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 \
|
||||
$(GOLANG_VERSION) \
|
||||
$(GOTENBERG_VERSION) \
|
||||
@@ -183,4 +197,7 @@ release: ## Build the Gotenberg's Docker image for many platforms, then push it
|
||||
$(GOTENBERG_USER_UID) \
|
||||
$(NOTO_COLOR_EMOJI_VERSION) \
|
||||
$(PDFTK_VERSION) \
|
||||
$(DOCKER_REPOSITORY)
|
||||
$(DOCKER_REGISTRY) \
|
||||
$(DOCKER_REPOSITORY) \
|
||||
$(LINUX_AMD64_RELEASE)
|
||||
|
||||
|
||||
23
README.md
23
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" />
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A Docker-powered stateless API for PDF files</p>
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
|
||||
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
|
||||
<br>
|
||||
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous_integration.yml/badge.svg"></a>
|
||||
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
|
||||
<a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a>
|
||||
</p>
|
||||
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> · <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
Gotenberg provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
|
||||
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
|
||||
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
|
||||
|
||||
## Quick Start
|
||||
@@ -32,14 +40,11 @@ Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduct
|
||||
|
||||
<p align="center">
|
||||
<a href="https://thecodingmachine.com">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="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>
|
||||
</p>
|
||||
|
||||
## Badges
|
||||
|
||||
[](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/v8)
|
||||
[](https://codecov.io/gh/gotenberg/gotenberg)
|
||||
Sponsorships help maintaining and improving Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️
|
||||
|
||||
@@ -9,7 +9,7 @@ ARG GOLANG_VERSION
|
||||
FROM golang:$GOLANG_VERSION AS binary-stage
|
||||
|
||||
ARG GOTENBERG_VERSION
|
||||
ENV CGO_ENABLED 0
|
||||
ENV CGO_ENABLED=0
|
||||
|
||||
# Define the working directory outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /home
|
||||
@@ -191,12 +191,12 @@ COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
|
||||
COPY --from=binary-stage /home/gotenberg /usr/bin/
|
||||
|
||||
# Environment variables required by modules or else.
|
||||
ENV CHROMIUM_BIN_PATH /usr/bin/chromium
|
||||
ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH /usr/bin/unoconverter
|
||||
ENV PDFTK_BIN_PATH /usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH /usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH /usr/bin/exiftool
|
||||
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
|
||||
ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin
|
||||
ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter
|
||||
ENV PDFTK_BIN_PATH=/usr/bin/pdftk
|
||||
ENV QPDF_BIN_PATH=/usr/bin/qpdf
|
||||
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
|
||||
|
||||
USER gotenberg
|
||||
WORKDIR /home/gotenberg
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_VERSION
|
||||
|
||||
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
43
go.mod
43
go.mod
@@ -1,43 +1,43 @@
|
||||
module github.com/gotenberg/gotenberg/v8
|
||||
|
||||
go 1.21
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/alexliesenfeld/health v0.8.0
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/barasher/go-exiftool v1.10.0
|
||||
github.com/chromedp/cdproto v0.0.0-20240519224452-66462be74baa
|
||||
github.com/chromedp/chromedp v0.9.5
|
||||
github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df
|
||||
github.com/chromedp/chromedp v0.10.0
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.6
|
||||
github.com/klauspost/compress v1.17.8 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7
|
||||
github.com/klauspost/compress v1.17.10 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/labstack/echo/v4 v4.12.0
|
||||
github.com/labstack/gommon v0.4.2
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mholt/archiver/v3 v3.5.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/nwaples/rardecode v1.1.3 // indirect
|
||||
github.com/pdfcpu/pdfcpu v0.8.0
|
||||
github.com/pdfcpu/pdfcpu v0.8.1
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/prometheus/client_golang v1.20.4
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||
go.uber.org/multierr v1.11.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/image v0.16.0 // indirect
|
||||
golang.org/x/net v0.25.0
|
||||
golang.org/x/sync v0.7.0
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/term v0.20.0
|
||||
golang.org/x/text v0.15.0
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/image v0.21.0 // indirect
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/sync v0.8.0
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/term v0.25.0
|
||||
golang.org/x/text v0.19.0
|
||||
)
|
||||
|
||||
require github.com/dlclark/regexp2 v1.11.0
|
||||
require github.com/dlclark/regexp2 v1.11.4
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
@@ -55,16 +55,17 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.53.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.0 // indirect
|
||||
github.com/prometheus/common v0.60.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
93
go.sum
93
go.sum
@@ -11,18 +11,18 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/cdproto v0.0.0-20240519224452-66462be74baa h1:T3Ho4BWIkoEoMPCj90W2HIPF/k56qk4JWzTs6JUBxVw=
|
||||
github.com/chromedp/cdproto v0.0.0-20240519224452-66462be74baa/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
|
||||
github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
|
||||
github.com/chromedp/cdproto v0.0.0-20240801214329-3f85d328b335/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df h1:cbtSn19AtqQha1cxmP2Qvgd3fFMz51AeAEKLJMyEUhc=
|
||||
github.com/chromedp/cdproto v0.0.0-20241003230502-a4a8f7c660df/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.10.0 h1:bRclRYVpMm/UVD76+1HcRW9eV3l58rFfy7AdBvKab1E=
|
||||
github.com/chromedp/chromedp v0.10.0/go.mod h1:ei/1ncZIqXX1YnAYDkxhD4gzBgavMEUu7JCKvztdomE=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
|
||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
@@ -32,7 +32,6 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
@@ -51,8 +50,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDyiVuGYfs9GZM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
|
||||
@@ -61,8 +60,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/compress v1.17.10 h1:oXAz+Vh0PMUvJczoi+flxpnBEPxoER1IaAnU/NMPtT0=
|
||||
github.com/klauspost/compress v1.17.10/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
@@ -71,6 +70,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
|
||||
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
@@ -84,19 +85,21 @@ 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
|
||||
github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pdfcpu/pdfcpu v0.8.0 h1:SuEB4uVsPFz1nb802r38YpFpj9TtZh/oB0bGG34IRZw=
|
||||
github.com/pdfcpu/pdfcpu v0.8.0/go.mod h1:jj03y/KKrwigt5xCi8t7px2mATcKuOzkIOoCX62yMho=
|
||||
github.com/pdfcpu/pdfcpu v0.8.1 h1:AiWUb8uXlrXqJ73OmiYXBjDF0Qxt4OuM281eAfkAOMA=
|
||||
github.com/pdfcpu/pdfcpu v0.8.1/go.mod h1:M5SFotxdaw0fedxthpjbA/PADytAo6wJnGH0SSBWJ7s=
|
||||
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
@@ -104,14 +107,14 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
|
||||
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
|
||||
github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
|
||||
github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI5Ek=
|
||||
github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk=
|
||||
github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA=
|
||||
github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
@@ -125,8 +128,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
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.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
@@ -143,28 +146,28 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
|
||||
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
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=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -156,7 +156,7 @@ func (cmd *Cmd) pipeOutput() error {
|
||||
r := bufio.NewReader(reader)
|
||||
defer func(reader io.ReadCloser) {
|
||||
err := reader.Close()
|
||||
if err != nil {
|
||||
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}(reader)
|
||||
|
||||
@@ -104,19 +104,8 @@ func TestCmd_Wait(t *testing.T) {
|
||||
expectWaitError: false,
|
||||
},
|
||||
{
|
||||
scenario: "wait error",
|
||||
cmd: func() *Cmd {
|
||||
cmd := Command(zap.NewNop(), "echo", "Hello", "World")
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = cmd.Kill()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}(),
|
||||
scenario: "wait error",
|
||||
cmd: Command(zap.NewNop(), "echo", "Hello", "World"),
|
||||
expectWaitError: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -168,33 +168,37 @@ func (f *ParsedFlags) MustDeprecatedDuration(deprecated string, newName string)
|
||||
return f.MustDuration(newName)
|
||||
}
|
||||
|
||||
// MustHumanReadableBytesString returns the human-readable bytes string of a
|
||||
// flag given by name.
|
||||
// MustHumanReadableBytes returns the human-readable bytes string of a flag
|
||||
// given by name.
|
||||
// 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)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
_, err = bytes.Parse(val)
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
b, err := bytes.Parse(val)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return val
|
||||
return b
|
||||
}
|
||||
|
||||
// MustDeprecatedHumanReadableBytesString returns the human-readable bytes
|
||||
// string of a deprecated flag if it was explicitly set or the human-readable
|
||||
// bytes string of the new flag.
|
||||
// MustDeprecatedHumanReadableBytes returns the human-readable bytes of a
|
||||
// deprecated flag if it was explicitly set or the human-readable bytes string
|
||||
// of the new flag.
|
||||
// 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) {
|
||||
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.
|
||||
|
||||
@@ -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.String("foo", "1MB", "")
|
||||
fs.String("bar", "1MB", "")
|
||||
fs.String("qux", "", "")
|
||||
|
||||
err := fs.Parse([]string{"--foo=1GB", "--bar=foo"})
|
||||
if err != nil {
|
||||
@@ -671,6 +672,11 @@ func TestParsedFlags_MustHumanReadableBytesString(t *testing.T) {
|
||||
name: "bar",
|
||||
expectPanic: true,
|
||||
},
|
||||
{
|
||||
scenario: "success: empty value",
|
||||
name: "qux",
|
||||
expectPanic: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.expectPanic {
|
||||
@@ -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 {
|
||||
scenario string
|
||||
rawFlags []string
|
||||
expectValue string
|
||||
expectValue int64
|
||||
}{
|
||||
{
|
||||
scenario: "deprecated flag value",
|
||||
rawFlags: []string{"--foo=1MB"},
|
||||
expectValue: "1MB",
|
||||
expectValue: 1000000,
|
||||
},
|
||||
{
|
||||
scenario: "non-deprecated flag value",
|
||||
rawFlags: []string{"--bar=2MB"},
|
||||
expectValue: "2MB",
|
||||
expectValue: 2000000,
|
||||
},
|
||||
{
|
||||
scenario: "deprecated flag value > non-deprecated flag value",
|
||||
rawFlags: []string{"--foo=1MB", "--bar=2MB"},
|
||||
expectValue: "1MB",
|
||||
expectValue: 1000000,
|
||||
},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
|
||||
actual := parsedFlags.MustDeprecatedHumanReadableBytesString("foo", "bar")
|
||||
actual := parsedFlags.MustDeprecatedHumanReadableBytes("foo", "bar")
|
||||
if actual != tc.expectValue {
|
||||
t.Errorf("expected '%s' but got '%s'", tc.expectValue, actual)
|
||||
t.Errorf("expected %d but got %d", tc.expectValue, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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
|
||||
// creating a [zap.Logger] instance for use by other modules.
|
||||
@@ -12,3 +17,41 @@ import "go.uber.org/zap"
|
||||
type LoggerProvider interface {
|
||||
Logger(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
// LeveledLogger is wrapper around a [zap.Logger] so that it may be used by a
|
||||
// [retryablehttp.Client].
|
||||
type LeveledLogger struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLeveledLogger instantiates a [LeveledLogger].
|
||||
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
|
||||
return &LeveledLogger{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Error logs a message at error level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Warn logs a message at warning level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Info logs a message at info level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Debug logs a message at debug level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package webhook
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
)
|
||||
|
||||
func TestLeveledLogger_Error(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Error("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Error("foo")
|
||||
}
|
||||
|
||||
func TestLeveledLogger_Warn(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Warn("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Warn("foo")
|
||||
}
|
||||
|
||||
func TestLeveledLogger_Info(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Info("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Info("foo")
|
||||
}
|
||||
|
||||
func TestLeveledLogger_Debug(t *testing.T) {
|
||||
leveledLogger{logger: zap.NewNop()}.Debug("foo")
|
||||
NewLeveledLogger(zap.NewNop()).Debug("foo")
|
||||
}
|
||||
@@ -173,6 +173,20 @@ func (s *processSupervisor) Healthy() bool {
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
// A user reported a potential issue:
|
||||
//
|
||||
// "Although the counting operation is atomic, nothing prevent 2 concurrent
|
||||
// goroutines to retrieve the same 'currentQueueSize' and to compare its
|
||||
// value against the max limit. Then, resulting queue size would be 1 above
|
||||
// the allowed limit."
|
||||
//
|
||||
// However, he was unable to actually trigger this issue, even when sending
|
||||
// a lot of requests.
|
||||
//
|
||||
// For now, the best option is to consider this issue to be unlikely to
|
||||
// happen, and keep the code as it is because it is more readable this way.
|
||||
//
|
||||
// See https://github.com/gotenberg/gotenberg/issues/951.
|
||||
currentQueueSize := s.reqQueueSize.Load()
|
||||
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
|
||||
return ErrMaximumQueueSizeExceeded
|
||||
@@ -187,10 +201,13 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
||||
logger.Debug("process lock acquired")
|
||||
s.reqQueueSize.Add(-1)
|
||||
s.reqCounter.Add(1)
|
||||
releaseMutexChan := true
|
||||
|
||||
defer func() {
|
||||
logger.Debug("process lock released")
|
||||
<-s.mutexChan
|
||||
if releaseMutexChan {
|
||||
logger.Debug("process lock released")
|
||||
<-s.mutexChan
|
||||
}
|
||||
}()
|
||||
|
||||
if !s.firstStart.Load() {
|
||||
@@ -212,18 +229,26 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
||||
}
|
||||
}
|
||||
|
||||
err := s.runWithDeadline(ctx, task)
|
||||
|
||||
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
|
||||
s.logger.Debug("max request limit reached, restarting...")
|
||||
err := s.runWithDeadline(ctx, func() error {
|
||||
return s.restart()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
}
|
||||
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 s.runWithDeadline(ctx, task)
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
logger.Debug("failed to acquire process lock before deadline")
|
||||
s.reqQueueSize.Add(-1)
|
||||
|
||||
@@ -325,7 +325,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
expectedStopCalls: 1,
|
||||
},
|
||||
{
|
||||
scenario: "cannot restart after reaching max request limit",
|
||||
scenario: "auto-restart after reaching max request limit",
|
||||
startError: errors.New("start error"),
|
||||
initiallyStarted: true,
|
||||
isRestarting: false,
|
||||
@@ -451,6 +451,10 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// Making sure restarts are finished.
|
||||
ps.mutexChan <- struct{}{}
|
||||
<-ps.mutexChan
|
||||
|
||||
if startCalls.Load() != tc.expectedStartCalls {
|
||||
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/alexliesenfeld/health"
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/labstack/echo/v4"
|
||||
flag "github.com/spf13/pflag"
|
||||
"go.uber.org/multierr"
|
||||
@@ -28,12 +29,16 @@ func init() {
|
||||
// middlewares or health checks.
|
||||
type Api struct {
|
||||
port int
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
startTimeout time.Duration
|
||||
bodyLimit int64
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
traceHeader string
|
||||
basicAuthUsername string
|
||||
basicAuthPassword string
|
||||
downloadFromCfg downloadFromConfig
|
||||
disableHealthCheckLogging bool
|
||||
|
||||
routes []Route
|
||||
@@ -45,6 +50,13 @@ type Api struct {
|
||||
srv *echo.Echo
|
||||
}
|
||||
|
||||
type downloadFromConfig struct {
|
||||
allowList *regexp2.Regexp
|
||||
denyList *regexp2.Regexp
|
||||
maxRetry int
|
||||
disable bool
|
||||
}
|
||||
|
||||
// Router is a module interface which adds routes to the [Api].
|
||||
type Router interface {
|
||||
Routes() ([]Route, error)
|
||||
@@ -159,11 +171,18 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs := flag.NewFlagSet("api", flag.ExitOnError)
|
||||
fs.Int("api-port", 3000, "Set the port on which the API should listen")
|
||||
fs.String("api-port-from-env", "", "Set the environment variable with the port on which the API should listen - override the default port")
|
||||
fs.String("api-tls-cert-file", "", "Path to the TLS/SSL certificate file - for HTTPS support")
|
||||
fs.String("api-tls-key-file", "", "Path to the TLS/SSL key file - for HTTPS support")
|
||||
fs.Duration("api-start-timeout", time.Duration(30)*time.Second, "Set the time limit for the API to start")
|
||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests")
|
||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||
fs.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")
|
||||
return fs
|
||||
}(),
|
||||
@@ -175,10 +194,19 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
flags := ctx.ParsedFlags()
|
||||
a.port = flags.MustInt("api-port")
|
||||
a.tlsCertFile = flags.MustString("api-tls-cert-file")
|
||||
a.tlsKeyFile = flags.MustString("api-tls-key-file")
|
||||
a.startTimeout = flags.MustDuration("api-start-timeout")
|
||||
a.timeout = flags.MustDuration("api-timeout")
|
||||
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
|
||||
a.rootPath = flags.MustString("api-root-path")
|
||||
a.traceHeader = flags.MustString("api-trace-header")
|
||||
a.downloadFromCfg = downloadFromConfig{
|
||||
allowList: flags.MustRegexp("api-download-from-allow-list"),
|
||||
denyList: flags.MustRegexp("api-download-from-deny-list"),
|
||||
maxRetry: flags.MustInt("api-download-from-max-retry"),
|
||||
disable: flags.MustBool("api-disable-download-from"),
|
||||
}
|
||||
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
|
||||
|
||||
// Port from env?
|
||||
@@ -301,6 +329,12 @@ func (a *Api) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("both TLS certificate and key files must be set"),
|
||||
)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(a.rootPath, "/") {
|
||||
err = multierr.Append(err,
|
||||
errors.New("root path must start with /"),
|
||||
@@ -399,13 +433,6 @@ func (a *Api) Start() error {
|
||||
loggerMiddleware(a.logger, disableLoggingForPaths),
|
||||
)
|
||||
|
||||
// Basic auth?
|
||||
if a.basicAuthUsername != "" {
|
||||
a.srv.Pre(
|
||||
basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword),
|
||||
)
|
||||
}
|
||||
|
||||
// Add the modules' middlewares in their respective stacks.
|
||||
var externalMultipartMiddlewares []Middleware
|
||||
for _, externalMiddleware := range a.externalMiddlewares {
|
||||
@@ -425,8 +452,13 @@ func (a *Api) Start() error {
|
||||
for _, route := range a.routes {
|
||||
var middlewares []echo.MiddlewareFunc
|
||||
|
||||
// Basic auth?
|
||||
if a.basicAuthUsername != "" {
|
||||
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
|
||||
}
|
||||
|
||||
if route.IsMultipart {
|
||||
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout))
|
||||
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
|
||||
|
||||
for _, externalMultipartMiddleware := range externalMultipartMiddlewares {
|
||||
middlewares = append(middlewares, externalMultipartMiddleware.Handler)
|
||||
@@ -443,13 +475,22 @@ 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(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
|
||||
checker := health.NewChecker(checks...)
|
||||
return echo.WrapHandler(health.NewHandler(checker))
|
||||
return echo.WrapHandler(healthCheckHandler)
|
||||
}(),
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
a.srv.HEAD(
|
||||
fmt.Sprintf("%s%s", a.rootPath, "health"),
|
||||
func() echo.HandlerFunc {
|
||||
return echo.WrapHandler(healthCheckHandler)
|
||||
}(),
|
||||
hardTimeoutMiddleware(hardTimeout),
|
||||
)
|
||||
@@ -478,8 +519,15 @@ func (a *Api) Start() error {
|
||||
|
||||
// As the following code is blocking, run it in a goroutine.
|
||||
go func() {
|
||||
server := &http2.Server{}
|
||||
err := a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
||||
var err error
|
||||
if a.tlsCertFile != "" && a.tlsKeyFile != "" {
|
||||
// Start an HTTPS server (supports HTTP/2).
|
||||
err = a.srv.StartTLS(fmt.Sprintf(":%d", a.port), a.tlsCertFile, a.tlsKeyFile)
|
||||
} else {
|
||||
// Start an HTTP/2 Cleartext (non-HTTPS) server.
|
||||
server := &http2.Server{}
|
||||
err = a.srv.StartH2CServer(fmt.Sprintf(":%d", a.port), server)
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
a.logger.Fatal(err.Error())
|
||||
}
|
||||
|
||||
@@ -462,6 +462,8 @@ func TestApi_Validate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
port int
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
rootPath string
|
||||
traceHeader string
|
||||
routes []Route
|
||||
@@ -486,6 +488,26 @@ func TestApi_Validate(t *testing.T) {
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid TLS files: only cert file provided",
|
||||
port: 10,
|
||||
tlsCertFile: "cert.pem",
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid TLS files: only key file provided",
|
||||
port: 10,
|
||||
tlsKeyFile: "key.pem",
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: nil,
|
||||
middlewares: nil,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid root path: missing / prefix",
|
||||
port: 10,
|
||||
@@ -647,10 +669,33 @@ func TestApi_Validate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "success with TLS",
|
||||
port: 10,
|
||||
tlsCertFile: "cert.pem",
|
||||
tlsKeyFile: "key.pem",
|
||||
rootPath: "/foo/",
|
||||
traceHeader: "foo",
|
||||
routes: []Route{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/forms/foo",
|
||||
Handler: func(_ echo.Context) error { return nil },
|
||||
IsMultipart: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := Api{
|
||||
port: tc.port,
|
||||
tlsCertFile: tc.tlsCertFile,
|
||||
tlsKeyFile: tc.tlsKeyFile,
|
||||
rootPath: tc.rootPath,
|
||||
traceHeader: tc.traceHeader,
|
||||
routes: tc.routes,
|
||||
@@ -673,6 +718,8 @@ func TestApi_Start(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
readyFn []func() error
|
||||
tlsCertFile string
|
||||
tlsKeyFile string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
@@ -691,10 +738,22 @@ func TestApi_Start(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success with TLS",
|
||||
readyFn: []func() error{
|
||||
func() error { return nil },
|
||||
func() error { return nil },
|
||||
},
|
||||
tlsCertFile: "/tests/test/testdata/api/cert.pem",
|
||||
tlsKeyFile: "/tests/test/testdata/api/key.pem",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
mod := new(Api)
|
||||
mod.port = 3000
|
||||
mod.tlsCertFile = tc.tlsCertFile
|
||||
mod.tlsKeyFile = tc.tlsKeyFile
|
||||
mod.startTimeout = time.Duration(30) * time.Second
|
||||
mod.rootPath = "/"
|
||||
mod.basicAuthUsername = "foo"
|
||||
@@ -780,20 +839,23 @@ func TestApi_Start(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
// health request.
|
||||
// health requests.
|
||||
recorder := httptest.NewRecorder()
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
healthRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||
|
||||
mod.srv.ServeHTTP(recorder, healthRequest)
|
||||
healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
mod.srv.ServeHTTP(recorder, healthGetRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil)
|
||||
mod.srv.ServeHTTP(recorder, healthHeadRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
// version request.
|
||||
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
|
||||
versionRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||
|
||||
mod.srv.ServeHTTP(recorder, versionRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
|
||||
@@ -802,7 +864,6 @@ func TestApi_Start(t *testing.T) {
|
||||
// "multipart/form-data" request.
|
||||
multipartRequest := func(url string) *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
defer func() {
|
||||
|
||||
@@ -3,20 +3,25 @@ package api
|
||||
import (
|
||||
"compress/flate"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mholt/archiver/v3"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -46,6 +51,36 @@ type Context struct {
|
||||
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 {
|
||||
@@ -53,9 +88,25 @@ func (o *osPathRename) Rename(oldpath, newpath string) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// We want to make sure the multipart/form-data does not exceed a given
|
||||
// limit. We consider: form fields (keys, values, files) and files
|
||||
// downloaded remotely ("download from" feature).
|
||||
var totalBytesRead atomic.Int64
|
||||
|
||||
addReadBytes := func(n int64) error {
|
||||
newTotal := totalBytesRead.Add(n)
|
||||
if bodyLimit != 0 && newTotal > bodyLimit {
|
||||
return WrapError(
|
||||
fmt.Errorf("body limit reached (> %d)", bodyLimit),
|
||||
NewSentinelHttpError(http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := &Context{
|
||||
outputPaths: make([]string, 0),
|
||||
cancelled: false,
|
||||
@@ -117,6 +168,19 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||
}
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
var formValuesSize int64
|
||||
for key, valArray := range form.Value {
|
||||
formValuesSize += int64(len(key))
|
||||
for _, val := range valArray {
|
||||
formValuesSize += int64(len(val))
|
||||
}
|
||||
}
|
||||
err = addReadBytes(formValuesSize)
|
||||
if err != nil {
|
||||
return nil, cancel, fmt.Errorf("add read bytes: %w", err)
|
||||
}
|
||||
|
||||
dirPath, err := fs.MkdirAll()
|
||||
if err != nil {
|
||||
return nil, cancel, fmt.Errorf("create working directory: %w", err)
|
||||
@@ -126,6 +190,150 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
ctx.values = form.Value
|
||||
ctx.files = make(map[string]string)
|
||||
|
||||
// First, try to download files listed in the "downloadFrom" form field, if
|
||||
// any.
|
||||
raw, ok := ctx.values["downloadFrom"]
|
||||
if !downloadFromCfg.disable && ok {
|
||||
var dls []downloadFrom
|
||||
err = json.Unmarshal([]byte(raw[0]), &dls)
|
||||
if err != nil {
|
||||
return nil, cancel, WrapError(
|
||||
fmt.Errorf("unmarshal json: %w", err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field value: %s", err)),
|
||||
)
|
||||
}
|
||||
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
for i, dl := range dls {
|
||||
eg.Go(func() error {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
// Should not happen, as context is created with a timeout.
|
||||
return errors.New("context has no deadline")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(dl.Url) == "" {
|
||||
return WrapError(
|
||||
errors.New("empty download from URL"),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'downloadFrom' form field entry %d: URL must be set", i)),
|
||||
)
|
||||
}
|
||||
|
||||
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter URL: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
|
||||
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Gotenberg")
|
||||
for key, value := range dl.ExtraHttpHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
req.Header.Set(traceHeader, trace)
|
||||
|
||||
client := &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: time.Until(deadline),
|
||||
},
|
||||
RetryMax: downloadFromCfg.maxRetry,
|
||||
RetryWaitMin: time.Duration(1) * time.Second,
|
||||
RetryWaitMax: time.Until(deadline),
|
||||
Logger: gotenberg.NewLeveledLogger(logger),
|
||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||
Backoff: retryablehttp.DefaultBackoff,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return WrapError(
|
||||
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
|
||||
)
|
||||
}
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return WrapError(
|
||||
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
|
||||
)
|
||||
}
|
||||
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if contentDisposition == "" {
|
||||
return WrapError(
|
||||
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
|
||||
)
|
||||
}
|
||||
|
||||
// FIXME: the implementation of this method might not be
|
||||
// complete, as it fails to parse an empty mediatype.
|
||||
// See: https://github.com/golang/go/issues/69551.
|
||||
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||
if err != nil {
|
||||
return WrapError(
|
||||
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
|
||||
)
|
||||
}
|
||||
|
||||
filename, ok := params["filename"]
|
||||
if !ok {
|
||||
return WrapError(
|
||||
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
|
||||
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
|
||||
)
|
||||
}
|
||||
|
||||
// Avoid directory traversal and make sure filename characters are
|
||||
// normalized.
|
||||
// See: https://github.com/gotenberg/gotenberg/issues/662.
|
||||
filename = norm.NFC.String(filepath.Base(filename))
|
||||
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("close local file: %s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// This will ensure we do not exceed the body limit.
|
||||
reader := &trackingReader{R: resp.Body, AddReadBytes: addReadBytes}
|
||||
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
|
||||
}
|
||||
|
||||
ctx.files[filename] = path
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err = eg.Wait()
|
||||
if err != nil {
|
||||
return ctx, cancel, err
|
||||
}
|
||||
}
|
||||
|
||||
copyToDisk := func(fh *multipart.FileHeader) error {
|
||||
in, err := fh.Open()
|
||||
if err != nil {
|
||||
@@ -139,6 +347,9 @@ 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.
|
||||
@@ -149,7 +360,6 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local file: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := out.Close()
|
||||
if err != nil {
|
||||
@@ -157,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 {
|
||||
return fmt.Errorf("copy multipart file to local file: %w", err)
|
||||
}
|
||||
@@ -167,6 +377,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
|
||||
return nil
|
||||
}
|
||||
|
||||
// Then, copy the form files, if any.
|
||||
for _, files := range form.File {
|
||||
for _, fh := range files {
|
||||
err = copyToDisk(fh)
|
||||
@@ -178,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 files: %+v", ctx.files))
|
||||
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
|
||||
|
||||
return ctx, cancel, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dlclark/regexp2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"go.uber.org/zap"
|
||||
@@ -75,9 +77,28 @@ func TestOsPathRename_Rename(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 {
|
||||
scenario string
|
||||
request *http.Request
|
||||
bodyLimit int64
|
||||
downloadFromCfg downloadFromConfig
|
||||
downloadFromSrv *echo.Echo
|
||||
expectContext *Context
|
||||
expectError bool
|
||||
expectHttpError bool
|
||||
expectHttpStatus int
|
||||
@@ -123,6 +144,325 @@ func TestNewContext(t *testing.T) {
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "request entity too large: form values",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("key", "value")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
bodyLimit: 1,
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
scenario: "request entity too large: downloadFrom",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
bodyLimit: 45, // form values = 44 bytes.
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
|
||||
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
scenario: "request entity too large: form files",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
part, err := writer.CreateFormFile("foo.txt", "foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
_, err = part.Write([]byte("foo"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
bodyLimit: 1,
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: cannot unmarshal",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: no URL",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: filtered URL",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"https://foo.bar"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: func() downloadFromConfig {
|
||||
denyList, err := regexp2.Compile("https://foo.bar", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
return downloadFromConfig{allowList: defaultAllowList, denyList: denyList, maxRetry: 1, disable: false}
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: unreachable URL",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: invalid status code",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
return c.String(http.StatusNotFound, http.StatusText(http.StatusNotFound))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: no 'Content-Disposition' header",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: malformed 'Content-Disposition' header",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, ";;")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "invalid downloadFrom form field: no filename parameter in 'Content-Disposition' header",
|
||||
request: func() *http.Request {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
}()
|
||||
err := writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/"}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, "inline;")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
scenario: "success",
|
||||
request: func() *http.Request {
|
||||
@@ -146,17 +486,69 @@ func TestNewContext(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = writer.WriteField("downloadFrom", `[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
return req
|
||||
}(),
|
||||
downloadFromSrv: func() *echo.Echo {
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.GET("/", func(c echo.Context) error {
|
||||
if c.Request().Header.Get("User-Agent") != "Gotenberg" {
|
||||
t.Fatalf("expected 'Gotenberg' from header 'User-Agent', but got '%s'", c.Request().Header.Get("User-Agent"))
|
||||
}
|
||||
if c.Request().Header.Get("X-Foo") != "Bar" {
|
||||
t.Fatalf("expected 'Bar' from header 'X-Foo', but got '%s'", c.Request().Header.Get("X-Foo"))
|
||||
}
|
||||
if c.Request().Header.Get("Gotenberg-Trace") != "123" {
|
||||
t.Fatalf("expected '123' from header 'Gotenberg-Trace', but got '%s'", c.Request().Header.Get("Gotenberg-Trace"))
|
||||
}
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="bar.txt"`)
|
||||
c.Response().Header().Set(echo.HeaderContentType, "text/plain")
|
||||
return c.String(http.StatusOK, http.StatusText(http.StatusOK))
|
||||
})
|
||||
return srv
|
||||
}(),
|
||||
downloadFromCfg: defaultDownloadFromCfg,
|
||||
expectContext: &Context{
|
||||
values: map[string][]string{
|
||||
"foo": {"foo"},
|
||||
"downloadFrom": {
|
||||
`[{"url":"http://localhost:80/","extraHttpHeaders":{"X-Foo":"Bar"}}]`,
|
||||
},
|
||||
},
|
||||
files: map[string]string{
|
||||
"foo.txt": "foo.txt",
|
||||
"bar.txt": "bar.txt", // downloadFrom.
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
expectHttpError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if tc.downloadFromSrv != nil {
|
||||
go func() {
|
||||
err := tc.downloadFromSrv.Start(":80")
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
err := tc.downloadFromSrv.Shutdown(context.TODO())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
handler := func(c echo.Context) error {
|
||||
_, 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()
|
||||
// Context already cancelled.
|
||||
defer cancel()
|
||||
@@ -165,6 +557,20 @@ func TestNewContext(t *testing.T) {
|
||||
return err
|
||||
}
|
||||
|
||||
if tc.expectContext != nil {
|
||||
if !reflect.DeepEqual(tc.expectContext.values, ctx.values) {
|
||||
t.Fatalf("expected context.values to be %v but got %v", tc.expectContext.values, ctx.values)
|
||||
}
|
||||
if len(tc.expectContext.files) != len(ctx.files) {
|
||||
t.Fatalf("expected context.files to contain %d items but got %d", len(tc.expectContext.files), len(ctx.files))
|
||||
}
|
||||
for key, value := range tc.expectContext.files {
|
||||
if !strings.HasSuffix(ctx.files[key], value) {
|
||||
t.Fatalf("expected context.files to contain '%s' but got '%s'", value, ctx.files[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -236,14 +236,16 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
//
|
||||
// ctx := c.Get("context").(*api.Context)
|
||||
// cancel := c.Get("cancel").(context.CancelFunc)
|
||||
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration) 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(c echo.Context) error {
|
||||
logger := c.Get("logger").(*zap.Logger)
|
||||
traceHeader := c.Get("traceHeader").(string)
|
||||
trace := c.Get("trace").(string)
|
||||
|
||||
// We create a context with a timeout so that underlying processes are
|
||||
// able to stop early and handle correctly a timeout scenario.
|
||||
ctx, cancel, err := newContext(c, logger, fs, timeout)
|
||||
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
|
||||
if err != nil {
|
||||
cancel()
|
||||
|
||||
|
||||
@@ -458,10 +458,11 @@ func TestContextMiddleware(t *testing.T) {
|
||||
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
c.Set("logger", zap.NewNop())
|
||||
c.Set("traceHeader", "Gotenberg-Trace")
|
||||
c.Set("trace", "foo")
|
||||
c.Set("startTime", time.Now())
|
||||
|
||||
err := contextMiddleware(gotenberg.NewFileSystem(), 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 {
|
||||
t.Errorf("test %d: expected error but got: %v", i, err)
|
||||
|
||||
@@ -227,6 +227,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
|
||||
@@ -250,6 +251,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
|
||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||
setCookiesActionFunc(logger, options.Cookies),
|
||||
userAgentOverride(logger, options.UserAgent),
|
||||
extraHttpHeadersActionFunc(logger, options.ExtraHttpHeaders),
|
||||
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
|
||||
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
|
||||
@@ -312,6 +314,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
listenForEventExceptionThrown(taskCtx, logger, &consoleExceptions, &consoleExceptionsMu)
|
||||
}
|
||||
|
||||
var (
|
||||
loadingFailed error
|
||||
loadingFailedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913
|
||||
// https://github.com/gotenberg/gotenberg/issues/959
|
||||
listenForEventLoadingFailed(taskCtx, logger, &loadingFailed, &loadingFailedMu)
|
||||
|
||||
err = chromedp.Run(taskCtx, tasks...)
|
||||
if err != nil {
|
||||
errMessage := err.Error()
|
||||
@@ -347,6 +359,16 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
||||
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
|
||||
}
|
||||
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913
|
||||
// https://github.com/gotenberg/gotenberg/issues/959
|
||||
loadingFailedMu.RLock()
|
||||
defer loadingFailedMu.RUnlock()
|
||||
|
||||
if loadingFailed != nil {
|
||||
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
browser browser
|
||||
fs *gotenberg.FileSystem
|
||||
options PdfOptions
|
||||
url string
|
||||
noDeadline bool
|
||||
start bool
|
||||
expectError bool
|
||||
@@ -378,7 +379,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "skip networkIdle event",
|
||||
scenario: "do not skip networkIdle event",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
@@ -403,13 +404,13 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
return fs
|
||||
}(),
|
||||
options: PdfOptions{
|
||||
Options: Options{SkipNetworkIdleEvent: true},
|
||||
Options: Options{SkipNetworkIdleEvent: false},
|
||||
},
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
"skipping network idle event",
|
||||
"event networkIdle fired",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -478,6 +479,32 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
expectError: true,
|
||||
expectedError: ErrConsoleExceptions,
|
||||
},
|
||||
{
|
||||
scenario: "ErrLoadingFailed",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
url: "http://localhost:100",
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: true,
|
||||
expectedError: ErrLoadingFailed,
|
||||
},
|
||||
{
|
||||
scenario: "clear cache",
|
||||
browser: newChromiumBrowser(
|
||||
@@ -614,6 +641,41 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
"set cookie",
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "user agent override",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<h1>User-Agent override</h1>"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: PdfOptions{
|
||||
Options: Options{UserAgent: "foo"},
|
||||
},
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
fmt.Sprintf("user agent override: foo"),
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "extra HTTP headers",
|
||||
browser: newChromiumBrowser(
|
||||
@@ -1163,6 +1225,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
"no cookies to set",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"skipping network idle event",
|
||||
"default white background not hidden",
|
||||
"no emulated media type",
|
||||
"no wait delay",
|
||||
@@ -1208,10 +1271,15 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath())
|
||||
if tc.url != "" {
|
||||
url = tc.url
|
||||
}
|
||||
|
||||
err := tc.browser.pdf(
|
||||
ctx,
|
||||
logger,
|
||||
fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()),
|
||||
url,
|
||||
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
|
||||
tc.options,
|
||||
)
|
||||
@@ -1251,6 +1319,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
browser browser
|
||||
fs *gotenberg.FileSystem
|
||||
options ScreenshotOptions
|
||||
url string
|
||||
noDeadline bool
|
||||
start bool
|
||||
expectError bool
|
||||
@@ -1384,7 +1453,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
scenario: "skip networkIdle event",
|
||||
scenario: "do not skip networkIdle event",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
@@ -1409,13 +1478,13 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
return fs
|
||||
}(),
|
||||
options: ScreenshotOptions{
|
||||
Options: Options{SkipNetworkIdleEvent: true},
|
||||
Options: Options{SkipNetworkIdleEvent: false},
|
||||
},
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
expectedLogEntries: []string{
|
||||
"skipping network idle event",
|
||||
"event networkIdle fired",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1484,6 +1553,33 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
expectError: true,
|
||||
expectedError: ErrConsoleExceptions,
|
||||
},
|
||||
{
|
||||
scenario: "ErrLoadingFailed",
|
||||
browser: newChromiumBrowser(
|
||||
browserArguments{
|
||||
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||
wsUrlReadTimeout: 5 * time.Second,
|
||||
allowList: regexp2.MustCompile("", 0),
|
||||
denyList: regexp2.MustCompile("", 0),
|
||||
},
|
||||
),
|
||||
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
url: "http://localhost:100",
|
||||
noDeadline: false,
|
||||
start: true,
|
||||
expectError: true,
|
||||
expectedError: ErrLoadingFailed,
|
||||
},
|
||||
{
|
||||
scenario: "clear cache",
|
||||
browser: newChromiumBrowser(
|
||||
@@ -1990,6 +2086,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
"cache not cleared",
|
||||
"cookies not cleared",
|
||||
"JavaScript not disabled",
|
||||
"no user agent override",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"default white background not hidden",
|
||||
@@ -2037,6 +2134,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
"cookies not cleared",
|
||||
"JavaScript not disabled",
|
||||
"no cookies to set",
|
||||
"no user agent override",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"default white background not hidden",
|
||||
@@ -2084,8 +2182,10 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
"cookies not cleared",
|
||||
"JavaScript not disabled",
|
||||
"no cookies to set",
|
||||
"no user agent override",
|
||||
"no extra HTTP headers",
|
||||
"navigate to",
|
||||
"skipping network idle event",
|
||||
"default white background not hidden",
|
||||
"no emulated media type",
|
||||
"no wait delay",
|
||||
@@ -2131,10 +2231,15 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath())
|
||||
if tc.url != "" {
|
||||
url = tc.url
|
||||
}
|
||||
|
||||
err := tc.browser.screenshot(
|
||||
ctx,
|
||||
logger,
|
||||
fmt.Sprintf("file://%s/index.html", tc.fs.WorkingDirPath()),
|
||||
url,
|
||||
fmt.Sprintf("%s/%s.pdf", tc.fs.WorkingDirPath(), uuid.NewString()),
|
||||
tc.options,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,9 @@ var (
|
||||
// is set to true.
|
||||
ErrConsoleExceptions = errors.New("console exceptions")
|
||||
|
||||
// ErrLoadingFailed happens when a URL failed to load.
|
||||
ErrLoadingFailed = errors.New("loading failed")
|
||||
|
||||
// PDF specific.
|
||||
|
||||
// ErrOmitBackgroundWithoutPrintBackground happens if
|
||||
@@ -76,64 +79,58 @@ type Options struct {
|
||||
// "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.
|
||||
// Optional.
|
||||
SkipNetworkIdleEvent bool
|
||||
|
||||
// FailOnHttpStatusCodes sets if the conversion should fail if the status
|
||||
// code from the main page matches with one of its entries.
|
||||
// Optional.
|
||||
FailOnHttpStatusCodes []int64
|
||||
|
||||
// FailOnConsoleExceptions sets if the conversion should fail if there are
|
||||
// exceptions in the Chromium console.
|
||||
// Optional.
|
||||
FailOnConsoleExceptions bool
|
||||
|
||||
// WaitDelay is the duration to wait when loading an HTML document before
|
||||
// converting it.
|
||||
// Optional.
|
||||
WaitDelay time.Duration
|
||||
|
||||
// WaitWindowStatus is the window.status value to wait for before
|
||||
// converting an HTML document.
|
||||
// Optional.
|
||||
WaitWindowStatus string
|
||||
|
||||
// WaitForExpression is the custom JavaScript expression to wait before
|
||||
// converting an HTML document until it returns true
|
||||
// Optional.
|
||||
WaitForExpression string
|
||||
|
||||
// Cookies are the cookies to put in the Chromium cookies' jar.
|
||||
// Optional
|
||||
Cookies []Cookie
|
||||
|
||||
// ExtraHttpHeaders are the HTTP headers to send by Chromium while loading
|
||||
// the HTML document.
|
||||
// Optional.
|
||||
// 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 map[string]string
|
||||
|
||||
// EmulatedMediaType is the media type to emulate, either "screen" or
|
||||
// "print".
|
||||
// Optional.
|
||||
EmulatedMediaType string
|
||||
|
||||
// OmitBackground hides default white background and allows generating PDFs
|
||||
// with transparency.
|
||||
// Optional.
|
||||
OmitBackground bool
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
SkipNetworkIdleEvent: false,
|
||||
SkipNetworkIdleEvent: true,
|
||||
FailOnHttpStatusCodes: []int64{499, 599},
|
||||
FailOnConsoleExceptions: false,
|
||||
WaitDelay: 0,
|
||||
WaitWindowStatus: "",
|
||||
WaitForExpression: "",
|
||||
Cookies: nil,
|
||||
UserAgent: "",
|
||||
ExtraHttpHeaders: nil,
|
||||
EmulatedMediaType: "",
|
||||
OmitBackground: false,
|
||||
@@ -145,48 +142,37 @@ type PdfOptions struct {
|
||||
Options
|
||||
|
||||
// Landscape sets the paper orientation.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
|
||||
// PrintBackground prints the background graphics.
|
||||
// Optional.
|
||||
PrintBackground bool
|
||||
|
||||
// Scale is the scale of the page rendering.
|
||||
// Optional.
|
||||
Scale float64
|
||||
|
||||
// SinglePage defines whether to print the entire content in one single
|
||||
// page.
|
||||
// Optional.
|
||||
SinglePage bool
|
||||
|
||||
// PaperWidth is the paper width, in inches.
|
||||
// Optional.
|
||||
PaperWidth float64
|
||||
|
||||
// PaperHeight is the paper height, in inches.
|
||||
// Optional.
|
||||
PaperHeight float64
|
||||
|
||||
// MarginTop is the top margin, in inches.
|
||||
// Optional.
|
||||
MarginTop float64
|
||||
|
||||
// MarginBottom is the bottom margin, in inches.
|
||||
// Optional.
|
||||
MarginBottom float64
|
||||
|
||||
// MarginLeft is the left margin, in inches.
|
||||
// Optional.
|
||||
MarginLeft float64
|
||||
|
||||
// MarginRight is the right margin, in inches.
|
||||
// Optional.
|
||||
MarginRight float64
|
||||
|
||||
// Page ranges to print, e.g., '1-5, 8, 11-13'. Empty means all pages.
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// HeaderTemplate is the HTML template of the header. It should be valid
|
||||
@@ -199,17 +185,14 @@ type PdfOptions struct {
|
||||
// - totalPages: total pages in the document
|
||||
// For example, <span class=title></span> would generate span containing
|
||||
// the title.
|
||||
// Optional.
|
||||
HeaderTemplate string
|
||||
|
||||
// FooterTemplate is the HTML template of the footer. It should use the
|
||||
// same format as the HeaderTemplate.
|
||||
// Optional.
|
||||
FooterTemplate string
|
||||
|
||||
// PreferCssPageSize defines whether to prefer page size as defined by CSS.
|
||||
// If false, the content will be scaled to fit the paper size.
|
||||
// Optional.
|
||||
PreferCssPageSize bool
|
||||
}
|
||||
|
||||
@@ -240,30 +223,24 @@ type ScreenshotOptions struct {
|
||||
Options
|
||||
|
||||
// Width is the device screen width in pixels.
|
||||
// Optional.
|
||||
Width int
|
||||
|
||||
// Height is the device screen height in pixels.
|
||||
// Optional.
|
||||
Height int
|
||||
|
||||
// Clip defines whether to clip the screenshot according to the device
|
||||
// dimensions.
|
||||
// Optional.
|
||||
Clip bool
|
||||
|
||||
// Format is the image compression format, either "png" or "jpeg" or
|
||||
// "webp".
|
||||
// Optional.
|
||||
Format string
|
||||
|
||||
// Quality is the compression quality from range [0..100] (jpeg only).
|
||||
// Optional.
|
||||
Quality int
|
||||
|
||||
// OptimizeForSpeed defines whether to optimize image encoding for speed,
|
||||
// not for resulting size.
|
||||
// Optional.
|
||||
OptimizeForSpeed bool
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,15 @@ import (
|
||||
)
|
||||
|
||||
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 TestDefaultPdfOptions(t *testing.T) {
|
||||
actual := DefaultPdfOptions()
|
||||
notExpect := PdfOptions{}
|
||||
|
||||
@@ -23,6 +32,15 @@ func TestDefaultOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultScreenshotOptions(t *testing.T) {
|
||||
actual := DefaultScreenshotOptions()
|
||||
notExpect := ScreenshotOptions{}
|
||||
|
||||
if reflect.DeepEqual(actual, notExpect) {
|
||||
t.Errorf("expected %v and got identical %v", actual, notExpect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromium_Descriptor(t *testing.T) {
|
||||
descriptor := new(Chromium).Descriptor()
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ import (
|
||||
)
|
||||
|
||||
// listenForEventRequestPaused listens for requests to check if they are
|
||||
// allowed or not.
|
||||
// allowed or not.network.SetBlockedURLS()
|
||||
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
|
||||
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowList *regexp2.Regexp, denyList *regexp2.Regexp) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
@@ -64,8 +65,8 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, allowL
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventResponseReceived listens for an invalid HTTP status code is
|
||||
// returned by the main page.
|
||||
// listenForEventResponseReceived listens for an invalid HTTP status code that
|
||||
// is returned by the main page.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
||||
func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url string, failOnHttpStatusCodes []int64, invalidHttpStatusCode *error, invalidHttpStatusCodeMu *sync.RWMutex) {
|
||||
for _, code := range []int64{199, 299, 399, 499, 599} {
|
||||
@@ -95,6 +96,52 @@ func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventLoadingFailed listens for an event indicating that the main
|
||||
// page failed to load.
|
||||
// See:
|
||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadingFailed *error, loadingFailedMu *sync.RWMutex) {
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventLoadingFailed:
|
||||
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||
|
||||
if ev.Type != network.ResourceTypeDocument {
|
||||
logger.Debug("skip EventLoadingFailed: is not resource type Document")
|
||||
return
|
||||
}
|
||||
|
||||
// Supposition: except iframe, an event loading failed with a
|
||||
// resource type Document is about the main page.
|
||||
|
||||
// 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",
|
||||
}
|
||||
if !slices.Contains(errors, ev.ErrorText) {
|
||||
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
||||
return
|
||||
}
|
||||
|
||||
loadingFailedMu.Lock()
|
||||
defer loadingFailedMu.Unlock()
|
||||
|
||||
*loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// listenForEventExceptionThrown listens for exceptions in the console and
|
||||
// appends those exceptions to the given error pointer.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||
|
||||
@@ -35,6 +35,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
waitWindowStatus string
|
||||
waitForExpression string
|
||||
cookies []Cookie
|
||||
userAgent string
|
||||
extraHttpHeaders map[string]string
|
||||
emulatedMediaType string
|
||||
omitBackground bool
|
||||
@@ -78,6 +79,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
|
||||
return err
|
||||
}).
|
||||
String("userAgent", &userAgent, defaultOptions.UserAgent).
|
||||
Custom("extraHttpHeaders", func(value string) error {
|
||||
if value == "" {
|
||||
extraHttpHeaders = defaultOptions.ExtraHttpHeaders
|
||||
@@ -115,6 +117,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
||||
WaitWindowStatus: waitWindowStatus,
|
||||
WaitForExpression: waitForExpression,
|
||||
Cookies: cookies,
|
||||
UserAgent: userAgent,
|
||||
ExtraHttpHeaders: extraHttpHeaders,
|
||||
EmulatedMediaType: emulatedMediaType,
|
||||
OmitBackground: omitBackground,
|
||||
@@ -685,5 +688,15 @@ func handleChromiumError(err error, options Options) error {
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrLoadingFailed) {
|
||||
return api.WrapError(
|
||||
err,
|
||||
api.NewSentinelHttpError(
|
||||
http.StatusBadRequest,
|
||||
fmt.Sprintf("Chromium returned %v", err),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1435,6 +1435,18 @@ func TestConvertUrl(t *testing.T) {
|
||||
expectHttpStatus: http.StatusConflict,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrLoadingFailed",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||
return ErrLoadingFailed
|
||||
}},
|
||||
options: DefaultPdfOptions(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from Chromium",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
@@ -1633,6 +1645,18 @@ func TestScreenshotUrl(t *testing.T) {
|
||||
expectHttpStatus: http.StatusConflict,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrLoadingFailed",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||
return ErrLoadingFailed
|
||||
}},
|
||||
options: DefaultScreenshotOptions(),
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "error from Chromium",
|
||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||
|
||||
@@ -281,6 +281,23 @@ func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionF
|
||||
}
|
||||
}
|
||||
|
||||
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(userAgent) == 0 {
|
||||
logger.Debug("no user agent override")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
|
||||
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("set user agent override: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if len(extraHttpHeaders) == 0 {
|
||||
|
||||
@@ -25,9 +25,16 @@ var (
|
||||
// by LibreOffice.
|
||||
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||
|
||||
// ErrMalformedPageRanges happens if the page ranges option cannot be
|
||||
// interpreted by LibreOffice.
|
||||
ErrMalformedPageRanges = errors.New("page ranges are malformed")
|
||||
// ErrUnoException happens when unoconverter returns an exit code 5.
|
||||
ErrUnoException = errors.New("uno exception")
|
||||
|
||||
// ErrRuntimeException happens when unoconverter returns an exit code 6.
|
||||
ErrRuntimeException = errors.New("uno exception")
|
||||
|
||||
// ErrCoreDumped happens randomly; sometime a conversion will work as
|
||||
// expected, and some other time the same conversion will fail.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
ErrCoreDumped = errors.New("core dumped")
|
||||
)
|
||||
|
||||
// Api is a module which provides a [Uno] to interact with LibreOffice.
|
||||
@@ -41,29 +48,133 @@ type Api struct {
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
|
||||
type Options struct {
|
||||
// Password specifies the password for opening the source file.
|
||||
Password string
|
||||
|
||||
// Landscape allows to change the orientation of the resulting PDF.
|
||||
// Optional.
|
||||
Landscape bool
|
||||
|
||||
// PageRanges allows to select the pages to convert.
|
||||
// Optional.
|
||||
PageRanges string
|
||||
|
||||
// ExportFormFields allows to... export form fields in the resulting PDF.
|
||||
// Optional.
|
||||
// ExportFormFields specifies whether form fields are exported as widgets
|
||||
// or only their fixed print representation is exported.
|
||||
ExportFormFields bool
|
||||
|
||||
// SinglePageSheets allows to output each sheet as a single page in the resulting PDF.
|
||||
// Optional
|
||||
// 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.
|
||||
// Optional.
|
||||
PdfFormats gotenberg.PdfFormats
|
||||
}
|
||||
|
||||
// DefaultOptions returns the default values for Options.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
Password: "",
|
||||
Landscape: false,
|
||||
PageRanges: "",
|
||||
ExportFormFields: true,
|
||||
AllowDuplicateFieldNames: false,
|
||||
ExportBookmarks: true,
|
||||
ExportBookmarksToPdfDestination: false,
|
||||
ExportPlaceholders: false,
|
||||
ExportNotes: false,
|
||||
ExportNotesPages: false,
|
||||
ExportOnlyNotesPages: false,
|
||||
ExportNotesInMargin: false,
|
||||
ConvertOooTargetToPdfTarget: false,
|
||||
ExportLinksRelativeFsys: false,
|
||||
ExportHiddenSlides: false,
|
||||
SkipEmptyPages: false,
|
||||
AddOriginalDocumentAsStream: false,
|
||||
SinglePageSheets: false,
|
||||
LosslessImageCompression: false,
|
||||
Quality: 90,
|
||||
ReduceImageResolution: false,
|
||||
MaxImageResolution: 300,
|
||||
PdfFormats: gotenberg.PdfFormats{
|
||||
PdfA: "",
|
||||
PdfUa: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Uno is an abstraction on top of the Universal Network Objects API.
|
||||
type Uno interface {
|
||||
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
@@ -265,9 +376,21 @@ func (a *Api) LibreOffice() (Uno, error) {
|
||||
|
||||
// Pdf converts a document to PDF.
|
||||
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
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)
|
||||
})
|
||||
|
||||
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.
|
||||
|
||||
@@ -14,6 +14,15 @@ import (
|
||||
"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) {
|
||||
descriptor := new(Api).Descriptor()
|
||||
|
||||
@@ -424,6 +433,13 @@ func TestApi_Pdf(t *testing.T) {
|
||||
}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "ErrCoreDumped",
|
||||
libreOffice: &libreOfficeMock{pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return ErrCoreDumped
|
||||
}},
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
a := new(Api)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -265,6 +266,10 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
args = append(args, "-vvv")
|
||||
}
|
||||
|
||||
if options.Password != "" {
|
||||
args = append(args, "--password", options.Password)
|
||||
}
|
||||
|
||||
if options.Landscape {
|
||||
args = append(args, "--printer", "PaperOrientation=landscape")
|
||||
}
|
||||
@@ -273,13 +278,26 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
args = append(args, "--export", fmt.Sprintf("PageRange=%s", options.PageRanges))
|
||||
}
|
||||
|
||||
if !options.ExportFormFields {
|
||||
args = append(args, "--export", "ExportFormFields=false")
|
||||
}
|
||||
|
||||
if options.SinglePageSheets {
|
||||
args = append(args, "--export", "SinglePageSheets=true")
|
||||
}
|
||||
args = append(args, "--export", fmt.Sprintf("ExportFormFields=%t", options.ExportFormFields))
|
||||
args = append(args, "--export", fmt.Sprintf("AllowDuplicateFieldNames=%t", options.AllowDuplicateFieldNames))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarks=%t", options.ExportBookmarks))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportBookmarksToPDFDestination=%t", options.ExportBookmarksToPdfDestination))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportPlaceholders=%t", options.ExportPlaceholders))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotes=%t", options.ExportNotes))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotesPages=%t", options.ExportNotesPages))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportOnlyNotesPages=%t", options.ExportOnlyNotesPages))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportNotesInMargin=%t", options.ExportNotesInMargin))
|
||||
args = append(args, "--export", fmt.Sprintf("ConvertOOoTargetToPDFTarget=%t", options.ConvertOooTargetToPdfTarget))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportLinksRelativeFsys=%t", options.ExportLinksRelativeFsys))
|
||||
args = append(args, "--export", fmt.Sprintf("ExportHiddenSlides=%t", options.ExportHiddenSlides))
|
||||
args = append(args, "--export", fmt.Sprintf("IsSkipEmptyPages=%t", options.SkipEmptyPages))
|
||||
args = append(args, "--export", fmt.Sprintf("IsAddStream=%t", options.AddOriginalDocumentAsStream))
|
||||
args = append(args, "--export", fmt.Sprintf("SinglePageSheets=%t", options.SinglePageSheets))
|
||||
args = append(args, "--export", fmt.Sprintf("UseLosslessCompression=%t", options.LosslessImageCompression))
|
||||
args = append(args, "--export", fmt.Sprintf("Quality=%d", options.Quality))
|
||||
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
|
||||
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
|
||||
|
||||
switch options.PdfFormats.PdfA {
|
||||
case "":
|
||||
@@ -296,12 +314,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
if options.PdfFormats.PdfUa {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "PDFUACompliance=true",
|
||||
"--export", "UseTaggedPDF=true",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=true",
|
||||
)
|
||||
} else {
|
||||
args = append(
|
||||
args,
|
||||
"--export", "PDFUACompliance=false",
|
||||
"--export", "UseTaggedPDF=false",
|
||||
"--export", "EnableTextAccessForAccessibilityTools=false",
|
||||
)
|
||||
@@ -327,10 +347,22 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
}
|
||||
|
||||
// LibreOffice's errors are not explicit.
|
||||
// That's why we have to make an educated guess according to the exit code
|
||||
// and given inputs.
|
||||
if exitCode == 5 && options.PageRanges != "" {
|
||||
return ErrMalformedPageRanges
|
||||
// For instance, an exit code 5 may be explained by a malformed page
|
||||
// ranges, but also by a not required password.
|
||||
|
||||
// We may want to retry in case of a core dumped event.
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if strings.Contains(err.Error(), "core dumped") {
|
||||
return ErrCoreDumped
|
||||
}
|
||||
|
||||
if exitCode == 5 {
|
||||
// Potentially malformed page ranges or password not required.
|
||||
return ErrUnoException
|
||||
}
|
||||
if exitCode == 6 {
|
||||
// Password potentially required or invalid.
|
||||
return ErrRuntimeException
|
||||
}
|
||||
|
||||
// Possible errors:
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -250,7 +251,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
expectedError: ErrInvalidPdfFormats,
|
||||
},
|
||||
{
|
||||
scenario: "ErrMalformedPageRanges",
|
||||
scenario: "ErrUnoException",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -277,7 +278,61 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
cancelledCtx: false,
|
||||
start: 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",
|
||||
@@ -336,7 +391,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (landscape)",
|
||||
scenario: "success (not default options)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
@@ -352,101 +407,37 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{Landscape: true},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (disable form fields)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("DisableFormFields"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{ExportFormFields: false},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (single page sheets)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("SinglePageSheets"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{SinglePageSheets: true},
|
||||
cancelledCtx: false,
|
||||
start: true,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
scenario: "success (page ranges)",
|
||||
libreOffice: newLibreOfficeProcess(
|
||||
libreOfficeArguments{
|
||||
binPath: os.Getenv("LIBREOFFICE_BIN_PATH"),
|
||||
unoBinPath: os.Getenv("UNOCONVERTER_BIN_PATH"),
|
||||
startTimeout: 5 * time.Second,
|
||||
},
|
||||
),
|
||||
fs: func() *gotenberg.FileSystem {
|
||||
fs := gotenberg.NewFileSystem()
|
||||
|
||||
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||
}
|
||||
|
||||
err = os.WriteFile(fmt.Sprintf("%s/document.txt", fs.WorkingDirPath()), []byte("Landscape"), 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return fs
|
||||
}(),
|
||||
options: Options{PageRanges: "1-1"},
|
||||
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,
|
||||
start: true,
|
||||
expectError: false,
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -33,12 +34,21 @@ func (provider *ProviderMock) LibreOffice() (Uno, error) {
|
||||
|
||||
// libreOfficeMock is a mock for the [libreOffice] interface.
|
||||
type libreOfficeMock struct {
|
||||
errCoreDumpedCount int
|
||||
|
||||
gotenberg.ProcessMock
|
||||
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
|
||||
}
|
||||
|
||||
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
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.
|
||||
|
||||
@@ -42,14 +42,53 @@ func TestProviderMock(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLibreOfficeMock(t *testing.T) {
|
||||
mock := &libreOfficeMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
|
||||
return nil
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
mock *libreOfficeMock
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "success",
|
||||
mock: &libreOfficeMock{
|
||||
pdfMock: func(ctx context.Context, logger *zap.Logger, input, outputPath string, options Options) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
{
|
||||
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 err != nil {
|
||||
t.Errorf("expected no error from libreOfficeMock.pdf, but got: %v", err)
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error from libreOfficeMock.pdf but got: %v", err)
|
||||
}
|
||||
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error from libreOfficeMock.pdf but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
|
||||
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
|
||||
// error.
|
||||
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
|
||||
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, api.Options{
|
||||
PdfFormats: formats,
|
||||
})
|
||||
opts := api.DefaultOptions()
|
||||
opts.PdfFormats = formats
|
||||
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
@@ -22,27 +24,102 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
IsMultipart: true,
|
||||
Handler: func(c echo.Context) error {
|
||||
ctx := c.Get("context").(*api.Context)
|
||||
defaultOptions := libreofficeapi.DefaultOptions()
|
||||
|
||||
// Let's get the data from the form and validate them.
|
||||
var (
|
||||
inputPaths []string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
exportFormFields bool
|
||||
singlePageSheets bool
|
||||
pdfa string
|
||||
pdfua bool
|
||||
nativePdfFormats bool
|
||||
merge bool
|
||||
metadata map[string]interface{}
|
||||
inputPaths []string
|
||||
password string
|
||||
landscape bool
|
||||
nativePageRanges string
|
||||
exportFormFields bool
|
||||
allowDuplicateFieldNames bool
|
||||
exportBookmarks bool
|
||||
exportBookmarksToPdfDestination bool
|
||||
exportPlaceholders bool
|
||||
exportNotes bool
|
||||
exportNotesPages bool
|
||||
exportOnlyNotesPages bool
|
||||
exportNotesInMargin bool
|
||||
convertOooTargetToPdfTarget bool
|
||||
exportLinksRelativeFsys bool
|
||||
exportHiddenSlides bool
|
||||
skipEmptyPages bool
|
||||
addOriginalDocumentAsStream bool
|
||||
singlePageSheets bool
|
||||
losslessImageCompression bool
|
||||
quality int
|
||||
reduceImageResolution bool
|
||||
maxImageResolution int
|
||||
pdfa string
|
||||
pdfua bool
|
||||
nativePdfFormats bool
|
||||
merge bool
|
||||
metadata map[string]interface{}
|
||||
)
|
||||
|
||||
err := ctx.FormData().
|
||||
MandatoryPaths(libreOffice.Extensions(), &inputPaths).
|
||||
Bool("landscape", &landscape, false).
|
||||
String("nativePageRanges", &nativePageRanges, "").
|
||||
Bool("exportFormFields", &exportFormFields, true).
|
||||
Bool("singlePageSheets", &singlePageSheets, false).
|
||||
String("password", &password, defaultOptions.Password).
|
||||
Bool("landscape", &landscape, defaultOptions.Landscape).
|
||||
String("nativePageRanges", &nativePageRanges, defaultOptions.PageRanges).
|
||||
Bool("exportFormFields", &exportFormFields, defaultOptions.ExportFormFields).
|
||||
Bool("allowDuplicateFieldNames", &allowDuplicateFieldNames, defaultOptions.AllowDuplicateFieldNames).
|
||||
Bool("exportBookmarks", &exportBookmarks, defaultOptions.ExportBookmarks).
|
||||
Bool("exportBookmarksToPdfDestination", &exportBookmarksToPdfDestination, defaultOptions.ExportBookmarksToPdfDestination).
|
||||
Bool("exportPlaceholders", &exportPlaceholders, defaultOptions.ExportPlaceholders).
|
||||
Bool("exportNotes", &exportNotes, defaultOptions.ExportNotes).
|
||||
Bool("exportNotesPages", &exportNotesPages, defaultOptions.ExportNotesPages).
|
||||
Bool("exportOnlyNotesPages", &exportOnlyNotesPages, defaultOptions.ExportOnlyNotesPages).
|
||||
Bool("exportNotesInMargin", &exportNotesInMargin, defaultOptions.ExportNotesInMargin).
|
||||
Bool("convertOooTargetToPdfTarget", &convertOooTargetToPdfTarget, defaultOptions.ConvertOooTargetToPdfTarget).
|
||||
Bool("exportLinksRelativeFsys", &exportLinksRelativeFsys, defaultOptions.ExportLinksRelativeFsys).
|
||||
Bool("exportHiddenSlides", &exportHiddenSlides, defaultOptions.ExportHiddenSlides).
|
||||
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
|
||||
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
|
||||
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
|
||||
Bool("losslessImageCompression", &losslessImageCompression, defaultOptions.LosslessImageCompression).
|
||||
Custom("quality", func(value string) error {
|
||||
if value == "" {
|
||||
quality = defaultOptions.Quality
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if intValue < 1 {
|
||||
return errors.New("value is inferior to 1")
|
||||
}
|
||||
|
||||
if intValue > 100 {
|
||||
return errors.New("value is superior to 100")
|
||||
}
|
||||
|
||||
quality = intValue
|
||||
return nil
|
||||
}).
|
||||
Bool("reduceImageResolution", &reduceImageResolution, defaultOptions.ReduceImageResolution).
|
||||
Custom("maxImageResolution", func(value string) error {
|
||||
if value == "" {
|
||||
maxImageResolution = defaultOptions.MaxImageResolution
|
||||
return nil
|
||||
}
|
||||
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains([]int{75, 150, 300, 600, 1200}, intValue) {
|
||||
return errors.New("value is not 75, 150, 300, 600 or 1200")
|
||||
}
|
||||
|
||||
maxImageResolution = intValue
|
||||
return nil
|
||||
}).
|
||||
String("pdfa", &pdfa, "").
|
||||
Bool("pdfua", &pdfua, false).
|
||||
Bool("nativePdfFormats", &nativePdfFormats, true).
|
||||
@@ -71,10 +148,28 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
for i, inputPath := range inputPaths {
|
||||
outputPaths[i] = ctx.GeneratePath(".pdf")
|
||||
options := libreofficeapi.Options{
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
ExportFormFields: exportFormFields,
|
||||
SinglePageSheets: singlePageSheets,
|
||||
Password: password,
|
||||
Landscape: landscape,
|
||||
PageRanges: nativePageRanges,
|
||||
ExportFormFields: exportFormFields,
|
||||
AllowDuplicateFieldNames: allowDuplicateFieldNames,
|
||||
ExportBookmarks: exportBookmarks,
|
||||
ExportBookmarksToPdfDestination: exportBookmarksToPdfDestination,
|
||||
ExportPlaceholders: exportPlaceholders,
|
||||
ExportNotes: exportNotes,
|
||||
ExportNotesPages: exportNotesPages,
|
||||
ExportOnlyNotesPages: exportOnlyNotesPages,
|
||||
ExportNotesInMargin: exportNotesInMargin,
|
||||
ConvertOooTargetToPdfTarget: convertOooTargetToPdfTarget,
|
||||
ExportLinksRelativeFsys: exportLinksRelativeFsys,
|
||||
ExportHiddenSlides: exportHiddenSlides,
|
||||
SkipEmptyPages: skipEmptyPages,
|
||||
AddOriginalDocumentAsStream: addOriginalDocumentAsStream,
|
||||
SinglePageSheets: singlePageSheets,
|
||||
LosslessImageCompression: losslessImageCompression,
|
||||
Quality: quality,
|
||||
ReduceImageResolution: reduceImageResolution,
|
||||
MaxImageResolution: maxImageResolution,
|
||||
}
|
||||
|
||||
if nativePdfFormats {
|
||||
@@ -93,10 +188,17 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
||||
)
|
||||
}
|
||||
|
||||
if errors.Is(err, libreofficeapi.ErrMalformedPageRanges) {
|
||||
if errors.Is(err, libreofficeapi.ErrUnoException) {
|
||||
return api.WrapError(
|
||||
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("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."),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,116 @@ func TestConvertRoute(t *testing.T) {
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid quality form field (not an integer)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"quality": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
}},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid quality form field (< 1)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"quality": {
|
||||
"0",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
}},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid quality form field (> 100)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"quality": {
|
||||
"101",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
}},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid maxImageResolution form field (not an integer)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"maxImageResolution": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
}},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid maxImageResolution form field (not in range)",
|
||||
ctx: func() *api.ContextMock {
|
||||
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||
ctx.SetFiles(map[string]string{
|
||||
"document.docx": "/document.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"maxImageResolution": {
|
||||
"1",
|
||||
},
|
||||
})
|
||||
return ctx
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{ExtensionsMock: func() []string {
|
||||
return []string{".docx"}
|
||||
}},
|
||||
expectError: true,
|
||||
expectHttpError: true,
|
||||
expectHttpStatus: http.StatusBadRequest,
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "invalid metadata form field",
|
||||
ctx: func() *api.ContextMock {
|
||||
@@ -84,14 +194,14 @@ func TestConvertRoute(t *testing.T) {
|
||||
expectOutputPathsCount: 0,
|
||||
},
|
||||
{
|
||||
scenario: "ErrMalformedPageRanges",
|
||||
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{
|
||||
"pdfa": {
|
||||
"nativePageRanges": {
|
||||
"foo",
|
||||
},
|
||||
})
|
||||
@@ -99,7 +209,34 @@ func TestConvertRoute(t *testing.T) {
|
||||
}(),
|
||||
libreOffice: &libreofficeapi.ApiMock{
|
||||
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
|
||||
return libreofficeapi.ErrMalformedPageRanges
|
||||
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 {
|
||||
return []string{".docx"}
|
||||
@@ -285,6 +422,12 @@ func TestConvertRoute(t *testing.T) {
|
||||
"document2.docx": "/document2.docx",
|
||||
})
|
||||
ctx.SetValues(map[string][]string{
|
||||
"quality": {
|
||||
"100",
|
||||
},
|
||||
"maxImageResolution": {
|
||||
"1200",
|
||||
},
|
||||
"merge": {
|
||||
"true",
|
||||
},
|
||||
|
||||
@@ -27,9 +27,9 @@ type client struct {
|
||||
|
||||
// send call the webhook either to send the success response or the error response.
|
||||
func (c client) send(body io.Reader, headers map[string]string, erroed bool) error {
|
||||
URL := c.url
|
||||
url := c.url
|
||||
if erroed {
|
||||
URL = c.errorUrl
|
||||
url = c.errorUrl
|
||||
}
|
||||
|
||||
method := c.method
|
||||
@@ -37,9 +37,9 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
|
||||
method = c.errorMethod
|
||||
}
|
||||
|
||||
req, err := retryablehttp.NewRequest(method, URL, body)
|
||||
req, err := retryablehttp.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create '%s' request to '%s': %w", method, URL, err)
|
||||
return fmt.Errorf("create '%s' request to '%s': %w", method, url, err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Gotenberg")
|
||||
@@ -75,17 +75,17 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send '%s' request to '%s': %w", method, URL, err)
|
||||
return fmt.Errorf("send '%s' request to '%s': %w", method, url, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, URL, resp.Status)
|
||||
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", URL, err))
|
||||
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", url, err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -94,7 +94,7 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
|
||||
|
||||
// Now let's log!
|
||||
fields := make([]zap.Field, 5)
|
||||
fields[0] = zap.String("webhook_url", URL)
|
||||
fields[0] = zap.String("webhook_url", url)
|
||||
fields[1] = zap.String("method", method)
|
||||
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
|
||||
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
|
||||
@@ -110,34 +110,3 @@ func (c client) send(body io.Reader, headers map[string]string, erroed bool) err
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// leveledLogger is wrapper around a [zap.Logger] which is used by the
|
||||
// [retryablehttp.Client].
|
||||
type leveledLogger struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Error logs a message at error level using the wrapped zap.Logger.
|
||||
func (leveled leveledLogger) Error(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Warn logs a message at warning level using the wrapped zap.Logger.
|
||||
func (leveled leveledLogger) Warn(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Info logs a message at info level using the wrapped zap.Logger.
|
||||
func (leveled leveledLogger) Info(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Debug logs a message at debug level using the wrapped zap.Logger.
|
||||
func (leveled leveledLogger) Debug(msg string, keysAndValues ...interface{}) {
|
||||
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ retryablehttp.LeveledLogger = (*leveledLogger)(nil)
|
||||
)
|
||||
|
||||
@@ -100,11 +100,11 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
}
|
||||
|
||||
// What about extra HTTP headers?
|
||||
var extraHTTPHeaders map[string]string
|
||||
var extraHttpHeaders map[string]string
|
||||
|
||||
extraHTTPHeadersJSON := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
|
||||
if extraHTTPHeadersJSON != "" {
|
||||
err = json.Unmarshal([]byte(extraHTTPHeadersJSON), &extraHTTPHeaders)
|
||||
extraHttpHeadersJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
|
||||
if extraHttpHeadersJson != "" {
|
||||
err = json.Unmarshal([]byte(extraHttpHeadersJson), &extraHttpHeaders)
|
||||
if err != nil {
|
||||
return api.WrapError(
|
||||
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
|
||||
@@ -113,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{
|
||||
url: webhookUrl,
|
||||
method: webhookMethod,
|
||||
errorUrl: webhookErrorUrl,
|
||||
errorMethod: webhookErrorMethod,
|
||||
extraHttpHeaders: extraHTTPHeaders,
|
||||
startTime: c.Get("startTime").(time.Time),
|
||||
extraHttpHeaders: extraHttpHeaders,
|
||||
startTime: startTime,
|
||||
|
||||
client: &retryablehttp.Client{
|
||||
HTTPClient: &http.Client{
|
||||
@@ -128,11 +134,9 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
RetryMax: w.maxRetry,
|
||||
RetryWaitMin: w.retryMinWait,
|
||||
RetryWaitMax: w.retryMaxWait,
|
||||
Logger: leveledLogger{
|
||||
logger: ctx.Log(),
|
||||
},
|
||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||
Backoff: retryablehttp.DefaultBackoff,
|
||||
Logger: gotenberg.NewLeveledLogger(ctx.Log()),
|
||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||
Backoff: retryablehttp.DefaultBackoff,
|
||||
},
|
||||
logger: ctx.Log(),
|
||||
}
|
||||
@@ -159,8 +163,8 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
echo.HeaderContentType: echo.MIMEApplicationJSON,
|
||||
c.Get("traceHeader").(string): c.Get("trace").(string),
|
||||
echo.HeaderContentType: echo.MIMEApplicationJSON,
|
||||
traceHeader: trace,
|
||||
}
|
||||
|
||||
err = client.send(bytes.NewReader(b), headers, true)
|
||||
@@ -238,7 +242,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
|
||||
echo.HeaderContentDisposition: fmt.Sprintf("attachement; filename=%q", ctx.OutputFilename(outputPath)),
|
||||
echo.HeaderContentType: http.DetectContentType(fileHeader),
|
||||
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
|
||||
c.Get("traceHeader").(string): c.Get("trace").(string),
|
||||
traceHeader: trace,
|
||||
}
|
||||
|
||||
// Send the output file to the webhook.
|
||||
|
||||
@@ -2,50 +2,60 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Args.
|
||||
GOLANG_VERSION="$1"
|
||||
GOTENBERG_VERSION="$2"
|
||||
GOTENBERG_USER_GID="$3"
|
||||
GOTENBERG_USER_UID="$4"
|
||||
NOTO_COLOR_EMOJI_VERSION="$5"
|
||||
PDFTK_VERSION="$6"
|
||||
DOCKER_REPOSITORY="$7"
|
||||
|
||||
if [ "$GOTENBERG_VERSION" == "edge" ]; then
|
||||
docker buildx build \
|
||||
--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
|
||||
DOCKER_REGISTRY="$7"
|
||||
DOCKER_REPOSITORY="$8"
|
||||
LINUX_AMD64_RELEASE="$9"
|
||||
|
||||
# Find out if given version is "semver".
|
||||
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
|
||||
IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
|
||||
VERSION_LENGTH=${#SEMVER[@]}
|
||||
TAGS=()
|
||||
TAGS_CLOUD_RUN=()
|
||||
|
||||
if [ "$VERSION_LENGTH" -ne 3 ]; then
|
||||
echo "$VERSION is not semver."
|
||||
exit 1
|
||||
if [ "$VERSION_LENGTH" -eq 3 ]; then
|
||||
MAJOR="${SEMVER[0]}"
|
||||
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
|
||||
|
||||
docker buildx build \
|
||||
@@ -55,26 +65,18 @@ docker buildx build \
|
||||
--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: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]}" \
|
||||
$PLATFORM_FLAG \
|
||||
"${TAGS[@]}" \
|
||||
--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_REGISTRY="$DOCKER_REGISTRY" \
|
||||
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
|
||||
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
|
||||
--platform linux/amd64 \
|
||||
-t "$DOCKER_REPOSITORY/gotenberg:latest-cloudrun" \
|
||||
-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" \
|
||||
"${TAGS_CLOUD_RUN[@]}" \
|
||||
--push \
|
||||
-f build/Dockerfile.cloudrun .
|
||||
-f build/Dockerfile.cloudrun .
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
ARG GOLANG_VERSION
|
||||
ARG DOCKER_REGISTRY
|
||||
ARG DOCKER_REPOSITORY
|
||||
ARG GOTENBERG_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
|
||||
# dependencies like Google Chrome, LibreOffice, etc.
|
||||
FROM $DOCKER_REPOSITORY/gotenberg:$GOTENBERG_VERSION
|
||||
FROM $DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION
|
||||
|
||||
USER root
|
||||
|
||||
ENV GOPATH /go
|
||||
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
|
||||
ENV CGO_ENABLED 1
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
COPY --from=golang /usr/local/go /usr/local/go
|
||||
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
# Credits: https://github.com/thecodingmachine/docker-images-php.
|
||||
|
||||
set +e
|
||||
mkdir testing_file_system_rights.foo
|
||||
mkdir -p testing_file_system_rights.foo
|
||||
chmod 700 testing_file_system_rights.foo
|
||||
su gotenberg -c "touch testing_file_system_rights.foo/foo > /dev/null 2>&1"
|
||||
HAS_CONSISTENT_RIGHTS=$?
|
||||
|
||||
if [[ "$HAS_CONSISTENT_RIGHTS" != "0" ]]; then
|
||||
# If not specified, the DOCKER_USER is the owner of the current working directory (heuristic!).
|
||||
DOCKER_USER=$(stat -c '%U' "$(pwd)")
|
||||
DOCKER_USER=$(stat -c '%u' "$(pwd)")
|
||||
else
|
||||
# macOs or Windows.
|
||||
# Note: in most cases, we don't care about the rights (they are not respected).
|
||||
FILE_OWNER=$(stat -c '%U' "testing_file_system_rights.foo/foo")
|
||||
if [[ "$FILE_OWNER" == "root" ]]; then
|
||||
FILE_OWNER=$(stat -c '%u' "testing_file_system_rights.foo/foo")
|
||||
if [[ "$FILE_OWNER" == "0" ]]; then
|
||||
# If root, we are likely on a Windows host.
|
||||
# All files will belong to root, but it does not matter as everybody can write/delete
|
||||
# those (0777 access rights).
|
||||
@@ -56,4 +56,4 @@ go mod tidy
|
||||
set +x
|
||||
|
||||
# Run the command with the correct user.
|
||||
exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"
|
||||
exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"
|
||||
|
||||
14
test/testdata/api/README.md
vendored
Normal file
14
test/testdata/api/README.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
To generate a valid certificate and private key use the following command:
|
||||
|
||||
```bash
|
||||
# In OpenSSL ≥ 1.1.1
|
||||
openssl req -x509 -newkey rsa:4096 -sha256 -days 9999 -nodes \
|
||||
-keyout key.pem -out cert.pem -subj "/CN=localhost" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1"
|
||||
```
|
||||
|
||||
To check a certificate use the following command:
|
||||
|
||||
```bash
|
||||
openssl x509 -in cert.pem -text
|
||||
```
|
||||
30
test/testdata/api/cert.pem
vendored
Normal file
30
test/testdata/api/cert.pem
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFOjCCAyKgAwIBAgIUZaht2QoZwWDK8wEuwm+wmip9sqwwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI0MDUyNjA1MDExNFoYDzIwNTEx
|
||||
MDExMDUwMTE0WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEB
|
||||
AQUAA4ICDwAwggIKAoICAQCm3TNckD55ae6pxXS03iV2P3X/edZT4vRa8D15elsi
|
||||
CSRf6mdU2TVAQyg22GEcMXgsbba7sotJVg7559LkWAgb3Dj9rbPputdS29ebncat
|
||||
x1nzo1QHMCS/v7nWoPe9fQVlDEf3ebM198f+5vhccSYFxFa+YvsUmSw9yMF8z1nA
|
||||
6POZTpm6ErByAY9EGvC8EcNOQd9xLOfBZc0e+mY/rMRxmL+0N+sSRE/ACU+2zxHN
|
||||
KcCiFX5WQdLDYW1ZjX5xAVatCPNlxSQpj97/ktGsnqwGnJWSTeMUOPv7L0LrQasf
|
||||
k1VXBvfTW2/ZqhI9WA8qfdC5XOuzVwfU14gv+6vv9AGZnUFmOjeo9XG4xWBxlIKj
|
||||
AQAzeNwAGFltPkp5a3yPczxMjKY4xLsMGcDfpaNtv1B24+7fV77Nyty/gLLT9pUa
|
||||
FtXLuRfIPSaUK3kFhAuYzGUL/sO9O9jJkNlxxygxQ+VyIgmdw0zWrUi3q+Lfmdya
|
||||
FFjHdM7LnLrLCjSv3dEoMINngR3+IejsY+98Ehd25O3oW+ZnGIQX0VwykV8sOQWq
|
||||
rpnnZ+2iHMtF6Vsj40z/Y9y7yVE18AIJFC+Yr/dD/nQyg6SE70wdNcOx0Ay1xHbP
|
||||
WuV3fqKOfhONR7NEBSO2UbHi6GZAiYMMwwul+4yKAQ4+lHOEXG4JQcKvtZasxq9v
|
||||
GwIDAQABo4GBMH8wHQYDVR0OBBYEFPPMaH3C0rAkD/E0gtBZwM0x8N9MMB8GA1Ud
|
||||
IwQYMBaAFPPMaH3C0rAkD/E0gtBZwM0x8N9MMA8GA1UdEwEB/wQFMAMBAf8wLAYD
|
||||
VR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqG
|
||||
SIb3DQEBCwUAA4ICAQB0DXz59JfQgfJvRJfraa+Cv/3nG4NNmHYtNGhrid5sagH3
|
||||
LM8jkbA+JKRAn34SmsqUZb1f2XFI64TwrScgMfAvfi+5ibRqBjM/YL06bTlG61Ua
|
||||
t2IQchV2kcX4zQnaMCIYG0dUj6JoXIspiFULMHyGDs7G9BKVcZxlaLrEvPXBqQjd
|
||||
nwF80KHS/rCConmICU9uWHPgHxS1VmhQiwYGbrakETilau1ppRS0bpH2k/stVyjc
|
||||
plttIr1DUD6xaRV4mZ0NZVlIXl2nDf78iQvreJeMrGl6AcNHzV+/rGHpY/gOrziP
|
||||
PQkB1VtSiHtBe/VMKXcVB69SEzsoGEknqs4GPXuVYm47POo3/U5HmGU+FadSATNP
|
||||
qtorRECUMbVn0IfDzJX2jBLUjZlhPI9gqO9DhiAT8FWHRV2yPsZKuiBBg5/hUvyb
|
||||
Qsswtmi/YV5e6AnXEuvylwE3wYzlqDzxwlZM08bWFyZYnSf38d3hAjVwUHAugiaX
|
||||
4sgE6GPAiONW5FkTZ4Z2oXYiN213QDIIcQeFAl04qSEFkGBV8x2hM0uXgFH1S++j
|
||||
JePdKBbV/CjxegyyYsmnln+eO1kC8pCalB6k6pEtYMqNYOnmimC8UKrJATrH9EVq
|
||||
eAehGBiIe514Oz06Ws2Vm4PTiY2x0KVbqphwkuz85ohSF9UXOHUcMryusr1zlg==
|
||||
-----END CERTIFICATE-----
|
||||
52
test/testdata/api/key.pem
vendored
Normal file
52
test/testdata/api/key.pem
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQQIBADANBgkqhkiG9w0BAQEFAASCCSswggknAgEAAoICAQCm3TNckD55ae6p
|
||||
xXS03iV2P3X/edZT4vRa8D15elsiCSRf6mdU2TVAQyg22GEcMXgsbba7sotJVg75
|
||||
59LkWAgb3Dj9rbPputdS29ebncatx1nzo1QHMCS/v7nWoPe9fQVlDEf3ebM198f+
|
||||
5vhccSYFxFa+YvsUmSw9yMF8z1nA6POZTpm6ErByAY9EGvC8EcNOQd9xLOfBZc0e
|
||||
+mY/rMRxmL+0N+sSRE/ACU+2zxHNKcCiFX5WQdLDYW1ZjX5xAVatCPNlxSQpj97/
|
||||
ktGsnqwGnJWSTeMUOPv7L0LrQasfk1VXBvfTW2/ZqhI9WA8qfdC5XOuzVwfU14gv
|
||||
+6vv9AGZnUFmOjeo9XG4xWBxlIKjAQAzeNwAGFltPkp5a3yPczxMjKY4xLsMGcDf
|
||||
paNtv1B24+7fV77Nyty/gLLT9pUaFtXLuRfIPSaUK3kFhAuYzGUL/sO9O9jJkNlx
|
||||
xygxQ+VyIgmdw0zWrUi3q+LfmdyaFFjHdM7LnLrLCjSv3dEoMINngR3+IejsY+98
|
||||
Ehd25O3oW+ZnGIQX0VwykV8sOQWqrpnnZ+2iHMtF6Vsj40z/Y9y7yVE18AIJFC+Y
|
||||
r/dD/nQyg6SE70wdNcOx0Ay1xHbPWuV3fqKOfhONR7NEBSO2UbHi6GZAiYMMwwul
|
||||
+4yKAQ4+lHOEXG4JQcKvtZasxq9vGwIDAQABAoICAA3s7URkT/0XTMZOUZcRPanI
|
||||
p12oGq2Vhd8GUvBjDbKhdgUAgu8fJsUzgD+o1Ic4eaxQ26x+Z1wPb+CF6vZeLQ8Q
|
||||
1JDB1lSYiy/BuRSmQ4EX30yTQjKiCe8Ws7j9u1nM/Tju9XKUbX2rgr1QeURTDQvz
|
||||
fr8UyO9xzly5oawmLHoKTDTBzS0s3Md8Pys9NFSoXCxDwxSm3WXTy8fcuHvH51Je
|
||||
SmFik/OfpSgu9BVwN9lvX+wm7wwzzMBmzmwS1Y0zbLsJbDFUv9fYHJF8/JKeC1fh
|
||||
6Xme67yG+kB+3o7TmXxhHKt5TBAPfsjOMLIojzDJ9IlY/91PqLpSjRhetdKorRlf
|
||||
gvTUGLPY8lHXwz6UBpdHmMhNHTBBvgHcOVAMD46Y81SdqMhH6TAYSh7XQVuA06Ql
|
||||
MTWzGqzKriaQDWmJ6Nx8eZCRDZGXDq4rKiJFvRFUrGYYr818qU8yC+qv5uLrOF1w
|
||||
YZV0WnZ7DtcaFliBrJVUfeTmrSJBa3yoIKbeaTDuh3TMHqgAbTx0lBdGoI6noRxx
|
||||
v7fCGho/aSOJ2rtFOjRoLQ5K8onZ226arJEyfkTh16wztQgh9DC430OkL4cQkfA0
|
||||
BxN8tGh4P0lqwcBnCOyzi4NV1NkwaaVv+pt757Cz+Cy6e2DQOtDkrFEh4p56NqDr
|
||||
KcJz88LYbbChoFKlb/kBAoIBAQDhcuI0KWTErojWu/dB2Okth8OFaTdvAPEvo+qO
|
||||
ItJgxyYL8JIdWUzS9vKro6vMctwIXvNa3K2AAwdZ1kHvaxDZaikQbnjnSjOqRlZa
|
||||
4rVnI6SSKrYj6xmKcsxxTi9YP8b2msjdLuGhPfO8Dw4EQgOt6xg0rq4msnv6bYki
|
||||
G7vsk/InnKLm8SEhR1XsmtVrpC7KbXniLQEehgs1Kp0nMLptSsOTnA1kGoZDVlXc
|
||||
u+kBBai6MM0MMDpsJgM1IFkUousQl3ItBcTXD2ZCMcg5psQJbdEhu7oXiGkHdKlL
|
||||
1UEIdSGNlzHiVM62uNxhNfwh7I6SPesDksJbE0Yd//jz/7FRAoIBAQC9ee+pDsSs
|
||||
yqunQjMWImTpD5h9fSRTzWT8h0dXsJyYi7s9Dj4mQIuoarscMI6L7njeTqHE8ptc
|
||||
b9DzIEWL+233nTOS59grCTD84Kyrn+Hw6EebgKYmtpZnvMw5cccoZ3qUsxVGurhA
|
||||
lzVaKpHtPKWT1fduNmnqt6RkZ7aRBLZre/i9UntZ7VSZYRKMOBvJLpLdqmiSPkvM
|
||||
w8ra+cedHwDegqLkoX0sxdI1yNYO1GSkJ+w1I1m1rzuCd9X5QXvyQhPezVB9wUwr
|
||||
anNGb6/YgvthzvMQAXYqv4pmb769przieMJ6geQL3O8QEtvpDAV4SvrwnA+H/wfs
|
||||
BvwI4MTHNJ6rAoIBADEfnzpOaq6QeTCQ9GdcpDJPisLfEj0Vr3f593nRDJYZzqh7
|
||||
WtsaQisVs/rCRwTdYiRQzCXYP9XM0yU4ElXgtmMpRplV+PIssOVBPj+/dldq5MkU
|
||||
lWtuJrqMGQ/3QUmxW+EQCQRo2rEPlrQ7c4pp9/NFbpjMFxZHfcwrYd4UvPXnprQK
|
||||
5VP85oMh0A8mDarOs4NYJ16o/71u57JF/sgzTevShpr7Pc+6F9dEUKEwMK0QVpt7
|
||||
VrJ5L8Gw5rIEwmREu4N5/F7jCujxag6yWjZ1p/GGBDOdfAb0qi39tYXYibsWCqGX
|
||||
eD8gy4n07dAguzeJG+expiu5JrBBmt2ekNhZtoECggEAQ90VoraAaOcaPgju5UGi
|
||||
ZPtKAcA9r6pEPlJxkg3WuKhrgr3EMs0xeDvqEnmtxJ7AOdduB21hieUesMXhQvbl
|
||||
9ycd3sMdYKSlIB2Ums/kC/YWF7MnT3xcOIGdoAGJdIZDmFjgCPo9nhzKZiYTKCZt
|
||||
o5qiSZ5Bp2jo/3FKKXzHnY3ElIKjXZ0sjNcA3qoGAbOltLEhTSf03D1QPcfy3+rH
|
||||
0+p0T3ErtUqRNORfDuaxuT33Uzz7F6DQrrUfNWxn6WgTV9yvtKr0DiTNbu/3ypN3
|
||||
WBvYYOY1HbCG8JObfK/ovTDFbz1woXMbSOWIUFsW5nPslxs+UNZeTwJdaQygTj0h
|
||||
iwKCAQBuXOPtjWGZINgfjo/n67Q8FuzUXxDK/IuXzuDXhi+B3/O3/PcVEDHFByhI
|
||||
KHOh2mTD25vgFWh+8dNg1R+m5uIeQ5zbZ5U0205XzmrZgBXXCAHKNruTwqZfQ6er
|
||||
np+8wHhcCbBuy0Z0ponzBGlrDYmbxO9anXGxoe/rE3+pWbzMneZ+cHgNjXCtfZCY
|
||||
XGfLeJWW3+B8y5Lff39ADawmjm8VWVEdd8DP5o2zulbXhgMMnAsSVsR/XgwBqpbf
|
||||
82k1bqvN2v08ID5nmTRdzD+D51Hjw80Uoqhm5DJL4IwaoK/YstCu2TCbmLGiyqSk
|
||||
lrSOZMPTLX1tN/cWa+Ii+/WALnkI
|
||||
-----END PRIVATE KEY-----
|
||||
BIN
test/testdata/libreoffice/protected.docx
vendored
Normal file
BIN
test/testdata/libreoffice/protected.docx
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user