Compare commits

...

36 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
Julien Neuhart
45318497d2 chore(deps): update Go dependencies 2026-01-27 11:20:29 +00:00
dependabot[bot]
b762143d75 chore(deps): bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 09:23:09 +01:00
dependabot[bot]
ba1a730d3a chore(deps-dev): bump prettier from 3.7.4 to 3.8.1
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.4 to 3.8.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 09:23:01 +01:00
Adam Romanek
463bb2f91c fix(webhook): detach context from request lifecycle to prevent cancellation in async mode 2026-01-26 09:11:48 +01:00
Adam Romanek
e14cab0c8b fix(api): propagate request context to stop processing on client disconnect 2026-01-26 09:11:48 +01:00
Daniel Moran
3220ca4140 feat(chromium): add waitForSelector option to Chromium conversions (#1446)
* Add `waitForSelector` option to Chromium conversions

Closes #960

As an alternative to waiting on an expression, this allows users to wait
for a specific node matching a selector to become visible in the HTML /
at the remote URL before converting to PDF.

* Fix style / prettify
2026-01-17 14:57:10 +01:00
David
92de0cf6fe feat(prometheus): add new flag --prometheus-metrics-path 2026-01-14 10:04:57 +01:00
dependabot[bot]
d111591c8b chore(deps): bump golang.org/x/term from 0.38.0 to 0.39.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/term/compare/v0.38.0...v0.39.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 09:22:10 +01:00
dependabot[bot]
935686b9e3 chore(deps): bump golang.org/x/text from 0.32.0 to 0.33.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.32.0 to 0.33.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.32.0...v0.33.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 09:18:20 +01:00
John W. Mendes
b1ad21c7d3 chore(go): update Go version to 1.25.5 (#1440)
* Update Go to 1.25.5

* Update go version in go.mod
2026-01-05 15:42:47 +01:00
dependabot[bot]
4f800c8a76 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.25.11 to 4.25.12 (#1438)
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.25.11 to 4.25.12.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.11...v4.25.12)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 13:19:58 +01:00
dependabot[bot]
5c498219ed chore(deps): bump github.com/labstack/echo/v4 from 4.14.0 to 4.15.0 (#1439)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.14.0 to 4.15.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.14.0...v4.15.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.15.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-01-05 13:19:43 +01:00
markfrost
0b211c39eb feat(chromium): add IgnoreResourceHttpStatusDomains option to filter out resources based on their hostnames (#1434)
* Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames

- Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames during HTTP status code checks.
- Updated relevant functions to handle domain normalization and matching.
- Enhanced the form data handling to include the new option.
- Added integration test scenario to verify the functionality of ignoring specified domains.

* Updated the `normalizeDomains` function to initialize the `normalized` slice with a predefined capacity based on the input `domains` slice length, improving memory allocation efficiency.
2025-12-25 15:36:45 +01:00
dependabot[bot]
77b9776d9c chore(deps): bump github.com/labstack/echo/v4 from 4.13.4 to 4.14.0
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.13.4 to 4.14.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.13.4...v4.14.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 09:28:49 +01:00
dependabot[bot]
04e596b616 chore(deps): bump golang.org/x/text from 0.31.0 to 0.32.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.31.0 to 0.32.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.31.0...v0.32.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 09:25:25 +01:00
dependabot[bot]
9fba76fe5c chore(deps-dev): bump prettier from 3.7.3 to 3.7.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.3 to 3.7.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.3...3.7.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.7.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-11 10:12:14 +01:00
57 changed files with 1665 additions and 632 deletions

View File

@@ -31,7 +31,7 @@ jobs:
- name: Run linters
uses: golangci/golangci-lint-action@v9
with:
version: v2.5.0
version: v2.10.1
lint-prettier:
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) \
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
TZ=UTC
GOTENBERG_HIDE_BANNER=false
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
GOTENBERG_BUILD_DEBUG_DATA=true
@@ -30,8 +31,9 @@ API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=10
CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_MAX_CONCURRENCY=6
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
@@ -68,6 +70,7 @@ PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_COLLECT=false
PROMETHEUS_METRICS_PATH=/prometheus/metrics
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
@@ -85,6 +88,7 @@ run: ## Start a Gotenberg container
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
-e TZ=$(TZ) \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \
@@ -108,6 +112,7 @@ run: ## Start a Gotenberg container
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-max-concurrency=$(CHROMIUM_MAX_CONCURRENCY) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
@@ -143,6 +148,7 @@ run: ## Start a Gotenberg container
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--prometheus-metrics-path=$(PROMETHEUS_METRICS_PATH) \
--webhook-enable-sync-mode="$(WEBHOOK_ENABLE_SYNC_MODE)" \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
@@ -162,6 +168,7 @@ PLATFORM=
NO_CONCURRENCY=false
# Available tags:
# chromium
# chromium-concurrent
# chromium-convert-html
# chromium-convert-markdown
# chromium-convert-url
@@ -214,6 +221,7 @@ lint-todo: ## Find TODOs in Golang codebase
.PHONY: fmt
fmt: ## Format Golang codebase and "optimize" the dependencies
go fix ./...
golangci-lint fmt
go mod tidy

View File

@@ -4,60 +4,46 @@
<p align="center">A containerized API for seamless PDF conversion</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<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>
</p>
<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>
</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>
---
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
**Gotenberg** is a containerized API that abstracts the complexity of PDF conversion.
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
Open a terminal and run the following command:
```
```bash
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
```
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 🚀
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.
## Sponsors
<p align="center">
<a href="https://thecodingmachine.com">
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
</a>
<a href="https://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>
Open-source development takes a significant amount of time, energy, and dedication. If Gotenberg helps streamline your
workflow or powers your business, please consider supporting its continuous improvement by [**becoming a sponsor**](https://github.com/sponsors/gulien)! ❤️
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">
<strong>Powered by</strong>
</p>
**Powered By**
<p align="center">
<a href="https://jb.gg/OpenSource">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains logo" width="200"/>
</a>
</p>
- [Docker](https://docs.docker.com/docker-hub/repos/manage/trusted-content/dsos-program/)
- [JetBrains](https://www.jetbrains.com/community/opensource/)

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION=1.25.4
ARG GOLANG_VERSION=1.26.0
# ----------------------------------------------
# pdfcpu binary build stage
@@ -79,6 +79,9 @@ RUN jlink \
# ----------------------------------------------
FROM debian:13-slim AS base-image-stage
ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE
COPY --from=custom-jre-stage /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}"
@@ -212,7 +215,7 @@ RUN \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
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 &&\
# unoconverter will look for the Python binary, which has to be at version 3.
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")
descriptors := gotenberg.GetModuleDescriptors()
var modsInfo string
var modsInfo strings.Builder
for _, desc := range descriptors {
fs.AddFlagSet(desc.FlagSet)
modsInfo += desc.ID + " "
modsInfo.WriteString(desc.ID + " ")
}
// Parse the flags.
@@ -94,7 +94,7 @@ func Run() {
if !hideBanner {
fmt.Printf(banner, Version)
}
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
ctx := gotenberg.NewContext(parsedFlags, descriptors)

52
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/gotenberg/gotenberg/v8
go 1.25.4
go 1.26.0
require (
github.com/alexliesenfeld/health v0.8.1
@@ -11,23 +11,23 @@ require (
github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.5.2+incompatible
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/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.13.4
github.com/labstack/echo/v4 v4.15.0
github.com/labstack/gommon v0.4.2
github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.25.11
github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/pflag v1.0.10
github.com/testcontainers/testcontainers-go v0.40.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.47.0
golang.org/x/sync v0.18.0
golang.org/x/term v0.37.0
golang.org/x/text v0.31.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
)
require (
@@ -57,7 +57,7 @@ require (
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.9.1 // 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/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -71,34 +71,34 @@ require (
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.4 // 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/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.1 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
@@ -109,19 +109,17 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.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/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

346
go.sum
View File

@@ -1,28 +1,9 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
@@ -45,7 +26,6 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
@@ -54,10 +34,6 @@ github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZ
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -96,16 +72,12 @@ github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj6
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
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 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
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.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -124,38 +96,13 @@ 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.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
@@ -176,24 +123,18 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
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/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -203,14 +144,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24=
github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
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/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
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/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -227,8 +168,8 @@ github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -241,20 +182,20 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.2.1 h1:DgHK/O/fkTQEKBJxBMC5d9IU8IgauifbpG78+rZJMnI=
github.com/nwaples/rardecode/v2 v2.2.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -263,22 +204,19 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
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/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -294,8 +232,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -317,31 +253,26 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
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/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
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/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/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
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/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -352,201 +283,42 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
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=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
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/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
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/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

9
package-lock.json generated
View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}
@@ -58,12 +58,11 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},

View File

@@ -1,6 +1,6 @@
{
"devDependencies": {
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}

View File

@@ -10,7 +10,7 @@ import (
type Context struct {
flags ParsedFlags
descriptors []ModuleDescriptor
moduleInstances map[string]interface{}
moduleInstances map[string]any
}
// NewContext creates a [Context].
@@ -22,7 +22,7 @@ func NewContext(
return &Context{
flags: flags,
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
// 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)
if err != nil {
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
// 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()
var mods []interface{}
var mods []any
for _, desc := range ctx.descriptors {
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
// 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 {
// The instance can be provisioned.
err := prov.Provision(ctx)

View File

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

View File

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

View File

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

View File

@@ -35,10 +35,10 @@ func (mod *ValidatorMock) Validate() error {
}
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()
}
@@ -50,8 +50,8 @@ type PdfEngineMock struct {
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
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
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]any, inputPath 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
}
@@ -72,11 +72,11 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, fo
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)
}
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)
}

View File

@@ -78,7 +78,7 @@ type SystemLogger interface {
// Debuggable is a module interface for modules which want to provide
// additional debug data.
type Debuggable interface {
Debug() map[string]interface{}
Debug() map[string]any
}
// 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
// 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(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.
// The userPassword is required to open the document.

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"go.uber.org/zap"
@@ -79,27 +80,38 @@ type processSupervisor struct {
process Process
maxReqLimit int64
maxQueueSize int64
mutexChan chan struct{}
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
firstStartErr error
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
}
// 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{
logger: logger,
process: process,
mutexChan: make(chan struct{}, 1),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
b.restartsCounter.Store(0)
b.isRestarting.Store(false)
b.activeTasks.Store(0)
return b
}
@@ -130,15 +142,7 @@ func (s *processSupervisor) Shutdown() 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.isRestarting.Store(true)
defer s.isRestarting.Store(false)
err := s.Shutdown()
if err != nil {
@@ -197,33 +201,43 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
for {
err := func() error {
select {
case s.mutexChan <- struct{}{}:
case s.semaphore <- struct{}{}:
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.reqCounter.Add(1)
releaseMutexChan := true
s.activeTasks.Add(1)
releaseSemaphore := true
defer func() {
if releaseMutexChan {
s.activeTasks.Add(-1)
if releaseSemaphore {
logger.Debug("process lock released")
<-s.mutexChan
<-s.semaphore
}
}()
if !s.firstStart.Load() {
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
s.firstStartOnce.Do(func() {
s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", s.firstStartErr)
}
}
if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
err := s.doRestart(ctx)
if err != nil {
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)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseMutexChan = false
// Only one goroutine should trigger the restart.
if s.restartMutex.TryLock() {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseSemaphore = false
go func() {
err := s.runWithDeadline(context.Background(), func() error {
return s.restart()
})
if err != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
}
logger.Debug("process lock released")
<-s.mutexChan
}()
go func() {
restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr))
}
logger.Debug("process lock released")
<-s.semaphore
}()
}
}
// 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) {
logger.Debug("process is already restarting, trying to acquire process lock again...")
s.reqQueueSize.Add(1)
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 {
runChan := make(chan error, 1)
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 {
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()
if !tc.expectError && err != nil {
@@ -110,19 +110,11 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
func TestProcessSupervisor_restart(t *testing.T) {
for _, tc := range []struct {
scenario string
initiallyRestarting bool
startError error
stopError error
expectError bool
expectedError error
scenario string
startError error
stopError error
expectError bool
}{
{
scenario: "already restarting",
initiallyRestarting: true,
expectError: true,
expectedError: ErrProcessAlreadyRestarting,
},
{
scenario: "successful restart",
startError: nil,
@@ -154,10 +146,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
if tc.initiallyRestarting {
ps.isRestarting.Store(true)
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
err := ps.restart()
@@ -168,10 +157,6 @@ func TestProcessSupervisor_restart(t *testing.T) {
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}
@@ -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 {
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 {
ps.firstStart.Store(true)
}
@@ -424,14 +409,12 @@ func TestProcessSupervisor_Run(t *testing.T) {
errorChan := make(chan error, tc.tasksToRun)
for i := 0; i < tc.tasksToRun; i++ {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
err := ps.Run(ctx, logger, task)
if err != nil {
errorChan <- err
}
}()
})
}
wg.Wait()
@@ -452,8 +435,8 @@ func TestProcessSupervisor_Run(t *testing.T) {
}
// Making sure restarts are finished.
ps.mutexChan <- struct{}{}
<-ps.mutexChan
ps.semaphore <- struct{}{}
<-ps.semaphore
if startCalls.Load() != tc.expectedStartCalls {
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) {
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)
if tc.ctxDone {
@@ -522,10 +505,10 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
// Simulating a lock.
ps.mutexChan <- struct{}{}
ps.semaphore <- struct{}{}
if ps.ReqQueueSize() != 0 {
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
errorChan := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range 10 {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
errorChan <- err
}
}()
})
}
// 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)
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

@@ -87,7 +87,7 @@ type downloadFrom struct {
// newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
// We want to make sure the multipart/form-data does not exceed a given
// limit. We consider: form fields (keys, values, files) and files

View File

@@ -0,0 +1,71 @@
package api
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// Propagate HTTP request context cancellation to processing modules to save resources
// https://github.com/gotenberg/gotenberg/issues/1455
func TestNewContext_Cancellation(t *testing.T) {
e := echo.New()
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err := writer.Close()
if err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
// Create a request with a cancellable context.
reqCtx, cancelReq := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodPost, "/", body).WithContext(reqCtx)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
logger := zap.NewNop()
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
timeout := time.Duration(10) * time.Second
downloadFromCfg := downloadFromConfig{
disable: true,
}
ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg, "trace", "trace")
if err != nil {
t.Fatalf("expected no error from newContext, got: %v", err)
}
defer cancel()
// Verify initial state: context SHOULD NOT be done yet.
select {
case <-ctx.Done():
t.Fatal("context should not be done immediately")
default:
}
// Simulate Client Disconnect
cancelReq()
// Verify Propagation
select {
case <-ctx.Done():
// Success! The context was cancelled.
if ctx.Err() != context.Canceled {
t.Errorf("expected context error to be 'context.Canceled', got: %v", ctx.Err())
}
case <-time.After(100 * time.Millisecond):
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
}
}

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
// 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.
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]
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.
// Currently, only the string, bool, int, float64 and time.Duration types are
// bindable.
func (form *FormData) mustMandatoryField(key string, target interface{}) *FormData {
func (form *FormData) mustMandatoryField(key string, target any) *FormData {
val, ok := form.values[key]
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
// interface real type. Currently, only the string, bool, int, float64 and
// 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
switch t := (target).(type) {

View File

@@ -288,8 +288,9 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// PDF specific.
printToPdfActionFunc(logger, outputPath, options),
@@ -313,8 +314,9 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// Screenshot specific.
setDeviceMetricsOverride(logger, options.Width, options.Height),
@@ -376,6 +378,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
invalidHttpStatusCode: &invalidHttpStatusCode,
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
})

View File

@@ -33,6 +33,10 @@ var (
// returns an exception or undefined.
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
// ErrInvalidSelectorQuery happens if a selector query returns an exception
// or undefined.
ErrInvalidSelectorQuery = errors.New("invalid selector query")
// ErrRpccMessageTooLarge happens when the messages received by
// ChromeDevTools are larger than 100 MB.
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
@@ -82,9 +86,10 @@ var (
// Chromium is a module that provides both an [Api] and routes for converting
// an HTML document to PDF.
type Chromium struct {
autoStart bool
disableRoutes bool
args browserArguments
autoStart bool
disableRoutes bool
maxConcurrency int64
args browserArguments
logger *zap.Logger
browser browser
@@ -108,6 +113,20 @@ type Options struct {
// status code from at least one resource matches with one if its entries.
FailOnResourceHttpStatusCodes []int64
// IgnoreResourceHttpStatusDomains excludes resources whose hostname matches
// one of these domains from the application of
// [Options.FailOnResourceHttpStatusCodes].
//
// A match happens if the hostname equals the domain or is a subdomain of it
// (e.g., "browser.sentry-cdn.com" matches "sentry-cdn.com").
//
// Values are normalized (trimmed, lowercased) and may be provided as:
// - "example.com"
// - "*.example.com" or ".example.com"
// - "example.com:443" (port is ignored)
// - "https://example.com/path" (scheme/path are ignored)
IgnoreResourceHttpStatusDomains []string
// FailOnResourceLoadingFailed sets if the conversion should fail like the
// main page if Chromium fails to load at least one resource.
FailOnResourceLoadingFailed bool
@@ -128,6 +147,10 @@ type Options struct {
// converting an HTML document until it returns true
WaitForExpression string
// WaitForSelector is the element query to wait until visible before
// converting an HTML document.
WaitForSelector string
// Cookies are the cookies to put in the Chromium cookies' jar.
Cookies []Cookie
@@ -142,27 +165,47 @@ type Options struct {
// "print".
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
// PDFs with transparency.
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.
func DefaultOptions() Options {
return Options{
SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
FailOnResourceLoadingFailed: false,
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
IgnoreResourceHttpStatusDomains: nil,
FailOnResourceLoadingFailed: false,
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
WaitForSelector: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
EmulatedMediaFeatures: nil,
OmitBackground: false,
}
}
@@ -367,8 +410,9 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
ID: "chromium",
FlagSet: func() *flag.FlagSet {
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-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.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")
@@ -402,6 +446,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.autoStart = flags.MustBool("chromium-auto-start")
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
mod.maxConcurrency = flags.MustInt64("chromium-max-concurrency")
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
if !ok {
@@ -444,7 +489,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Process.
mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"))
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -462,6 +507,10 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Validate validates the module properties.
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)
if os.IsNotExist(err) {
return fmt.Errorf("chromium binary path does not exist: %w", err)
@@ -516,8 +565,8 @@ func (mod *Chromium) Stop(ctx context.Context) error {
}
// Debug returns additional debug data.
func (mod *Chromium) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (mod *Chromium) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
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.
func (debug *debugLogger) Printf(format string, v ...interface{}) {
func (debug *debugLogger) Printf(format string, v ...any) {
debug.logger.Debug(fmt.Sprintf(format, v...))
}

View File

@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"github.com/chromedp/cdproto/cdp"
@@ -37,7 +39,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
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) {
case *fetch.EventRequestPaused:
go func() {
@@ -143,6 +145,7 @@ type eventResponseReceivedOptions struct {
invalidHttpStatusCode *error
invalidHttpStatusCodeMu *sync.RWMutex
failOnResourceOnHttpStatusCode []int64
ignoreResourceHttpStatusDomains []string
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
}
@@ -157,6 +160,8 @@ func listenForEventResponseReceived(
logger *zap.Logger,
options eventResponseReceivedOptions,
) {
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
for _, code := range []int64{199, 299, 399, 499, 599} {
if slices.Contains(options.failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ {
@@ -171,7 +176,7 @@ func listenForEventResponseReceived(
}
}
chromedp.ListenTarget(ctx, func(ev interface{}) {
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *network.EventResponseReceived:
if ev.Response.URL == options.mainPageUrl {
@@ -190,6 +195,11 @@ func listenForEventResponseReceived(
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
return
}
options.invalidResourceHttpStatusCodeMu.Lock()
defer options.invalidResourceHttpStatusCodeMu.Unlock()
@@ -202,6 +212,79 @@ func listenForEventResponseReceived(
})
}
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
host := hostnameFromURL(rawURL)
if len(ignoreDomains) > 0 && matchesAnyDomain(host, ignoreDomains) {
return false
}
return true
}
func hostnameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return strings.ToLower(u.Hostname())
}
func normalizeDomains(domains []string) []string {
normalized := make([]string, 0, len(domains))
for _, domain := range domains {
d := normalizeDomain(domain)
if d == "" {
continue
}
normalized = append(normalized, d)
}
return normalized
}
func normalizeDomain(domain string) string {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" {
return ""
}
// Accept "example.com", "*.example.com", ".example.com", "https://example.com/path",
// or "example.com:443".
if strings.Contains(d, "://") || strings.HasPrefix(d, "//") {
u, err := url.Parse(d)
if err == nil && u.Hostname() != "" {
d = strings.ToLower(u.Hostname())
}
} else {
// Make it parseable as a URL to extract the hostname and drop any port/path.
u, err := url.Parse("https://" + d)
if err == nil && u.Hostname() != "" {
d = strings.ToLower(u.Hostname())
}
}
d = strings.TrimPrefix(d, "*.")
d = strings.TrimPrefix(d, ".")
return d
}
func matchesAnyDomain(host string, domains []string) bool {
if host == "" || len(domains) == 0 {
return false
}
for _, domain := range domains {
if host == domain || strings.HasSuffix(host, "."+domain) {
return true
}
}
return false
}
type eventLoadingFailedOptions struct {
loadingFailed *error
loadingFailedMu *sync.RWMutex
@@ -216,7 +299,7 @@ type eventLoadingFailedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
@@ -272,7 +355,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
// appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262.
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) {
case *runtime.EventExceptionThrown:
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
@@ -291,7 +374,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *page.EventDomContentEventFired:
cancel()
@@ -315,7 +398,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *page.EventLoadEventFired:
cancel()
@@ -339,7 +422,7 @@ func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() err
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch e := ev.(type) {
case *page.EventLifecycleEvent:
if e.Name == "networkIdle" {
@@ -365,7 +448,7 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *network.EventLoadingFinished:
cancel()

View File

@@ -0,0 +1,63 @@
package chromium
import "testing"
func TestNormalizeDomain(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "empty", in: "", want: ""},
{name: "spaces", in: " ", want: ""},
{name: "simple", in: "example.com", want: "example.com"},
{name: "mixed case", in: "ExAmPlE.Com", want: "example.com"},
{name: "leading wildcard", in: "*.example.com", want: "example.com"},
{name: "leading dot", in: ".example.com", want: "example.com"},
{name: "with scheme and path", in: "https://example.com/foo/bar", want: "example.com"},
{name: "with port", in: "example.com:443", want: "example.com"},
{name: "with scheme and port", in: "https://example.com:443/foo", want: "example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDomain(tt.in); got != tt.want {
t.Fatalf("normalizeDomain(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestMatchesAnyDomain(t *testing.T) {
host := "browser.sentry-cdn.com"
if !matchesAnyDomain(host, []string{"sentry-cdn.com"}) {
t.Fatalf("expected %q to match %q", host, "sentry-cdn.com")
}
if matchesAnyDomain("not-sentry-cdn.com", []string{"sentry-cdn.com"}) {
t.Fatalf("expected %q to not match %q", "not-sentry-cdn.com", "sentry-cdn.com")
}
}
func TestShouldCheckResourceHttpStatusCode_IgnoreDomains(t *testing.T) {
ignore := normalizeDomains([]string{"sentry.io"})
if shouldCheckResourceHttpStatusCode("https://sentry.io/api/123", ignore) {
t.Fatalf("expected ignored domain to be skipped")
}
if shouldCheckResourceHttpStatusCode("https://sub.sentry.io/api/123", ignore) {
t.Fatalf("expected ignored subdomain to be skipped")
}
if !shouldCheckResourceHttpStatusCode("https://other.com/api/123", ignore) {
t.Fatalf("expected non-ignored domain to be checked")
}
}
func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
if !shouldCheckResourceHttpStatusCode("data:text/plain,hello", nil) {
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
}
}

View File

@@ -29,25 +29,40 @@ var sameSiteRegexp = regexp2.MustCompile(
regexp2.None,
)
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
// the default value if the considered key is not present.
// FormDataChromiumOptions creates [Options] from the form data.
//
// It falls back to the default value if the considered key is not present.
//
// JSON-encoded fields:
// - failOnHttpStatusCodes: []int
// - failOnResourceHttpStatusCodes: []int
// - ignoreResourceHttpStatusDomains: []string
// - cookies: []Cookie
// - extraHttpHeaders: map[string]string
// - emulatedMediaFeatures: map[string]string
//
// Domain filtering only applies to resource checks triggered by
// "failOnResourceHttpStatusCodes".
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
omitBackground bool
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
ignoreResourceHttpStatusDomains []string
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
waitForSelector string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
emulatedMediaFeatures []EmulatedMediaFeature
omitBackground bool
)
form := ctx.FormData().
@@ -78,11 +93,25 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
Custom("ignoreResourceHttpStatusDomains", func(value string) error {
if value == "" {
ignoreResourceHttpStatusDomains = defaultOptions.IgnoreResourceHttpStatusDomains
return nil
}
err := json.Unmarshal([]byte(value), &ignoreResourceHttpStatusDomains)
if err != nil {
return fmt.Errorf("unmarshal ignoreResourceHttpStatusDomains: %w", err)
}
return nil
}).
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
String("waitForSelector", &waitForSelector, defaultOptions.WaitForSelector).
Custom("cookies", func(value string) error {
if value == "" {
cookies = defaultOptions.Cookies
@@ -143,8 +172,8 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
var valueTokens []string
var invalidScopeToken bool
tokens := strings.Split(v, ";")
for _, token := range tokens {
tokens := strings.SplitSeq(v, ";")
for token := range tokens {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
tokenNoSpaces := strings.Join(strings.Fields(token), "")
parts := strings.SplitN(tokenNoSpaces, "=", 2)
@@ -200,22 +229,46 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
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)
options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
WaitForSelector: waitForSelector,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
EmulatedMediaFeatures: emulatedMediaFeatures,
OmitBackground: omitBackground,
}
return form, options
@@ -633,7 +686,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
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")
// See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath)
@@ -783,6 +836,22 @@ func handleChromiumError(err error, options Options) error {
)
}
if errors.Is(err, ErrInvalidSelectorQuery) {
if options.WaitForSelector == "" {
// We only expect to see this error if the user specified a selector.
// If they didn't and we still generated the error, return a 500.
return err
}
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The selector '%s' (waitForSelector) returned an exception or undefined", options.WaitForSelector),
),
)
}
if errors.Is(err, ErrInvalidHttpStatusCode) {
return api.WrapError(
err,

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
// least for base64 encoded data), usually it will overflow. We subtract
// one to make sure it fits into p.
size := len(p) - 1
if size < 1 {
size := max(len(p)-1,
// Safety-check to avoid crashing Chrome (e.g. via SetSize(-1)).
size = 1
}
1)
reply, err := reader.next(reader.pos, size)
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 {
if mediaType == "" {
logger.Debug("no emulated media type")
if mediaType == "" && len(mediaFeatures) == 0 {
logger.Debug("no emulated media type or features")
return nil
}
if mediaType != "screen" && mediaType != "print" {
if mediaType != "" && mediaType != "screen" && mediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
}
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
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 {
return nil
}
return fmt.Errorf("emulate media type '%s': %w", mediaType, err)
return fmt.Errorf("emulate media: %w", err)
}
}
@@ -512,3 +530,19 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
}
}
}
func waitForSelectorVisibleBeforePrintActionFunc(logger *zap.Logger, selector string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if selector == "" {
logger.Debug("no wait selector")
return nil
}
logger.Debug(fmt.Sprintf("wait until '%s' is visible before print", selector))
err := chromedp.WaitVisible(selector, chromedp.ByQuery, chromedp.RetryInterval(time.Duration(100)*time.Millisecond)).Do(ctx)
if err != nil {
return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery)
}
return nil
}
}

View File

@@ -57,8 +57,8 @@ func (engine *ExifTool) Validate() error {
}
// Debug returns additional debug data.
func (engine *ExifTool) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *ExifTool) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
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.
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))
if err != nil {
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.
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))
if err != nil {
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)
}
// 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 {
switch val := value.(type) {
case string:
fileMetadata[0].SetString(key, val)
case []string:
fileMetadata[0].SetStrings(key, val)
case []interface{}:
case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048.
strs := make([]string, len(val))
for i, entry := range val {
@@ -148,7 +175,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
strs[i] = str
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)
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.
type Options struct {
// Password specifies the password for opening the source file.
Password string
Password string // #nosec
// Landscape allows changing the orientation of the resulting PDF.
Landscape bool
@@ -253,7 +253,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1)
return nil
}
@@ -316,8 +316,8 @@ func (a *Api) Stop(ctx context.Context) error {
}
// Debug returns additional debug data.
func (a *Api) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (a *Api) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
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.
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)
}
// 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)
}

View File

@@ -177,7 +177,7 @@ func newLogLevel(level string) (zapcore.Level, 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()
// 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.
func (engine *PdfCpu) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *PdfCpu) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -71,10 +71,10 @@ func (engine *PdfCpu) Debug() map[string]interface{} {
debug["version"] = "Unable to determine pdfcpu version"
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "pdfcpu:") {
debug["version"] = strings.TrimSpace(strings.TrimPrefix(line, "pdfcpu:"))
lines := strings.SplitSeq(string(output), "\n")
for line := range lines {
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
debug["version"] = strings.TrimSpace(after)
break
}
}
@@ -84,7 +84,7 @@ func (engine *PdfCpu) Debug() map[string]interface{} {
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args := make([]string, 0, 2+len(inputPaths))
args = append(args, "merge", outputPath)
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.
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)
}
// 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)
}
@@ -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))
args := []string{
"attachments", "add",
inputPath,
}
args := make([]string, 0, 3+len(filePaths))
args = append(args, "attachments", "add", inputPath)
args = append(args, filePaths...)
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
}
var args []string
args := make([]string, 0, 11)
args = append(args, "encrypt")
args = append(args, "-mode", "aes")
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 {
metadata map[string]interface{}
metadata map[string]any
err error
}
// ReadMetadata extracts metadata from a PDF file using the first available
// 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 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
// 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
errChan := make(chan error, 1)

View File

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

View File

@@ -3,6 +3,7 @@ package pdfengines
import (
"errors"
"fmt"
"slices"
"strings"
flag "github.com/spf13/pflag"
@@ -179,13 +180,7 @@ func (mod *PdfEngines) Validate() error {
continue
}
alreadyInSlice := false
for _, engine := range nonExistingEngines {
if engine == name {
alreadyInSlice = true
break
}
}
alreadyInSlice := slices.Contains(nonExistingEngines, name)
if !alreadyInSlice {
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.
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} {
var metadata map[string]interface{}
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any {
var metadata map[string]any
metadataFunc := func(value string) error {
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
// 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 {
return nil
}
@@ -558,7 +558,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
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 {
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
if err != nil {

View File

@@ -56,8 +56,8 @@ func (engine *PdfTk) Validate() error {
}
// Debug returns additional debug data.
func (engine *PdfTk) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *PdfTk) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
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.
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, "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.
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)
}
// 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)
}
@@ -158,7 +158,7 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
// Create a temp output file in the same directory.
tmpPath := inputPath + ".tmp"
var args []string
args := make([]string, 0, 8)
args = append(args, inputPath)
args = append(args, "output", tmpPath)
args = append(args, "encrypt_128bit")

View File

@@ -27,6 +27,7 @@ type Prometheus struct {
interval time.Duration
disableRouteLogging bool
disableCollect bool
metricsPath string
metrics []gotenberg.Metric
registry *prometheus.Registry
@@ -42,6 +43,7 @@ func (mod *Prometheus) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("prometheus-collect-interval", time.Duration(1)*time.Second, "Set the interval for collecting modules' metrics")
fs.Bool("prometheus-disable-route-logging", false, "Disable the route logging")
fs.Bool("prometheus-disable-collect", false, "Disable the collect of metrics")
fs.String("prometheus-metrics-path", "/prometheus/metrics", "Path for Prometheus metrics endpoint")
return fs
}(),
@@ -56,6 +58,7 @@ func (mod *Prometheus) Provision(ctx *gotenberg.Context) error {
mod.interval = flags.MustDuration("prometheus-collect-interval")
mod.disableRouteLogging = flags.MustBool("prometheus-disable-route-logging")
mod.disableCollect = flags.MustBool("prometheus-disable-collect")
mod.metricsPath = flags.MustString("prometheus-metrics-path")
if mod.disableCollect {
// Exit early.
@@ -98,6 +101,10 @@ func (mod *Prometheus) Validate() error {
return errors.New("namespace must not be empty")
}
if mod.metricsPath == "" {
return errors.New("metrics path cannot be empty")
}
metricsMap := make(map[string]string, len(mod.metrics))
for _, metric := range mod.metrics {
@@ -171,7 +178,7 @@ func (mod *Prometheus) Routes() ([]api.Route, error) {
return []api.Route{
{
Method: http.MethodGet,
Path: "/prometheus/metrics",
Path: mod.metricsPath,
DisableLogging: mod.disableRouteLogging,
Handler: echo.WrapHandler(
promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}),

View File

@@ -59,8 +59,8 @@ func (engine *QPdf) Validate() error {
}
// Debug returns additional debug data.
func (engine *QPdf) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *QPdf) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
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.
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, engine.globalArgs...)
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
// original annotations.
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, "--generate-appearances")
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.
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)
}
// 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)
}
@@ -182,7 +182,7 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
ownerPassword = userPassword
}
var args []string
args := make([]string, 0, 7+len(engine.globalArgs))
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
args = append(args, "--replace-input")

View File

@@ -272,6 +272,29 @@ func webhookMiddleware(w *Webhook) api.Middleware {
})
return c.NoContent(http.StatusNoContent)
}
if deadline, ok := ctx.Deadline(); ok {
// Create a new context derived from Background (detached from Request)
// but with the same deadline as the original context.
detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline)
// Replace the embedded context in the api.Context struct.
// The modules downstream will now use this detached context.
ctx.Context = detachedCtx
// We must wrap the cancel function.
// 1. detachedCancel() cleans up our new detached context.
// 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory.
originalCancel := cancel
cancel = func() {
detachedCancel()
originalCancel()
}
} else {
// Fallback if no deadline was set (rare, as newContext enforces it).
ctx.Context = context.Background()
}
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
w.asyncCount.Add(1)

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

@@ -195,6 +195,36 @@ Feature: /forms/chromium/convert/html
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/html (Wait For Selector)
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:
"""
Wait on selector returns true.
"""
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 |
| waitForSelector | #wait-selector | 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:
"""
Wait on selector returns true.
"""
Scenario: POST /forms/chromium/convert/html (Emulated Media Type)
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):
@@ -251,6 +281,82 @@ Feature: /forms/chromium/convert/html
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)
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):
@@ -343,6 +449,16 @@ Feature: /forms/chromium/convert/html
https://gethttpstatus.com/400 - 400: Bad Request
"""
Scenario: POST /forms/chromium/convert/html (Fail On Resource HTTP Status Codes - Ignore Domains)
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 |
| failOnResourceHttpStatusCodes | [499,599] | field |
| ignoreResourceHttpStatusDomains | ["gethttpstatus.com"] | field |
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
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
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):
@@ -510,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 `*.`)
"""
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):
| files | testdata/page-1-html/index.html | file |
| splitMode | foo | field |

View File

@@ -255,6 +255,38 @@ Feature: /forms/chromium/convert/url
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/url (Wait For Selector)
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:
"""
Wait on selector returns true.
"""
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 |
| waitForSelector | #wait-selector | 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:
"""
Wait on selector returns true.
"""
Scenario: POST /forms/chromium/convert/url (Emulated Media Type)
Given I have a default Gotenberg container
Given I have a static server
@@ -314,6 +346,86 @@ Feature: /forms/chromium/convert/url
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)
Given I have a default Gotenberg container
Given I have a static server
@@ -595,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 `*.`)
"""
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):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | foo | field |

View File

@@ -16,6 +16,127 @@ Feature: /debug
"""
{
"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",
"modules": [
"api",
@@ -84,8 +205,9 @@ Feature: /debug
"chromium-ignore-certificate-errors": "false",
"chromium-incognito": "false",
"chromium-max-queue-size": "0",
"chromium-max-concurrency": "6",
"chromium-proxy-server": "",
"chromium-restart-after": "10",
"chromium-restart-after": "100",
"chromium-start-timeout": "20s",
"gotenberg-build-debug-data": "true",
"gotenberg-graceful-shutdown-duration": "30s",
@@ -109,6 +231,7 @@ Feature: /debug
"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": "",
@@ -133,6 +256,7 @@ Feature: /debug
"""
{
"version": "",
"timezone": "",
"architecture": "",
"modules": null,
"modules_additional_data": null,

View File

@@ -29,6 +29,31 @@ Feature: /prometheus/metrics
Then the Gotenberg container should log the following entries:
| "path":"/prometheus/metrics" |
Scenario: GET /custom/metrics (Custom Metrics Path)
Given I have a Gotenberg container with the following environment variable(s):
| PROMETHEUS_METRICS_PATH | /custom/metrics |
When I make a "GET" request to Gotenberg at the "/custom/metrics" endpoint
Then the response status code should be 200
Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores"
Then the response body should match string:
"""
# HELP gotenberg_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated.
# TYPE gotenberg_chromium_requests_queue_size gauge
gotenberg_chromium_requests_queue_size 0
# HELP gotenberg_chromium_restarts_count Current number of Chromium restarts.
# TYPE gotenberg_chromium_restarts_count gauge
gotenberg_chromium_restarts_count 0
# HELP gotenberg_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated.
# TYPE gotenberg_libreoffice_requests_queue_size gauge
gotenberg_libreoffice_requests_queue_size 0
# HELP gotenberg_libreoffice_restarts_count Current number of LibreOffice restarts.
# TYPE gotenberg_libreoffice_restarts_count gauge
gotenberg_libreoffice_restarts_count 0
"""
Then the Gotenberg container should log the following entries:
| "path":"/custom/metrics" |
Scenario: GET /prometheus/metrics (Custom Namespace)
Given I have a Gotenberg container with the following environment variable(s):
| PROMETHEUS_NAMESPACE | foo |

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ func doRequest(method, url string, headers map[string]string, body io.Reader) (*
req.Header.Set(header, value)
}
// #nosec
resp, err := http.DefaultClient.Do(req)
if err != nil {
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())
// #nosec
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send a request: %w", err)

View File

@@ -14,6 +14,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/cucumber/godog"
@@ -24,6 +25,7 @@ import (
type scenario struct {
resp *httptest.ResponseRecorder
concurrentResps []*httptest.ResponseRecorder
workdir string
gotenbergContainer testcontainers.Container
gotenbergContainerNetwork *testcontainers.DockerNetwork
@@ -33,6 +35,7 @@ type scenario struct {
func (s *scenario) reset(ctx context.Context) error {
s.resp = httptest.NewRecorder()
s.concurrentResps = nil
err := os.RemoveAll(s.workdir)
if err != nil {
@@ -281,6 +284,168 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
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 {
if s.server == nil {
return errors.New("server not initialized")
@@ -327,7 +492,7 @@ func (s *scenario) theGotenbergContainerShouldLogTheFollowingEntries(ctx context
}
var err error
for i := 0; i < 3; i++ {
for range 3 {
err = check()
if err != nil && !invert {
// 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
}
var expected, actual interface{}
var expected, actual any
content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
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 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 (\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.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(`^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) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
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"))
// #nosec
err = os.MkdirAll(dirPath, 0o755)
if err != nil {
return webhookErr(fmt.Errorf("create working directory: %w", err))
}
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
// #nosec
file, err := os.Create(fpath)
if err != nil {
return webhookErr(fmt.Errorf("create file %q: %w", fpath, err))

View File

@@ -20,6 +20,14 @@
display: none;
}
}
#reduced-motion {
display: none;
}
@media (prefers-reduced-motion: reduce) {
#reduced-motion {
display: block;
}
}
</style>
</head>
<body>
@@ -29,6 +37,7 @@
</p>
<p id="print">Emulated media type is 'print'.</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>
<iframe src="file:///etc/passwd"></iframe>
@@ -37,8 +46,14 @@
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
delay(2000).then(() => {
document.getElementById("wait").style.display = "contents";
const waitText = document.getElementById("wait");
waitText.style.display = "contents";
window.globalVar = "ready";
const newText = document.createElement("p");
newText.id = "wait-selector";
newText.textContent = "Wait on selector returns true.";
waitText.parentNode.insertBefore(newText, waitText);
});
</script>

View File

@@ -28,6 +28,14 @@
display: none;
}
}
#reduced-motion {
display: none;
}
@media (prefers-reduced-motion: reduce) {
#reduced-motion {
display: block;
}
}
</style>
</head>
<body>
@@ -37,6 +45,7 @@
</p>
<p id="print">Emulated media type is 'print'.</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>
<iframe src="/etc/passwd"></iframe>
<iframe src="\\localhost/etc/passwd"></iframe>
@@ -46,8 +55,14 @@
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
delay(2000).then(() => {
document.getElementById("wait").style.display = "contents";
const waitText = document.getElementById("wait");
waitText.style.display = "contents";
window.globalVar = "ready";
const newText = document.createElement("p");
newText.id = "wait-selector";
newText.textContent = "Wait on selector returns true.";
waitText.parentNode.insertBefore(newText, waitText);
});
</script>

View File

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