Compare commits

...

20 Commits

Author SHA1 Message Date
Julien Neuhart
3113e034f6 fix(tests): update unconverter and add concurrency in debug output 2026-02-22 11:47:30 +01:00
Julien Neuhart
85291fdec3 fix(Dockerfile): bump go version to 1.26 2026-02-21 21:30:33 +01:00
Julien Neuhart
57e1b7efda chore(golangci-lint): bump golangci-lint to v2.10.1 2026-02-21 17:54:46 +01:00
Julien Neuhart
be78a71bb7 feat(Makefile): add go fix to fmt command 2026-02-20 21:22:34 +01:00
Julien Neuhart
2baa59cb3a refactor(gofix): modernize 2026-02-20 21:21:19 +01:00
Julien Neuhart
aea7c5952a chore(go): bump Go to 1.26.0 2026-02-20 21:19:26 +01:00
Julien Neuhart
cbde321d3b docs(README): still small improvements 2026-02-20 20:44:04 +01:00
Julien Neuhart
aa5de988cf docs(README): small improvements 2026-02-20 20:13:35 +01:00
dependabot[bot]
fd485a0d3e chore(deps): bump golang.org/x/net from 0.49.0 to 0.50.0 (#1471)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.49.0 to 0.50.0.
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 11:06:42 +01:00
dependabot[bot]
aea06a98b7 chore(deps): bump golang.org/x/text from 0.33.0 to 0.34.0 (#1470)
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 11:05:10 +01:00
Daniel Moran
82401bdfdd feat(chromium): add support for emulated media features in Chromium (#1474)
Closes https://github.com/gotenberg/gotenberg/issues/1460

It can be easier to print a "clean" PDF of some pages if you emulate
media features like `prefers-reduced-motion`. Add support for that
emulation.
2026-02-17 20:16:28 +01:00
Tom Brouws
12c25a2d21 feat(chromium): re-add concurrency support for Chromium (#1467)
* feat: add concurrency support to ProcessSupervisor

- Replace the single-slot mutex channel with a configurable semaphore to
allow multiple concurrent tasks.
- Add drain logic to ensure all active
tasks complete before process restarts.

* feat: add chromium-max-concurrency flag

- Add a --chromium-max-concurrency flag (1-6) to the Chromium module to
control how many conversions run in parallel.
- Update LibreOffice to pass maxConcurrency=1 as LibreOffice only supports
a single concurrent conversion.

* test: add integration tests for concurrent Chromium conversions

- Add concurrent request support to the integration test framework with
new step definitions for sending parallel requests and asserting on all
responses.
- Add a feature file for concurrent HTML to PDF conversions.
2026-02-13 10:00:58 +01:00
dependabot[bot]
241d5077c9 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.25.12 to 4.26.1 (#1464)
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.25.12 to 4.26.1.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.12...v4.26.1)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 09:59:40 +01:00
David
37757315d0 feat(Dockerfile): manage system time zone (#1465)
* displays timezone in debug information

* fixes cs

* fixes debug_test.go
2026-02-13 09:59:23 +01:00
John W. Mendes
1195c37508 chore(go): bump Go to 1.25.7 2026-02-10 09:14:20 +01:00
Julien Neuhart
3d9b2deb59 fix(style): AGENTS.md 2026-02-08 19:22:36 +01:00
Julien Neuhart
32d948554f docs(ai): add AGENTS.md 2026-02-08 17:32:43 +01:00
Julien Neuhart
b7a6b7aba2 Revert "chore(.gitignore): add junie folder"
This reverts commit 05e15a1d06.
2026-02-08 17:31:58 +01:00
Julien Neuhart
05e15a1d06 chore(.gitignore): add junie folder 2026-02-08 12:52:14 +01:00
Julien Neuhart
f57ecb6ef2 fix(exiftool): remove derived tags to prevent side effects when writing metadata (fixes #1463) 2026-01-29 17:25:09 +01:00
49 changed files with 1131 additions and 314 deletions

View File

@@ -31,7 +31,7 @@ jobs:
- name: Run linters - name: Run linters
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.5.0 version: v2.10.1
lint-prettier: lint-prettier:
name: Lint non-Golang codebase name: Lint non-Golang codebase

66
AGENTS.md Normal file
View File

@@ -0,0 +1,66 @@
# Operational Guidelines for Gotenberg
As an AI agent working on the Gotenberg repository, you are expected to act with the diligence and architectural foresight of a Senior Go Engineer. Gotenberg is a widely used production dependency; stability and backward compatibility are paramount.
## 1. Core Philosophy & Stability
- **Backward Compatibility is Law:** This project creates a public API. Never modify existing flags, configuration environment variables, or API form fields unless explicitly instructed to perform a breaking change. If a change is breaking, it must be flagged immediately in the plan.
- **Defensive Programming:** Assume input data is malformed. Handle errors explicitly. Do not panic.
- **Atomic Commits:** Isolate refactoring from feature additions. A Pull Request should do one thing well.
## 2. Development Workflow & Tooling
You must rely strictly on the project's Makefile for build and verification tasks. Do not run `go` commands directly unless debugging a specific package requires it.
- **Formatting:** Run `make fmt` to format Go code before committing.
- **Linting:**
- Run `make lint` to ensure Go code strictly adheres to the `.golangci.yml` configuration.
- Run `make lint-prettier` to verify formatting for non-Go files (Markdown, YAML, etc.).
- Zero linting errors are permitted.
- **Building:** Run `make build` to verify compilation and Docker image construction.
## 3. Architecture & Code Structure
- **Idiomatic Go:** Follow "Effective Go" principles.
- **Directory Separation:**
- `cmd/`: Application entry points only. Contains wiring and startup logic. **No business logic is permitted here.**
- `pkg/`: Core library code and modules. All business logic resides here.
- **Module System:** Gotenberg is modular (e.g., Chromium, LibreOffice). When adding features, determine if they belong to an existing module or require a new strict isolation.
## 4. Testing Standards
Gotenberg utilizes a split testing strategy. **Integration tests are the primary and preferred method for verifying features.**
- **Integration Tests (`make test-integration`):**
- **First Priority:** Always start here when adding features or routes.
- Gotenberg uses **Gherkin (Godog)** for end-to-end verification.
- You **must** create or update the corresponding `.feature` file in `test/integration`.
- These tests run within the Docker context; ensure environment consistency.
- **Unit Tests (`make test-unit`):**
- Use table-driven tests for pure logic within `pkg/`.
- Mock external dependencies (filesystem, network) where appropriate.
## 5. Documentation Requirements
- **No README Updates:** Do not modify the root `README.md` unless explicitly asked.
- **GoDoc is Mandatory:**
- **New Packages:** If creating a new package, you must include a `doc.go` file containing the package-level documentation.
- **Exported Symbols:** Every exported function, type, constant, and variable must have a proper GoDoc comment starting with its name.
- **Quality:** Comments must be complete sentences explaining _what_ the symbol does and _how_ to use it.
- **Example:**
```go
// Convert transforms the input document to PDF using the Chromium engine.
// It returns an error if the connection to the browser instance fails.
func Convert(...) error
```
## 6. Definition of Done
A task is considered complete only when:
1. The code compiles via `make build`.
2. The code is formatted via `make fmt`.
3. All linters pass via `make lint` and `make lint-prettier`.
4. Integration scenarios pass via `make test-integration`.
5. Unit tests pass via `make test-unit`.
6. All exported symbols and new packages have compliant GoDoc.

View File

@@ -10,6 +10,7 @@ build: ## Build the Gotenberg's Docker image
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT) -f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
TZ=UTC
GOTENBERG_HIDE_BANNER=false GOTENBERG_HIDE_BANNER=false
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
GOTENBERG_BUILD_DEBUG_DATA=true GOTENBERG_BUILD_DEBUG_DATA=true
@@ -30,8 +31,9 @@ API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false API_DISABLE_HEALTH_CHECK_LOGGING=false
API_ENABLE_DEBUG_ROUTE=false API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=10 CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0 CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_MAX_CONCURRENCY=6
CHROMIUM_AUTO_START=false CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s CHROMIUM_START_TIMEOUT=20s
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
@@ -86,6 +88,7 @@ run: ## Start a Gotenberg container
-p $(API_PORT):$(API_PORT) \ -p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \ -e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \ -e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
-e TZ=$(TZ) \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \ gotenberg \
--gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \ --gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \
@@ -109,6 +112,7 @@ run: ## Start a Gotenberg container
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \ --chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \ --chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-max-concurrency=$(CHROMIUM_MAX_CONCURRENCY) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \ --chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \ --chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \ --chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
@@ -164,6 +168,7 @@ PLATFORM=
NO_CONCURRENCY=false NO_CONCURRENCY=false
# Available tags: # Available tags:
# chromium # chromium
# chromium-concurrent
# chromium-convert-html # chromium-convert-html
# chromium-convert-markdown # chromium-convert-markdown
# chromium-convert-url # chromium-convert-url
@@ -216,6 +221,7 @@ lint-todo: ## Find TODOs in Golang codebase
.PHONY: fmt .PHONY: fmt
fmt: ## Format Golang codebase and "optimize" the dependencies fmt: ## Format Golang codebase and "optimize" the dependencies
go fix ./...
golangci-lint fmt golangci-lint fmt
go mod tidy go mod tidy

View File

@@ -4,60 +4,46 @@
<p align="center">A containerized API for seamless PDF conversion</p> <p align="center">A containerized API for seamless PDF conversion</p>
<p align="center"> <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/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>
<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://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://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
</p> </p>
<p align="center"> <p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p> <p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Read the Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Try the Live Demo</a> 🔥</p>
</p> </p>
--- ---
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting **Gotenberg** is a containerized API that abstracts the complexity of PDF conversion.
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
It provides a `multipart/form-data` interface for interacting with powerful engines like Chromium and LibreOffice.
Instead of managing heavy dependencies, browser versions, or fonts in your own backend, simply send your files to
Gotenberg and get a PDF in return.
## Quick Start ## Quick Start
Open a terminal and run the following command: Open a terminal and run the following command:
``` ```bash
docker run --rm -p 3000:3000 gotenberg/gotenberg:8 docker run --rm -p 3000:3000 gotenberg/gotenberg:8
``` ```
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com): With the API running at `http://localhost:3000`, you are now ready to head
to the **[Full Documentation](https://gotenberg.dev/docs/getting-started/introduction)** to discover how to convert URLs,
``` local files, inject custom CSS, merge PDFs, and more.
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8
```
The API is now available on your host at http://localhost:3000.
Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduction) to learn how to interact with it 🚀
## Sponsors ## Sponsors
<p align="center"> Open-source development takes a significant amount of time, energy, and dedication. If Gotenberg helps streamline your
<a href="https://thecodingmachine.com"> workflow or powers your business, please consider supporting its continuous improvement by [**becoming a sponsor**](https://github.com/sponsors/gulien)! ❤️
<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://pdfme.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/user-attachments/assets/2a75dd40-ca18-4d34-acd5-5dd474595168" alt="pdfme Logo" width="333" height="163" />
</a>
</p>
Sponsorships help maintain and improve Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️ **GitHub Sponsors**
--- - [TheCodingMachine](https://thecodingmachine.com/)
- [pdfme](https://pdfme.com/)
<p align="center"> **Powered By**
<strong>Powered by</strong>
</p>
<p align="center"> - [Docker](https://docs.docker.com/docker-hub/repos/manage/trusted-content/dsos-program/)
<a href="https://jb.gg/OpenSource"> - [JetBrains](https://www.jetbrains.com/community/opensource/)
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains logo" width="200"/>
</a>
</p>

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will # ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build # concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them. # stage that uses them.
ARG GOLANG_VERSION=1.25.5 ARG GOLANG_VERSION=1.26.0
# ---------------------------------------------- # ----------------------------------------------
# pdfcpu binary build stage # pdfcpu binary build stage
@@ -79,6 +79,9 @@ RUN jlink \
# ---------------------------------------------- # ----------------------------------------------
FROM debian:13-slim AS base-image-stage FROM debian:13-slim AS base-image-stage
ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE
COPY --from=custom-jre-stage /custom-jre /opt/java COPY --from=custom-jre-stage /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}" ENV PATH="/opt/java/bin:${PATH}"
@@ -212,7 +215,7 @@ RUN \
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\ apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.1.1/unoconv -o /usr/bin/unoconverter &&\ curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\ chmod +x /usr/bin/unoconverter &&\
# unoconverter will look for the Python binary, which has to be at version 3. # unoconverter will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\ ln -s /usr/bin/python3 /usr/bin/python &&\

View File

@@ -45,10 +45,10 @@ func Run() {
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed") fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
descriptors := gotenberg.GetModuleDescriptors() descriptors := gotenberg.GetModuleDescriptors()
var modsInfo string var modsInfo strings.Builder
for _, desc := range descriptors { for _, desc := range descriptors {
fs.AddFlagSet(desc.FlagSet) fs.AddFlagSet(desc.FlagSet)
modsInfo += desc.ID + " " modsInfo.WriteString(desc.ID + " ")
} }
// Parse the flags. // Parse the flags.
@@ -94,7 +94,7 @@ func Run() {
if !hideBanner { if !hideBanner {
fmt.Printf(banner, Version) fmt.Printf(banner, Version)
} }
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo) fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
ctx := gotenberg.NewContext(parsedFlags, descriptors) ctx := gotenberg.NewContext(parsedFlags, descriptors)

30
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/gotenberg/gotenberg/v8 module github.com/gotenberg/gotenberg/v8
go 1.25.5 go 1.26.0
require ( require (
github.com/alexliesenfeld/health v0.8.1 github.com/alexliesenfeld/health v0.8.1
@@ -11,7 +11,7 @@ require (
github.com/dlclark/regexp2 v1.11.5 github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0 github.com/docker/go-connections v0.6.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.0 github.com/labstack/echo/v4 v4.15.0
@@ -19,15 +19,15 @@ require (
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.25.12 github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go v0.40.0
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/net v0.49.0 golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0 golang.org/x/sync v0.19.0
golang.org/x/term v0.39.0 golang.org/x/term v0.40.0
golang.org/x/text v0.33.0 golang.org/x/text v0.34.0
) )
require ( require (
@@ -57,7 +57,7 @@ require (
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
@@ -71,9 +71,9 @@ require (
github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -109,16 +109,16 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

64
go.sum
View File

@@ -76,8 +76,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -96,8 +96,8 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -130,8 +130,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
@@ -150,8 +150,8 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -213,8 +213,8 @@ github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05Zp
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY= github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
@@ -257,22 +257,22 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -285,10 +285,10 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -296,12 +296,12 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -10,7 +10,7 @@ import (
type Context struct { type Context struct {
flags ParsedFlags flags ParsedFlags
descriptors []ModuleDescriptor descriptors []ModuleDescriptor
moduleInstances map[string]interface{} moduleInstances map[string]any
} }
// NewContext creates a [Context]. // NewContext creates a [Context].
@@ -22,7 +22,7 @@ func NewContext(
return &Context{ return &Context{
flags: flags, flags: flags,
descriptors: descriptors, descriptors: descriptors,
moduleInstances: make(map[string]interface{}), moduleInstances: make(map[string]any),
} }
} }
@@ -45,7 +45,7 @@ func (ctx *Context) ParsedFlags() ParsedFlags {
// //
// If the module has not yet been initialized, this method // If the module has not yet been initialized, this method
// initializes it. Otherwise, returns the already initialized instance. // initializes it. Otherwise, returns the already initialized instance.
func (ctx *Context) Module(kind interface{}) (interface{}, error) { func (ctx *Context) Module(kind any) (any, error) {
mods, err := ctx.Modules(kind) mods, err := ctx.Modules(kind)
if err != nil { if err != nil {
return nil, fmt.Errorf("get module: %w", err) return nil, fmt.Errorf("get module: %w", err)
@@ -70,10 +70,10 @@ func (ctx *Context) Module(kind interface{}) (interface{}, error) {
// //
// If one or more modules have not yet been initialized, this method // If one or more modules have not yet been initialized, this method
// initializes them. Otherwise, returns the already initialized instances. // initializes them. Otherwise, returns the already initialized instances.
func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) { func (ctx *Context) Modules(kind any) ([]any, error) {
realKind := reflect.TypeOf(kind).Elem() realKind := reflect.TypeOf(kind).Elem()
var mods []interface{} var mods []any
for _, desc := range ctx.descriptors { for _, desc := range ctx.descriptors {
newInstance := desc.New() newInstance := desc.New()
@@ -101,7 +101,7 @@ func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
// loadModule calls the Provision and/or Validate methods of the requested // loadModule calls the Provision and/or Validate methods of the requested
// module if it satisfies the [Provisioner] and/or [Validator] interfaces. // module if it satisfies the [Provisioner] and/or [Validator] interfaces.
func (ctx *Context) loadModule(id string, instance interface{}) error { func (ctx *Context) loadModule(id string, instance any) error {
if prov, ok := instance.(Provisioner); ok { if prov, ok := instance.(Provisioner); ok {
// The instance can be provisioned. // The instance can be provisioned.
err := prov.Provision(ctx) err := prov.Provision(ctx)

View File

@@ -9,7 +9,7 @@ func TestContext_Module(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
mods []ModuleDescriptor mods []ModuleDescriptor
kind interface{} kind any
expectError bool expectError bool
}{ }{
{ {
@@ -80,7 +80,7 @@ func TestContext_Modules(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
mods []ModuleDescriptor mods []ModuleDescriptor
kind interface{} kind any
expectError bool expectError bool
}{ }{
{ {
@@ -151,12 +151,12 @@ func TestContext_Modules(t *testing.T) {
func TestContext_loadModule(t *testing.T) { func TestContext_loadModule(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
instance interface{} instance any
expectError bool expectError bool
}{ }{
{ {
scenario: "module with error on provision", scenario: "module with error on provision",
instance: func() interface{} { instance: func() any {
mod := &struct { mod := &struct {
ModuleMock ModuleMock
ProvisionerMock ProvisionerMock
@@ -171,7 +171,7 @@ func TestContext_loadModule(t *testing.T) {
}, },
{ {
scenario: "module with error on validation", scenario: "module with error on validation",
instance: func() interface{} { instance: func() any {
mod := &struct { mod := &struct {
ModuleMock ModuleMock
ValidatorMock ValidatorMock
@@ -186,7 +186,7 @@ func TestContext_loadModule(t *testing.T) {
}, },
{ {
scenario: "success", scenario: "success",
instance: func() interface{} { instance: func() any {
mod := &struct { mod := &struct {
ModuleMock ModuleMock
ValidatorMock ValidatorMock

View File

@@ -4,17 +4,19 @@ import (
"runtime" "runtime"
"sort" "sort"
"sync" "sync"
"time"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
) )
// DebugInfo gathers data for debugging. // DebugInfo gathers data for debugging.
type DebugInfo struct { type DebugInfo struct {
Version string `json:"version"` Version string `json:"version"`
Architecture string `json:"architecture"` Timezone string `json:"timezone"`
Modules []string `json:"modules"` Architecture string `json:"architecture"`
ModulesAdditionalData map[string]map[string]interface{} `json:"modules_additional_data"` Modules []string `json:"modules"`
Flags map[string]interface{} `json:"flags"` ModulesAdditionalData map[string]map[string]any `json:"modules_additional_data"`
Flags map[string]any `json:"flags"`
} }
// BuildDebug builds the debug data from modules. // BuildDebug builds the debug data from modules.
@@ -24,10 +26,11 @@ func BuildDebug(ctx *Context) {
debug = &DebugInfo{ debug = &DebugInfo{
Version: Version, Version: Version,
Timezone: time.Now().Location().String(),
Architecture: runtime.GOARCH, Architecture: runtime.GOARCH,
Modules: make([]string, len(ctx.moduleInstances)), Modules: make([]string, len(ctx.moduleInstances)),
ModulesAdditionalData: make(map[string]map[string]interface{}), ModulesAdditionalData: make(map[string]map[string]any),
Flags: make(map[string]interface{}), Flags: make(map[string]any),
} }
i := 0 i := 0

View File

@@ -13,6 +13,8 @@ func TestBuildDebug(t *testing.T) {
t.Errorf("Debug() should return empty debug data") t.Errorf("Debug() should return empty debug data")
} }
t.Setenv("TZ", "UTC")
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError) fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
fs.String("foo", "bar", "Set foo") fs.String("foo", "bar", "Set foo")
ctx := NewContext(ParsedFlags{ ctx := NewContext(ParsedFlags{
@@ -31,8 +33,8 @@ func TestBuildDebug(t *testing.T) {
mod2.DescriptorMock = func() ModuleDescriptor { mod2.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "bar", New: func() Module { return mod2 }} return ModuleDescriptor{ID: "bar", New: func() Module { return mod2 }}
} }
mod2.DebugMock = func() map[string]interface{} { mod2.DebugMock = func() map[string]any {
return map[string]interface{}{ return map[string]any{
"foo": "bar", "foo": "bar",
} }
} }
@@ -51,17 +53,18 @@ func TestBuildDebug(t *testing.T) {
expect := DebugInfo{ expect := DebugInfo{
Version: Version, Version: Version,
Timezone: "UTC",
Architecture: runtime.GOARCH, Architecture: runtime.GOARCH,
Modules: []string{ Modules: []string{
"bar", "bar",
"foo", "foo",
}, },
ModulesAdditionalData: map[string]map[string]interface{}{ ModulesAdditionalData: map[string]map[string]any{
"bar": { "bar": {
"foo": "bar", "foo": "bar",
}, },
}, },
Flags: map[string]interface{}{ Flags: map[string]any{
"foo": "bar", "foo": "bar",
}, },
} }

View File

@@ -32,22 +32,22 @@ func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
} }
// Error logs a message at the error level using the wrapped zap.Logger. // Error logs a message at the error level using the wrapped zap.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) { func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues)) leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
} }
// Warn logs a message at the warning level using the wrapped zap.Logger. // Warn logs a message at the warning level using the wrapped zap.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) { func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues)) leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
} }
// Info logs a message at the info level using the wrapped zap.Logger. // Info logs a message at the info level using the wrapped zap.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) { func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues)) leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
} }
// Debug logs a message at the debug level using the wrapped zap.Logger. // Debug logs a message at the debug level using the wrapped zap.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) { func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues)) leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
} }

View File

@@ -35,10 +35,10 @@ func (mod *ValidatorMock) Validate() error {
} }
type DebuggableMock struct { type DebuggableMock struct {
DebugMock func() map[string]interface{} DebugMock func() map[string]any
} }
func (mod *DebuggableMock) Debug() map[string]interface{} { func (mod *DebuggableMock) Debug() map[string]any {
return mod.DebugMock() return mod.DebugMock()
} }
@@ -50,8 +50,8 @@ type PdfEngineMock struct {
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
} }
@@ -72,11 +72,11 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, fo
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
} }
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return engine.ReadMetadataMock(ctx, logger, inputPath) return engine.ReadMetadataMock(ctx, logger, inputPath)
} }
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath) return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
} }

View File

@@ -78,7 +78,7 @@ type SystemLogger interface {
// Debuggable is a module interface for modules which want to provide // Debuggable is a module interface for modules which want to provide
// additional debug data. // additional debug data.
type Debuggable interface { type Debuggable interface {
Debug() map[string]interface{} Debug() map[string]any
} }
// MustRegisterModule registers a module. // MustRegisterModule registers a module.

View File

@@ -133,10 +133,10 @@ type PdfEngine interface {
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
// ReadMetadata extracts the metadata of a given PDF file. // ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
// WriteMetadata writes the metadata into a given PDF file. // WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
// Encrypt adds password protection to a PDF file. // Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document. // The userPassword is required to open the document.

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sync"
"sync/atomic" "sync/atomic"
"go.uber.org/zap" "go.uber.org/zap"
@@ -79,27 +80,38 @@ type processSupervisor struct {
process Process process Process
maxReqLimit int64 maxReqLimit int64
maxQueueSize int64 maxQueueSize int64
mutexChan chan struct{} maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool firstStart atomic.Bool
firstStartOnce sync.Once
firstStartErr error
reqCounter atomic.Int64 reqCounter atomic.Int64
reqQueueSize atomic.Int64 reqQueueSize atomic.Int64
restartsCounter atomic.Int64 restartsCounter atomic.Int64
isRestarting atomic.Bool isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
} }
// NewProcessSupervisor initializes a new [ProcessSupervisor]. // NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor { func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
if maxConcurrency < 1 {
maxConcurrency = 1
}
b := &processSupervisor{ b := &processSupervisor{
logger: logger, logger: logger,
process: process, process: process,
mutexChan: make(chan struct{}, 1), semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit, maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize, maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
} }
b.reqCounter.Store(0) b.reqCounter.Store(0)
b.reqQueueSize.Store(0) b.reqQueueSize.Store(0)
b.restartsCounter.Store(0) b.restartsCounter.Store(0)
b.isRestarting.Store(false) b.isRestarting.Store(false)
b.activeTasks.Store(0)
return b return b
} }
@@ -130,15 +142,7 @@ func (s *processSupervisor) Shutdown() error {
} }
func (s *processSupervisor) restart() error { func (s *processSupervisor) restart() error {
if s.isRestarting.Load() {
s.logger.Debug("process already restarting, skip restart")
return ErrProcessAlreadyRestarting
}
s.logger.Debug("restart process") s.logger.Debug("restart process")
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)
err := s.Shutdown() err := s.Shutdown()
if err != nil { if err != nil {
@@ -197,33 +201,43 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
for { for {
err := func() error { err := func() error {
select { select {
case s.mutexChan <- struct{}{}: case s.semaphore <- struct{}{}:
logger.Debug("process lock acquired") logger.Debug("process lock acquired")
// If a restart drain is in progress, release the slot
// immediately so the drain can acquire it instead.
if s.isRestarting.Load() {
<-s.semaphore
return ErrProcessAlreadyRestarting
}
s.reqQueueSize.Add(-1) s.reqQueueSize.Add(-1)
s.reqCounter.Add(1) s.reqCounter.Add(1)
releaseMutexChan := true s.activeTasks.Add(1)
releaseSemaphore := true
defer func() { defer func() {
if releaseMutexChan { s.activeTasks.Add(-1)
if releaseSemaphore {
logger.Debug("process lock released") logger.Debug("process lock released")
<-s.mutexChan <-s.semaphore
} }
}() }()
if !s.firstStart.Load() { if !s.firstStart.Load() {
err := s.runWithDeadline(ctx, func() error { s.firstStartOnce.Do(func() {
return s.Launch() s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
}) })
if err != nil { if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", err) return fmt.Errorf("process first start: %w", s.firstStartErr)
} }
} }
if !s.Healthy() { if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...") s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
err := s.runWithDeadline(ctx, func() error { err := s.doRestart(ctx)
return s.restart()
})
if err != nil { if err != nil {
return fmt.Errorf("process restart before task: %w", err) return fmt.Errorf("process restart before task: %w", err)
} }
@@ -232,19 +246,21 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
err := s.runWithDeadline(ctx, task) err := s.runWithDeadline(ctx, task)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit { if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting eagerly...") // Only one goroutine should trigger the restart.
releaseMutexChan = false if s.restartMutex.TryLock() {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseSemaphore = false
go func() { go func() {
err := s.runWithDeadline(context.Background(), func() error { restartErr := s.doRestartLocked(context.Background())
return s.restart() s.restartMutex.Unlock()
}) if restartErr != nil {
if err != nil { s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr))
s.logger.Error(fmt.Sprintf("process restart after task: %v", err)) }
} logger.Debug("process lock released")
logger.Debug("process lock released") <-s.semaphore
<-s.mutexChan }()
}() }
} }
// Note: no error wrapping because it leaks on Chromium console exceptions output. // Note: no error wrapping because it leaks on Chromium console exceptions output.
@@ -259,7 +275,6 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
if errors.Is(err, ErrProcessAlreadyRestarting) { if errors.Is(err, ErrProcessAlreadyRestarting) {
logger.Debug("process is already restarting, trying to acquire process lock again...") logger.Debug("process is already restarting, trying to acquire process lock again...")
s.reqQueueSize.Add(1)
continue continue
} }
@@ -268,6 +283,47 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
} }
} }
// doRestart coordinates a process restart, draining all active concurrent
// tasks before stopping and restarting the process.
func (s *processSupervisor) doRestart(ctx context.Context) error {
s.restartMutex.Lock()
defer s.restartMutex.Unlock()
return s.doRestartLocked(ctx)
}
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)
// Drain all other active semaphore slots so no other tasks are running during the restart.
slotsToAcquire := s.maxConcurrency - 1
acquired := make([]struct{}, 0, slotsToAcquire)
for range slotsToAcquire {
select {
case s.semaphore <- struct{}{}:
acquired = append(acquired, struct{}{})
case <-ctx.Done():
for range acquired {
<-s.semaphore
}
return fmt.Errorf("drain active tasks before restart: %w", ctx.Err())
}
}
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
for range acquired {
<-s.semaphore
}
return err
}
func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error { func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error {
runChan := make(chan error, 1) runChan := make(chan error, 1)
go func() { go func() {

View File

@@ -46,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
}, },
} }
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor) ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
if tc.firstStartSet { if tc.firstStartSet {
ps.firstStart.Store(true) ps.firstStart.Store(true)
} }
@@ -94,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
}, },
} }
ps := NewProcessSupervisor(logger, process, 5, 0) ps := NewProcessSupervisor(logger, process, 5, 0, 1)
err := ps.Shutdown() err := ps.Shutdown()
if !tc.expectError && err != nil { if !tc.expectError && err != nil {
@@ -110,19 +110,11 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
func TestProcessSupervisor_restart(t *testing.T) { func TestProcessSupervisor_restart(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
initiallyRestarting bool startError error
startError error stopError error
stopError error expectError bool
expectError bool
expectedError error
}{ }{
{
scenario: "already restarting",
initiallyRestarting: true,
expectError: true,
expectedError: ErrProcessAlreadyRestarting,
},
{ {
scenario: "successful restart", scenario: "successful restart",
startError: nil, startError: nil,
@@ -154,10 +146,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
}, },
} }
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor) ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
if tc.initiallyRestarting {
ps.isRestarting.Store(true)
}
err := ps.restart() err := ps.restart()
@@ -168,10 +157,6 @@ func TestProcessSupervisor_restart(t *testing.T) {
if tc.expectError && err == nil { if tc.expectError && err == nil {
t.Fatal("expected error but got none") t.Fatal("expected error but got none")
} }
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
}) })
} }
} }
@@ -217,7 +202,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
}, },
} }
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor) ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
if tc.initiallyStarted { if tc.initiallyStarted {
ps.firstStart.Store(true) ps.firstStart.Store(true)
} }
@@ -402,7 +387,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
}, },
} }
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor) ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1).(*processSupervisor)
if tc.initiallyStarted { if tc.initiallyStarted {
ps.firstStart.Store(true) ps.firstStart.Store(true)
} }
@@ -424,14 +409,12 @@ func TestProcessSupervisor_Run(t *testing.T) {
errorChan := make(chan error, tc.tasksToRun) errorChan := make(chan error, tc.tasksToRun)
for i := 0; i < tc.tasksToRun; i++ { for i := 0; i < tc.tasksToRun; i++ {
wg.Add(1) wg.Go(func() {
go func() {
defer wg.Done()
err := ps.Run(ctx, logger, task) err := ps.Run(ctx, logger, task)
if err != nil { if err != nil {
errorChan <- err errorChan <- err
} }
}() })
} }
wg.Wait() wg.Wait()
@@ -452,8 +435,8 @@ func TestProcessSupervisor_Run(t *testing.T) {
} }
// Making sure restarts are finished. // Making sure restarts are finished.
ps.mutexChan <- struct{}{} ps.semaphore <- struct{}{}
<-ps.mutexChan <-ps.semaphore
if startCalls.Load() != tc.expectedStartCalls { if startCalls.Load() != tc.expectedStartCalls {
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load()) t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
@@ -488,7 +471,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor) ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone { if tc.ctxDone {
@@ -522,10 +505,10 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
return true return true
}, },
} }
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor) ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
// Simulating a lock. // Simulating a lock.
ps.mutexChan <- struct{}{} ps.semaphore <- struct{}{}
if ps.ReqQueueSize() != 0 { if ps.ReqQueueSize() != 0 {
t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize()) t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
@@ -537,17 +520,15 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
var wg sync.WaitGroup var wg sync.WaitGroup
errorChan := make(chan error, 10) errorChan := make(chan error, 10)
for i := 0; i < 10; i++ { for range 10 {
wg.Add(1) wg.Go(func() {
go func() {
defer wg.Done()
err := ps.Run(ctx, logger, func() error { err := ps.Run(ctx, logger, func() error {
return nil return nil
}) })
if err != nil { if err != nil {
errorChan <- err errorChan <- err
} }
}() })
} }
// We have to wait a little bit so that the request queue size may change. // We have to wait a little bit so that the request queue size may change.
@@ -623,7 +604,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
}, },
} }
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor) ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount) ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ { for i := 0; i < tc.restartAttempts; i++ {
@@ -637,3 +618,122 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
}) })
} }
} }
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
logger := zap.NewNop()
var startCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
startCalls.Add(1)
return nil
},
StopMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var running atomic.Int64
var maxRunning atomic.Int64
var wg sync.WaitGroup
tasks := 6
for range tasks {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
cur := running.Add(1)
for {
old := maxRunning.Load()
if cur <= old || maxRunning.CompareAndSwap(old, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
running.Add(-1)
return nil
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
wg.Wait()
observed := maxRunning.Load()
if observed > maxConcurrency {
t.Fatalf("expected at most %d concurrent tasks, but observed %d", maxConcurrency, observed)
}
if observed < 2 {
t.Fatalf("expected concurrent execution (at least 2 tasks running simultaneously), but observed max %d", observed)
}
if startCalls.Load() != 1 {
t.Errorf("expected 1 start call, got %d", startCalls.Load())
}
}
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return nil
},
StopMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
ps.firstStart.Store(true)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wg sync.WaitGroup
tasks := 3
for range tasks {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
time.Sleep(50 * time.Millisecond)
return nil
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
wg.Wait()
// Wait for the async restart goroutine to complete.
deadline := time.After(5 * time.Second)
for ps.RestartsCount() < 1 {
select {
case <-deadline:
t.Fatal("timed out waiting for restart to complete")
default:
time.Sleep(10 * time.Millisecond)
}
}
if ps.RestartsCount() != 1 {
t.Fatalf("expected 1 restart, got %d", ps.RestartsCount())
}
}

View File

@@ -439,7 +439,7 @@ func (form *FormData) append(err error) {
// mustValue binds the target interface with a form field. If the value is // mustValue binds the target interface with a form field. If the value is
// empty or the "key" does not exist, it binds the default value. Currently, // empty or the "key" does not exist, it binds the default value. Currently,
// only the string, bool, int, float64 and time.Duration types are bindable. // only the string, bool, int, float64 and time.Duration types are bindable.
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData { func (form *FormData) mustValue(key string, target any, defaultValue any) *FormData {
val, ok := form.values[key] val, ok := form.values[key]
if !ok || val[0] == "" { if !ok || val[0] == "" {
@@ -468,7 +468,7 @@ func (form *FormData) mustValue(key string, target interface{}, defaultValue int
// populates an error if the value is empty or the "key" does not exist. // populates an error if the value is empty or the "key" does not exist.
// Currently, only the string, bool, int, float64 and time.Duration types are // Currently, only the string, bool, int, float64 and time.Duration types are
// bindable. // bindable.
func (form *FormData) mustMandatoryField(key string, target interface{}) *FormData { func (form *FormData) mustMandatoryField(key string, target any) *FormData {
val, ok := form.values[key] val, ok := form.values[key]
if !ok || val[0] == "" { if !ok || val[0] == "" {
@@ -487,7 +487,7 @@ func (form *FormData) mustMandatoryField(key string, target interface{}) *FormDa
// mustAssign parses the string value and tries to convert it to the target // mustAssign parses the string value and tries to convert it to the target
// interface real type. Currently, only the string, bool, int, float64 and // interface real type. Currently, only the string, bool, int, float64 and
// time.Duration types are bindable. // time.Duration types are bindable.
func (form *FormData) mustAssign(key, value string, target interface{}) *FormData { func (form *FormData) mustAssign(key, value string, target any) *FormData {
var err error var err error
switch t := (target).(type) { switch t := (target).(type) {

View File

@@ -288,7 +288,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground), forceExactColorsActionFunc(logger, options.PrintBackground),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector), waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
@@ -314,7 +314,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true), forceExactColorsActionFunc(logger, true),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector), waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),

View File

@@ -86,9 +86,10 @@ var (
// Chromium is a module that provides both an [Api] and routes for converting // Chromium is a module that provides both an [Api] and routes for converting
// an HTML document to PDF. // an HTML document to PDF.
type Chromium struct { type Chromium struct {
autoStart bool autoStart bool
disableRoutes bool disableRoutes bool
args browserArguments maxConcurrency int64
args browserArguments
logger *zap.Logger logger *zap.Logger
browser browser browser browser
@@ -164,11 +165,28 @@ type Options struct {
// "print". // "print".
EmulatedMediaType string EmulatedMediaType string
// EmulatedMediaFeatures are the media features to emulate, e.g.,
// [{"name": "prefers-color-scheme", "value": "dark"}].
EmulatedMediaFeatures []EmulatedMediaFeature
// OmitBackground hides the default white background and allows generating // OmitBackground hides the default white background and allows generating
// PDFs with transparency. // PDFs with transparency.
OmitBackground bool OmitBackground bool
} }
// EmulatedMediaFeature gathers the available entries for emulating a media
// feature.
type EmulatedMediaFeature struct {
// Name is the media feature name (e.g., "prefers-color-scheme",
// "prefers-reduced-motion").
// Required.
Name string `json:"name"`
// Value is the media feature value (e.g., "dark", "reduce").
// Required.
Value string `json:"value"`
}
// DefaultOptions returns the default values for Options. // DefaultOptions returns the default values for Options.
func DefaultOptions() Options { func DefaultOptions() Options {
return Options{ return Options{
@@ -186,6 +204,7 @@ func DefaultOptions() Options {
UserAgent: "", UserAgent: "",
ExtraHttpHeaders: nil, ExtraHttpHeaders: nil,
EmulatedMediaType: "", EmulatedMediaType: "",
EmulatedMediaFeatures: nil,
OmitBackground: false, OmitBackground: false,
} }
} }
@@ -391,8 +410,9 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
ID: "chromium", ID: "chromium",
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("chromium", flag.ExitOnError) fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int64("chromium-restart-after", 10, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature") fs.Int64("chromium-restart-after", 100, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature") fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
fs.Int64("chromium-max-concurrency", 6, "Maximum number of concurrent conversions. Chromium supports up to 6")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion") fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart") fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost") fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
@@ -426,6 +446,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
mod.autoStart = flags.MustBool("chromium-auto-start") mod.autoStart = flags.MustBool("chromium-auto-start")
mod.disableRoutes = flags.MustBool("chromium-disable-routes") mod.disableRoutes = flags.MustBool("chromium-disable-routes")
mod.maxConcurrency = flags.MustInt64("chromium-max-concurrency")
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH") binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
if !ok { if !ok {
@@ -468,7 +489,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Process. // Process.
mod.browser = newChromiumBrowser(mod.args) mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size")) mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
// PDF Engine. // PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider)) provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -486,6 +507,10 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Validate validates the module properties. // Validate validates the module properties.
func (mod *Chromium) Validate() error { func (mod *Chromium) Validate() error {
if mod.maxConcurrency < 1 || mod.maxConcurrency > 6 {
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
}
_, err := os.Stat(mod.args.binPath) _, err := os.Stat(mod.args.binPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("chromium binary path does not exist: %w", err) return fmt.Errorf("chromium binary path does not exist: %w", err)
@@ -540,8 +565,8 @@ func (mod *Chromium) Stop(ctx context.Context) error {
} }
// Debug returns additional debug data. // Debug returns additional debug data.
func (mod *Chromium) Debug() map[string]interface{} { func (mod *Chromium) Debug() map[string]any {
debug := make(map[string]interface{}) debug := make(map[string]any)
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

View File

@@ -21,7 +21,7 @@ func (debug *debugLogger) Write(p []byte) (n int, err error) {
} }
// Printf logs a debug message. // Printf logs a debug message.
func (debug *debugLogger) Printf(format string, v ...interface{}) { func (debug *debugLogger) Printf(format string, v ...any) {
debug.logger.Debug(fmt.Sprintf(format, v...)) debug.logger.Debug(fmt.Sprintf(format, v...))
} }

View File

@@ -39,7 +39,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders)) logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
} }
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev any) {
switch e := ev.(type) { switch e := ev.(type) {
case *fetch.EventRequestPaused: case *fetch.EventRequestPaused:
go func() { go func() {
@@ -176,7 +176,7 @@ func listenForEventResponseReceived(
} }
} }
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { switch ev := ev.(type) {
case *network.EventResponseReceived: case *network.EventResponseReceived:
if ev.Response.URL == options.mainPageUrl { if ev.Response.URL == options.mainPageUrl {
@@ -299,7 +299,7 @@ type eventLoadingFailedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/959. // https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021. // https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) { func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { switch ev := ev.(type) {
case *network.EventLoadingFailed: case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText)) logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
@@ -355,7 +355,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
// appends those exceptions to the given error pointer. // appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262. // See https://github.com/gotenberg/gotenberg/issues/262.
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) { func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
chromedp.ListenTarget(ctx, func(ev interface{}) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { switch ev := ev.(type) {
case *runtime.EventExceptionThrown: case *runtime.EventExceptionThrown:
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails)) logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
@@ -374,7 +374,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) { chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) { switch ev.(type) {
case *page.EventDomContentEventFired: case *page.EventDomContentEventFired:
cancel() cancel()
@@ -398,7 +398,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) { chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) { switch ev.(type) {
case *page.EventLoadEventFired: case *page.EventLoadEventFired:
cancel() cancel()
@@ -422,7 +422,7 @@ func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() err
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) { chromedp.ListenTarget(cctx, func(ev any) {
switch e := ev.(type) { switch e := ev.(type) {
case *page.EventLifecycleEvent: case *page.EventLifecycleEvent:
if e.Name == "networkIdle" { if e.Name == "networkIdle" {
@@ -448,7 +448,7 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) { chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) { switch ev.(type) {
case *network.EventLoadingFinished: case *network.EventLoadingFinished:
cancel() cancel()

View File

@@ -39,6 +39,7 @@ var sameSiteRegexp = regexp2.MustCompile(
// - ignoreResourceHttpStatusDomains: []string // - ignoreResourceHttpStatusDomains: []string
// - cookies: []Cookie // - cookies: []Cookie
// - extraHttpHeaders: map[string]string // - extraHttpHeaders: map[string]string
// - emulatedMediaFeatures: map[string]string
// //
// Domain filtering only applies to resource checks triggered by // Domain filtering only applies to resource checks triggered by
// "failOnResourceHttpStatusCodes". // "failOnResourceHttpStatusCodes".
@@ -60,6 +61,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
userAgent string userAgent string
extraHttpHeaders []ExtraHttpHeader extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string emulatedMediaType string
emulatedMediaFeatures []EmulatedMediaFeature
omitBackground bool omitBackground bool
) )
@@ -170,8 +172,8 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
var valueTokens []string var valueTokens []string
var invalidScopeToken bool var invalidScopeToken bool
tokens := strings.Split(v, ";") tokens := strings.SplitSeq(v, ";")
for _, token := range tokens { for token := range tokens {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") { if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
tokenNoSpaces := strings.Join(strings.Fields(token), "") tokenNoSpaces := strings.Join(strings.Fields(token), "")
parts := strings.SplitN(tokenNoSpaces, "=", 2) parts := strings.SplitN(tokenNoSpaces, "=", 2)
@@ -227,6 +229,27 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil return nil
}). }).
Custom("emulatedMediaFeatures", func(value string) error {
if value == "" {
emulatedMediaFeatures = defaultOptions.EmulatedMediaFeatures
return nil
}
var features map[string]string
err := json.Unmarshal([]byte(value), &features)
if err != nil {
return fmt.Errorf("unmarshal emulatedMediaFeatures: %w", err)
}
for k, v := range features {
emulatedMediaFeatures = append(emulatedMediaFeatures, EmulatedMediaFeature{
Name: k,
Value: v,
})
}
return err
}).
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground) Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{ options := Options{
@@ -244,6 +267,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
UserAgent: userAgent, UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders, ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType, EmulatedMediaType: emulatedMediaType,
EmulatedMediaFeatures: emulatedMediaFeatures,
OmitBackground: omitBackground, OmitBackground: omitBackground,
} }
@@ -662,7 +686,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil return fmt.Sprintf("file://%s", inputPath), nil
} }
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string, embedPaths []string) error { func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string) error {
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130. // See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath) filename := ctx.OutputFilename(outputPath)

View File

@@ -39,11 +39,9 @@ func (reader *streamReader) Read(p []byte) (n int, err error) {
// Chromium might have an off-by-one when deciding the maximum size (at // Chromium might have an off-by-one when deciding the maximum size (at
// least for base64 encoded data), usually it will overflow. We subtract // least for base64 encoded data), usually it will overflow. We subtract
// one to make sure it fits into p. // one to make sure it fits into p.
size := len(p) - 1 size := max(len(p)-1,
if size < 1 {
// Safety-check to avoid crashing Chrome (e.g. via SetSize(-1)). // Safety-check to avoid crashing Chrome (e.g. via SetSize(-1)).
size = 1 1)
}
reply, err := reader.next(reader.pos, size) reply, err := reader.next(reader.pos, size)
if err != nil { if err != nil {

View File

@@ -423,26 +423,44 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
} }
} }
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.ActionFunc { func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if mediaType == "" { if mediaType == "" && len(mediaFeatures) == 0 {
logger.Debug("no emulated media type") logger.Debug("no emulated media type or features")
return nil return nil
} }
if mediaType != "screen" && mediaType != "print" { if mediaType != "" && mediaType != "screen" && mediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType) return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
} }
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia := emulation.SetEmulatedMedia() emulatedMedia := emulation.SetEmulatedMedia()
err := emulatedMedia.WithMedia(mediaType).Do(ctx)
if mediaType != "" {
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia = emulatedMedia.WithMedia(mediaType)
}
if len(mediaFeatures) > 0 {
logger.Debug(fmt.Sprintf("emulate media features %+v", mediaFeatures))
features := make([]*emulation.MediaFeature, len(mediaFeatures))
for i, f := range mediaFeatures {
features[i] = &emulation.MediaFeature{
Name: f.Name,
Value: f.Value,
}
}
emulatedMedia = emulatedMedia.WithFeatures(features)
}
err := emulatedMedia.Do(ctx)
if err == nil { if err == nil {
return nil return nil
} }
return fmt.Errorf("emulate media type '%s': %w", mediaType, err) return fmt.Errorf("emulate media: %w", err)
} }
} }

View File

@@ -57,8 +57,8 @@ func (engine *ExifTool) Validate() error {
} }
// Debug returns additional debug data. // Debug returns additional debug data.
func (engine *ExifTool) Debug() map[string]interface{} { func (engine *ExifTool) Debug() map[string]any {
debug := make(map[string]interface{}) debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -94,7 +94,7 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats
} }
// ReadMetadata extracts the metadata of a given PDF file. // ReadMetadata extracts the metadata of a given PDF file.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil { if err != nil {
return nil, fmt.Errorf("new ExifTool: %w", err) return nil, fmt.Errorf("new ExifTool: %w", err)
@@ -116,7 +116,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, in
} }
// WriteMetadata writes the metadata into a given PDF file. // WriteMetadata writes the metadata into a given PDF file.
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil { if err != nil {
return fmt.Errorf("new ExifTool: %w", err) return fmt.Errorf("new ExifTool: %w", err)
@@ -134,13 +134,40 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err) return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
} }
// Define a list of derived, system, or computed tags that ExifTool
// extracts but should never be written back. Writing these can break PDF/A
// compliance (e.g., PageCount -> prism:pageCount) or cause side effects
// (e.g., FileModifyDate).
derivedTags := []string{
"PageCount", // Causes prism:pageCount injection
"Linearized", // Computed status; writing it may invalidate structure
"PDFVersion", // Header version; should not be manually forced via metadata
"MIMEType", // Read-only derived
"FileType", // Read-only derived
"FileTypeExtension", // Read-only derived
"FileSize", // System attribute
"FileModifyDate", // System attribute
"FileAccessDate", // System attribute
"FileInodeChangeDate", // System attribute
"FilePermissions", // System attribute
"FileName", // Writing this triggers a file rename in ExifTool
"Directory", // System attribute
"ExifToolVersion", // Tool metadata
"Error", // Extraction error messages
"Warning", // Extraction warning messages
}
for _, tag := range derivedTags {
delete(fileMetadata[0].Fields, tag)
}
for key, value := range metadata { for key, value := range metadata {
switch val := value.(type) { switch val := value.(type) {
case string: case string:
fileMetadata[0].SetString(key, val) fileMetadata[0].SetString(key, val)
case []string: case []string:
fileMetadata[0].SetStrings(key, val) fileMetadata[0].SetStrings(key, val)
case []interface{}: case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048. // See https://github.com/gotenberg/gotenberg/issues/1048.
strs := make([]string, len(val)) strs := make([]string, len(val))
for i, entry := range val { for i, entry := range val {
@@ -148,7 +175,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
strs[i] = str strs[i] = str
continue continue
} }
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported) return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
} }
fileMetadata[0].SetStrings(key, strs) fileMetadata[0].SetStrings(key, strs)
case bool: case bool:

View File

@@ -54,7 +54,7 @@ type Api struct {
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html. // See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
type Options struct { type Options struct {
// Password specifies the password for opening the source file. // Password specifies the password for opening the source file.
Password string Password string // #nosec
// Landscape allows changing the orientation of the resulting PDF. // Landscape allows changing the orientation of the resulting PDF.
Landscape bool Landscape bool
@@ -253,7 +253,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
// Process. // Process.
a.libreOffice = newLibreOfficeProcess(a.args) a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size")) a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1)
return nil return nil
} }
@@ -316,8 +316,8 @@ func (a *Api) Stop(ctx context.Context) error {
} }
// Debug returns additional debug data. // Debug returns additional debug data.
func (a *Api) Debug() map[string]interface{} { func (a *Api) Debug() map[string]any {
debug := make(map[string]interface{}) debug := make(map[string]any)
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

View File

@@ -82,12 +82,12 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log
} }
// ReadMetadata is not available in this implementation. // ReadMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // WriteMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }

View File

@@ -177,7 +177,7 @@ func newLogLevel(level string) (zapcore.Level, error) {
} }
func newLogEncoder(format string, gcpFields bool) (zapcore.Encoder, error) { func newLogEncoder(format string, gcpFields bool) (zapcore.Encoder, error) {
isTerminal := term.IsTerminal(int(os.Stdout.Fd())) isTerminal := term.IsTerminal(int(os.Stdout.Fd())) // #nosec
encCfg := zap.NewProductionEncoderConfig() encCfg := zap.NewProductionEncoderConfig()
// Normalize the log format based on the output device. // Normalize the log format based on the output device.

View File

@@ -57,8 +57,8 @@ func (engine *PdfCpu) Validate() error {
} }
// Debug returns additional debug data. // Debug returns additional debug data.
func (engine *PdfCpu) Debug() map[string]interface{} { func (engine *PdfCpu) Debug() map[string]any {
debug := make(map[string]interface{}) debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "version") //nolint:gosec cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -71,10 +71,10 @@ func (engine *PdfCpu) Debug() map[string]interface{} {
debug["version"] = "Unable to determine pdfcpu version" debug["version"] = "Unable to determine pdfcpu version"
lines := strings.Split(string(output), "\n") lines := strings.SplitSeq(string(output), "\n")
for _, line := range lines { for line := range lines {
if strings.HasPrefix(line, "pdfcpu:") { if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
debug["version"] = strings.TrimSpace(strings.TrimPrefix(line, "pdfcpu:")) debug["version"] = strings.TrimSpace(after)
break break
} }
} }
@@ -84,7 +84,7 @@ func (engine *PdfCpu) Debug() map[string]interface{} {
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string args := make([]string, 0, 2+len(inputPaths))
args = append(args, "merge", outputPath) args = append(args, "merge", outputPath)
args = append(args, inputPaths...) args = append(args, inputPaths...)
@@ -162,12 +162,12 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats g
} }
// ReadMetadata is not available in this implementation. // ReadMetadata is not available in this implementation.
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // WriteMetadata is not available in this implementation.
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
@@ -180,10 +180,8 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePa
logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths)) logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
args := []string{ args := make([]string, 0, 3+len(filePaths))
"attachments", "add", args = append(args, "attachments", "add", inputPath)
inputPath,
}
args = append(args, filePaths...) args = append(args, filePaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
@@ -209,7 +207,7 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath
ownerPassword = userPassword ownerPassword = userPassword
} }
var args []string args := make([]string, 0, 11)
args = append(args, "encrypt") args = append(args, "encrypt")
args = append(args, "-mode", "aes") args = append(args, "-mode", "aes")
args = append(args, "-upw", userPassword) args = append(args, "-upw", userPassword)

View File

@@ -156,13 +156,13 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
} }
type readMetadataResult struct { type readMetadataResult struct {
metadata map[string]interface{} metadata map[string]any
err error err error
} }
// ReadMetadata extracts metadata from a PDF file using the first available // ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading. // engine that supports metadata reading.
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
var err error var err error
var mu sync.Mutex // to safely append errors. var mu sync.Mutex // to safely append errors.
@@ -193,7 +193,7 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
// WriteMetadata embeds metadata into a PDF file using the first available // WriteMetadata embeds metadata into a PDF file using the first available
// engine that supports metadata writing. // engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)

View File

@@ -479,8 +479,8 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return make(map[string]interface{}), nil return make(map[string]any), nil
}, },
}, },
}, },
@@ -492,13 +492,13 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
}, },
}, },
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return make(map[string]interface{}), nil return make(map[string]any), nil
}, },
}, },
}, },
@@ -510,12 +510,12 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
}, },
}, },
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
}, },
}, },
@@ -529,8 +529,8 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{ readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return make(map[string]interface{}), nil return make(map[string]any), nil
}, },
}, },
}, },
@@ -570,7 +570,7 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return nil return nil
}, },
}, },
@@ -583,12 +583,12 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo") return errors.New("foo")
}, },
}, },
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return nil return nil
}, },
}, },
@@ -601,12 +601,12 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo") return errors.New("foo")
}, },
}, },
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo") return errors.New("foo")
}, },
}, },
@@ -620,7 +620,7 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
engine: &multiPdfEngines{ engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{ writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return nil return nil
}, },
}, },

View File

@@ -3,6 +3,7 @@ package pdfengines
import ( import (
"errors" "errors"
"fmt" "fmt"
"slices"
"strings" "strings"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
@@ -179,13 +180,7 @@ func (mod *PdfEngines) Validate() error {
continue continue
} }
alreadyInSlice := false alreadyInSlice := slices.Contains(nonExistingEngines, name)
for _, engine := range nonExistingEngines {
if engine == name {
alreadyInSlice = true
break
}
}
if !alreadyInSlice { if !alreadyInSlice {
nonExistingEngines = append(nonExistingEngines, name) nonExistingEngines = append(nonExistingEngines, name)

View File

@@ -102,8 +102,8 @@ func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
} }
// FormDataPdfMetadata creates metadata object from the form data. // FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} { func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any {
var metadata map[string]interface{} var metadata map[string]any
metadataFunc := func(value string) error { metadataFunc := func(value string) error {
if len(value) > 0 { if len(value) > 0 {
@@ -239,7 +239,7 @@ func ConvertStub(ctx *api.Context, engine gotenberg.PdfEngine, formats gotenberg
// WriteMetadataStub writes the metadata into PDF files. If no metadata, it // WriteMetadataStub writes the metadata into PDF files. If no metadata, it
// does nothing. // does nothing.
func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]interface{}, inputPaths []string) error { func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]any, inputPaths []string) error {
if len(metadata) == 0 { if len(metadata) == 0 {
return nil return nil
} }
@@ -558,7 +558,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
res := make(map[string]map[string]interface{}, len(inputPaths)) res := make(map[string]map[string]any, len(inputPaths))
for _, inputPath := range inputPaths { for _, inputPath := range inputPaths {
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath) metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
if err != nil { if err != nil {

View File

@@ -56,8 +56,8 @@ func (engine *PdfTk) Validate() error {
} }
// Debug returns additional debug data. // Debug returns additional debug data.
func (engine *PdfTk) Debug() map[string]interface{} { func (engine *PdfTk) Debug() map[string]any {
debug := make(map[string]interface{}) debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -108,7 +108,7 @@ func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenbe
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string args := make([]string, 0, 3+len(inputPaths))
args = append(args, inputPaths...) args = append(args, inputPaths...)
args = append(args, "cat", "output", outputPath) args = append(args, "cat", "output", outputPath)
@@ -136,12 +136,12 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats go
} }
// ReadMetadata is not available in this implementation. // ReadMetadata is not available in this implementation.
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // WriteMetadata is not available in this implementation.
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
@@ -158,7 +158,7 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
// Create a temp output file in the same directory. // Create a temp output file in the same directory.
tmpPath := inputPath + ".tmp" tmpPath := inputPath + ".tmp"
var args []string args := make([]string, 0, 8)
args = append(args, inputPath) args = append(args, inputPath)
args = append(args, "output", tmpPath) args = append(args, "output", tmpPath)
args = append(args, "encrypt_128bit") args = append(args, "encrypt_128bit")

View File

@@ -59,8 +59,8 @@ func (engine *QPdf) Validate() error {
} }
// Debug returns additional debug data. // Debug returns additional debug data.
func (engine *QPdf) Debug() map[string]interface{} { func (engine *QPdf) Debug() map[string]any {
debug := make(map[string]interface{}) debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -114,7 +114,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenber
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string args := make([]string, 0, 4+len(engine.globalArgs)+len(inputPaths))
args = append(args, "--empty") args = append(args, "--empty")
args = append(args, engine.globalArgs...) args = append(args, engine.globalArgs...)
args = append(args, "--pages") args = append(args, "--pages")
@@ -137,7 +137,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
// Flatten merges annotation appearances with page content, deleting the // Flatten merges annotation appearances with page content, deleting the
// original annotations. // original annotations.
func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var args []string args := make([]string, 0, 4+len(engine.globalArgs))
args = append(args, inputPath) args = append(args, inputPath)
args = append(args, "--generate-appearances") args = append(args, "--generate-appearances")
args = append(args, "--flatten-annotations=all") args = append(args, "--flatten-annotations=all")
@@ -163,12 +163,12 @@ func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats got
} }
// ReadMetadata is not available in this implementation. // ReadMetadata is not available in this implementation.
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // WriteMetadata is not available in this implementation.
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
@@ -182,7 +182,7 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
ownerPassword = userPassword ownerPassword = userPassword
} }
var args []string args := make([]string, 0, 7+len(engine.globalArgs))
args = append(args, inputPath) args = append(args, inputPath)
args = append(args, engine.globalArgs...) args = append(args, engine.globalArgs...)
args = append(args, "--replace-input") args = append(args, "--replace-input")

View File

@@ -0,0 +1,20 @@
@chromium
@chromium-concurrent
Feature: Chromium concurrent conversions
Scenario: Concurrent HTML to PDF conversions with max concurrency 3
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_MAX_CONCURRENCY | 3 |
When I make 3 concurrent "POST" requests to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
Then all concurrent response status codes should be 200
Then all concurrent responses should have 1 PDF(s)
Scenario: Concurrent conversions exceeding restart-after limit
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_MAX_CONCURRENCY | 3 |
| CHROMIUM_RESTART_AFTER | 5 |
When I make 10 concurrent "POST" requests to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
Then all concurrent response status codes should be 200
Then all concurrent responses should have 1 PDF(s)

View File

@@ -281,6 +281,82 @@ Feature: /forms/chromium/convert/html
Emulated media type is 'print'. Emulated media type is 'print'.
""" """
Scenario: POST /forms/chromium/convert/html (Emulated Media Features)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Prefers reduced motion.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaType | screen | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'print'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaType | print | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists) Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists)
Given I have a default Gotenberg container Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
@@ -550,6 +626,15 @@ Feature: /forms/chromium/convert/html
""" """
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`) Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
""" """
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| emulatedMediaFeatures | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'emulatedMediaFeatures' is invalid (got 'foo', resulting to unmarshal emulatedMediaFeatures: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file | | files | testdata/page-1-html/index.html | file |
| splitMode | foo | field | | splitMode | foo | field |

View File

@@ -346,6 +346,86 @@ Feature: /forms/chromium/convert/url
Emulated media type is 'print'. Emulated media type is 'print'.
""" """
Scenario: POST /forms/chromium/convert/url (Emulated Media Features)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Prefers reduced motion.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaType | screen | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaType | print | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists) Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists)
Given I have a default Gotenberg container Given I have a default Gotenberg container
Given I have a static server Given I have a static server
@@ -627,6 +707,16 @@ Feature: /forms/chromium/convert/url
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`) Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
""" """
Given I have a static server Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| emulatedMediaFeatures | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'emulatedMediaFeatures' is invalid (got 'foo', resulting to unmarshal emulatedMediaFeatures: invalid character 'o' in literal false (expecting 'a'))
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | foo | field | | splitMode | foo | field |

View File

@@ -16,6 +16,127 @@ Feature: /debug
""" """
{ {
"version": "{version}", "version": "{version}",
"timezone": "UTC",
"architecture": "ignore",
"modules": [
"api",
"chromium",
"exiftool",
"libreoffice",
"libreoffice-api",
"libreoffice-pdfengine",
"logging",
"pdfcpu",
"pdfengines",
"pdftk",
"prometheus",
"qpdf",
"webhook"
],
"modules_additional_data": {
"chromium": {
"version": "ignore"
},
"exiftool": {
"version": "ignore"
},
"libreoffice-api": {
"version": "ignore"
},
"pdfcpu": {
"version": "ignore"
},
"pdftk": {
"version": "ignore"
},
"qpdf": {
"version": "ignore"
}
},
"flags": {
"api-bind-ip": "",
"api-body-limit": "",
"api-disable-download-from": "false",
"api-disable-health-check-logging": "false",
"api-download-from-allow-list": "",
"api-download-from-deny-list": "",
"api-download-from-max-retry": "4",
"api-enable-basic-auth": "false",
"api-enable-debug-route": "true",
"api-port": "3000",
"api-port-from-env": "",
"api-root-path": "/",
"api-start-timeout": "30s",
"api-timeout": "30s",
"api-tls-cert-file": "",
"api-tls-key-file": "",
"api-trace-header": "Gotenberg-Trace",
"chromium-allow-file-access-from-files": "false",
"chromium-allow-insecure-localhost": "false",
"chromium-allow-list": "",
"chromium-auto-start": "false",
"chromium-clear-cache": "false",
"chromium-clear-cookies": "false",
"chromium-deny-list": "^file:(?!//\\/tmp/).*",
"chromium-disable-javascript": "false",
"chromium-disable-routes": "false",
"chromium-disable-web-security": "false",
"chromium-host-resolver-rules": "",
"chromium-ignore-certificate-errors": "false",
"chromium-incognito": "false",
"chromium-max-concurrency": "6",
"chromium-max-queue-size": "0",
"chromium-proxy-server": "",
"chromium-restart-after": "100",
"chromium-start-timeout": "20s",
"gotenberg-build-debug-data": "true",
"gotenberg-graceful-shutdown-duration": "30s",
"libreoffice-auto-start": "false",
"libreoffice-disable-routes": "false",
"libreoffice-max-queue-size": "0",
"libreoffice-restart-after": "10",
"libreoffice-start-timeout": "20s",
"log-fields-prefix": "",
"log-format": "auto",
"log-level": "info",
"pdfengines-convert-engines": "[libreoffice-pdfengine]",
"pdfengines-disable-routes": "false",
"pdfengines-engines": "[]",
"pdfengines-flatten-engines": "[qpdf]",
"pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]",
"pdfengines-read-metadata-engines": "[exiftool]",
"pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]",
"pdfengines-write-metadata-engines": "[exiftool]",
"prometheus-collect-interval": "1s",
"prometheus-disable-collect": "false",
"prometheus-disable-route-logging": "false",
"prometheus-namespace": "gotenberg",
"prometheus-metrics-path": "/prometheus/metrics",
"webhook-allow-list": "",
"webhook-client-timeout": "30s",
"webhook-deny-list": "",
"webhook-disable": "false",
"webhook-error-allow-list": "",
"webhook-error-deny-list": "",
"webhook-max-retry": "4",
"webhook-retry-max-wait": "30s",
"webhook-retry-min-wait": "1s"
}
}
"""
Scenario: GET /debug (Environment based timezone)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| TZ | America/New_York |
When I make a "GET" request to Gotenberg at the "/debug" endpoint
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"version": "{version}",
"timezone": "America/New_York",
"architecture": "ignore", "architecture": "ignore",
"modules": [ "modules": [
"api", "api",
@@ -84,8 +205,9 @@ Feature: /debug
"chromium-ignore-certificate-errors": "false", "chromium-ignore-certificate-errors": "false",
"chromium-incognito": "false", "chromium-incognito": "false",
"chromium-max-queue-size": "0", "chromium-max-queue-size": "0",
"chromium-max-concurrency": "6",
"chromium-proxy-server": "", "chromium-proxy-server": "",
"chromium-restart-after": "10", "chromium-restart-after": "100",
"chromium-start-timeout": "20s", "chromium-start-timeout": "20s",
"gotenberg-build-debug-data": "true", "gotenberg-build-debug-data": "true",
"gotenberg-graceful-shutdown-duration": "30s", "gotenberg-graceful-shutdown-duration": "30s",
@@ -134,6 +256,7 @@ Feature: /debug
""" """
{ {
"version": "", "version": "",
"timezone": "",
"architecture": "", "architecture": "",
"modules": null, "modules": null,
"modules_additional_data": null, "modules_additional_data": null,

View File

@@ -5,11 +5,11 @@ import (
"reflect" "reflect"
) )
func compareJson(expected, actual interface{}) error { func compareJson(expected, actual any) error {
// Handle maps (JSON objects). // Handle maps (JSON objects).
expectedMap, ok := expected.(map[string]interface{}) expectedMap, ok := expected.(map[string]any)
if ok { if ok {
actualMap, ok := actual.(map[string]interface{}) actualMap, ok := actual.(map[string]any)
if !ok { if !ok {
return fmt.Errorf("expected an object, but actual is: %T", actual) return fmt.Errorf("expected an object, but actual is: %T", actual)
} }
@@ -31,9 +31,9 @@ func compareJson(expected, actual interface{}) error {
} }
// Handle slices (JSON arrays). // Handle slices (JSON arrays).
expectedSlice, ok := expected.([]interface{}) expectedSlice, ok := expected.([]any)
if ok { if ok {
actualSlice, ok := actual.([]interface{}) actualSlice, ok := actual.([]any)
if !ok { if !ok {
return fmt.Errorf("expected an array, but actual is: %T", actual) return fmt.Errorf("expected an array, but actual is: %T", actual)
} }

View File

@@ -22,7 +22,7 @@ var (
type noopLogger struct{} type noopLogger struct{}
func (n *noopLogger) Printf(format string, v ...interface{}) { func (n *noopLogger) Printf(format string, v ...any) {
// NOOP // NOOP
} }

View File

@@ -20,6 +20,7 @@ func doRequest(method, url string, headers map[string]string, body io.Reader) (*
req.Header.Set(header, value) req.Header.Set(header, value)
} }
// #nosec
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("send a request: %w", err) return nil, fmt.Errorf("send a request: %w", err)
@@ -74,6 +75,7 @@ func doFormDataRequest(method, url string, fields map[string]string, files map[s
} }
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
// #nosec
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("send a request: %w", err) return nil, fmt.Errorf("send a request: %w", err)

View File

@@ -14,6 +14,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/cucumber/godog" "github.com/cucumber/godog"
@@ -24,6 +25,7 @@ import (
type scenario struct { type scenario struct {
resp *httptest.ResponseRecorder resp *httptest.ResponseRecorder
concurrentResps []*httptest.ResponseRecorder
workdir string workdir string
gotenbergContainer testcontainers.Container gotenbergContainer testcontainers.Container
gotenbergContainerNetwork *testcontainers.DockerNetwork gotenbergContainerNetwork *testcontainers.DockerNetwork
@@ -33,6 +35,7 @@ type scenario struct {
func (s *scenario) reset(ctx context.Context) error { func (s *scenario) reset(ctx context.Context) error {
s.resp = httptest.NewRecorder() s.resp = httptest.NewRecorder()
s.concurrentResps = nil
err := os.RemoveAll(s.workdir) err := os.RemoveAll(s.workdir)
if err != nil { if err != nil {
@@ -281,6 +284,168 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
return nil return nil
} }
func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count int, method, endpoint string, dataTable *godog.Table) error {
if s.gotenbergContainer == nil {
return errors.New("no Gotenberg container")
}
fields := make(map[string]string)
files := make(map[string][]string)
headers := make(map[string]string)
for _, row := range dataTable.Rows {
name := row.Cells[0].Value
value := row.Cells[1].Value
kind := row.Cells[2].Value
switch kind {
case "field":
fields[name] = value
case "file":
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get current directory: %w", err)
}
value = fmt.Sprintf("%s/%s", wd, value)
files[name] = append(files[name], value)
case "header":
headers[name] = value
default:
return fmt.Errorf("unexpected %q %q", kind, value)
}
}
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
if err != nil {
return fmt.Errorf("get container HTTP endpoint: %w", err)
}
var (
mu sync.Mutex
wg sync.WaitGroup
)
s.concurrentResps = make([]*httptest.ResponseRecorder, 0, count)
errs := make([]error, 0)
for range count {
wg.Go(func() {
resp, reqErr := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
if reqErr != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("do request: %w", reqErr))
mu.Unlock()
return
}
defer resp.Body.Close()
body, reqErr := io.ReadAll(resp.Body)
if reqErr != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("read response body: %w", reqErr))
mu.Unlock()
return
}
rec := httptest.NewRecorder()
rec.Code = resp.StatusCode
for key, values := range resp.Header {
for _, v := range values {
rec.Header().Add(key, v)
}
}
_, _ = rec.Body.Write(body)
if resp.StatusCode == http.StatusOK {
cd := resp.Header.Get("Content-Disposition")
if cd != "" {
_, params, parseErr := mime.ParseMediaType(cd)
if parseErr == nil {
if filename, ok := params["filename"]; ok {
traceID := resp.Header.Get("Gotenberg-Trace")
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
mu.Lock()
mkErr := os.MkdirAll(dirPath, 0o755)
mu.Unlock()
if mkErr == nil {
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
f, fErr := os.Create(fpath)
if fErr == nil {
_, _ = f.Write(body)
f.Close()
}
}
}
}
}
}
mu.Lock()
s.concurrentResps = append(s.concurrentResps, rec)
mu.Unlock()
})
}
wg.Wait()
if len(errs) > 0 {
return fmt.Errorf("concurrent requests failed: %v", errs)
}
return nil
}
func (s *scenario) allConcurrentResponseStatusCodesShouldBe(expected int) error {
if len(s.concurrentResps) == 0 {
return errors.New("no concurrent responses recorded")
}
for i, resp := range s.concurrentResps {
if resp.Code != expected {
return fmt.Errorf("concurrent response %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
}
}
return nil
}
func (s *scenario) allConcurrentResponsesShouldHavePdfs(expected int) error {
if len(s.concurrentResps) == 0 {
return errors.New("no concurrent responses recorded")
}
for i, resp := range s.concurrentResps {
traceID := resp.Header().Get("Gotenberg-Trace")
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("concurrent response %d: directory %q does not exist", i+1, dirPath)
}
var paths []string
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
paths = append(paths, path)
}
return nil
})
if err != nil {
return fmt.Errorf("concurrent response %d: walk %q: %w", i+1, dirPath, err)
}
if len(paths) != expected {
return fmt.Errorf("concurrent response %d: expected %d PDF(s), got %d", i+1, expected, len(paths))
}
}
return nil
}
func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error { func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error {
if s.server == nil { if s.server == nil {
return errors.New("server not initialized") return errors.New("server not initialized")
@@ -327,7 +492,7 @@ func (s *scenario) theGotenbergContainerShouldLogTheFollowingEntries(ctx context
} }
var err error var err error
for i := 0; i < 3; i++ { for range 3 {
err = check() err = check()
if err != nil && !invert { if err != nil && !invert {
// We have to retry as not all logs may have been produced. // We have to retry as not all logs may have been produced.
@@ -450,7 +615,7 @@ func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocStr
body = s.server.bodyCopy body = s.server.bodyCopy
} }
var expected, actual interface{} var expected, actual any
content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion) content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
err := json.Unmarshal([]byte(content), &expected) err := json.Unmarshal([]byte(content), &expected)
@@ -965,9 +1130,12 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg) ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg)
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders) ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders) ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
ctx.When(`^I make (\d+) concurrent "(POST)" requests to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeConcurrentRequestsToGotenberg)
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook) ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries) ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe) ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe) ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe) ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString) ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)

View File

@@ -79,12 +79,14 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
} }
dirPath := fmt.Sprintf("%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace")) dirPath := fmt.Sprintf("%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace"))
// #nosec
err = os.MkdirAll(dirPath, 0o755) err = os.MkdirAll(dirPath, 0o755)
if err != nil { if err != nil {
return webhookErr(fmt.Errorf("create working directory: %w", err)) return webhookErr(fmt.Errorf("create working directory: %w", err))
} }
fpath := fmt.Sprintf("%s/%s", dirPath, filename) fpath := fmt.Sprintf("%s/%s", dirPath, filename)
// #nosec
file, err := os.Create(fpath) file, err := os.Create(fpath)
if err != nil { if err != nil {
return webhookErr(fmt.Errorf("create file %q: %w", fpath, err)) return webhookErr(fmt.Errorf("create file %q: %w", fpath, err))

View File

@@ -20,6 +20,14 @@
display: none; display: none;
} }
} }
#reduced-motion {
display: none;
}
@media (prefers-reduced-motion: reduce) {
#reduced-motion {
display: block;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -29,6 +37,7 @@
</p> </p>
<p id="print">Emulated media type is 'print'.</p> <p id="print">Emulated media type is 'print'.</p>
<p id="screen">Emulated media type is 'screen'.</p> <p id="screen">Emulated media type is 'screen'.</p>
<p id="reduced-motion">Prefers reduced motion.</p>
<p id="javascript" style="display: none">JavaScript is enabled.</p> <p id="javascript" style="display: none">JavaScript is enabled.</p>
<iframe src="file:///etc/passwd"></iframe> <iframe src="file:///etc/passwd"></iframe>

View File

@@ -28,6 +28,14 @@
display: none; display: none;
} }
} }
#reduced-motion {
display: none;
}
@media (prefers-reduced-motion: reduce) {
#reduced-motion {
display: block;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -37,6 +45,7 @@
</p> </p>
<p id="print">Emulated media type is 'print'.</p> <p id="print">Emulated media type is 'print'.</p>
<p id="screen">Emulated media type is 'screen'.</p> <p id="screen">Emulated media type is 'screen'.</p>
<p id="reduced-motion">Prefers reduced motion.</p>
<p id="javascript" style="display: none">JavaScript is enabled.</p> <p id="javascript" style="display: none">JavaScript is enabled.</p>
<iframe src="/etc/passwd"></iframe> <iframe src="/etc/passwd"></iframe>
<iframe src="\\localhost/etc/passwd"></iframe> <iframe src="\\localhost/etc/passwd"></iframe>

View File

@@ -8,6 +8,7 @@
</style> </style>
</head> </head>
<body> <body>
<span class="date"></span>
<span class="title"></span> <span class="title"></span>
</body> </body>
</html> </html>